Compare commits

...

144 Commits

Author SHA1 Message Date
Ravi Khadiwala
aa5ac70ad3 Select lifecycle-manager based on environment variable (like in FoundationDbClusterExtension)
Some checks are pending
Update Documentation / build (push) Waiting to run
Service CI / build (push) Waiting to run
2026-06-25 17:42:04 -04:00
Jon Chambers
2abf55e395
Don't trigger state transitions inside retryable transactions 2026-06-25 17:41:33 -04:00
Jon Chambers
808bb16103 Add basic read/acknowledgement counters to RedisDynamoDbMessageStream 2026-06-25 17:41:11 -04:00
Ameya Lokare
4743abcfbd Port OneTimeDonationController to gRPC 2026-06-25 17:40:49 -04:00
Katherine
1b09529ece
Use V2 key transparency query RPCs 2026-06-24 15:02:34 -07:00
Chris Eager
0c3c390a0b
Update Donation-Permit header parameter documentation 2026-06-24 14:51:46 -07:00
Chris Eager
ca4ee141d8 Disable a flaky test 2026-06-24 13:18:07 -05:00
Jonathan Klabunde Tomer
671a6e1d7c address review comments
Co-authored-by: Jon Chambers <63609320+jon-signal@users.noreply.github.com>
2026-06-24 12:14:52 -05:00
Jonathan Klabunde Tomer
f4e16676c9 add FoundationDbMessageStore api to clear messages before a given time 2026-06-24 12:14:52 -05:00
Chris Eager
78b3147491
Implement donation permit spending 2026-06-24 12:13:21 -05:00
Ravi Khadiwala
8b617b64f8 Add metric for requested attachment upload size 2026-06-24 12:11:57 -05:00
Jon Chambers
28aefe0ebe Revert "Mirror message reads/acknowledgements via FoundationDbMessageStream"
This reverts commit 4e52317e26.
2026-06-24 13:01:13 -04:00
Jon Chambers
4e52317e26
Mirror message reads/acknowledgements via FoundationDbMessageStream
Some checks failed
Update Documentation / build (push) Has been cancelled
Service CI / build (push) Has been cancelled
2026-06-24 10:18:15 -04:00
Ravi Khadiwala
99465c1f41 Update to the latest version of the spam filter
Some checks are pending
Update Documentation / build (push) Waiting to run
Service CI / build (push) Waiting to run
2026-06-23 13:13:49 -05:00
Chris Eager
041c29bfa8 Update foundationdb to 7.3.68 2026-06-23 13:11:05 -05:00
Chris Eager
8e5ad7f52e Remove unused mocking in CredentialsGrpcServiceTest 2026-06-23 13:11:05 -05:00
Jon Chambers
ca5d203f1c Make RegistrationServiceClient blocking 2026-06-23 13:09:12 -05:00
ravi-signal
9a9b15ee0a
Add per-element constraint validation 2026-06-23 13:08:39 -05:00
ravi-signal
503941ec6a
Add a load-shedding GOAWAY 2026-06-23 13:07:49 -05:00
Ravi Khadiwala
ac720595e6 Add header to disable messages on authenticated websocket
Some checks failed
Update Documentation / build (push) Has been cancelled
Service CI / build (push) Has been cancelled
Integration Tests / build (push) Has been cancelled
2026-06-18 15:28:41 -05:00
Jonathan Klabunde Tomer
8deb5a803a
foundationdb "versionstamp clock" 2026-06-18 13:27:59 -07:00
Jon Chambers
45f96cd702 Delete messages from FoundationDB asynchronously 2026-06-18 14:06:26 -04:00
Chris Eager
4a3275ad63 Add POST /v1/donation/permit 2026-06-18 13:06:18 -05:00
Ameya Lokare
911feceacb Handle "no sender available" error code from registration service
Some checks are pending
Update Documentation / build (push) Waiting to run
Service CI / build (push) Waiting to run
2026-06-18 09:03:02 -04:00
Jon Chambers
fe0956c8f7 Don't add Dropwizard metrics to shared registries 2026-06-18 09:02:33 -04:00
Jon Chambers
60002a8a0f
Port StickerController to gRPC 2026-06-18 08:51:00 -04:00
Ameya Lokare
1876b71d39 Port SubscriptionController to gRPC 2026-06-18 08:27:38 -04:00
Jon Chambers
3cfe8b9f6c
Mirror message writes/deletions to FoundationDB 2026-06-18 08:27:03 -04:00
Jon Chambers
691a4162e1 Update to the latest version of the spam filter 2026-06-18 08:25:07 -04:00
Ravi Khadiwala
9505e7b1a1 Fix incorrect mockito matcher in BackupManagerTest 2026-06-17 12:43:34 -05:00
Jon Chambers
569f4f31e8 Update to the latest version of the spam filter 2026-06-17 08:46:11 -04:00
Ravi Khadiwala
06519ce9d1 Use uint64 for backups.proto object length 2026-06-17 08:45:43 -04:00
Jon Chambers
024fa9ce5f Move "active epoch" configuration from dynamic to static configuration 2026-06-17 08:35:07 -04:00
Jon Chambers
7c9e3d029b Remove an unused availableProcessors variable 2026-06-17 08:33:29 -04:00
Jon Chambers
cc6ef53f91 Retire unused methods in MessagesManager 2026-06-17 08:33:29 -04:00
Ravi Khadiwala
4c0f454209 Add a generic STREAM_CLOSED status 2026-06-17 08:32:47 -04:00
Chris Eager
9c393972e9 Update to the latest version of the spam filter 2026-06-16 20:02:56 -05:00
Jon Chambers
31c1bb8940 Support multiple "configuration epochs" of the FoundationDB message store
Some checks failed
Update Documentation / build (push) Has been cancelled
Service CI / build (push) Has been cancelled
2026-06-16 09:06:18 -04:00
Jon Chambers
c3b2b43813 Port CallLinkController to gRPC 2026-06-16 09:02:24 -04:00
Jon Chambers
19f2574799 Update to the latest version of the spam filter 2026-06-16 09:02:03 -04:00
Ravi Khadiwala
adb5b6a4ea Check message type before deserializing source serviceId
Some checks failed
Update Documentation / build (push) Has been cancelled
Service CI / build (push) Has been cancelled
Integration Tests / build (push) Has been cancelled
2026-06-11 13:16:20 -05:00
Ravi Khadiwala
e79eb9904d Update to the latest version of the spam filter 2026-06-11 11:36:07 -05:00
ravi-signal
fcdc0bbd74
Generate grpc api documentation 2026-06-11 11:35:20 -05:00
Ravi Khadiwala
b6317a1b43 Add gRPC message retrieval 2026-06-11 11:34:07 -05:00
Ravi Khadiwala
329d229ac7 Revert "Remove unused ClosableEpoch"
This reverts commit d0e022c23b.
2026-06-11 11:34:07 -05:00
Ravi Khadiwala
c50b4e52b0 Move some message retrieval metrics to MessageMetrics 2026-06-11 11:34:07 -05:00
Ravi Khadiwala
660011017d Throw checked exceptions from CaptchaChecker 2026-06-11 11:33:56 -04:00
Jon Chambers
82e3c16fba Port CallRoutingControllerV2 to gRPC 2026-06-11 11:31:57 -04:00
Jon Chambers
ae9f43bb3c Don't include IOException messages in gRPC error responses 2026-06-11 11:31:57 -04:00
Jonathan Klabunde Tomer
ab4b5a1298 ignore disconnects from already-disconnected message listeners 2026-06-11 11:31:24 -04:00
Katherine Yen
97a77ce23e Remove duplicate constructor for invalid argument gRPC exception with no details 2026-06-11 11:30:56 -04:00
Ameya Lokare
50ef5272f7 Fix handling write conflicts when redeeming subscription receipt
credentials
2026-06-11 11:30:16 -04:00
Chris Eager
d6c7c5e7bf Use ProfileHelper.isPaymentAddressUpdateForbidden for both REST and gRPC 2026-06-11 10:29:14 -05:00
Ameya Lokare
32befd7c9a Port DonationController to gRPC 2026-06-11 11:28:23 -04:00
Jon Chambers
dd93833324 Avoid retaining messages while waiting for acknowledgement 2026-06-11 11:14:02 -04:00
Jon Chambers
5d624b463a Acknowledge messages by ID rather than by passing the whole message around 2026-06-11 11:14:02 -04:00
Jon Chambers
f524d9fb25
Retire string-based service identifiers/UUIDs in Envelope entities
Some checks are pending
Update Documentation / build (push) Waiting to run
Service CI / build (push) Waiting to run
2026-06-10 14:28:30 -04:00
Jon Chambers
4a9302033a Remove the additional callback executor from FoundationDbMessageStore 2026-06-10 13:12:40 -04:00
Jon Chambers
292f03586b Add an IP-based rate limiter for adding payment methods for subscriptions 2026-06-10 11:29:51 -04:00
Jon Chambers
8257859834 Add an IP-based rate limiter for creating one-time donations 2026-06-10 11:29:51 -04:00
Katherine Yen
2c5210e7c4 Fix a flaky test by waiting for the async insert to complete before attempting to remove the message
Some checks failed
Update Documentation / build (push) Has been cancelled
Service CI / build (push) Has been cancelled
2026-06-08 17:42:35 -04:00
Katherine
b93da1323b
Discard ephemeral messages from the finite publisher in FoundationDB 2026-06-08 17:23:21 -04:00
Ravi Khadiwala
d9c39cc12b Update messages integration test to use websocket 2026-06-08 16:01:23 -05:00
Ravi Khadiwala
d69027ce5c Remove REST message retrieval entities 2026-06-08 16:01:23 -05:00
Jon Chambers
a4381a2617 Parse FoundationDB envelopes at load time and add GUIDs later 2026-06-08 16:56:53 -04:00
Jon Chambers
3e4ad32fce Introduce AcknowledgedMessageBuffer 2026-06-08 16:56:15 -04:00
Jon Chambers
c3a48fd08b
Port CertificateController to gRPC 2026-06-08 16:55:53 -04:00
Ravi Khadiwala
7471a21fee Update to the latest version of the spam filter
Some checks failed
Update Documentation / build (push) Has been cancelled
Service CI / build (push) Has been cancelled
2026-06-05 12:41:26 -05:00
Ameya Lokare
13a5458344 Port ChallengeController to gRPC 2026-06-05 12:32:46 -05:00
Ravi Khadiwala
704b53aa79 Close responses in WhisperServerServiceTest 2026-06-05 12:30:09 -05:00
Jon Chambers
5774ffdeab Wait for test message insertion to complete before attempting to read 2026-06-05 10:22:34 -04:00
Ravi Khadiwala
fac7dbbeb7 Add a timeout to WhisperServerServiceTest 2026-06-04 18:06:27 -05:00
Ravi Khadiwala
29b1ec694b Update to the latest version of the spam filter
Some checks failed
Update Documentation / build (push) Waiting to run
Service CI / build (push) Waiting to run
Integration Tests / build (push) Has been cancelled
2026-06-03 11:39:07 -05:00
dependabot[bot]
181004f031 Bump the minor-actions-dependencies group with 2 updates
Bumps the minor-actions-dependencies group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials).


Updates `actions/checkout` from 6.0.2 to 6.0.3
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](de0fac2e45...df4cb1c069)

Updates `aws-actions/configure-aws-credentials` from 6.1.0 to 6.2.0
- [Release notes](https://github.com/aws-actions/configure-aws-credentials/releases)
- [Changelog](https://github.com/aws-actions/configure-aws-credentials/blob/main/CHANGELOG.md)
- [Commits](ec61189d14...e7f100cf4c)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-actions-dependencies
- dependency-name: aws-actions/configure-aws-credentials
  dependency-version: 6.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-03 12:03:36 -04:00
Jon Chambers
bb18a84808 Add an experiment to recompress messages just before delivery 2026-06-03 12:00:34 -04:00
Jon Chambers
27fb4f227d Revert "Add an experiment to skip envelope expansion"
This reverts commit 0d32ca120d.
2026-06-03 12:00:34 -04:00
Ravi Khadiwala
324c4eafb6 Retire REST message retrieval
Remove `GET /v1/messages`
2026-06-03 10:59:41 -05:00
Jon Chambers
0d32ca120d Add an experiment to skip envelope expansion
Some checks failed
Update Documentation / build (push) Has been cancelled
Service CI / build (push) Has been cancelled
2026-06-02 16:01:06 -04:00
Katherine
69a954e889
Discard stale ephemeral messages in FoundationDB 2026-06-02 16:00:25 -04:00
Jon Chambers
d57ed3133c Avoid spurious padding 2026-06-01 16:51:56 -05:00
Jon Chambers
4a9f44b88c Use an all-zero IV and the parameter block as the first plaintext block 2026-06-01 16:51:56 -05:00
Jon Chambers
264897656b Actually make the "copy to S3" command available 2026-05-29 10:03:47 -04:00
Chris Eager
f8f60c92ff Use getByPhoneNumberIdentifier in getAccountsForChangeNumber 2026-05-29 10:00:17 -04:00
Jon Chambers
e6c24c373d Update to the latest version of the spam filter 2026-05-29 09:59:17 -04:00
Jon Chambers
ab34e7c10e Update to the latest version of the spam filter 2026-05-28 16:50:48 -04:00
Jon Chambers
c32d15617b Add a command for copying files from the open internet to S3 2026-05-28 16:17:37 -04:00
Jon Chambers
11136868ac Update to the latest version of the spam filter 2026-05-28 10:37:58 -04:00
Katherine
81167a8ae5
Add a device capability for username change sync messages 2026-05-27 15:34:01 -04:00
ameya-signal
a6bf60e4cb
Fix flaky test FoundationDbMessageStoreTest#getMessagesPublishMoreAfterQueueEmpty 2026-05-27 15:33:07 -04:00
Jon Chambers
15698aee46
Wire PaymentsGrpcService up as an authenticated service 2026-05-27 15:32:49 -04:00
Katherine
b2942a8dbd
Limit the number of unacknowledged FoundationDB messages 2026-05-27 15:32:27 -04:00
Jon Chambers
f10cc26144
Include optional call ID hashes in call quality survey responses 2026-05-27 15:31:53 -04:00
Katherine Yen
d4581931ff Update to the latest version of the spam filter 2026-05-27 15:31:32 -04:00
Chris Eager
41b953f5a1 Add rotation_id to ZK Credential key 2026-05-27 14:23:20 -05:00
Jon Chambers
3f1df3032f Update to the latest version of the spam filter 2026-05-26 09:17:49 -04:00
Jon Chambers
cb6123125f Only send verification code push notifications to primary devices 2026-05-26 09:17:32 -04:00
Jon Chambers
9f6d80cb39 Update to the latest version of the spam filter
Some checks failed
Update Documentation / build (push) Has been cancelled
Service CI / build (push) Has been cancelled
2026-05-21 14:33:55 -04:00
Jon Chambers
da439e7144 Guard against NullPointerExceptions when getting details from a GoogleJsonResponseException 2026-05-21 13:10:44 -04:00
Jonathan Klabunde Tomer
22656396dc serialize account current profile version as base64 2026-05-21 13:10:03 -04:00
Chris Eager
90b280d6a0 Convert Subscriptions to sync DynamoDB client 2026-05-21 13:09:31 -04:00
Chris Eager
482a1b7bb5 Convert RedeemedReceiptsManager to sync DynamoDB client 2026-05-21 13:09:31 -04:00
Chris Eager
fea4300d7d Convert RegistrationRecoveryPasswords to sync DynamoDB client 2026-05-21 13:08:49 -04:00
Chris Eager
66b0ed16d1 Convert VerificationSessions to sync DynamoDB client 2026-05-21 13:08:49 -04:00
Ameya Lokare
defbc1c853 Implement FoundationDbMessageStream#acknowledgeMessage 2026-05-21 13:07:50 -04:00
Jon Chambers
68a5e4e8ee Update to the latest version of the spam filter 2026-05-20 12:49:36 -04:00
Jon Chambers
f83efce4d4 Add a phased enrollment mechanism for verification code push notifications 2026-05-20 12:20:00 -04:00
Jon Chambers
0945e953f2 Send a "verification code requested" push notification to existing accounts 2026-05-20 12:20:00 -04:00
Jon Chambers
51f6e57bbd Allow differing TTLs by notification type 2026-05-20 12:20:00 -04:00
Jon Chambers
539a84d0d0 Allow PushNotification data to be any type of object 2026-05-20 12:20:00 -04:00
Ravi Khadiwala
848c457bbe Use micrometer's netty metrics 2026-05-20 12:18:27 -04:00
Ravi Khadiwala
c5ae3963b5 Fix ProxyProtocolHandlerTest resource leak
Also replace AbstractLeakDetectionTest with netty LeakPresenceExtension
2026-05-19 16:33:00 -05:00
ravi-signal
771fecd396
Fix flaky backpressure test 2026-05-19 15:59:35 -05:00
Chris Eager
b9a24fedea Move handleAccountCreated() call to outside re-registration 2026-05-19 13:07:44 -05:00
Ravi Khadiwala
9d3b6ebb4a Update to the latest version of the spam filter 2026-05-19 11:30:51 -05:00
Chris Eager
2cf91af5d9 Remove obsolete client public keys table from config 2026-05-19 11:19:19 -05:00
Chris Eager
d0e022c23b Remove unused ClosableEpoch 2026-05-19 11:19:19 -05:00
Chris Eager
460e5cb499 Use DynamoDB for change number waiting periods
DynamoDB is even simpler for integration tests.
2026-05-19 11:18:10 -05:00
Jonathan Klabunde Tomer
23305e4460 add a counter for profile version format 2026-05-19 09:15:20 -07:00
Jonathan Klabunde Tomer
06d96a04df store account profile current version as byte array, not hex 2026-05-19 09:15:20 -07:00
Jon Chambers
022f2b874d Bypass circuit breakers, timeouts when clearing Redis instances before test runs 2026-05-19 11:32:09 -04:00
Ravi Khadiwala
b09a9b8f39 Add netty dependencies from omnibus 2026-05-18 11:55:59 -05:00
Chris Eager
4e72422cfd Add netty dependencies to POM 2026-05-18 11:53:21 -05:00
Chris Eager
d41c73917f Update integration test with post-registration change number waiting period 2026-05-18 11:49:56 -05:00
Chris Eager
dd29ee1f27 Refactor Change Number waiting period to dedicated manager
This simplifies integration testing.
2026-05-18 11:49:56 -05:00
Chris Eager
9dc6e049f4 Remove obsolete remote config response 2026-05-18 11:31:20 -05:00
Jon Chambers
7fe3d4a30f Retire oversized message warning metrics 2026-05-18 11:29:54 -05:00
Jon Chambers
88a308fac0 Reduce max message size to 96 KiB 2026-05-18 11:29:54 -05:00
Ravi Khadiwala
34f5351a4c Update to the latest version of the spam filter 2026-05-18 11:29:15 -05:00
Ravi Khadiwala
59f704b6cc Add omnibus H2 server and update to dropwizard 5.0.1 2026-05-18 11:27:17 -05:00
Ravi Khadiwala
0f7d5d7fa4 normalize header combination, special-case user-agent header 2026-05-18 11:25:48 -05:00
Ravi Khadiwala
1ae361b15f Update to the latest version of the spam filter 2026-05-15 12:14:42 -05:00
Ravi Khadiwala
2c9676a1ef Revert omnibus H2 server and dropwizard 5.0.1 2026-05-15 12:13:54 -05:00
Ravi Khadiwala
2ae5c92df2 Update to the latest version of the spam filter 2026-05-15 10:15:23 -05:00
Ravi Khadiwala
3c02f8c3e1 Add documentation to BackupInfo responses 2026-05-15 09:49:40 -05:00
Ravi Khadiwala
4c4282162f Update to dropwizard 5.0.1 2026-05-15 09:44:38 -05:00
Ravi Khadiwala
0beeb8a935 Add h2 omnibus server 2026-05-15 09:44:38 -05:00
Ravi Khadiwala
8d0ad7b085 h2 websocket support 2026-05-15 09:44:38 -05:00
Chris Eager
12e265fb71 Refine Profiles gRPC errors and documentation 2026-05-14 10:55:44 -05:00
Chris Eager
90c27f6969 Add post-registration change number waiting period 2026-05-14 10:55:02 -05:00
Jon Chambers
f045e3ee0f
Introduce an emergency "read only" mode for messages 2026-05-11 11:43:41 -04:00
Ravi Khadiwala
4485e26562 rename maxUploadSizeInBytes to maxAttachmentUploadSizeInBytes 2026-05-08 13:39:36 -05:00
Ravi Khadiwala
05b7a140fc Add a configuration field for max message backup upload size 2026-05-08 13:18:17 -05:00
Ameya Lokare
bde52d775e Update to the latest version of the spam filter 2026-05-07 12:36:51 -07:00
Ravi Khadiwala
59f2f04b7c Handle BackupFailedZkAuthenticationException in grpc SetPublicKey 2026-05-07 11:43:24 -05:00
386 changed files with 18474 additions and 5852 deletions

View File

@ -11,23 +11,35 @@ jobs:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
distribution: 'temurin'
java-version-file: .java-version
cache: 'maven'
- name: Compile and Build OpenAPI file
- name: Install gRPC documentation tooling
run: |
sudo apt-get update
sudo apt-get install -y protobuf-compiler
pip install sabledocs
- name: Generate OpenAPI documentation
run: ./mvnw compile
- name: Update Documentation
- name: Generate gRPC documentation
run: |
protoc -I service/src/main/proto service/src/main/proto/org/signal/chat/*.proto \
-o api-doc/grpc/descriptor.pb --include_source_info
cd api-doc/grpc && sabledocs
- name: Push documentation to gh-pages
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
cp -r api-doc/target/openapi/signal-server-openapi.yaml /tmp/
git config user.email "github@signal.org"
git config user.name "Documentation Updater"
git fetch origin gh-pages
git checkout gh-pages
cp /tmp/signal-server-openapi.yaml .
git diff --quiet || git commit -a -m "Updating documentation"
cp api-doc/target/openapi/signal-server-openapi.yaml .
rm -rf grpc
cp -r api-doc/grpc/sabledocs_output grpc
git add -A signal-server-openapi.yaml grpc
git diff --cached --quiet || git commit -m "Updating documentation"
git push origin gh-pages -q

View File

@ -18,13 +18,13 @@ jobs:
id-token: write
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
distribution: 'temurin'
java-version-file: .java-version
cache: 'maven'
- uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0
- uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0
name: Configure AWS credentials from Test account
with:
role-to-assume: ${{ vars.AWS_ROLE }}

View File

@ -14,18 +14,18 @@ jobs:
services:
foundationdb0:
# 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
# Note: this should generally match the version of the FoundationDB SERVER deployed in production; no need to
# bump it purely to match the CLIENT version
image: foundationdb/foundationdb:7.3.68
options: --name foundationdb0
foundationdb1:
# 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
# Note: this should generally match the version of the FoundationDB SERVER deployed in production; no need to
# bump it purely to match the CLIENT version
image: foundationdb/foundationdb:7.3.68
options: --name foundationdb1
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up JDK
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
@ -61,7 +61,7 @@ jobs:
- 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
cp service/target/jib-extra/usr/lib/libfdb_c.so /usr/lib/libfdb_c.x86_64.so
ldconfig
- name: Build with Maven
run: ./mvnw -e -B clean verify -DfoundationDb.serviceContainerNamePrefix=foundationdb

4
.gitignore vendored
View File

@ -29,3 +29,7 @@ deployer.log
.classpath
.settings
.DS_Store
# Generated gRPC documentation build artifacts
/api-doc/grpc/descriptor.pb
/api-doc/grpc/sabledocs_output/

View File

@ -0,0 +1,2 @@
main-page-content-file = "../../service/src/main/proto/org/signal/chat/README.md"
markdown-extensions = ["fenced_code", "tables"]

View File

@ -10,6 +10,10 @@
<modelVersion>4.0.0</modelVersion>
<artifactId>integration-tests</artifactId>
<properties>
<jetty.http2-client.version>12.1.5</jetty.http2-client.version>
</properties>
<dependencies>
<dependency>
<groupId>org.whispersystems.textsecure</groupId>
@ -21,6 +25,22 @@
<groupId>software.amazon.awssdk</groupId>
<artifactId>dynamodb</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.websocket</groupId>
<artifactId>jetty-websocket-jetty-api</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.websocket</groupId>
<artifactId>jetty-websocket-jetty-client</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.http2</groupId>
<artifactId>jetty-http2-client-transport</artifactId>
<version>${jetty.http2-client.version}</version>
</dependency>
</dependencies>
<build>

View File

@ -9,19 +9,22 @@ import java.time.Clock;
import java.time.Duration;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.signal.integration.config.Config;
import org.whispersystems.textsecuregcm.metrics.NoopAwsSdkMetricPublisher;
import org.whispersystems.textsecuregcm.registration.VerificationSession;
import org.whispersystems.textsecuregcm.storage.ChangeNumberWaitingPeriods;
import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswords;
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;
import org.whispersystems.textsecuregcm.storage.VerificationSessionManager;
import org.whispersystems.textsecuregcm.storage.VerificationSessions;
import org.whispersystems.textsecuregcm.util.Util;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
public class IntegrationTools {
@ -31,6 +34,7 @@ public class IntegrationTools {
private final PhoneNumberIdentifiers phoneNumberIdentifiers;
private final ChangeNumberWaitingPeriods changeNumberWaitingPeriods;
public static IntegrationTools create(final Config config) {
final AwsCredentialsProvider credentialsProvider = DefaultCredentialsProvider.builder().build();
@ -38,37 +42,49 @@ public class IntegrationTools {
final DynamoDbAsyncClient dynamoDbAsyncClient =
config.dynamoDbClient().buildAsyncClient(credentialsProvider, new NoopAwsSdkMetricPublisher());
final DynamoDbClient dynamoDbClient =
config.dynamoDbClient().buildSyncClient(credentialsProvider, new NoopAwsSdkMetricPublisher());
final RegistrationRecoveryPasswords registrationRecoveryPasswords = new RegistrationRecoveryPasswords(
config.dynamoDbTables().registrationRecovery(), Duration.ofDays(1), dynamoDbAsyncClient, Clock.systemUTC());
config.dynamoDbTables().registrationRecovery(), Duration.ofDays(1), dynamoDbClient, Clock.systemUTC());
final VerificationSessions verificationSessions = new VerificationSessions(
dynamoDbAsyncClient, config.dynamoDbTables().verificationSessions(), Clock.systemUTC());
dynamoDbClient, config.dynamoDbTables().verificationSessions(), Clock.systemUTC());
return new IntegrationTools(
new RegistrationRecoveryPasswordsManager(registrationRecoveryPasswords),
new VerificationSessionManager(verificationSessions),
new PhoneNumberIdentifiers(dynamoDbAsyncClient, config.dynamoDbTables().phoneNumberIdentifiers())
new PhoneNumberIdentifiers(dynamoDbAsyncClient, config.dynamoDbTables().phoneNumberIdentifiers()),
new ChangeNumberWaitingPeriods(config.dynamoDbTables().changeNumberWaitingPeriods(), dynamoDbClient)
);
}
private IntegrationTools(
final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager,
final VerificationSessionManager verificationSessionManager,
final PhoneNumberIdentifiers phoneNumberIdentifiers) {
final PhoneNumberIdentifiers phoneNumberIdentifiers,
final ChangeNumberWaitingPeriods changeNumberWaitingPeriods) {
this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager;
this.verificationSessionManager = verificationSessionManager;
this.phoneNumberIdentifiers = phoneNumberIdentifiers;
this.changeNumberWaitingPeriods = changeNumberWaitingPeriods;
}
public CompletableFuture<Void> populateRecoveryPassword(final String phoneNumber, final byte[] password) {
return phoneNumberIdentifiers
.getPhoneNumberIdentifier(phoneNumber)
.thenCompose(pni -> registrationRecoveryPasswordsManager.store(pni, password))
.thenRun(Util.NOOP);
public void populateRecoveryPassword(final String phoneNumber, final byte[] password) {
try {
final UUID pni = phoneNumberIdentifiers
.getPhoneNumberIdentifier(phoneNumber).get(5, TimeUnit.SECONDS);
registrationRecoveryPasswordsManager.store(pni, password);
} catch (ExecutionException | InterruptedException | TimeoutException e) {
throw new RuntimeException("failed to get pni", e);
}
}
public CompletableFuture<Optional<String>> peekVerificationSessionPushChallenge(final String sessionId) {
return verificationSessionManager.findForId(sessionId)
.thenApply(maybeSession -> maybeSession.map(VerificationSession::pushChallenge));
public Optional<String> peekVerificationSessionPushChallenge(final String sessionId) {
return verificationSessionManager.findForId(sessionId).map(VerificationSession::pushChallenge);
}
public void clearChangeNumberWaitingPeriod(TestUser user) {
changeNumberWaitingPeriods.delete(user.aciUuid());
}
}

View File

@ -20,18 +20,28 @@ import java.net.URL;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.KeyStore;
import java.security.SecureRandom;
import java.security.cert.CertificateException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Validate;
import org.apache.commons.lang3.tuple.Pair;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.http2.client.HTTP2Client;
import org.eclipse.jetty.http2.client.transport.HttpClientTransportOverHTTP2;
import org.eclipse.jetty.io.ClientConnector;
import org.eclipse.jetty.util.ssl.SslContextFactory;
import org.eclipse.jetty.websocket.client.ClientUpgradeRequest;
import org.eclipse.jetty.websocket.client.WebSocketClient;
import org.signal.integration.config.Config;
import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.protocol.ecc.ECKeyPair;
@ -49,6 +59,7 @@ import org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;
import org.whispersystems.textsecuregcm.entities.RegistrationRequest;
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.util.CertificateUtil;
import org.whispersystems.textsecuregcm.util.HeaderUtils;
import org.whispersystems.textsecuregcm.util.HttpUtils;
import org.whispersystems.textsecuregcm.util.SystemMapper;
@ -65,6 +76,8 @@ public final class Operations {
private static final FaultTolerantHttpClient CLIENT = buildClient();
private static final WebSocketClient WEB_SOCKET_CLIENT = buildWebSocketClient();
private Operations() {
// utility class
@ -117,17 +130,21 @@ public final class Operations {
}
public static String peekVerificationSessionPushChallenge(final String sessionId) {
return INTEGRATION_TOOLS.peekVerificationSessionPushChallenge(sessionId).join()
return INTEGRATION_TOOLS.peekVerificationSessionPushChallenge(sessionId)
.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();
INTEGRATION_TOOLS.populateRecoveryPassword(number, recoveryPassword);
return recoveryPassword;
}
public static void clearChangeNumberWaitingPeriod(final TestUser user) {
INTEGRATION_TOOLS.clearChangeNumberWaitingPeriod(user);
}
public static <T> T sendEmptyRequestAuthenticated(
final String endpoint,
final String method,
@ -214,14 +231,10 @@ public final class Operations {
}
private static <R> RequestBuilder withJsonBody(final String endpoint, final String method, final R input) {
try {
final byte[] body = SystemMapper.jsonMapper().writeValueAsBytes(input);
return new RequestBuilder(HttpRequest.newBuilder()
.header(HttpHeaders.CONTENT_TYPE, "application/json")
.method(method, HttpRequest.BodyPublishers.ofByteArray(body)), endpoint);
} catch (final JsonProcessingException e) {
throw new RuntimeException(e);
}
final byte[] body = encodeJsonBody(input);
return new RequestBuilder(HttpRequest.newBuilder()
.header(HttpHeaders.CONTENT_TYPE, "application/json")
.method(method, HttpRequest.BodyPublishers.ofByteArray(body)), endpoint);
}
public RequestBuilder authorized(final TestUser user) {
@ -301,6 +314,7 @@ public final class Operations {
})
.join();
}
}
private static FaultTolerantHttpClient buildClient() {
@ -313,6 +327,53 @@ public final class Operations {
}
}
private static WebSocketClient buildWebSocketClient() {
try {
final KeyStore trustStore = CertificateUtil.buildKeyStoreForPem(CONFIG.rootCert());
final SslContextFactory.Client sslContextFactory = new SslContextFactory.Client();
sslContextFactory.setTrustStore(trustStore);
final ClientConnector connector = new ClientConnector();
connector.setSslContextFactory(sslContextFactory);
final HTTP2Client http2Client = new HTTP2Client(connector);
final HttpClient httpClient = new HttpClient(new HttpClientTransportOverHTTP2(http2Client));
final WebSocketClient wsClient = new WebSocketClient(httpClient);
wsClient.start();
return wsClient;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public static WebsocketClientSession authenticatedWebsocket(final TestUser user, final byte deviceId) throws IOException {
final String username = "%s.%d".formatted(user.aciUuid().toString(), deviceId);
return connect("/v1/websocket/", Map.of(HttpHeaders.AUTHORIZATION, HeaderUtils.basicAuthHeader(username, user.accountPassword())));
}
public static WebsocketClientSession anonymousWebsocket() throws IOException {
return connect("/v1/websocket/", Collections.emptyMap());
}
private static WebsocketClientSession connect(
final String path,
final Map<String, String> headers) throws IOException {
final URI uri = URI.create("wss://grpc." + CONFIG.domain() + path);
final ClientUpgradeRequest request = new ClientUpgradeRequest(uri);
headers.forEach(request::setHeader);
final WebsocketClientSession listener = new WebsocketClientSession();
try {
WEB_SOCKET_CLIENT.connect(listener, request).get(5, TimeUnit.SECONDS);
} catch (Exception e) {
throw new IOException(e);
}
logger.info("Successfully connected to websocket on {}", uri);
return listener;
}
private static Config loadConfigFromClasspath(final String filename) {
try {
final URL configFileUrl = Resources.getResource(filename);
@ -341,4 +402,12 @@ public final class Operations {
final byte[] signature = identityKeyPair.getPrivateKey().calculateSignature(pubKey.serialize());
return new KEMSignedPreKey(id, pubKey, signature);
}
public static <R> byte[] encodeJsonBody(final R input) {
try {
return SystemMapper.jsonMapper().writeValueAsBytes(input);
} catch (final JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}

View File

@ -0,0 +1,150 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.integration;
import com.google.protobuf.InvalidProtocolBufferException;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.ByteBuffer;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicLong;
import org.eclipse.jetty.websocket.api.Callback;
import org.eclipse.jetty.websocket.api.Session;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.entities.MessageProtos;
import org.whispersystems.textsecuregcm.util.SystemMapper;
import org.whispersystems.websocket.messages.WebSocketMessage;
import org.whispersystems.websocket.messages.WebSocketMessageFactory;
import org.whispersystems.websocket.messages.WebSocketRequestMessage;
import org.whispersystems.websocket.messages.WebSocketResponseMessage;
import org.whispersystems.websocket.messages.protobuf.ProtobufWebSocketMessageFactory;
public class WebsocketClientSession implements Session.Listener.AutoDemanding {
private static final Logger log = LoggerFactory.getLogger(WebsocketClientSession.class);
private final WebSocketMessageFactory messageFactory = new ProtobufWebSocketMessageFactory();
private final AtomicLong requestId = new AtomicLong();
private final ConcurrentHashMap<Long, CompletableFuture<WebSocketResponseMessage>> responseFutures = new ConcurrentHashMap<>();
private final List<MessageProtos.Envelope> receivedEnvelopes = new CopyOnWriteArrayList<>();
private final CompletableFuture<Session> opened = new CompletableFuture<>();
private final CompletableFuture<Void> queueEmpty = new CompletableFuture<>();
private final CompletableFuture<Integer> closed = new CompletableFuture<>();
@Override
public void onWebSocketOpen(final Session session) {
opened.complete(session);
}
@Override
public void onWebSocketBinary(final ByteBuffer payload, final Callback callback) {
try {
final WebSocketMessage message = messageFactory.parseMessage(payload);
switch (message.getType()) {
case REQUEST_MESSAGE -> {
log.info("received request message {} {}", message.getRequestMessage().getVerb(), message.getRequestMessage().getPath());
switch (message.getRequestMessage().getPath()) {
case "/api/v1/message" -> acknowledge(message.getRequestMessage());
case "/api/v1/queue/empty" -> queueEmpty.complete(null);
default -> throw new IllegalStateException("Unexpected path: " + message.getRequestMessage().getPath());
}
}
case RESPONSE_MESSAGE -> {
final WebSocketResponseMessage response = message.getResponseMessage();
log.info("received response message {}", response.getStatus());
final CompletableFuture<WebSocketResponseMessage> future = responseFutures.remove(response.getRequestId());
if (future == null) {
throw new IllegalArgumentException("Received response with no matching request: " + response.getRequestId());
}
future.complete(response);
}
default -> throw new IllegalStateException("Unexpected message type: " + message.getType());
}
callback.succeed();
} catch (final Exception e) {
log.warn("Failed to process message received over the websocket", e);
callback.fail(e);
opened.join().close(1006, e.getMessage(), Callback.NOOP);
}
}
@Override
public void onWebSocketClose(final int statusCode, final String reason, final Callback callback) {
log.info("Received websocket close: {}", statusCode);
closed.complete(statusCode);
final IOException exception = new IOException("WebSocket closed: " + statusCode + " " + reason);
responseFutures.values()
.forEach(f -> f.completeExceptionally(exception));
responseFutures.clear();
if (!queueEmpty.isDone()) {
queueEmpty.completeExceptionally(exception);
}
callback.succeed();
}
public <T> WebSocketResponseMessage sendRequest(
final String verb,
final String path,
final List<String> headers,
final T body) {
final Session session = opened.join();
final long id = requestId.incrementAndGet();
final CompletableFuture<WebSocketResponseMessage> future = new CompletableFuture<>();
responseFutures.put(id, future);
final Optional<byte[]> maybeBody = Optional.ofNullable(body).map(Operations::encodeJsonBody);
final byte[] bytes = messageFactory.createRequest(Optional.of(id), verb, path, headers, maybeBody).toByteArray();
session.sendBinary(ByteBuffer.wrap(bytes), Callback.from(() -> {}, throwable -> {
if (responseFutures.remove(id) != null) {
future.completeExceptionally(throwable);
}
}));
return future.join();
}
public List<MessageProtos.Envelope> getReceivedEnvelopes() {
return receivedEnvelopes;
}
public void waitForQueueEmpty() {
queueEmpty.join();
}
public void close(final int closeCode) {
final Session session = opened.join();
session.close(closeCode, "client close", Callback.NOOP);
closed.join();
}
private void acknowledge(WebSocketRequestMessage message) {
final byte[] envelopeBytes = message.getBody()
.orElseThrow(() -> new IllegalStateException("Messages should have a response body"));
try {
final MessageProtos.Envelope envelope = MessageProtos.Envelope.parseFrom(envelopeBytes);
receivedEnvelopes.add(envelope);
final Session session = opened.join();
final WebSocketMessage response = messageFactory.createResponse(message.getRequestId(), 200, "",
Collections.emptyList(), Optional.empty());
session.sendBinary(ByteBuffer.wrap(response.toByteArray()), Callback.NOOP);
} catch (InvalidProtocolBufferException e) {
throw new IllegalStateException(e);
}
}
public static <R> R decode(Class<R> expectedType, WebSocketResponseMessage message) {
try {
return SystemMapper.jsonMapper()
.readValue(message.getBody().orElseThrow(() -> new IllegalStateException("No response body")), expectedType);
} catch (final IOException e) {
throw new UncheckedIOException(e);
}
}
}

View File

@ -9,5 +9,6 @@ import jakarta.validation.constraints.NotBlank;
public record DynamoDbTables(@NotBlank String registrationRecovery,
@NotBlank String verificationSessions,
@NotBlank String phoneNumberIdentifiers) {
@NotBlank String phoneNumberIdentifiers,
@NotBlank String changeNumberWaitingPeriods) {
}

View File

@ -62,14 +62,20 @@ public class AccountTest {
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);
try {
Operations.clearChangeNumberWaitingPeriod(user);
assertEquals(user.aciUuid(), accountIdentityResponse.uuid());
assertNotEquals(user.pniUuid(), accountIdentityResponse.pni());
assertEquals(targetNumber, accountIdentityResponse.number());
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());
} finally {
Operations.deleteUser(user);
}
}
@Test

View File

@ -6,43 +6,72 @@
package org.signal.integration;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
import com.google.common.net.HttpHeaders;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import org.apache.commons.lang3.tuple.Pair;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import jakarta.ws.rs.core.MediaType;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.entities.IncomingMessage;
import org.whispersystems.textsecuregcm.entities.IncomingMessageList;
import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntityList;
import org.whispersystems.textsecuregcm.entities.MessageProtos;
import org.whispersystems.textsecuregcm.entities.SendMessageResponse;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.websocket.messages.WebSocketResponseMessage;
@Timeout(value = 1, unit = TimeUnit.MINUTES, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
public class MessagingTest {
TestUser userA;
TestUser userB;
@BeforeEach
public void setup() {
userA = Operations.newRegisteredUser("+19995550102");
userB = Operations.newRegisteredUser("+19995550103");
}
@AfterEach
public void teardown() {
Operations.deleteUser(userA);
Operations.deleteUser(userB);
}
@Test
public void testSendMessageUnsealed() {
final TestUser userA = Operations.newRegisteredUser("+19995550102");
final TestUser userB = Operations.newRegisteredUser("+19995550103");
try {
public void testSendMessageUnsealed() throws IOException {
final byte[] expectedContent = "Hello, World!".getBytes(StandardCharsets.UTF_8);
final IncomingMessage message = new IncomingMessage(1, Device.PRIMARY_ID, userB.registrationId(), expectedContent);
final IncomingMessageList messages = new IncomingMessageList(List.of(message), false, true, System.currentTimeMillis());
Operations
.apiPut("/v1/messages/%s".formatted(userB.aciUuid().toString()), messages)
.authorized(userA)
.execute(SendMessageResponse.class);
final WebsocketClientSession websocketA = Operations.authenticatedWebsocket(userA, Device.PRIMARY_ID);
final WebSocketResponseMessage responseMessage = websocketA.sendRequest(
"PUT",
"/v1/messages/%s".formatted(userB.aciUuid().toString()),
List.of(HttpHeaders.CONTENT_TYPE + ":" + MediaType.APPLICATION_JSON),
messages);
assertEquals(200, responseMessage.getStatus());
assertDoesNotThrow(() -> WebsocketClientSession.decode(SendMessageResponse.class, responseMessage));
final Pair<Integer, OutgoingMessageEntityList> receiveMessages = Operations.apiGet("/v1/messages")
.authorized(userB)
.execute(OutgoingMessageEntityList.class);
final WebsocketClientSession websocketB = Operations.authenticatedWebsocket(userB, Device.PRIMARY_ID);
assertTimeoutPreemptively(Duration.ofSeconds(5), websocketB::waitForQueueEmpty);
final byte[] actualContent = receiveMessages.getRight().messages().getFirst().content();
assertArrayEquals(expectedContent, actualContent);
} finally {
Operations.deleteUser(userA);
Operations.deleteUser(userB);
}
assertEquals(1, websocketB.getReceivedEnvelopes().size());
final MessageProtos.Envelope envelope = websocketB.getReceivedEnvelopes().getFirst();
assertArrayEquals(expectedContent, envelope.getContent().toByteArray());
websocketB.close(1000);
websocketA.close(1000);
}
}

27
pom.xml
View File

@ -49,14 +49,13 @@
<braintree.version>3.48.0</braintree.version>
<commons-csv.version>1.14.1</commons-csv.version>
<commons-io.version>2.21.0</commons-io.version>
<dropwizard.version>4.0.16</dropwizard.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>
<dropwizard.version>5.0.1</dropwizard.version>
<!-- Note: 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 FoundationDBused by GitHub
Actions. -->
<foundationdb.version>7.3.68</foundationdb.version>
<foundationdb.api-version>730</foundationdb.api-version>
<foundationdb.client-library-sha256>bfed237b787fae3cde1222676e6bfbb0d218fc27bf9e903397a7a7aa96fb2d33</foundationdb.client-library-sha256>
<foundationdb.client-library-sha256>8c96a1f7ab561cd38e16e4c269c5e50ae0fd8063854e0d72c89339a7ac1b6873</foundationdb.client-library-sha256>
<google-cloud-libraries.version>26.79.0</google-cloud-libraries.version>
<grpc.version>1.76.3</grpc.version> <!-- should be kept in sync with the value from Google libraries-bom -->
<gson.version>2.13.2</gson.version>
@ -70,13 +69,13 @@
<kotlin.version>2.3.20</kotlin.version>
<logback.version>1.5.32</logback.version>
<logback-access-common.version>2.0.12</logback-access-common.version>
<lettuce.version>6.8.2.RELEASE</lettuce.version>
<lettuce.version>7.5.1.RELEASE</lettuce.version>
<libphonenumber.version>9.0.21</libphonenumber.version>
<logstash.logback.version>8.1</logstash.logback.version>
<log4j-bom.version>2.25.4</log4j-bom.version>
<luajava.version>3.5.0</luajava.version>
<micrometer.version>1.16.4</micrometer.version>
<netty.version>4.1.127.Final</netty.version>
<netty.version>4.2.13.Final</netty.version>
<!-- Must be less than or equal to the value from Google libraries-bom which controls the protobuf runtime version.
See https://protobuf.dev/support/cross-version-runtime-guarantee/. -->
<protoc.version>4.33.2</protoc.version>
@ -253,12 +252,6 @@
<artifactId>commons-logging</artifactId>
<version>1.3.6</version>
</dependency>
<dependency>
<groupId>org.ow2.asm</groupId>
<artifactId>asm</artifactId>
<version>9.9.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.stripe</groupId>
<artifactId>stripe-java</artifactId>
@ -293,7 +286,7 @@
<dependency>
<groupId>org.signal</groupId>
<artifactId>libsignal-server</artifactId>
<version>0.86.6</version>
<version>0.96.2</version>
</dependency>
<dependency>
<groupId>org.signal</groupId>
@ -361,7 +354,7 @@
</dependency>
<dependency>
<groupId>org.wiremock</groupId>
<artifactId>wiremock</artifactId>
<artifactId>wiremock-jetty12</artifactId>
<version>3.13.1</version>
<scope>test</scope>
</dependency>

View File

@ -100,3 +100,5 @@ tlsKeyStore.password: unset
hlrLookup.apiKey: AAAAAAAAAAA
hlrLookup.apiSecret: AAAAAAAAAAA
foundationDbMessages.versionstampCipherKey.0: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=

View File

@ -92,12 +92,17 @@ dynamoDbTables:
tableName: Example_AppleDeviceCheckPublicKeys
backups:
tableName: Example_Backups
changeNumberWaitingPeriods:
tableName: Example_ChangeNumberWaitingPeriods
clientReleases:
tableName: Example_ClientReleases
deletedAccounts:
tableName: Example_DeletedAccounts
deletedAccountsLock:
tableName: Example_DeletedAccountsLock
donationPermits:
tableName: Example_DonationPermits
expiration: P7D # Duration of time until rows expire
issuedReceipts:
tableName: Example_IssuedReceipts
expiration: P30D # Duration of time until rows expire
@ -146,8 +151,6 @@ dynamoDbTables:
expiration: P7D
subscriptions:
tableName: Example_Subscriptions
clientPublicKeys:
tableName: Example_ClientPublicKeys
verificationSessions:
tableName: Example_VerificationSessions
@ -234,7 +237,8 @@ messageCache: # Redis server configuration for message store cache
configurationUri: redis://redis.example.com:6379/
attachments:
maxUploadSizeInBytes: 1024
maxAttachmentUploadSizeInBytes: 1024
maxMessageBackupUploadSizeInBytes: 1024
gcpAttachments: # GCP Storage configuration
domain: example.com
@ -516,6 +520,7 @@ idlePrimaryDeviceReminder:
grpc:
port: 50051
websocketPort: 8080
asnTable:
s3Region: a-region
@ -536,3 +541,27 @@ callQualitySurvey:
hlrLookup:
apiKey: secret://hlrLookup.apiKey
apiSecret: secret://hlrLookup.apiSecret
foundationDbMessages:
maxWatchesPerClient: 10000
versionstampCipherKeys:
0: secret://foundationDbMessages.versionstampCipherKey.0
currentVersionstampCipherKey: 0
clusters:
"messages-0":
clusterFileUrl: http://clusterfiles.example.com/messages-0
"messages-1":
clusterFileUrl: http://clusterfiles.example.com/messages-1
"messages-2":
clusterFileUrl: http://clusterfiles.example.com/messages-2
"messages-3":
clusterFileUrl: http://clusterfiles.example.com/messages-3
epochs:
0:
- messages-0
- messages-1
1:
- messages-0
- messages-1
- messages-2
- messages-3

View File

@ -23,6 +23,7 @@
<opentelemetry-logback-appender-1.0.version>2.22.0-alpha</opentelemetry-logback-appender-1.0.version>
<storekit.version>4.0.0</storekit.version>
<webauthn4j.version>0.30.2.RELEASE</webauthn4j.version>
<jetty.http2-client.version>12.1.5</jetty.http2-client.version>
</properties>
<dependencies>
@ -137,6 +138,12 @@
<groupId>io.dropwizard</groupId>
<artifactId>dropwizard-jetty</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.http2</groupId>
<artifactId>jetty-http2-client-transport</artifactId>
<scope>test</scope>
<version>${jetty.http2-client.version}</version>
</dependency>
<dependency>
<groupId>io.dropwizard</groupId>
<artifactId>dropwizard-validation</artifactId>
@ -242,15 +249,15 @@
<dependency>
<groupId>org.eclipse.jetty.websocket</groupId>
<artifactId>websocket-jetty-api</artifactId>
<artifactId>jetty-websocket-jetty-api</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-servlets</artifactId>
<groupId>org.eclipse.jetty.ee10</groupId>
<artifactId>jetty-ee10-servlets</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.websocket</groupId>
<artifactId>websocket-jetty-client</artifactId>
<artifactId>jetty-websocket-jetty-client</artifactId>
<scope>test</scope>
</dependency>
@ -439,17 +446,59 @@
<artifactId>argparse4j</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-buffer</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-codec-haproxy</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-codec-http2</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-handler</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-common</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-resolver</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-resolver-dns</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-transport</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-transport-native-epoll</artifactId>
<classifier>linux-x86_64</classifier>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-testsuite-common</artifactId>
<scope>test</scope>
<version>${netty.version}</version>
</dependency>
<dependency>
<groupId>org.glassfish.jersey.test-framework</groupId>
<artifactId>jersey-test-framework-core</artifactId>
@ -568,7 +617,7 @@
<plugin>
<groupId>io.github.download-maven-plugin</groupId>
<artifactId>download-maven-plugin</artifactId>
<version>2.0.0</version>
<version>2.1.0</version>
<executions>
<execution>
@ -583,6 +632,7 @@
<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>
<outputFileName>libfdb_c.so</outputFileName>
<sha256>${foundationdb.client-library-sha256}</sha256>
</configuration>
</plugin>

View File

@ -22,6 +22,7 @@ import org.whispersystems.textsecuregcm.configuration.BraintreeConfiguration;
import org.whispersystems.textsecuregcm.configuration.CallQualitySurveyConfiguration;
import org.whispersystems.textsecuregcm.configuration.Cdn3StorageManagerConfiguration;
import org.whispersystems.textsecuregcm.configuration.CdnConfiguration;
import org.whispersystems.textsecuregcm.configuration.ChangeNumberConfiguration;
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
import org.whispersystems.textsecuregcm.configuration.ClientReleaseConfiguration;
import org.whispersystems.textsecuregcm.configuration.DefaultAwsCredentialsFactory;
@ -33,6 +34,7 @@ import org.whispersystems.textsecuregcm.configuration.ExternalRequestFilterConfi
import org.whispersystems.textsecuregcm.configuration.FaultTolerantRedisClientFactory;
import org.whispersystems.textsecuregcm.configuration.FaultTolerantRedisClusterFactory;
import org.whispersystems.textsecuregcm.configuration.FcmConfiguration;
import org.whispersystems.textsecuregcm.configuration.FoundationDbMessagesConfiguration;
import org.whispersystems.textsecuregcm.configuration.GcpAttachmentsConfiguration;
import org.whispersystems.textsecuregcm.configuration.GenericZkConfig;
import org.whispersystems.textsecuregcm.configuration.GooglePlayBillingConfiguration;
@ -355,6 +357,16 @@ public class WhisperServerConfiguration extends Configuration {
@JsonProperty
private CallQualitySurveyConfiguration callQualitySurvey;
@Valid
@NotNull
@JsonProperty
private ChangeNumberConfiguration changeNumber = new ChangeNumberConfiguration(Duration.ofHours(1));
@Valid
@NotNull
@JsonProperty
private FoundationDbMessagesConfiguration foundationDbMessages;
public TlsKeyStoreConfiguration getTlsKeyStoreConfiguration() {
return tlsKeyStore;
}
@ -591,4 +603,11 @@ public class WhisperServerConfiguration extends Configuration {
return hlrLookup;
}
public ChangeNumberConfiguration getChangeNumber() {
return changeNumber;
}
public FoundationDbMessagesConfiguration getFoundationDbMessagesConfiguration() {
return foundationDbMessages;
}
}

View File

@ -7,6 +7,8 @@ package org.whispersystems.textsecuregcm;
import static java.util.Objects.requireNonNull;
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
import com.apple.foundationdb.Database;
import com.apple.foundationdb.FDB;
import com.google.common.collect.Lists;
import com.webauthn4j.appattest.DeviceCheckManager;
import io.dropwizard.auth.AuthDynamicFeature;
@ -31,24 +33,31 @@ import io.lettuce.core.metrics.MicrometerOptions;
import io.lettuce.core.resource.ClientResources;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.binder.jvm.ExecutorServiceMetrics;
import io.netty.channel.DefaultEventLoopGroup;
import io.netty.channel.local.LocalAddress;
import io.netty.channel.local.LocalServerChannel;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioDatagramChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.ssl.SslContext;
import io.netty.resolver.ResolvedAddressTypes;
import io.netty.resolver.dns.DnsNameResolver;
import io.netty.resolver.dns.DnsNameResolverBuilder;
import io.netty.util.Mapping;
import jakarta.servlet.DispatcherType;
import jakarta.servlet.Filter;
import jakarta.servlet.ServletRegistration;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.net.http.HttpClient;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.ServiceLoader;
import java.util.Set;
@ -59,10 +68,12 @@ import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.annotation.Nullable;
import org.eclipse.jetty.ee10.websocket.server.config.JettyWebSocketServletContainerInitializer;
import org.eclipse.jetty.websocket.core.WebSocketExtensionRegistry;
import org.eclipse.jetty.websocket.core.server.WebSocketServerComponents;
import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer;
import org.glassfish.jersey.server.ServerProperties;
import org.signal.i18n.HeaderControlledResourceBundleLookup;
import org.signal.libsignal.zkgroup.GenericServerSecretParams;
@ -135,10 +146,12 @@ import org.whispersystems.textsecuregcm.currency.CurrencyConversionManager;
import org.whispersystems.textsecuregcm.currency.FixerClient;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.filters.ExternalRequestFilter;
import org.whispersystems.textsecuregcm.filters.PriorityFilter;
import org.whispersystems.textsecuregcm.filters.RemoteAddressFilter;
import org.whispersystems.textsecuregcm.filters.RemoteDeprecationFilter;
import org.whispersystems.textsecuregcm.filters.RequestStatisticsFilter;
import org.whispersystems.textsecuregcm.filters.RestDeprecationFilter;
import org.whispersystems.textsecuregcm.filters.StripContentLengthOnConnectFilter;
import org.whispersystems.textsecuregcm.filters.TimestampResponseFilter;
import org.whispersystems.textsecuregcm.grpc.AccountsAnonymousGrpcService;
import org.whispersystems.textsecuregcm.grpc.AccountsGrpcService;
@ -146,25 +159,35 @@ import org.whispersystems.textsecuregcm.grpc.AttachmentsGrpcService;
import org.whispersystems.textsecuregcm.grpc.BackupsAnonymousGrpcService;
import org.whispersystems.textsecuregcm.grpc.BackupsGrpcService;
import org.whispersystems.textsecuregcm.grpc.CallQualitySurveyGrpcService;
import org.whispersystems.textsecuregcm.grpc.CallingGrpcService;
import org.whispersystems.textsecuregcm.grpc.ChallengeGrpcService;
import org.whispersystems.textsecuregcm.grpc.CredentialsAnonymousGrpcService;
import org.whispersystems.textsecuregcm.grpc.CredentialsGrpcService;
import org.whispersystems.textsecuregcm.grpc.DevicesGrpcService;
import org.whispersystems.textsecuregcm.grpc.DonationsGrpcService;
import org.whispersystems.textsecuregcm.grpc.ErrorConformanceInterceptor;
import org.whispersystems.textsecuregcm.grpc.ErrorMappingInterceptor;
import org.whispersystems.textsecuregcm.grpc.ExternalServiceCredentialsAnonymousGrpcService;
import org.whispersystems.textsecuregcm.grpc.ExternalServiceCredentialsGrpcService;
import org.whispersystems.textsecuregcm.grpc.ExternalServiceDefinitions;
import org.whispersystems.textsecuregcm.grpc.GroupSendTokenUtil;
import org.whispersystems.textsecuregcm.grpc.GrpcAllowListInterceptor;
import org.whispersystems.textsecuregcm.grpc.KeysAnonymousGrpcService;
import org.whispersystems.textsecuregcm.grpc.KeysGrpcService;
import org.whispersystems.textsecuregcm.grpc.MessageDispatcher;
import org.whispersystems.textsecuregcm.grpc.MessagesAnonymousGrpcService;
import org.whispersystems.textsecuregcm.grpc.MessagesGrpcService;
import org.whispersystems.textsecuregcm.grpc.MetricServerInterceptor;
import org.whispersystems.textsecuregcm.grpc.OneTimeDonationsGrpcService;
import org.whispersystems.textsecuregcm.grpc.PaymentsGrpcService;
import org.whispersystems.textsecuregcm.grpc.ProfileAnonymousGrpcService;
import org.whispersystems.textsecuregcm.grpc.ProfileGrpcService;
import org.whispersystems.textsecuregcm.grpc.RequestAttributesInterceptor;
import org.whispersystems.textsecuregcm.grpc.SubscriptionsGrpcService;
import org.whispersystems.textsecuregcm.grpc.ValidatingInterceptor;
import org.whispersystems.textsecuregcm.grpc.net.ManagedEventLoopGroup;
import org.whispersystems.textsecuregcm.grpc.net.ManagedGrpcServer;
import org.whispersystems.textsecuregcm.grpc.net.ManagedNioEventLoopGroup;
import org.whispersystems.textsecuregcm.grpc.net.OmnibusH2Server;
import org.whispersystems.textsecuregcm.grpc.net.OmnibusRouter;
import org.whispersystems.textsecuregcm.grpc.net.SniMapper;
import org.whispersystems.textsecuregcm.jetty.JettyHttpConfigurationCustomizer;
import org.whispersystems.textsecuregcm.keytransparency.KeyTransparencyServiceClient;
import org.whispersystems.textsecuregcm.limits.CardinalityEstimator;
@ -193,7 +216,7 @@ import org.whispersystems.textsecuregcm.metrics.BackupMetrics;
import org.whispersystems.textsecuregcm.metrics.CallQualitySurveyManager;
import org.whispersystems.textsecuregcm.metrics.MessageMetrics;
import org.whispersystems.textsecuregcm.metrics.MetricsApplicationEventListener;
import org.whispersystems.textsecuregcm.metrics.MetricsHttpChannelListener;
import org.whispersystems.textsecuregcm.metrics.MetricsHttpEventHandler;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.metrics.MicrometerAwsSdkMetricPublisher;
import org.whispersystems.textsecuregcm.metrics.ReportedMessageMetricsListener;
@ -212,7 +235,6 @@ import org.whispersystems.textsecuregcm.redis.ConnectionEventLogger;
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClient;
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;
import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient;
import org.whispersystems.textsecuregcm.s3.PolicySigner;
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
import org.whispersystems.textsecuregcm.s3.S3MonitoringSupplier;
import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;
@ -226,9 +248,14 @@ import org.whispersystems.textsecuregcm.storage.AccountLockManager;
import org.whispersystems.textsecuregcm.storage.Accounts;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.ChangeNumberManager;
import org.whispersystems.textsecuregcm.storage.ChangeNumberWaitingPeriodManager;
import org.whispersystems.textsecuregcm.storage.ChangeNumberWaitingPeriods;
import org.whispersystems.textsecuregcm.storage.ClientReleaseManager;
import org.whispersystems.textsecuregcm.storage.ClientReleases;
import org.whispersystems.textsecuregcm.storage.DonationPermits;
import org.whispersystems.textsecuregcm.storage.DonationPermitsManager;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.storage.FoundationDbVersion;
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
import org.whispersystems.textsecuregcm.storage.KeysManager;
import org.whispersystems.textsecuregcm.storage.MessagesCache;
@ -259,6 +286,8 @@ import org.whispersystems.textsecuregcm.storage.VerificationSessions;
import org.whispersystems.textsecuregcm.storage.devicecheck.AppleDeviceCheckManager;
import org.whispersystems.textsecuregcm.storage.devicecheck.AppleDeviceCheckTrustAnchor;
import org.whispersystems.textsecuregcm.storage.devicecheck.AppleDeviceChecks;
import org.whispersystems.textsecuregcm.storage.foundationdb.FoundationDbMessageStore;
import org.whispersystems.textsecuregcm.storage.foundationdb.VersionstampUUIDCipher;
import org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreClient;
import org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreManager;
import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator;
@ -287,6 +316,7 @@ import org.whispersystems.textsecuregcm.workers.BackupUsageRecalculationCommand;
import org.whispersystems.textsecuregcm.workers.CertificateCommand;
import org.whispersystems.textsecuregcm.workers.CheckDynamicConfigurationCommand;
import org.whispersystems.textsecuregcm.workers.ClearIssuedReceiptRedemptionsCommand;
import org.whispersystems.textsecuregcm.workers.CopyToS3Command;
import org.whispersystems.textsecuregcm.workers.DeleteUserCommand;
import org.whispersystems.textsecuregcm.workers.IdleDeviceNotificationSchedulerFactory;
import org.whispersystems.textsecuregcm.workers.MessagePersisterServiceCommand;
@ -359,6 +389,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
bootstrap.addCommand(new UnlinkDevicesWithIdlePrimaryCommand(Clock.systemUTC()));
bootstrap.addCommand(new NotifyIdleDevicesCommand());
bootstrap.addCommand(new ClearIssuedReceiptRedemptionsCommand());
bootstrap.addCommand(new CopyToS3Command());
bootstrap.addCommand(new ProcessScheduledJobsServiceCommand("process-idle-device-notification-jobs",
"Processes scheduled jobs to send notifications to idle devices",
@ -381,7 +412,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
@Override
public void run(WhisperServerConfiguration config, Environment environment) throws Exception {
final Clock clock = Clock.systemUTC();
final int availableProcessors = Runtime.getRuntime().availableProcessors();
final AwsCredentialsProvider awsCredentialsProvider = config.getAwsCredentialsConfiguration().build();
@ -442,6 +472,39 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
final DynamoDbClient dynamoDbClient = config.getDynamoDbClientConfiguration()
.buildSyncClient(awsCredentialsProvider, new MicrometerAwsSdkMetricPublisher(awsSdkMetricsExecutor, "dynamoDbSync"));
final FDB fdb = FDB.selectAPIVersion(FoundationDbVersion.getFoundationDbApiVersion());
// Jetty and the FoundationDB client both register shutdown hooks to begin shutdown/cleanup operations. There isn't
// a good way to coordinate or enforce ordering between shutdown hooks, and so the two processes will race.
// Generally, FoundationDB will shut down before Jetty does, meaning we'll still be trying to serve requests that
// require talking to FoundationDB even though FoundationDB has shut down. To avoid that scenario, we disabled
// FoundationDB's shutdown hook and let the JVM terminate its (daemon) threads at exit. This isn't as graceful as
// we'd like, but is the least bad option given current constraints.
fdb.disableShutdownHook();
final Map<Integer, List<Database>> messageDatabasesByEpoch;
{
final Map<String, Database> databasesByName =
config.getFoundationDbMessagesConfiguration().clusters().entrySet().stream()
.collect(Collectors.toUnmodifiableMap(Map.Entry::getKey,
entry -> {
try {
final Database database = entry.getValue().build(fdb);
database.options().setMaxWatches(config.getFoundationDbMessagesConfiguration().maxWatchesPerClient());
return database;
} catch (final IOException e) {
throw new UncheckedIOException(e);
}
}));
messageDatabasesByEpoch = config.getFoundationDbMessagesConfiguration().epochs().entrySet().stream()
.collect(Collectors.toUnmodifiableMap(Map.Entry::getKey,
entry -> entry.getValue().stream()
.map(databasesByName::get)
.toList()));
}
final AwsCredentialsProvider cdnCredentialsProvider = config.getCdnConfiguration().credentials().build();
final S3AsyncClient asyncCdnS3Client = S3AsyncClient.builder()
.credentialsProvider(cdnCredentialsProvider)
@ -493,7 +556,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
MessagesDynamoDb messagesDynamoDb = new MessagesDynamoDb(dynamoDbClient, dynamoDbAsyncClient,
config.getDynamoDbTables().getMessages().getTableName(),
config.getDynamoDbTables().getMessages().getExpiration(),
messageDeletionAsyncExecutor, experimentEnrollmentManager);
messageDeletionAsyncExecutor);
RemoteConfigs remoteConfigs = new RemoteConfigs(dynamoDbClient,
config.getDynamoDbTables().getRemoteConfig().getTableName());
PushChallengeDynamoDb pushChallengeDynamoDb = new PushChallengeDynamoDb(dynamoDbClient,
@ -504,10 +567,10 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
RegistrationRecoveryPasswords registrationRecoveryPasswords = new RegistrationRecoveryPasswords(
config.getDynamoDbTables().getRegistrationRecovery().getTableName(),
config.getDynamoDbTables().getRegistrationRecovery().getExpiration(),
dynamoDbAsyncClient,
dynamoDbClient,
clock);
final VerificationSessions verificationSessions = new VerificationSessions(dynamoDbAsyncClient,
final VerificationSessions verificationSessions = new VerificationSessions(dynamoDbClient,
config.getDynamoDbTables().getVerificationSessions().getTableName(), clock);
final ClientResources sharedClientResources = ClientResources.builder()
@ -573,10 +636,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
.workQueue(receiptSenderQueue)
.rejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy())
.build();
ExecutorService registrationCallbackExecutor = ExecutorServiceBuilder.of(environment, "registration")
.maxThreads(2)
.minThreads(2)
.build();
ExecutorService accountLockExecutor = ExecutorServiceBuilder.of(environment, "accountLock")
.minThreads(8)
.maxThreads(8)
@ -617,10 +676,9 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
ScheduledExecutorService cloudflareTurnRetryExecutor = ScheduledExecutorServiceBuilder.of(environment, "cloudflareTurnRetry").threads(1).build();
ScheduledExecutorService messagePollExecutor = ScheduledExecutorServiceBuilder.of(environment, "messagePollExecutor").threads(1).build();
ScheduledExecutorService provisioningWebsocketTimeoutExecutor = ScheduledExecutorServiceBuilder.of(environment, "provisioningWebsocketTimeout").threads(1).build();
ScheduledExecutorService jmxDumper = ScheduledExecutorServiceBuilder.of(environment, "jmxDumper").threads(1).build();
final ManagedNioEventLoopGroup dnsResolutionEventLoopGroup = new ManagedNioEventLoopGroup();
final DnsNameResolver cloudflareDnsResolver = new DnsNameResolverBuilder(dnsResolutionEventLoopGroup.next())
final ManagedEventLoopGroup<NioEventLoopGroup> dnsResolutionEventLoopGroup = new ManagedEventLoopGroup<>(new NioEventLoopGroup());
final DnsNameResolver cloudflareDnsResolver = new DnsNameResolverBuilder(dnsResolutionEventLoopGroup.getEventLoopGroup().next())
.resolvedAddressTypes(ResolvedAddressTypes.IPV6_PREFERRED)
.completeOncePreferredResolved(false)
.channelType(NioDatagramChannel.class)
@ -659,7 +717,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
retryExecutor);
RegistrationServiceClient registrationServiceClient = config.getRegistrationServiceConfiguration()
.build(environment, registrationCallbackExecutor, registrationIdentityTokenRefreshExecutor);
.build(environment, registrationIdentityTokenRefreshExecutor);
KeyTransparencyServiceClient keyTransparencyServiceClient = new KeyTransparencyServiceClient(
config.getKeyTransparencyServiceConfiguration().host(),
config.getKeyTransparencyServiceConfiguration().port(),
@ -685,7 +743,12 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
ProfilesManager profilesManager = new ProfilesManager(profilesV1, profiles, cacheCluster, retryExecutor, asyncCdnS3Client,
config.getCdnConfiguration().bucket());
MessagesCache messagesCache = new MessagesCache(messagesCluster, messageDeliveryScheduler,
messageDeletionAsyncExecutor, retryExecutor, clock, experimentEnrollmentManager);
messageDeletionAsyncExecutor, retryExecutor, clock);
final FoundationDbMessageStore foundationDbMessageStore = new FoundationDbMessageStore(messageDatabasesByEpoch,
config.getFoundationDbMessagesConfiguration().activeEpoch(),
new VersionstampUUIDCipher(config.getFoundationDbMessagesConfiguration().currentVersionstampCipherKey(),
config.getFoundationDbMessagesConfiguration().versionstampCipherKeys().get(config.getFoundationDbMessagesConfiguration().currentVersionstampCipherKey()).value()),
Clock.systemUTC());
ClientReleaseManager clientReleaseManager = new ClientReleaseManager(clientReleases,
recurringJobExecutor,
config.getClientReleaseConfiguration().refreshInterval(),
@ -694,17 +757,22 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
config.getReportMessageConfiguration().getCounterTtl());
RedisMessageAvailabilityManager redisMessageAvailabilityManager =
new RedisMessageAvailabilityManager(messagesCluster, clientEventExecutor, asyncOperationQueueingExecutor);
MessagesManager messagesManager = new MessagesManager(messagesDynamoDb, messagesCache, redisMessageAvailabilityManager,
reportMessageManager, messageDeletionAsyncExecutor, Clock.systemUTC());
MessagesManager messagesManager =
new MessagesManager(messagesDynamoDb, messagesCache, foundationDbMessageStore, redisMessageAvailabilityManager,
reportMessageManager, messageDeletionAsyncExecutor, Clock.systemUTC(), experimentEnrollmentManager);
final ChangeNumberWaitingPeriods changeNumberWaitingPeriods = new ChangeNumberWaitingPeriods(
config.getDynamoDbTables().getChangeNumberWaitingPeriods().getTableName(), dynamoDbClient);
final ChangeNumberWaitingPeriodManager changeNumberWaitingPeriodManager = new ChangeNumberWaitingPeriodManager(
changeNumberWaitingPeriods, config.getChangeNumber().postRegistrationWaitingPeriod(), clock);
AccountLockManager accountLockManager = new AccountLockManager(dynamoDbClient,
config.getDynamoDbTables().getDeletedAccountsLock().getTableName());
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
pubsubClient, accountLockManager, keysManager, messagesManager, profilesManager,
secureStorageClient, secureValueRecovery2Client, disconnectionRequestManager,
changeNumberWaitingPeriodManager, secureStorageClient, secureValueRecovery2Client, disconnectionRequestManager,
registrationRecoveryPasswordsManager, accountLockExecutor, messagePollExecutor,
retryExecutor, clock, config.getLinkDeviceSecretConfiguration().secret().value());
RemoteConfigsManager remoteConfigsManager = new RemoteConfigsManager(remoteConfigs);
APNSender apnSender = new APNSender(apnSenderExecutor, config.getApnConfiguration());
APNSender apnSender = new APNSender(apnSenderExecutor, Clock.systemUTC(), config.getApnConfiguration());
FcmSender fcmSender = new FcmSender(fcmSenderExecutor, config.getFcmConfiguration().credentials().value());
PushNotificationScheduler pushNotificationScheduler = new PushNotificationScheduler(pushSchedulerCluster,
apnSender, fcmSender, accountsManager, 0, 0, retryExecutor);
@ -715,17 +783,19 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
IssuedReceiptsManager issuedReceiptsManager = new IssuedReceiptsManager(
config.getDynamoDbTables().getIssuedReceipts().getTableName(),
config.getDynamoDbTables().getIssuedReceipts().getExpiration(),
dynamoDbAsyncClient,
dynamoDbClient,
config.getDynamoDbTables().getIssuedReceipts().getGenerator(),
config.getDynamoDbTables().getIssuedReceipts().getmaxIssuedReceiptsPerPaymentId());
OneTimeDonationsManager oneTimeDonationsManager = new OneTimeDonationsManager(
config.getDynamoDbTables().getOnetimeDonations().getTableName(), config.getDynamoDbTables().getOnetimeDonations().getExpiration(), dynamoDbAsyncClient);
config.getDynamoDbTables().getOnetimeDonations().getTableName(), config.getDynamoDbTables().getOnetimeDonations().getExpiration(), dynamoDbClient);
DonationPermits donationPermits = new DonationPermits(
config.getDynamoDbTables().getDonationPermits().getTableName(), config.getDynamoDbTables().getDonationPermits().getExpiration(), dynamoDbClient);
RedeemedReceiptsManager redeemedReceiptsManager = new RedeemedReceiptsManager(clock,
config.getDynamoDbTables().getRedeemedReceipts().getTableName(),
dynamoDbAsyncClient,
dynamoDbClient,
config.getDynamoDbTables().getRedeemedReceipts().getExpiration());
Subscriptions subscriptions = new Subscriptions(
config.getDynamoDbTables().getSubscriptions().getTableName(), dynamoDbAsyncClient);
config.getDynamoDbTables().getSubscriptions().getTableName(), dynamoDbClient);
MessageDeliveryLoopMonitor messageDeliveryLoopMonitor =
config.logMessageDeliveryLoops() ? new RedisMessageDeliveryLoopMonitor(rateLimitersCluster) : new NoopMessageDeliveryLoopMonitor();
CallQualitySurveyManager callQualitySurveyManager = new CallQualitySurveyManager(asnInfoProviderSupplier,
@ -743,7 +813,7 @@ 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, dynamicConfigurationManager);
final ReceiptSender receiptSender = new ReceiptSender(accountsManager, messageSender, receiptSenderExecutor);
final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager = new CloudflareTurnCredentialsManager(
config.getTurnConfiguration().cloudflare().apiToken().value(),
@ -828,11 +898,15 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
config.getGcpAttachmentsConfiguration().pathPrefix(),
config.getGcpAttachmentsConfiguration().rsaSigningKey().value());
PostPolicyGenerator profileCdnPolicyGenerator = new PostPolicyGenerator(config.getCdnConfiguration().region(),
config.getCdnConfiguration().bucket(), config.getCdnConfiguration().credentials().accessKeyId().value());
PolicySigner profileCdnPolicySigner = new PolicySigner(
config.getCdnConfiguration().credentials().secretAccessKey().value(),
config.getCdnConfiguration().region());
final PostPolicyGenerator profileCdnPolicyGenerator = new PostPolicyGenerator(config.getCdnConfiguration().region(),
config.getCdnConfiguration().bucket(),
config.getCdnConfiguration().credentials().accessKeyId().value(),
config.getCdnConfiguration().credentials().secretAccessKey().value());
final PostPolicyGenerator stickerPolicyGenerator = new PostPolicyGenerator(config.getCdnConfiguration().region(),
config.getCdnConfiguration().bucket(),
config.getCdnConfiguration().credentials().accessKeyId().value(),
config.getCdnConfiguration().credentials().secretAccessKey().value());
ServerSecretParams zkSecretParams = new ServerSecretParams(config.getZkConfig().serverSecret().value());
GenericServerSecretParams callingGenericZkSecretParams = new GenericServerSecretParams(config.getCallingZkConfig().serverSecret().value());
@ -880,6 +954,13 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
config.getAppleDeviceCheck().teamId(),
config.getAppleDeviceCheck().bundleId());
final DonationPermitsManager donationPermitsManager = new DonationPermitsManager(donationPermits, zkSecretParams,
clock);
final SubscriptionManager subscriptionManager = new SubscriptionManager(subscriptions,
List.of(stripeManager, braintreeManager, googlePlayBillingManager, appleAppStoreManager),
zkReceiptOperations, issuedReceiptsManager);
final List<SpamFilter> spamFilters = ServiceLoader.load(SpamFilter.class)
.stream()
.map(ServiceLoader.Provider::get)
@ -967,17 +1048,32 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
final RequireAuthenticationInterceptor requireAuthenticationInterceptor = new RequireAuthenticationInterceptor(accountAuthenticator);
final ProhibitAuthenticationInterceptor prohibitAuthenticationInterceptor = new ProhibitAuthenticationInterceptor();
final GroupSendTokenUtil groupSendTokenUtil = new GroupSendTokenUtil(zkSecretParams, Clock.systemUTC());
final MessageMetrics messageMetrics = new MessageMetrics();
final MessageDispatcher messageDispatcher = new MessageDispatcher(receiptSender, messagesManager, messageMetrics,
pushNotificationManager, pushNotificationScheduler, messageDeliveryLoopMonitor, disconnectionRequestManager,
clientReleaseManager);
final CertificateGenerator certificateGenerator =
new CertificateGenerator(config.getDeliveryCertificate().certificate(),
config.getDeliveryCertificate().ecPrivateKey(),
config.getDeliveryCertificate().expiresDays(),
config.getDeliveryCertificate().embedSigner());
final List<ServerServiceDefinition> authenticatedServices = Stream.of(
new AccountsGrpcService(accountsManager, rateLimiters, usernameHashZkProofVerifier, registrationRecoveryPasswordsManager),
ExternalServiceCredentialsGrpcService.createForAllExternalServices(config, rateLimiters),
new CallingGrpcService(cloudflareTurnCredentialsManager, rateLimiters),
new CredentialsGrpcService(accountsManager, certificateGenerator, zkAuthOperations, callingGenericZkSecretParams, rateLimiters, Clock.systemUTC(), ExternalServiceDefinitions.createExternalServiceList(config, Clock.systemUTC())),
new KeysGrpcService(accountsManager, keysManager, rateLimiters),
new ProfileGrpcService(clock, accountsManager, profilesManager, dynamicConfigurationManager, config.getBadges(), profileCdnPolicyGenerator, profileCdnPolicySigner, profileBadgeConverter, rateLimiters),
new MessagesGrpcService(accountsManager, rateLimiters, messageSender, messageByteLimitCardinalityEstimator, spamChecker, Clock.systemUTC()),
new ProfileGrpcService(clock, accountsManager, profilesManager, dynamicConfigurationManager, config.getBadges(), profileCdnPolicyGenerator, profileBadgeConverter, rateLimiters),
new MessagesGrpcService(accountsManager, rateLimiters, messageSender, messageByteLimitCardinalityEstimator, spamChecker, messageDispatcher, Clock.systemUTC()),
new BackupsGrpcService(accountsManager, backupAuthManager, backupMetrics),
new DevicesGrpcService(accountsManager),
new AttachmentsGrpcService(experimentEnrollmentManager, rateLimiters,
gcsAttachmentGenerator, tusAttachmentGenerator, config.getAttachments().maxUploadSizeInBytes()))
new AttachmentsGrpcService(experimentEnrollmentManager, rateLimiters, gcsAttachmentGenerator,
tusAttachmentGenerator, stickerPolicyGenerator,
config.getAttachments().maxAttachmentUploadSizeInBytes(), Clock.systemUTC()),
new PaymentsGrpcService(currencyManager),
new ChallengeGrpcService(accountsManager, rateLimitChallengeManager, challengeConstraintChecker),
new DonationsGrpcService(clock, zkReceiptOperations, redeemedReceiptsManager, accountsManager, config.getBadges(), ReceiptCredentialPresentation::new, donationPermitsManager, rateLimiters))
.map(bindableService -> ServerInterceptors.intercept(bindableService,
// Note: interceptors run in the reverse order they are added; the remote deprecation filter
// depends on the user-agent context so it has to come first here!
@ -995,10 +1091,15 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new CallQualitySurveyGrpcService(callQualitySurveyManager, rateLimiters),
new KeysAnonymousGrpcService(accountsManager, keysManager, zkSecretParams, Clock.systemUTC()),
new ProfileAnonymousGrpcService(accountsManager, profilesManager, profileBadgeConverter, zkSecretParams),
new PaymentsGrpcService(currencyManager),
new MessagesAnonymousGrpcService(accountsManager, rateLimiters, messageSender, groupSendTokenUtil, messageByteLimitCardinalityEstimator, spamChecker, Clock.systemUTC()),
new BackupsAnonymousGrpcService(backupManager, backupMetrics, config.getAttachments().maxUploadSizeInBytes()),
ExternalServiceCredentialsAnonymousGrpcService.create(accountsManager, config))
new BackupsAnonymousGrpcService(backupManager, backupMetrics, config.getAttachments().maxAttachmentUploadSizeInBytes(), config.getAttachments().maxMessageBackupUploadSizeInBytes()),
new CredentialsAnonymousGrpcService(accountsManager, ExternalServiceDefinitions.SVR.generatorFactory().apply(config, Clock.systemUTC())),
new SubscriptionsGrpcService(clock, config.getSubscription(), config.getOneTimeDonations(), subscriptionManager,
donationPermitsManager, stripeManager, braintreeManager, googlePlayBillingManager, appleAppStoreManager,
profileBadgeConverter, bankMandateTranslator, dynamicConfigurationManager),
new OneTimeDonationsGrpcService(config.getOneTimeDonations(), stripeManager, braintreeManager,
payPalDonationsTranslator, oneTimeDonationsManager, issuedReceiptsManager,
zkReceiptOperations, clock, rateLimiters, donationPermitsManager))
.map(bindableService -> ServerInterceptors.intercept(bindableService,
// Note: interceptors run in the reverse order they are added; the remote deprecation filter
// depends on the user-agent context so it has to come first here!
@ -1013,24 +1114,39 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
prohibitAuthenticationInterceptor))
.toList();
final ServerBuilder<?> serverBuilder =
NettyServerBuilder.forAddress(new InetSocketAddress(config.getGrpc().bindAddress(), config.getGrpc().port()));
final ManagedEventLoopGroup<DefaultEventLoopGroup> omnibusLocalEventLoopGroup = new ManagedEventLoopGroup<>(new DefaultEventLoopGroup());
final ManagedEventLoopGroup<NioEventLoopGroup> omnibusNioEventLoopGroup = new ManagedEventLoopGroup<>(new NioEventLoopGroup());
final LocalAddress grpcLocalAddress = new LocalAddress("grpc");
final ServerBuilder<?> serverBuilder = NettyServerBuilder
.forAddress(grpcLocalAddress)
.channelType(LocalServerChannel.class)
.bossEventLoopGroup(omnibusLocalEventLoopGroup.getEventLoopGroup())
.workerEventLoopGroup(omnibusLocalEventLoopGroup.getEventLoopGroup());
authenticatedServices.forEach(serverBuilder::addService);
unauthenticatedServices.forEach(serverBuilder::addService);
final ManagedGrpcServer exposedGrpcServer = new ManagedGrpcServer(serverBuilder.build());
final ManagedGrpcServer localGrpcServer = new ManagedGrpcServer(serverBuilder.build());
environment.lifecycle().manage(exposedGrpcServer);
final SocketAddress websocketAddress =
new InetSocketAddress(config.getGrpc().websocketAddress(), config.getGrpc().websocketPort());
final OmnibusRouter omnibusRouter = new OmnibusRouter(List.of(
new OmnibusRouter.OmnibusRoute("/v1/websocket", websocketAddress),
new OmnibusRouter.OmnibusRoute("/v1/provisioning", websocketAddress)),
grpcLocalAddress);
@Nullable final Mapping<String, SslContext> sniMapping = config.getGrpc().h2c()
? null
: SniMapper.buildSniMapping(config.getTlsKeyStoreConfiguration().path(), config.getTlsKeyStoreConfiguration().password().value());
final OmnibusH2Server omnibusH2Server = new OmnibusH2Server(
sniMapping,
omnibusNioEventLoopGroup.getEventLoopGroup(),
omnibusLocalEventLoopGroup.getEventLoopGroup(),
new InetSocketAddress(config.getGrpc().bindAddress(), config.getGrpc().port()), omnibusRouter,
() -> dynamicConfigurationManager.getConfiguration().getOmnibus(),
config.getGrpc().idleTimeout());
final List<Filter> filters = new ArrayList<>();
filters.add(remoteDeprecationFilter);
filters.add(new RemoteAddressFilter());
filters.add(new TimestampResponseFilter());
for (Filter filter : filters) {
environment.servlets()
.addFilter(filter.getClass().getSimpleName(), filter)
.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, "/*");
}
environment.lifecycle().manage(omnibusLocalEventLoopGroup);
environment.lifecycle().manage(omnibusNioEventLoopGroup);
environment.lifecycle().manage(localGrpcServer);
environment.lifecycle().manage(omnibusH2Server);
if (!config.getExternalRequestFilterConfiguration().paths().isEmpty()) {
environment.servlets().addFilter(ExternalRequestFilter.class.getSimpleName(),
@ -1048,10 +1164,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
final String websocketServletPath = "/v1/websocket/";
final String provisioningWebsocketServletPath = "/v1/websocket/provisioning/";
final MetricsHttpChannelListener metricsHttpChannelListener = new MetricsHttpChannelListener(clientReleaseManager,
Set.of(websocketServletPath, provisioningWebsocketServletPath, "/health-check"));
metricsHttpChannelListener.configure(environment);
final MessageMetrics messageMetrics = new MessageMetrics();
MetricsHttpEventHandler.configure(environment, Metrics.globalRegistry, clientReleaseManager, Set.of(websocketServletPath, provisioningWebsocketServletPath, "/health-check"));
// BufferingInterceptor is needed on the base environment but not the WebSocketEnvironment,
// because we handle serialization of http responses on the websocket on our own and can
@ -1096,21 +1209,20 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
phoneNumberIdentifiers, registrationServiceClient, registrationRecoveryPasswordsManager, registrationRecoveryChecker);
final ChangeNumberManager changeNumberManager = new ChangeNumberManager(messageSender, accountsManager,
phoneVerificationTokenManager, registrationLockVerificationManager, rateLimiters, Clock.systemUTC());
phoneVerificationTokenManager, registrationLockVerificationManager, rateLimiters,
changeNumberWaitingPeriodManager, Clock.systemUTC());
final List<Object> commonControllers = Lists.newArrayList(
new AccountController(accountsManager, rateLimiters, registrationRecoveryPasswordsManager,
usernameHashZkProofVerifier),
new AccountControllerV2(accountsManager, changeNumberManager),
new AttachmentControllerV4(rateLimiters, gcsAttachmentGenerator, tusAttachmentGenerator,
experimentEnrollmentManager, config.getAttachments().maxUploadSizeInBytes()),
new ArchiveController(accountsManager, backupAuthManager, backupManager, backupMetrics, config.getAttachments().maxUploadSizeInBytes()),
experimentEnrollmentManager, config.getAttachments().maxAttachmentUploadSizeInBytes()),
new ArchiveController(accountsManager, backupAuthManager, backupManager, backupMetrics, config.getAttachments().maxAttachmentUploadSizeInBytes(), config.getAttachments().maxMessageBackupUploadSizeInBytes()),
new CallRoutingControllerV2(rateLimiters, cloudflareTurnCredentialsManager),
new CallLinkController(rateLimiters, callingGenericZkSecretParams),
new CallQualitySurveyController(callQualitySurveyManager),
new CertificateController(accountsManager, new CertificateGenerator(config.getDeliveryCertificate().certificate(),
config.getDeliveryCertificate().ecPrivateKey(), config.getDeliveryCertificate().expiresDays(), config.getDeliveryCertificate().embedSigner()),
zkAuthOperations, callingGenericZkSecretParams, clock),
new CertificateController(accountsManager, certificateGenerator, zkAuthOperations, callingGenericZkSecretParams, clock),
new ChallengeController(accountsManager, rateLimitChallengeManager, challengeConstraintChecker),
new DeviceController(accountsManager, rateLimiters, persistentTimer),
new DeviceCheckController(clock, accountsManager, backupAuthManager, appleDeviceCheckManager, rateLimiters,
@ -1118,42 +1230,33 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
config.getDeviceCheck().backupRedemptionDuration()),
new DirectoryV2Controller(directoryV2CredentialsGenerator),
new DonationController(clock, zkReceiptOperations, redeemedReceiptsManager, accountsManager, config.getBadges(),
ReceiptCredentialPresentation::new),
ReceiptCredentialPresentation::new, donationPermitsManager, rateLimiters),
new KeysController(rateLimiters, keysManager, accountsManager, zkSecretParams, Clock.systemUTC()),
new KeyTransparencyController(keyTransparencyServiceClient),
new MessageController(rateLimiters, messageByteLimitCardinalityEstimator, messageSender,
accountsManager, messagesManager, phoneNumberIdentifiers, pushNotificationManager, pushNotificationScheduler,
reportMessageManager, messageDeliveryScheduler, clientReleaseManager,
zkSecretParams, spamChecker, messageMetrics, messageDeliveryLoopMonitor,
Clock.systemUTC()),
new MessageController(rateLimiters, messageByteLimitCardinalityEstimator, messageSender, accountsManager,
phoneNumberIdentifiers, reportMessageManager, zkSecretParams, spamChecker, Clock.systemUTC()),
new PaymentsController(currencyManager, paymentsCredentialsGenerator),
new ProfileController(clock, rateLimiters, accountsManager, profilesManager, dynamicConfigurationManager,
profileBadgeConverter, config.getBadges(), profileCdnPolicyGenerator, profileCdnPolicySigner,
profileBadgeConverter, config.getBadges(), profileCdnPolicyGenerator,
zkSecretParams, zkProfileOperations, batchIdentityCheckExecutor),
new ProvisioningController(rateLimiters, provisioningManager),
new RegistrationController(accountsManager, phoneVerificationTokenManager, registrationLockVerificationManager,
rateLimiters, registrationFraudChecker),
new RemoteConfigController(remoteConfigsManager, config.getRemoteConfigConfiguration().globalConfig(), clock),
new RemoteConfigController(remoteConfigsManager, config.getRemoteConfigConfiguration().globalConfig()),
new SecureStorageController(storageCredentialsGenerator),
new SecureValueRecovery2Controller(svr2CredentialsGenerator, accountsManager),
new StickerController(rateLimiters, config.getCdnConfiguration().credentials().accessKeyId().value(),
config.getCdnConfiguration().credentials().secretAccessKey().value(), config.getCdnConfiguration().region(),
config.getCdnConfiguration().bucket()),
new StickerController(rateLimiters, stickerPolicyGenerator, Clock.systemUTC()),
new VerificationController(registrationServiceClient, new VerificationSessionManager(verificationSessions),
pushNotificationManager, registrationCaptchaManager, registrationRecoveryPasswordsManager,
phoneNumberIdentifiers, rateLimiters, accountsManager, carrierDataProvider, registrationFraudChecker,
dynamicConfigurationManager, clock)
dynamicConfigurationManager, experimentEnrollmentManager, clock),
new SubscriptionController(clock, config.getSubscription(), config.getOneTimeDonations(),
subscriptionManager, stripeManager, braintreeManager, googlePlayBillingManager, appleAppStoreManager,
profileBadgeConverter, bankMandateTranslator, donationPermitsManager, dynamicConfigurationManager),
new OneTimeDonationController(clock, config.getOneTimeDonations(), stripeManager, braintreeManager,
payPalDonationsTranslator, zkReceiptOperations, issuedReceiptsManager, oneTimeDonationsManager,
donationPermitsManager)
);
if (config.getSubscription() != null && config.getOneTimeDonations() != null) {
SubscriptionManager subscriptionManager = new SubscriptionManager(subscriptions,
List.of(stripeManager, braintreeManager, googlePlayBillingManager, appleAppStoreManager),
zkReceiptOperations, issuedReceiptsManager);
commonControllers.add(new SubscriptionController(clock, config.getSubscription(), config.getOneTimeDonations(),
subscriptionManager, stripeManager, braintreeManager, googlePlayBillingManager, appleAppStoreManager,
profileBadgeConverter, bankMandateTranslator, dynamicConfigurationManager));
commonControllers.add(new OneTimeDonationController(clock, config.getOneTimeDonations(), stripeManager, braintreeManager,
payPalDonationsTranslator, zkReceiptOperations, issuedReceiptsManager, oneTimeDonationsManager));
}
for (Object controller : commonControllers) {
environment.jersey().register(controller);
@ -1173,36 +1276,36 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
webSocketEnvironment.jersey().property(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE);
provisioningEnvironment.jersey().property(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE);
JettyWebSocketServletContainerInitializer.configure(environment.getApplicationContext(), (context, container) -> {
final WebSocketExtensionRegistry extensionRegistry = WebSocketServerComponents
.getWebSocketComponents(environment.getApplicationContext().getServletContext())
.getExtensionRegistry();
if (config.getWebSocketConfiguration().isDisablePerMessageDeflate()) {
extensionRegistry.unregister("permessage-deflate");
} else if (config.getWebSocketConfiguration().isDisableCrossMessageOutgoingCompression()) {
extensionRegistry.unregister("permessage-deflate");
extensionRegistry.register("permessage-deflate", NoContextTakeoverPerMessageDeflateExtension.class);
}
});
WebSocketResourceProviderFactory<AuthenticatedDevice> webSocketServlet = new WebSocketResourceProviderFactory<>(
webSocketEnvironment, AuthenticatedDevice.class, config.getWebSocketConfiguration(),
RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME);
webSocketEnvironment, AuthenticatedDevice.class, RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME);
WebSocketResourceProviderFactory<AuthenticatedDevice> provisioningServlet = new WebSocketResourceProviderFactory<>(
provisioningEnvironment, AuthenticatedDevice.class, config.getWebSocketConfiguration(),
RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME);
provisioningEnvironment, AuthenticatedDevice.class, RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME);
ServletRegistration.Dynamic websocket = environment.servlets().addServlet("WebSocket", webSocketServlet);
ServletRegistration.Dynamic provisioning = environment.servlets().addServlet("Provisioning", provisioningServlet);
JettyWebSocketServletContainerInitializer.configure(environment.getApplicationContext(),
(servletContext, container) -> {
container.addMapping(websocketServletPath, webSocketServlet);
container.addMapping(provisioningWebsocketServletPath, provisioningServlet);
websocket.addMapping(websocketServletPath);
websocket.setAsyncSupported(true);
PriorityFilter.ensureFilter(servletContext, new StripContentLengthOnConnectFilter());
PriorityFilter.ensureFilter(servletContext, new TimestampResponseFilter());
PriorityFilter.ensureFilter(servletContext, new RemoteAddressFilter());
PriorityFilter.ensureFilter(servletContext, remoteDeprecationFilter);
provisioning.addMapping(provisioningWebsocketServletPath);
provisioning.setAsyncSupported(true);
container.setMaxBinaryMessageSize(config.getWebSocketConfiguration().getMaxBinaryMessageSize());
container.setMaxTextMessageSize(config.getWebSocketConfiguration().getMaxTextMessageSize());
final WebSocketExtensionRegistry extensionRegistry = WebSocketServerComponents
.getWebSocketComponents(environment.getApplicationContext())
.getExtensionRegistry();
if (config.getWebSocketConfiguration().isDisablePerMessageDeflate()) {
extensionRegistry.unregister("permessage-deflate");
} else if (config.getWebSocketConfiguration().isDisableCrossMessageOutgoingCompression()) {
extensionRegistry.unregister("permessage-deflate");
extensionRegistry.register("permessage-deflate", NoContextTakeoverPerMessageDeflateExtension.class);
}
});
environment.admin().addTask(new SetRequestLoggingEnabledTask());
}
private void registerExceptionMappers(Environment environment,

View File

@ -0,0 +1,24 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.auth;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Response;
import java.util.Base64;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.donation.DonationPermit;
public record DonationPermitHeader(DonationPermit permit) {
public static DonationPermitHeader valueOf(String header) {
try {
return new DonationPermitHeader(new DonationPermit(Base64.getDecoder().decode(header)));
} catch (InvalidInputException | IllegalArgumentException e) {
// Base64 throws IllegalArgumentException; DonationPermit ctor throws InvalidInputException
throw new WebApplicationException(e, Response.Status.UNAUTHORIZED);
}
}
}

View File

@ -10,10 +10,9 @@ import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Metrics;
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.eclipse.jetty.ee10.websocket.server.JettyServerUpgradeRequest;
import org.eclipse.jetty.ee10.websocket.server.JettyServerUpgradeResponse;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.websocket.auth.AuthenticatedWebSocketUpgradeFilter;

View File

@ -15,10 +15,11 @@ import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.core.Response;
import java.security.MessageDigest;
import java.time.Duration;
import java.util.concurrent.CancellationException;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import javax.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.entities.PhoneVerificationRequest;
@ -27,7 +28,6 @@ import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient;
import org.whispersystems.textsecuregcm.spam.RegistrationRecoveryChecker;
import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;
import javax.annotation.Nullable;
public class PhoneVerificationTokenManager {
@ -89,11 +89,10 @@ public class PhoneVerificationTokenManager {
return verificationType;
}
private void verifyBySessionId(final String number, final byte[] sessionId) throws InterruptedException {
private void verifyBySessionId(final String number, final byte[] sessionId) {
try {
final RegistrationServiceSession session = registrationServiceClient
.getSession(sessionId, REGISTRATION_RPC_TIMEOUT)
.get(VERIFICATION_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.orElseThrow(() -> new NotAuthorizedException("session not verified"));
if (!MessageDigest.isEqual(number.getBytes(), session.number().getBytes())) {
@ -102,19 +101,11 @@ public class PhoneVerificationTokenManager {
if (!session.verified()) {
throw new NotAuthorizedException("session not verified");
}
} catch (final ExecutionException e) {
if (e.getCause() instanceof StatusRuntimeException grpcRuntimeException) {
if (grpcRuntimeException.getStatus().getCode() == Status.Code.INVALID_ARGUMENT) {
throw new BadRequestException();
}
} catch (final StatusRuntimeException e) {
if (e.getStatus().getCode() == Status.Code.INVALID_ARGUMENT) {
throw new BadRequestException();
}
logger.error("Registration service failure", e);
throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE);
} catch (final CancellationException | TimeoutException e) {
logger.error("Registration service failure", e);
throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE);
}
@ -129,10 +120,11 @@ public class PhoneVerificationTokenManager {
}
try {
final boolean verified = phoneNumberIdentifiers.getPhoneNumberIdentifier(number)
.thenCompose(phoneNumberIdentifier -> registrationRecoveryPasswordsManager.verify(phoneNumberIdentifier, recoveryPassword))
final UUID phoneNumberIdentifier = phoneNumberIdentifiers.getPhoneNumberIdentifier(number)
.get(VERIFICATION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
final boolean verified = registrationRecoveryPasswordsManager.verify(phoneNumberIdentifier, recoveryPassword);
if (!verified) {
throw new ForbiddenException("recoveryPassword couldn't be verified");
}

View File

@ -152,7 +152,7 @@ public class RegistrationLockVerificationManager {
// This allows users to re-register via registration recovery password
// instead of always being forced to fall back to SMS verification.
if (!phoneVerificationType.equals(PhoneVerificationRequest.VerificationType.RECOVERY_PASSWORD) || clientRegistrationLock != null) {
registrationRecoveryPasswordsManager.remove(updatedAccount.getIdentifier(IdentityType.PNI)).join();
registrationRecoveryPasswordsManager.remove(updatedAccount.getIdentifier(IdentityType.PNI));
}
final List<Byte> deviceIds = updatedAccount.getDevices().stream().map(Device::getId).toList();

View File

@ -266,8 +266,7 @@ public class BackupAuthManager {
}
boolean receiptAllowed = redeemedReceiptsManager
.put(receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, account.getUuid())
.join();
.put(receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, account.getUuid());
if (!receiptAllowed) {
throw new BackupBadReceiptException("receipt serial is already redeemed");
}

View File

@ -86,7 +86,7 @@ public class Cdn3RemoteStorageManager implements RemoteStorageManager {
public CompletionStage<Void> copy(
final int sourceCdn,
final String sourceKey,
final int expectedSourceLength,
final long expectedSourceLength,
final MediaEncryptionParameters encryptionParameters,
final String destinationKey) {
final String sourceScheme = this.sourceSchemes.get(sourceCdn);
@ -132,10 +132,10 @@ public class Cdn3RemoteStorageManager implements RemoteStorageManager {
*/
record Cdn3CopyRequest(
String encryptionKey, String hmacKey,
SourceDescriptor source, int expectedSourceLength,
SourceDescriptor source, long expectedSourceLength,
String dst) {
Cdn3CopyRequest(MediaEncryptionParameters parameters, SourceDescriptor source, int expectedSourceLength,
Cdn3CopyRequest(MediaEncryptionParameters parameters, SourceDescriptor source, long expectedSourceLength,
String dst) {
this(Base64.getEncoder().encodeToString(parameters.aesEncryptionKey().getEncoded()),
Base64.getEncoder().encodeToString(parameters.hmacSHA256Key().getEncoded()),

View File

@ -18,7 +18,7 @@ import com.google.common.annotations.VisibleForTesting;
public record CopyParameters(
int sourceCdn,
String sourceKey,
int sourceLength,
long sourceLength,
MediaEncryptionParameters encryptionParameters,
byte[] destinationMediaId) {
@ -35,16 +35,17 @@ public record CopyParameters(
///
/// @return the size, in bytes, of the ciphertext of a media object with the given `inputSize`
@VisibleForTesting
static long destinationObjectSize(final int inputSize) {
static long destinationObjectSize(final long inputSize) {
if (inputSize < 0) {
throw new IllegalArgumentException("Size must be non-negative, but was " + inputSize);
}
// AES-256 has 16-byte block size, and always adds a block if the plaintext is a multiple of the block size
final long numBlocks = ((long) inputSize + 16) / 16;
final long numBlocks = Math.addExact(inputSize, 16L) / 16;
final long cipherTextLength = Math.multiplyExact(numBlocks, 16);
// 16-byte IV will be generated and prepended to the ciphertext
// 16-byte IV will be generated and prepended to the ciphertext.
// IV + AES-256 encrypted data + HmacSHA256
return 16 + (numBlocks * 16) + 32;
return Math.addExact(cipherTextLength, 16L + 32);
}
}

View File

@ -35,7 +35,7 @@ public interface RemoteStorageManager {
CompletionStage<Void> copy(
int sourceCdn,
String sourceKey,
int expectedSourceLength,
long expectedSourceLength,
MediaEncryptionParameters encryptionParameters,
String dstKey);

View File

@ -16,6 +16,7 @@ import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import javax.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -52,20 +53,20 @@ public class CaptchaChecker {
* @param userAgent User-Agent of the solver
* @return An {@link AssessmentResult} indicating whether the solution should be accepted, and a score that can be
* used for metrics
* @throws IOException if there is an error validating the captcha with the underlying service
* @throws BadRequestException if input is not in the expected format
* @throws IOException if there is an error validating the captcha with the underlying service
* @throws InvalidCaptchaArgumentException if input is not in the expected format
*/
public AssessmentResult verify(
final Optional<UUID> maybeAci,
final Action expectedAction,
final String input,
final String ip,
final String userAgent) throws IOException {
@Nullable final String userAgent) throws IOException, InvalidCaptchaArgumentException {
final String[] parts = input.split("\\" + SEPARATOR, 4);
// we allow missing actions, if we're missing 1 part, assume it's the action
if (parts.length < 4) {
throw new BadRequestException("too few parts");
throw new InvalidCaptchaArgumentException("too few parts");
}
final String prefix = parts[0];
@ -78,30 +79,30 @@ public class CaptchaChecker {
// This is a "short" solution that points to the actual solution. We need to fetch the
// full solution before proceeding
provider = prefix.substring(0, prefix.length() - SHORT_SUFFIX.length());
token = shortCodeExpander.retrieve(token).orElseThrow(() -> new BadRequestException("invalid shortcode"));
token = shortCodeExpander.retrieve(token).orElseThrow(() -> new InvalidCaptchaArgumentException("invalid shortcode"));
}
final CaptchaClient client = this.captchaClientSupplier.apply(provider);
if (client == null) {
throw new BadRequestException("invalid captcha scheme");
throw new InvalidCaptchaArgumentException("invalid captcha scheme");
}
final Action parsedAction = Action.parse(action)
.orElseThrow(() -> {
Metrics.counter(INVALID_ACTION_COUNTER_NAME, "action", action).increment();
return new BadRequestException("invalid captcha action");
Metrics.counter(INVALID_ACTION_COUNTER_NAME).increment();
return new InvalidCaptchaArgumentException("invalid captcha action");
});
if (!parsedAction.equals(expectedAction)) {
Metrics.counter(INVALID_ACTION_COUNTER_NAME, "action", action).increment();
throw new BadRequestException("invalid captcha action");
throw new InvalidCaptchaArgumentException("invalid captcha action");
}
final Set<String> allowedSiteKeys = client.validSiteKeys(parsedAction);
if (!allowedSiteKeys.contains(siteKey)) {
logger.debug("invalid site-key {}, action={}, token={}", siteKey, action, token);
Metrics.counter(INVALID_SITEKEY_COUNTER_NAME, "action", action).increment();
throw new BadRequestException("invalid captcha site-key");
throw new InvalidCaptchaArgumentException("invalid captcha site-key");
}
final AssessmentResult result = client.verify(maybeAci, siteKey, parsedAction, token, ip, userAgent);

View File

@ -5,6 +5,7 @@
package org.whispersystems.textsecuregcm.captcha;
import javax.annotation.Nullable;
import java.io.IOException;
import java.util.Optional;
import java.util.Set;
@ -42,7 +43,7 @@ public interface CaptchaClient {
final Action action,
final String token,
final String ip,
final String userAgent) throws IOException;
@Nullable final String userAgent) throws IOException;
static CaptchaClient noop() {
return new CaptchaClient() {
@ -58,7 +59,7 @@ public interface CaptchaClient {
@Override
public AssessmentResult verify(final Optional<UUID> maybeAci, final String siteKey, final Action action, final String token, final String ip,
final String userAgent) throws IOException {
@Nullable final String userAgent) throws IOException {
return AssessmentResult.alwaysValid();
}
};

View File

@ -0,0 +1,15 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.captcha;
/**
* Indicates that a captcha solution was malformed
*/
public class InvalidCaptchaArgumentException extends Exception {
public InvalidCaptchaArgumentException(String message) {
super(message);
}
}

View File

@ -19,7 +19,7 @@ public class RegistrationCaptchaManager {
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
public Optional<AssessmentResult> assessCaptcha(final Optional<UUID> aci, final Optional<String> captcha, final String sourceHost, final String userAgent)
throws IOException {
throws IOException, InvalidCaptchaArgumentException{
return captcha.isPresent()
? Optional.of(captchaChecker.verify(aci, Action.REGISTRATION, captcha.get(), sourceHost, userAgent))
: Optional.empty();

View File

@ -6,5 +6,7 @@ package org.whispersystems.textsecuregcm.configuration;
import jakarta.validation.constraints.Positive;
public record AttachmentsConfiguration(@Positive long maxUploadSizeInBytes) {
public record AttachmentsConfiguration(
@Positive long maxAttachmentUploadSizeInBytes,
@Positive long maxMessageBackupUploadSizeInBytes) {
}

View File

@ -0,0 +1,11 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.configuration;
import java.time.Duration;
public record ChangeNumberConfiguration(Duration postRegistrationWaitingPeriod) {
}

View File

@ -49,12 +49,13 @@ public class DynamoDbTables {
private final AccountsTableConfiguration accounts;
private final Table appleDeviceChecks;
private final Table changeNumberWaitingPeriods;
private final Table appleDeviceCheckPublicKeys;
private final Table backups;
private final Table clientPublicKeys;
private final Table clientReleases;
private final Table deletedAccounts;
private final Table deletedAccountsLock;
private final TableWithExpiration donationPermits;
private final IssuedReceiptsTableConfiguration issuedReceipts;
private final Table ecKeys;
private final Table ecSignedPreKeys;
@ -78,12 +79,13 @@ public class DynamoDbTables {
public DynamoDbTables(
@JsonProperty("accounts") final AccountsTableConfiguration accounts,
@JsonProperty("appleDeviceChecks") final Table appleDeviceChecks,
@JsonProperty("changeNumberWaitingPeriods") final Table changeNumberWaitingPeriods,
@JsonProperty("appleDeviceCheckPublicKeys") final Table appleDeviceCheckPublicKeys,
@JsonProperty("backups") final Table backups,
@JsonProperty("clientPublicKeys") final Table clientPublicKeys,
@JsonProperty("clientReleases") final Table clientReleases,
@JsonProperty("deletedAccounts") final Table deletedAccounts,
@JsonProperty("deletedAccountsLock") final Table deletedAccountsLock,
@JsonProperty("donationPermits") final TableWithExpiration donationPermits,
@JsonProperty("issuedReceipts") final IssuedReceiptsTableConfiguration issuedReceipts,
@JsonProperty("ecKeys") final Table ecKeys,
@JsonProperty("ecSignedPreKeys") final Table ecSignedPreKeys,
@ -106,12 +108,13 @@ public class DynamoDbTables {
this.accounts = accounts;
this.appleDeviceChecks = appleDeviceChecks;
this.changeNumberWaitingPeriods = changeNumberWaitingPeriods;
this.appleDeviceCheckPublicKeys = appleDeviceCheckPublicKeys;
this.backups = backups;
this.clientPublicKeys = clientPublicKeys;
this.clientReleases = clientReleases;
this.deletedAccounts = deletedAccounts;
this.deletedAccountsLock = deletedAccountsLock;
this.donationPermits = donationPermits;
this.issuedReceipts = issuedReceipts;
this.ecKeys = ecKeys;
this.ecSignedPreKeys = ecSignedPreKeys;
@ -145,6 +148,12 @@ public class DynamoDbTables {
return appleDeviceChecks;
}
@NotNull
@Valid
public Table getChangeNumberWaitingPeriods() {
return changeNumberWaitingPeriods;
}
@NotNull
@Valid
public Table getAppleDeviceCheckPublicKeys() {
@ -157,12 +166,6 @@ public class DynamoDbTables {
return backups;
}
@NotNull
@Valid
public Table getClientPublicKeys() {
return clientPublicKeys;
}
@NotNull
@Valid
public Table getClientReleases() {
@ -181,6 +184,12 @@ public class DynamoDbTables {
return deletedAccountsLock;
}
@NotNull
@Valid
public TableWithExpiration getDonationPermits() {
return donationPermits;
}
@NotNull
@Valid
public IssuedReceiptsTableConfiguration getIssuedReceipts() {

View File

@ -0,0 +1,65 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.configuration;
import com.apple.foundationdb.Database;
import com.apple.foundationdb.FDB;
import com.fasterxml.jackson.annotation.JsonTypeName;
import jakarta.validation.constraints.NotBlank;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
@JsonTypeName("default")
public record FoundationDbClusterConfiguration(@NotBlank String clusterFileUrl) implements FoundationDbDatabaseFactory {
@Override
public Database build(final FDB fdb) throws IOException {
final URI clusterFileUri = URI.create(clusterFileUrl());
final File clusterFile = switch (clusterFileUri.getScheme()) {
case "file" -> new File(clusterFileUri);
case "http", "https" -> {
try (final HttpClient clusterFileClient = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(10))
.build()) {
final HttpResponse<String> response = clusterFileClient.send(HttpRequest.newBuilder()
.uri(URI.create(clusterFileUrl()))
.timeout(Duration.ofSeconds(10))
.GET()
.build(), HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException("Could not load cluster file (status " + response.statusCode() + ")");
}
final File tempClusterFile = File.createTempFile("fdb.cluster-", "");
tempClusterFile.deleteOnExit();
try (final FileWriter fileWriter = new FileWriter(tempClusterFile)) {
fileWriter.write(response.body());
}
yield tempClusterFile;
} catch (final InterruptedException e) {
throw new IOException("Interrupted while waiting for cluster file response", e);
}
}
default -> throw new IllegalArgumentException("Unrecognized cluster file URI scheme: " + clusterFileUri.getScheme());
};
return fdb.open(clusterFile.getAbsolutePath());
}
}

View File

@ -0,0 +1,18 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.configuration;
import com.apple.foundationdb.Database;
import com.apple.foundationdb.FDB;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import io.dropwizard.jackson.Discoverable;
import java.io.IOException;
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type", defaultImpl = FoundationDbClusterConfiguration.class)
public interface FoundationDbDatabaseFactory extends Discoverable {
Database build(final FDB fdb) throws IOException;
}

View File

@ -0,0 +1,62 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.configuration;
import jakarta.validation.Valid;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;
import org.whispersystems.textsecuregcm.storage.foundationdb.FoundationDbMessageStore;
public record FoundationDbMessagesConfiguration(@NotEmpty Map<String, @Valid FoundationDbDatabaseFactory> clusters,
@NotEmpty Map<@PositiveOrZero @Max(FoundationDbMessageStore.MAX_EPOCHS - 1) Integer, @Size(min = 1, max = FoundationDbMessageStore.MAX_SHARDS - 1) List<String>> epochs,
@PositiveOrZero @Max(FoundationDbMessageStore.MAX_EPOCHS - 1) int activeEpoch,
@NotEmpty Map<@PositiveOrZero @Max(63) Integer, SecretBytes> versionstampCipherKeys,
@PositiveOrZero @Max(63) int currentVersionstampCipherKey,
@PositiveOrZero @Max(1_000_000) long maxWatchesPerClient) {
public static final long DEFAULT_MAX_WATCHES_PER_CLIENT = 10_000;
@AssertTrue
boolean isEveryEpochClusterConfigured() {
for (final List<String> clustersInEpoch : epochs().values()) {
for (final String cluster : clustersInEpoch) {
if (!clusters.containsKey(cluster)) {
return false;
}
}
}
return true;
}
@AssertTrue
boolean isEveryEpochFreeOfDuplicates() {
for (final List<String> clustersInEpoch : epochs().values()) {
if (new HashSet<>(clustersInEpoch).size() != clustersInEpoch.size()) {
return false;
}
}
return true;
}
@AssertTrue
boolean isActiveEpochConfigured() {
return epochs().containsKey(activeEpoch());
}
@AssertTrue
boolean isCurrentVersionstampCipherKeyConfigured() {
return versionstampCipherKeys().containsKey(currentVersionstampCipherKey());
}
}

View File

@ -5,11 +5,33 @@
package org.whispersystems.textsecuregcm.configuration;
import jakarta.validation.constraints.NotNull;
import java.time.Duration;
/// Configuration for the gRPC Server
///
/// @param bindAddress The host to bind the omnibus server to
/// @param port The port to bind the omnibus server to
/// @param websocketAddress The address of a listening websocket server for handling legacy requests
/// @param websocketPort The port of a listening websocket server for handling legacy requests
/// @param idleTimeout The duration after which an idle connection may be disconnected
/// @param h2c If true, listen for plaintext h2c with prior-knowledge
public record GrpcConfiguration(
@NotNull String bindAddress,
@NotNull Integer port,
@NotNull String websocketAddress,
@NotNull Integer websocketPort,
@NotNull Duration idleTimeout,
boolean h2c) {
public record GrpcConfiguration(@NotNull String bindAddress, @NotNull Integer port) {
public GrpcConfiguration {
if (bindAddress == null || bindAddress.isEmpty()) {
bindAddress = "localhost";
}
if (websocketAddress == null || websocketAddress.isEmpty()) {
websocketAddress = "localhost";
}
if (idleTimeout == null) {
idleTimeout = Duration.ofMinutes(5);
}
}
}

View File

@ -15,6 +15,5 @@ import java.util.concurrent.ScheduledExecutorService;
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type", defaultImpl = RegistrationServiceConfiguration.class)
public interface RegistrationServiceClientFactory extends Discoverable {
RegistrationServiceClient build(Environment environment, Executor callbackExecutor,
ScheduledExecutorService identityRefreshExecutor);
RegistrationServiceClient build(Environment environment, ScheduledExecutorService identityRefreshExecutor);
}

View File

@ -5,7 +5,6 @@ import io.dropwizard.core.setup.Environment;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.io.IOException;
import java.util.concurrent.Executor;
import java.util.concurrent.ScheduledExecutorService;
import org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;
import org.whispersystems.textsecuregcm.registration.IdentityTokenCallCredentials;
@ -21,16 +20,16 @@ public record RegistrationServiceConfiguration(@NotBlank String host,
RegistrationServiceClientFactory {
@Override
public RegistrationServiceClient build(final Environment environment, final Executor callbackExecutor,
public RegistrationServiceClient build(final Environment environment,
final ScheduledExecutorService identityRefreshExecutor) {
try {
final IdentityTokenCallCredentials callCredentials = IdentityTokenCallCredentials.fromCredentialConfig(
credentialConfigurationJson, identityTokenAudience, identityRefreshExecutor);
environment.lifecycle().manage(callCredentials);
return new RegistrationServiceClient(host, port, callCredentials, registrationCaCertificate, collationKeySalt.value(),
identityRefreshExecutor);
return new RegistrationServiceClient(host, port, callCredentials, registrationCaCertificate, collationKeySalt.value());
} catch (IOException e) {
throw new RuntimeException(e);
}

View File

@ -7,6 +7,9 @@ package org.whispersystems.textsecuregcm.configuration;
import jakarta.validation.constraints.NotNull;
import org.whispersystems.textsecuregcm.configuration.secrets.SecretString;
import javax.annotation.Nullable;
public record TlsKeyStoreConfiguration(@NotNull SecretString password) {
public record TlsKeyStoreConfiguration(
@Nullable String path,
@NotNull SecretString password) {
}

View File

@ -7,12 +7,12 @@ package org.whispersystems.textsecuregcm.configuration.dynamic;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.Valid;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.signal.chat.calling.GetTurnCredentialsResponseOrBuilder;
import org.whispersystems.textsecuregcm.limits.RateLimiterConfig;
public class DynamicConfiguration {
@ -45,6 +45,10 @@ public class DynamicConfiguration {
@Valid
DynamicMessagePersisterConfiguration messagePersister = new DynamicMessagePersisterConfiguration();
@JsonProperty
@Valid
DynamicMessageDeliveryConfiguration messageDelivery = new DynamicMessageDeliveryConfiguration();
@JsonProperty
@Valid
DynamicRegistrationConfiguration registrationConfiguration = new DynamicRegistrationConfiguration(false);
@ -77,6 +81,10 @@ public class DynamicConfiguration {
@Valid
private DynamicGrpcAllowListConfiguration grpcAllowList = new DynamicGrpcAllowListConfiguration();
@JsonProperty
@Valid
private DynamicOmnibusConfiguration omnibus = new DynamicOmnibusConfiguration(BigDecimal.ZERO);
@JsonProperty
@Valid
private DynamicTurnConfiguration turn = new DynamicTurnConfiguration();
@ -111,6 +119,10 @@ public class DynamicConfiguration {
return messagePersister;
}
public DynamicMessageDeliveryConfiguration getMessageDeliveryConfiguration() {
return messageDelivery;
}
public DynamicRegistrationConfiguration getRegistrationConfiguration() {
return registrationConfiguration;
}
@ -143,6 +155,10 @@ public class DynamicConfiguration {
return grpcAllowList;
}
public DynamicOmnibusConfiguration getOmnibus() {
return omnibus;
}
public DynamicTurnConfiguration getTurnConfiguration() {
return turn;
}

View File

@ -0,0 +1,18 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.configuration.dynamic;
import com.fasterxml.jackson.annotation.JsonProperty;
public class DynamicMessageDeliveryConfiguration {
@JsonProperty
private boolean readOnly = false;
public boolean isReadOnly() {
return readOnly;
}
}

View File

@ -0,0 +1,13 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.configuration.dynamic;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import java.math.BigDecimal;
/// @param rejectConnectionRatio The proportion of connection attempts that should be immediately closed with a GOAWAY
public record DynamicOmnibusConfiguration(@DecimalMin("0.0") @DecimalMax("1.0") BigDecimal rejectConnectionRatio) {
}

View File

@ -244,8 +244,7 @@ public class AccountController {
// if registration recovery password was sent to us, store it (or refresh its expiration)
attributes.recoveryPassword().ifPresent(registrationRecoveryPassword -> {
final boolean rrpCreated = registrationRecoveryPasswordsManager
.store(updatedAccount.getIdentifier(IdentityType.PNI), registrationRecoveryPassword)
.join();
.store(updatedAccount.getIdentifier(IdentityType.PNI), registrationRecoveryPassword);
Metrics.counter(RECOVERY_PASSWORD_SET_COUNTER_NAME, Tags.of(
UserAgentTagUtil.getPlatformTag(userAgent),
Tag.of("outcome", rrpCreated ? "created" : "updated")))

View File

@ -22,6 +22,7 @@ import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.ServiceUnavailableException;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.core.Context;
@ -75,7 +76,7 @@ public class AccountControllerV2 {
@ApiResponse(responseCode = "423", content = @Content(schema = @Schema(implementation = RegistrationLockFailure.class)))
@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"))
description = "If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed"))
public AccountIdentityResponse changeNumber(@Auth final AuthenticatedDevice authenticatedDevice,
@NotNull @Valid final ChangeNumberRequest request,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgentString,
@ -121,6 +122,8 @@ public class AccountControllerV2 {
throw new BadRequestException(e);
} catch (final MessageTooLargeException e) {
throw new WebApplicationException(Response.Status.REQUEST_ENTITY_TOO_LARGE);
} catch (final MessageDeliveryNotAllowedException e) {
throw new ServiceUnavailableException();
}
}

View File

@ -103,19 +103,22 @@ public class ArchiveController {
private final BackupManager backupManager;
private final BackupMetrics backupMetrics;
private final long maxAttachmentSize;
private final long maxMessageBackupSize;
public ArchiveController(
final AccountsManager accountsManager,
final BackupAuthManager backupAuthManager,
final BackupManager backupManager,
final BackupMetrics backupMetrics,
final long maxAttachmentSize) {
final long maxAttachmentSize,
final long maxMessageBackupSize) {
this.accountsManager = accountsManager;
this.backupAuthManager = backupAuthManager;
this.backupManager = backupManager;
this.backupMetrics = backupMetrics;
this.maxAttachmentSize = maxAttachmentSize;
this.maxMessageBackupSize = maxMessageBackupSize;
}
public record SetBackupIdRequest(
@ -602,7 +605,7 @@ public class ArchiveController {
backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent);
final boolean oversize = uploadLength
.map(length -> length > maxAttachmentSize)
.map(length -> length > maxMessageBackupSize)
.orElse(false);
backupMetrics.updateMessageBackupSizeDistribution(backupUser, oversize, uploadLength);
@ -610,7 +613,7 @@ public class ArchiveController {
throw new ClientErrorException("exceeded maximum uploadLength", Response.Status.REQUEST_ENTITY_TOO_LARGE);
}
final BackupUploadDescriptor uploadDescriptor =
backupManager.createMessageBackupUploadDescriptor(backupUser, uploadLength.orElse(maxAttachmentSize));
backupManager.createMessageBackupUploadDescriptor(backupUser, uploadLength.orElse(maxMessageBackupSize));
return new UploadDescriptorResponse(
uploadDescriptor.cdn(),
uploadDescriptor.key(),

View File

@ -5,16 +5,21 @@
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;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.headers.Header;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import jakarta.ws.rs.ClientErrorException;
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;
@ -24,6 +29,7 @@ import java.security.SecureRandom;
import java.util.Map;
import java.util.Optional;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import org.whispersystems.textsecuregcm.attachments.AttachmentGenerator;
import org.whispersystems.textsecuregcm.attachments.AttachmentUtil;
import org.whispersystems.textsecuregcm.attachments.GcsAttachmentGenerator;
@ -33,6 +39,8 @@ 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.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
/**
@ -40,7 +48,7 @@ import org.whispersystems.textsecuregcm.limits.RateLimiters;
* (message attachments) to a remote storage location. The location may be selected by the server at runtime.
*/
@Path("/v4/attachments")
@Tag(name = "Attachments")
@io.swagger.v3.oas.annotations.tags.Tag(name = "Attachments")
public class AttachmentControllerV4 {
private final ExperimentEnrollmentManager experimentEnrollmentManager;
@ -50,6 +58,9 @@ public class AttachmentControllerV4 {
private final Map<Integer, AttachmentGenerator> attachmentGenerators;
private static final String ATTACHMENT_SIZE_NAME =
MetricsUtil.name(AttachmentControllerV4.class, "attachmentSize");
@Nonnull
private final SecureRandom secureRandom;
@ -91,7 +102,8 @@ public class AttachmentControllerV4 {
public AttachmentDescriptorV3 getAttachmentUploadForm(
@Auth AuthenticatedDevice auth,
@Parameter(description = "The size of the attachment to upload in bytes")
@QueryParam("uploadLength") final @Valid Optional<@Positive Long> maybeUploadLength)
@QueryParam("uploadLength") final @Valid Optional<@Positive Long> maybeUploadLength,
@HeaderParam(HttpHeaders.USER_AGENT) @Nullable final String userAgent)
throws RateLimitExceededException {
final long uploadLength = maybeUploadLength.orElse(maxUploadLength);
@ -111,6 +123,12 @@ public class AttachmentControllerV4 {
}
}
DistributionSummary.builder(ATTACHMENT_SIZE_NAME)
.tags(Tags.of(UserAgentTagUtil.getPlatformTag(userAgent),
Tag.of("uploadLengthSupplied", Boolean.toString(maybeUploadLength.isPresent()))))
.register(Metrics.globalRegistry)
.record(uploadLength);
final String key = AttachmentUtil.generateAttachmentKey(secureRandom);
final boolean useCdn3 = this.experimentEnrollmentManager.isEnrolled(auth.accountIdentifier(), AttachmentUtil.CDN3_EXPERIMENT_NAME);
int cdn = useCdn3 ? 3 : 2;

View File

@ -20,7 +20,6 @@ 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;
import java.time.Instant;
@ -76,8 +75,7 @@ public class CertificateController {
@Produces(MediaType.APPLICATION_JSON)
@Path("/delivery")
public DeliveryCertificate getDeliveryCertificate(@Auth AuthenticatedDevice auth,
@QueryParam("includeE164") @DefaultValue("true") boolean includeE164)
throws InvalidKeyException {
@QueryParam("includeE164") @DefaultValue("true") boolean includeE164) {
Metrics.counter(GENERATE_DELIVERY_CERTIFICATE_COUNTER_NAME, INCLUDE_E164_TAG_NAME, String.valueOf(includeE164))
.increment();

View File

@ -34,6 +34,7 @@ import java.io.IOException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.captcha.InvalidCaptchaArgumentException;
import org.whispersystems.textsecuregcm.entities.AnswerCaptchaChallengeRequest;
import org.whispersystems.textsecuregcm.entities.AnswerChallengeRequest;
import org.whispersystems.textsecuregcm.entities.AnswerPushChallengeRequest;
@ -97,7 +98,7 @@ public class ChallengeController {
Tags tags = Tags.of(UserAgentTagUtil.getPlatformTag(userAgent));
final ChallengeConstraints constraints = challengeConstraintChecker.challengeConstraints(
final ChallengeConstraints constraints = challengeConstraintChecker.challengeConstraintsHttp(
requestContext, account);
try {
if (answerRequest instanceof final AnswerPushChallengeRequest pushChallengeRequest) {
@ -126,6 +127,8 @@ public class ChallengeController {
} else {
tags = tags.and(CHALLENGE_TYPE_TAG, "unrecognized");
}
} catch (final InvalidCaptchaArgumentException e) {
return Response.status(Response.Status.BAD_REQUEST.getStatusCode(), e.getMessage()).build();
} catch (final IOException e) {
logger.error("error assessing captcha during challenge response handling", e);
return Response.status(Response.Status.SERVICE_UNAVAILABLE).build();
@ -185,7 +188,7 @@ public class ChallengeController {
final Account account = accountsManager.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
final ChallengeConstraints constraints = challengeConstraintChecker.challengeConstraints(requestContext, account);
final ChallengeConstraints constraints = challengeConstraintChecker.challengeConstraintsHttp(requestContext, account);
if (!constraints.pushPermitted()) {
return Response.status(429).build();
}

View File

@ -11,6 +11,7 @@ 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;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
@ -21,48 +22,56 @@ import jakarta.ws.rs.core.Response.Status;
import java.time.Clock;
import java.time.Instant;
import java.util.Objects;
import javax.annotation.Nonnull;
import org.glassfish.jersey.server.ManagedAsync;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.donation.DonationPermitRequest;
import org.signal.libsignal.zkgroup.donation.DonationPermitResponse;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;
import org.signal.libsignal.zkgroup.receipts.ReceiptSerial;
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
import org.whispersystems.textsecuregcm.entities.CreateDonationPermitResponse;
import org.whispersystems.textsecuregcm.entities.CreateDonationPermitsRequest;
import org.whispersystems.textsecuregcm.entities.RedeemReceiptRequest;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.storage.AccountBadge;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.DonationPermitsManager;
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
import org.whispersystems.textsecuregcm.subscriptions.ReceiptCredentialPresentationFactory;
@Path("/v1/donation")
@Tag(name = "Donations")
public class DonationController {
public interface ReceiptCredentialPresentationFactory {
ReceiptCredentialPresentation build(byte[] bytes) throws InvalidInputException;
}
private final Clock clock;
private final ServerZkReceiptOperations serverZkReceiptOperations;
private final RedeemedReceiptsManager redeemedReceiptsManager;
private final AccountsManager accountsManager;
private final BadgesConfiguration badgesConfiguration;
private final ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory;
private final DonationPermitsManager donationPermitsManager;
private final RateLimiters rateLimiters;
public DonationController(
@Nonnull final Clock clock,
@Nonnull final ServerZkReceiptOperations serverZkReceiptOperations,
@Nonnull final RedeemedReceiptsManager redeemedReceiptsManager,
@Nonnull final AccountsManager accountsManager,
@Nonnull final BadgesConfiguration badgesConfiguration,
@Nonnull final ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory) {
final Clock clock,
final ServerZkReceiptOperations serverZkReceiptOperations,
final RedeemedReceiptsManager redeemedReceiptsManager,
final AccountsManager accountsManager,
final BadgesConfiguration badgesConfiguration,
final ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory,
final DonationPermitsManager donationPermitsManager,
final RateLimiters rateLimiters) {
this.clock = Objects.requireNonNull(clock);
this.serverZkReceiptOperations = Objects.requireNonNull(serverZkReceiptOperations);
this.redeemedReceiptsManager = Objects.requireNonNull(redeemedReceiptsManager);
this.accountsManager = Objects.requireNonNull(accountsManager);
this.badgesConfiguration = Objects.requireNonNull(badgesConfiguration);
this.receiptCredentialPresentationFactory = Objects.requireNonNull(receiptCredentialPresentationFactory);
this.donationPermitsManager = Objects.requireNonNull(donationPermitsManager);
this.rateLimiters = Objects.requireNonNull(rateLimiters);
}
@POST
@ -117,7 +126,7 @@ public class DonationController {
}
final boolean receiptMatched = redeemedReceiptsManager.put(
receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, auth.accountIdentifier()).join();
receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, auth.accountIdentifier());
if (!receiptMatched) {
return Response.status(Status.BAD_REQUEST)
.entity("receipt serial is already redeemed")
@ -134,4 +143,34 @@ public class DonationController {
return Response.ok().build();
}
@POST
@Path("/permit")
@Produces({MediaType.APPLICATION_JSON})
@Operation(
summary = "Generate permits for anonymous donation endpoints",
description = """
Generate a set of anonymous, single-use, permits for use with /v1/subscription endpoints.
""")
@ApiResponse(responseCode = "200", description = "`JSON` with generated credentials", useReturnTypeSchema = true)
@ApiResponse(responseCode = "400", description = "Invalid credential request")
@ApiResponse(responseCode = "401", description = "Account authentication check failed")
@ApiResponse(responseCode = "422", description = "Invalid request format")
@ApiResponse(responseCode = "429", description = "Rate-limited; reduce requested permit count and/or try again after the prescribed delay")
public CreateDonationPermitResponse createPermits(@Auth final AuthenticatedDevice auth,
@NotNull @Valid final CreateDonationPermitsRequest request) throws RateLimitExceededException {
final DonationPermitRequest permitRequest;
try {
permitRequest = new DonationPermitRequest(request.permitRequest());
} catch (InvalidInputException e) {
throw new BadRequestException();
}
rateLimiters.getCreateDonationPermitLimiter().validate(auth.accountIdentifier(), permitRequest.getPermitCount());
final DonationPermitResponse permitResponse = donationPermitsManager.issue(permitRequest);
return new CreateDonationPermitResponse(permitResponse.serialize());
}
}

View File

@ -33,6 +33,8 @@ import org.glassfish.jersey.server.ManagedAsync;
import org.signal.keytransparency.client.AciMonitorRequest;
import org.signal.keytransparency.client.E164MonitorRequest;
import org.signal.keytransparency.client.E164SearchRequest;
import org.signal.keytransparency.client.MonitorResponseV2;
import org.signal.keytransparency.client.SearchResponseV2;
import org.signal.keytransparency.client.UsernameHashMonitorRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -101,15 +103,23 @@ public class KeyTransparencyController {
.build()
));
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())
.toByteArray());
final SearchResponseV2 searchResponse = keyTransparencyServiceClient.search(
ByteString.copyFrom(request.aci().toCompactByteArray()),
ByteString.copyFrom(request.aciIdentityKey().serialize()),
request.usernameHash().map(ByteString::copyFrom),
maybeE164SearchRequest,
request.lastTreeHeadSize(),
request.distinguishedTreeHeadSize());
if (searchResponse.hasPermissionDenied()) {
throw new StatusRuntimeException(Status.PERMISSION_DENIED);
}
if (!searchResponse.hasSearchResponse()) {
throw new StatusRuntimeException(Status.UNAVAILABLE.withDescription("Missing search response"));
}
return new KeyTransparencySearchResponse(searchResponse.getSearchResponse().toByteArray());
} catch (final StatusRuntimeException exception) {
handleKeyTransparencyServiceError(exception);
}
@ -163,13 +173,22 @@ public class KeyTransparencyController {
.setCommitmentIndex(ByteString.copyFrom(e164.commitmentIndex()))
.build());
return new KeyTransparencyMonitorResponse(keyTransparencyServiceClient.monitor(
final MonitorResponseV2 monitorResponse = keyTransparencyServiceClient.monitor(
aciMonitorRequest,
usernameHashMonitorRequest,
e164MonitorRequest,
request.lastNonDistinguishedTreeHeadSize(),
request.lastDistinguishedTreeHeadSize())
.toByteArray());
request.lastDistinguishedTreeHeadSize());
if (monitorResponse.hasPermissionDenied()) {
throw new StatusRuntimeException(Status.PERMISSION_DENIED);
}
if (!monitorResponse.hasMonitorResponse()) {
throw new StatusRuntimeException(Status.UNAVAILABLE.withDescription("Missing monitor response"));
}
return new KeyTransparencyMonitorResponse(monitorResponse.getMonitorResponse().toByteArray());
} catch (final StatusRuntimeException exception) {
handleKeyTransparencyServiceError(exception);
}

View File

@ -4,8 +4,6 @@
*/
package org.whispersystems.textsecuregcm.controllers;
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
import com.google.common.net.HttpHeaders;
import io.dropwizard.auth.Auth;
import io.micrometer.core.instrument.Metrics;
@ -22,7 +20,6 @@ import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.InternalServerErrorException;
import jakarta.ws.rs.NotAuthorizedException;
@ -33,6 +30,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.ServiceUnavailableException;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.core.Context;
@ -40,7 +38,6 @@ import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import java.time.Clock;
import java.time.Duration;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
@ -51,7 +48,6 @@ import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.annotation.Nullable;
import org.glassfish.jersey.server.ManagedAsync;
import org.signal.libsignal.protocol.SealedSenderMultiRecipientMessage;
@ -74,8 +70,6 @@ import org.whispersystems.textsecuregcm.entities.IncomingMessage;
import org.whispersystems.textsecuregcm.entities.IncomingMessageList;
import org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope;
import org.whispersystems.textsecuregcm.entities.MismatchedDevicesResponse;
import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntity;
import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntityList;
import org.whispersystems.textsecuregcm.entities.SendMessageResponse;
import org.whispersystems.textsecuregcm.entities.SendMultiRecipientMessageResponse;
import org.whispersystems.textsecuregcm.entities.SpamReport;
@ -84,31 +78,22 @@ import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
import org.whispersystems.textsecuregcm.identity.IdentityType;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
import org.whispersystems.textsecuregcm.limits.CardinalityEstimator;
import org.whispersystems.textsecuregcm.limits.MessageDeliveryLoopMonitor;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.metrics.MessageMetrics;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.providers.MultiRecipientMessageProvider;
import org.whispersystems.textsecuregcm.push.MessageSender;
import org.whispersystems.textsecuregcm.push.MessageTooLargeException;
import org.whispersystems.textsecuregcm.push.MessageUtil;
import org.whispersystems.textsecuregcm.push.PushNotificationManager;
import org.whispersystems.textsecuregcm.push.PushNotificationScheduler;
import org.whispersystems.textsecuregcm.spam.MessageType;
import org.whispersystems.textsecuregcm.spam.SpamCheckResult;
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;
import org.whispersystems.textsecuregcm.util.HeaderUtils;
import org.whispersystems.textsecuregcm.util.Util;
import org.whispersystems.websocket.WebsocketHeaders;
import reactor.core.scheduler.Scheduler;
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
@Path("/v1/messages")
@ -121,23 +106,14 @@ public class MessageController {
private final CardinalityEstimator messageByteLimitEstimator;
private final MessageSender messageSender;
private final AccountsManager accountsManager;
private final MessagesManager messagesManager;
private final PhoneNumberIdentifiers phoneNumberIdentifiers;
private final PushNotificationManager pushNotificationManager;
private final PushNotificationScheduler pushNotificationScheduler;
private final ReportMessageManager reportMessageManager;
private final Scheduler messageDeliveryScheduler;
private final ClientReleaseManager clientReleaseManager;
private final ServerSecretParams serverSecretParams;
private final SpamChecker spamChecker;
private final MessageMetrics messageMetrics;
private final MessageDeliveryLoopMonitor messageDeliveryLoopMonitor;
private final Clock clock;
private static final CompletableFuture<?>[] EMPTY_FUTURE_ARRAY = new CompletableFuture<?>[0];
private static final String OUTGOING_MESSAGE_LIST_SIZE_BYTES_DISTRIBUTION_NAME = name(MessageController.class, "outgoingMessageListSizeBytes");
private static final Timer INDIVIDUAL_MESSAGE_LATENCY_TIMER;
private static final Timer MULTI_RECIPIENT_MESSAGE_LATENCY_TIMER;
@ -165,8 +141,6 @@ public class MessageController {
// for additional details.
public static final long MAX_TIMESTAMP = 86_400_000L * 100_000_000L;
private static final Duration NOTIFY_FOR_REMAINING_MESSAGES_DELAY = Duration.ofMinutes(1);
private static final SendMultiRecipientMessageResponse SEND_STORY_RESPONSE =
new SendMultiRecipientMessageResponse(Collections.emptyList());
@ -175,33 +149,19 @@ public class MessageController {
CardinalityEstimator messageByteLimitEstimator,
MessageSender messageSender,
AccountsManager accountsManager,
MessagesManager messagesManager,
PhoneNumberIdentifiers phoneNumberIdentifiers,
PushNotificationManager pushNotificationManager,
PushNotificationScheduler pushNotificationScheduler,
ReportMessageManager reportMessageManager,
Scheduler messageDeliveryScheduler,
final ClientReleaseManager clientReleaseManager,
final ServerSecretParams serverSecretParams,
final SpamChecker spamChecker,
final MessageMetrics messageMetrics,
final MessageDeliveryLoopMonitor messageDeliveryLoopMonitor,
final Clock clock) {
this.rateLimiters = rateLimiters;
this.messageByteLimitEstimator = messageByteLimitEstimator;
this.messageSender = messageSender;
this.accountsManager = accountsManager;
this.messagesManager = messagesManager;
this.phoneNumberIdentifiers = phoneNumberIdentifiers;
this.pushNotificationManager = pushNotificationManager;
this.pushNotificationScheduler = pushNotificationScheduler;
this.reportMessageManager = reportMessageManager;
this.messageDeliveryScheduler = messageDeliveryScheduler;
this.clientReleaseManager = clientReleaseManager;
this.serverSecretParams = serverSecretParams;
this.spamChecker = spamChecker;
this.messageMetrics = messageMetrics;
this.messageDeliveryLoopMonitor = messageDeliveryLoopMonitor;
this.clock = clock;
}
@ -471,6 +431,8 @@ public class MessageController {
}
} catch (final MessageTooLargeException e) {
throw new WebApplicationException(Status.REQUEST_ENTITY_TOO_LARGE);
} catch (final MessageDeliveryNotAllowedException e) {
throw new ServiceUnavailableException();
}
}
@ -724,6 +686,8 @@ public class MessageController {
.type(MediaType.APPLICATION_JSON)
.entity(accountStaleDevices)
.build());
} catch (final MessageDeliveryNotAllowedException e) {
throw new ServiceUnavailableException();
}
}
@ -762,84 +726,6 @@ public class MessageController {
}
}
@GET
@Produces(MediaType.APPLICATION_JSON)
public CompletableFuture<OutgoingMessageEntityList> getPendingMessages(@Auth AuthenticatedDevice auth,
@HeaderParam(WebsocketHeaders.X_SIGNAL_RECEIVE_STORIES) String receiveStoriesHeader,
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent) {
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));
final boolean shouldReceiveStories = WebsocketHeaders.parseReceiveStoriesHeader(receiveStoriesHeader);
pushNotificationManager.handleMessagesRetrieved(account, device, userAgent);
return messagesManager.getMessagesForDevice(
auth.accountIdentifier(),
device,
false)
.map(messagesAndHasMore -> {
Stream<Envelope> envelopes = messagesAndHasMore.first().stream();
if (!shouldReceiveStories) {
envelopes = envelopes.filter(e -> !e.getStory());
}
final OutgoingMessageEntityList messages = new OutgoingMessageEntityList(envelopes
.map(OutgoingMessageEntity::fromEnvelope)
.peek(outgoingMessageEntity -> {
messageMetrics.measureAccountOutgoingMessageUuidMismatches(account, outgoingMessageEntity);
messageMetrics.measureOutgoingMessageLatency(outgoingMessageEntity.serverTimestamp(),
"rest",
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);
})
.collect(Collectors.toList()),
messagesAndHasMore.second());
Metrics.summary(OUTGOING_MESSAGE_LIST_SIZE_BYTES_DISTRIBUTION_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent)))
.record(estimateMessageListSizeBytes(messages));
if (!messages.messages().isEmpty()) {
messageDeliveryLoopMonitor.recordDeliveryAttempt(auth.accountIdentifier(),
auth.deviceId(),
messages.messages().getFirst().guid(),
userAgent,
"rest");
}
if (messagesAndHasMore.second()) {
pushNotificationScheduler.scheduleDelayedNotification(account, device, NOTIFY_FOR_REMAINING_MESSAGES_DELAY);
}
return messages;
})
.timeout(Duration.ofSeconds(5))
.subscribeOn(messageDeliveryScheduler)
.toFuture();
});
}
private static long estimateMessageListSizeBytes(final OutgoingMessageEntityList messageList) {
long size = 0;
for (final OutgoingMessageEntity message : messageList.messages()) {
size += message.content() == null ? 0 : message.content().length;
size += message.sourceUuid() == null ? 0 : 36;
}
return size;
}
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Path("/report/{source}/{messageGuid}")

View File

@ -0,0 +1,11 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.controllers;
import org.whispersystems.textsecuregcm.util.NoStackTraceException;
public class MessageDeliveryNotAllowedException extends NoStackTraceException {
}

View File

@ -4,6 +4,8 @@
*/
package org.whispersystems.textsecuregcm.controllers;
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.getClientPlatform;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.google.common.net.HttpHeaders;
import io.dropwizard.auth.Auth;
@ -11,6 +13,7 @@ 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.StringToClassMapItem;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
@ -36,14 +39,13 @@ import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest;
@ -52,10 +54,17 @@ import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.DonationPermitHeader;
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
import org.whispersystems.textsecuregcm.grpc.OneTimeDonationUtil;
import org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil;
import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.storage.DonationPermitsManager;
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
import org.whispersystems.textsecuregcm.storage.OneTimeDonationsManager;
import org.whispersystems.textsecuregcm.storage.WriteConflictException;
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure;
import org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor;
@ -65,13 +74,8 @@ import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
import org.whispersystems.textsecuregcm.subscriptions.PaymentStatus;
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionCurrencyUtil;
import org.whispersystems.textsecuregcm.util.ExactlySize;
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;
/**
* Endpoints for making one-time donation payments (boost and gift)
@ -86,8 +90,6 @@ public class OneTimeDonationController {
private static final Logger logger = LoggerFactory.getLogger(OneTimeDonationController.class);
private static final String EURO_CURRENCY_CODE = "EUR";
private final Clock clock;
private final OneTimeDonationConfiguration oneTimeDonationConfiguration;
private final StripeManager stripeManager;
@ -96,16 +98,18 @@ public class OneTimeDonationController {
private final ServerZkReceiptOperations zkReceiptOperations;
private final IssuedReceiptsManager issuedReceiptsManager;
private final OneTimeDonationsManager oneTimeDonationsManager;
private final DonationPermitsManager donationPermitsManager;
public OneTimeDonationController(
@Nonnull Clock clock,
@Nonnull OneTimeDonationConfiguration oneTimeDonationConfiguration,
@Nonnull StripeManager stripeManager,
@Nonnull BraintreeManager braintreeManager,
@Nonnull PayPalDonationsTranslator payPalDonationsTranslator,
@Nonnull ServerZkReceiptOperations zkReceiptOperations,
@Nonnull IssuedReceiptsManager issuedReceiptsManager,
@Nonnull OneTimeDonationsManager oneTimeDonationsManager) {
final Clock clock,
final OneTimeDonationConfiguration oneTimeDonationConfiguration,
final StripeManager stripeManager,
final BraintreeManager braintreeManager,
final PayPalDonationsTranslator payPalDonationsTranslator,
final ServerZkReceiptOperations zkReceiptOperations,
final IssuedReceiptsManager issuedReceiptsManager,
final OneTimeDonationsManager oneTimeDonationsManager,
final DonationPermitsManager donationPermitsManager) {
this.clock = Objects.requireNonNull(clock);
this.oneTimeDonationConfiguration = Objects.requireNonNull(oneTimeDonationConfiguration);
this.stripeManager = Objects.requireNonNull(stripeManager);
@ -114,6 +118,7 @@ public class OneTimeDonationController {
this.zkReceiptOperations = Objects.requireNonNull(zkReceiptOperations);
this.issuedReceiptsManager = Objects.requireNonNull(issuedReceiptsManager);
this.oneTimeDonationsManager = Objects.requireNonNull(oneTimeDonationsManager);
this.donationPermitsManager = Objects.requireNonNull(donationPermitsManager);
}
public static class CreateBoostRequest {
@ -144,10 +149,10 @@ public class OneTimeDonationController {
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Create a Stripe payment intent", description = """
Create a Stripe PaymentIntent and return a client secret that can be used to complete the payment.
Once the payment is complete, the paymentIntentId can be used at /v1/subscriptions/receipt_credentials
""")
Create a Stripe PaymentIntent and return a client secret that can be used to complete the payment.
Once the payment is complete, the paymentIntentId can be used at /v1/subscriptions/receipt_credentials
""")
@ApiResponse(responseCode = "200", description = "Payment Intent created", content = @Content(schema = @Schema(implementation = CreateBoostResponse.class)))
@ApiResponse(responseCode = "403", description = "The request was made on an authenticated channel")
@ApiResponse(responseCode = "400", description = """
@ -161,15 +166,35 @@ public class OneTimeDonationController {
properties = {
@StringToClassMapItem(key = "error", value = String.class)
})))
@ApiResponse(responseCode = "401", description = "Donation permit was invalid or already spent")
@RateLimitedByIp(RateLimiters.For.ONE_TIME_DONATION)
public CompletableFuture<Response> createBoostPaymentIntent(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@NotNull @Valid CreateBoostRequest request,
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
@Parameter(description = "A base64-encoded donation permit retrieved from POST /v1/donation/permit")
@HeaderParam(HeaderUtils.DONATION_PERMIT) final Optional<DonationPermitHeader> donationPermitHeader,
@NotNull @Valid final CreateBoostRequest request,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) {
if (authenticatedAccount.isPresent()) {
throw new ForbiddenException("must not use authenticated connection for one-time donation operations");
}
SubscriptionsUtil.recordDonationPermitPresent(donationPermitHeader.isPresent(), "boostCreate", userAgent);
final boolean spendSuccessful = donationPermitHeader.map(
permitHeader -> {
try {
return SubscriptionsUtil.verifyAndSpendDonationPermit(permitHeader.permit(), donationPermitsManager, clock);
} catch (final VerificationFailedException e) {
return false;
}
})
.orElse(true);
if (!spendSuccessful) {
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
}
return CompletableFuture.runAsync(() ->
validateRequestCurrencyAmount(request, BigDecimal.valueOf(request.amount), stripeManager))
.thenCompose(_ -> stripeManager.createPaymentIntent(request.currency, request.amount, request.level,
@ -178,47 +203,34 @@ public class OneTimeDonationController {
}
/**
* Validates that the request level is valid, the currency is supported by the {@code manager} and {@code request.paymentMethod},
* and that the amount meets minimum and maximum constraints.
* Validates that the request level is valid, the currency is supported by the {@code manager} and
* {@code request.paymentMethod}, and that the amount meets minimum and maximum constraints.
*
* @throws BadRequestException indicates validation failed. Inspect {@code response.error} for details
*/
private void validateRequestCurrencyAmount(final CreateBoostRequest request, final BigDecimal amount,
final CustomerAwareSubscriptionPaymentProcessor manager) {
if (!(request.level == oneTimeDonationConfiguration.gift().level()
|| request.level == oneTimeDonationConfiguration.boost().level())) {
final Map<String, String> errorBody = switch (OneTimeDonationUtil.validateOneTimeDonationRequest(request.currency,
amount, request.level, request.paymentMethod, oneTimeDonationConfiguration, manager)) {
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedLevel _ ->
Map.of("error", "invalid_level");
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedCurrency _ ->
Map.of("error", "unsupported_currency");
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountBelowMinimum(final BigDecimal min) ->
Map.of("error", "amount_below_currency_minimum",
"minimum", min.toString());
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountAboveSepaLimit(final BigDecimal max) ->
Map.of("error", "amount_above_sepa_limit",
"maximum", max.toString());
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.Success _ -> Collections.emptyMap();
};
if (!errorBody.isEmpty()) {
throw new BadRequestException(
Response.status(Response.Status.BAD_REQUEST).entity(Map.of("error", "invalid_level")).build());
Response.status(Response.Status.BAD_REQUEST).entity(errorBody).build());
}
if (!manager.getSupportedCurrenciesForPaymentMethod(request.paymentMethod)
.contains(request.currency.toLowerCase(Locale.ROOT))) {
throw new BadRequestException(Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "unsupported_currency")).build());
}
final BigDecimal minCurrencyAmountMajorUnits = oneTimeDonationConfiguration.currencies()
.get(request.currency.toLowerCase(Locale.ROOT)).minimum();
final BigDecimal minCurrencyAmountMinorUnits = SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount(
request.currency,
minCurrencyAmountMajorUnits);
if (minCurrencyAmountMinorUnits.compareTo(amount) > 0) {
throw new BadRequestException(Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of(
"error", "amount_below_currency_minimum",
"minimum", minCurrencyAmountMajorUnits.toString())).build());
}
if (request.paymentMethod == PaymentMethod.SEPA_DEBIT &&
amount.compareTo(SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount(
EURO_CURRENCY_CODE,
oneTimeDonationConfiguration.sepaMaximumEuros())) > 0) {
throw new BadRequestException(Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of(
"error", "amount_above_sepa_limit",
"maximum", oneTimeDonationConfiguration.sepaMaximumEuros().toString())).build());
}
}
public static class CreatePayPalBoostRequest extends CreateBoostRequest {
@ -240,9 +252,9 @@ public class OneTimeDonationController {
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public CompletableFuture<Response> createPayPalBoost(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@NotNull @Valid CreatePayPalBoostRequest request,
@Context ContainerRequestContext containerRequestContext) {
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
@NotNull @Valid final CreatePayPalBoostRequest request,
@Context final ContainerRequestContext containerRequestContext) {
if (authenticatedAccount.isPresent()) {
throw new ForbiddenException("must not use authenticated connection for one-time donation operations");
@ -253,21 +265,11 @@ public class OneTimeDonationController {
.thenCompose(_ -> {
final List<Locale> acceptableLanguages =
HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext);
// These two localizations are a best-effort, and it's possible that the first `locale` and the localized line
// item name will not match. We could try to align with the locales PayPal documents <https://developer.paypal.com/reference/locale-codes/#supported-locale-codes>
// but that's a moving target, and we can hopefully have one of them be better for the user by selecting
// independently.
final Locale locale = acceptableLanguages.stream()
.filter(l -> !"*".equals(l.getLanguage()))
.findFirst()
.orElse(Locale.US);
final String localizedLineItemName = payPalDonationsTranslator.translate(acceptableLanguages,
PayPalDonationsTranslator.ONE_TIME_DONATION_LINE_ITEM_KEY);
final OneTimeDonationUtil.LocalizedPayPalDonationLineItem localizedLineItem = OneTimeDonationUtil.localizePayPalDonationLineItem(
payPalDonationsTranslator, acceptableLanguages);
return braintreeManager.createOneTimePayment(request.currency.toUpperCase(Locale.ROOT), request.amount,
locale.toLanguageTag(),
request.returnUrl, request.cancelUrl, localizedLineItemName);
localizedLineItem.locale().toLanguageTag(),
request.returnUrl, request.cancelUrl, localizedLineItem.itemName());
})
.thenApply(approvalDetails -> Response.ok(
new CreatePayPalBoostResponse(approvalDetails.approvalUrl(), approvalDetails.paymentId())).build());
@ -294,8 +296,8 @@ public class OneTimeDonationController {
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public CompletableFuture<Response> confirmPayPalBoost(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@NotNull @Valid ConfirmPayPalBoostRequest request,
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
@NotNull @Valid final ConfirmPayPalBoostRequest request,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) {
if (authenticatedAccount.isPresent()) {
@ -306,10 +308,11 @@ public class OneTimeDonationController {
validateRequestCurrencyAmount(request, BigDecimal.valueOf(request.amount), braintreeManager))
.thenCompose(_ -> braintreeManager.captureOneTimePayment(request.payerId, request.paymentId,
request.paymentToken, request.currency, request.amount, request.level, getClientPlatform(userAgent)))
.thenCompose(
chargeSuccessDetails -> oneTimeDonationsManager.putPaidAt(chargeSuccessDetails.paymentId(), Instant.now()))
.thenApply(paymentId -> Response.ok(
new ConfirmPayPalBoostResponse(paymentId)).build());
.thenApply(chargeSuccessDetails -> {
oneTimeDonationsManager.putPaidAt(chargeSuccessDetails.paymentId(), Instant.now());
return Response.ok(
new ConfirmPayPalBoostResponse(chargeSuccessDetails.paymentId())).build();
});
}
public static class CreateBoostReceiptCredentialsRequest {
@ -337,7 +340,7 @@ public class OneTimeDonationController {
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public CompletableFuture<Response> createBoostReceiptCredentials(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
@NotNull @Valid final CreateBoostReceiptCredentialsRequest request,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) {
@ -345,87 +348,68 @@ public class OneTimeDonationController {
throw new ForbiddenException("must not use authenticated connection for one-time donation operations");
}
final CompletableFuture<PaymentDetails> paymentDetailsFut = switch (request.processor) {
final CompletableFuture<Optional<PaymentDetails>> paymentDetailsFut = switch (request.processor) {
case STRIPE -> stripeManager.getPaymentDetails(request.paymentIntentId);
case BRAINTREE -> braintreeManager.getPaymentDetails(request.paymentIntentId);
case GOOGLE_PLAY_BILLING -> throw new BadRequestException("cannot use play billing for one-time donations");
case APPLE_APP_STORE -> throw new BadRequestException("cannot use app store purchases for one-time donations");
};
return paymentDetailsFut.thenCompose(paymentDetails -> {
if (paymentDetails == null) {
return paymentDetailsFut.thenApply(maybePaymentDetails -> {
if (maybePaymentDetails.isEmpty()) {
throw new WebApplicationException(Response.Status.NOT_FOUND);
} else if (paymentDetails.status() == PaymentStatus.PROCESSING) {
return CompletableFuture.completedFuture(Response.noContent().build());
} else if (paymentDetails.status() != PaymentStatus.SUCCEEDED) {
}
final PaymentDetails paymentDetails = maybePaymentDetails.get();
if (paymentDetails.status() == PaymentStatus.PROCESSING) {
return Response.noContent().build();
}
if (paymentDetails.status() != PaymentStatus.SUCCEEDED) {
throw new WebApplicationException(Response.status(Response.Status.PAYMENT_REQUIRED)
.entity(new CreateBoostReceiptCredentialsErrorResponse(paymentDetails.chargeFailure())).build());
}
// The payment was successful, try to issue the receipt credential
long level = oneTimeDonationConfiguration.boost().level();
if (paymentDetails.customMetadata() != null) {
String levelMetadata = paymentDetails.customMetadata()
.getOrDefault("level", Long.toString(oneTimeDonationConfiguration.boost().level()));
try {
level = Long.parseLong(levelMetadata);
} catch (NumberFormatException e) {
logger.error("failed to parse level metadata ({}) on payment intent {}", levelMetadata,
paymentDetails.id(), e);
throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
}
}
Duration levelExpiration;
if (oneTimeDonationConfiguration.boost().level() == level) {
levelExpiration = oneTimeDonationConfiguration.boost().expiration();
} else if (oneTimeDonationConfiguration.gift().level() == level) {
levelExpiration = oneTimeDonationConfiguration.gift().expiration();
} else {
logger.error("level ({}) returned from payment intent that is unknown to the server", level);
final OneTimeDonationUtil.DonationLevelDetails levelDetails;
try {
levelDetails = OneTimeDonationUtil.getLevelDetails(paymentDetails, oneTimeDonationConfiguration);
} catch (OneTimeDonationUtil.InvalidLevelException _) {
throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
}
ReceiptCredentialRequest receiptCredentialRequest;
final ReceiptCredentialRequest receiptCredentialRequest;
try {
receiptCredentialRequest = new ReceiptCredentialRequest(request.receiptCredentialRequest);
} catch (InvalidInputException e) {
} catch (final InvalidInputException e) {
throw new BadRequestException("invalid receipt credential request", e);
}
final long finalLevel = level;
return issuedReceiptsManager.recordIssuance(paymentDetails.id(), request.processor,
receiptCredentialRequest, clock.instant())
.thenCompose(_ -> oneTimeDonationsManager.getPaidAt(paymentDetails.id(), paymentDetails.created()))
.thenApply(paidAt -> {
Instant expiration = paidAt
.plus(levelExpiration)
.truncatedTo(ChronoUnit.DAYS)
.plus(1, ChronoUnit.DAYS);
ReceiptCredentialResponse receiptCredentialResponse;
try {
receiptCredentialResponse = zkReceiptOperations.issueReceiptCredential(
receiptCredentialRequest, expiration.getEpochSecond(), finalLevel);
} catch (VerificationFailedException e) {
throw new BadRequestException("receipt credential request failed verification", e);
}
Metrics.counter(SubscriptionController.RECEIPT_ISSUED_COUNTER_NAME,
Tags.of(
Tag.of(SubscriptionController.PROCESSOR_TAG_NAME, request.processor.toString()),
Tag.of(SubscriptionController.TYPE_TAG_NAME, "boost"),
UserAgentTagUtil.getPlatformTag(userAgent)))
.increment();
return Response.ok(
new CreateBoostReceiptCredentialsSuccessResponse(receiptCredentialResponse.serialize()))
.build();
});
try {
issuedReceiptsManager.recordIssuance(paymentDetails.id(), request.processor,
receiptCredentialRequest, clock.instant());
} catch (WriteConflictException _) {
throw new WebApplicationException(Response.Status.CONFLICT);
}
final Instant paidAt = oneTimeDonationsManager.getPaidAt(paymentDetails.id(), paymentDetails.created());
final Instant expiration = paidAt
.plus(levelDetails.levelExpiration())
.truncatedTo(ChronoUnit.DAYS)
.plus(1, ChronoUnit.DAYS);
final ReceiptCredentialResponse receiptCredentialResponse;
try {
receiptCredentialResponse = zkReceiptOperations.issueReceiptCredential(
receiptCredentialRequest, expiration.getEpochSecond(), levelDetails.level());
} catch (final VerificationFailedException e) {
throw new BadRequestException("receipt credential request failed verification", e);
}
Metrics.counter(SubscriptionController.RECEIPT_ISSUED_COUNTER_NAME,
Tags.of(
Tag.of(SubscriptionController.PROCESSOR_TAG_NAME, request.processor.toString()),
Tag.of(SubscriptionController.TYPE_TAG_NAME, "boost"),
UserAgentTagUtil.getPlatformTag(userAgent)))
.increment();
return Response.ok(
new CreateBoostReceiptCredentialsSuccessResponse(receiptCredentialResponse.serialize()))
.build();
});
}
@Nullable
private static ClientPlatform getClientPlatform(@Nullable final String userAgentString) {
try {
return UserAgentUtil.parseUserAgentString(userAgentString).platform();
} catch (final UnrecognizedUserAgentException e) {
return null;
}
}
}

View File

@ -39,7 +39,6 @@ import jakarta.ws.rs.core.Response;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
@ -88,7 +87,6 @@ import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.s3.PolicySigner;
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountBadge;
@ -114,7 +112,6 @@ public class ProfileController {
private final ProfileBadgeConverter profileBadgeConverter;
private final Map<String, BadgeConfiguration> badgeConfigurationMap;
private final PolicySigner policySigner;
private final PostPolicyGenerator policyGenerator;
private final ServerSecretParams serverSecretParams;
private final ServerZkProfileOperations zkProfileOperations;
@ -135,7 +132,6 @@ public class ProfileController {
ProfileBadgeConverter profileBadgeConverter,
BadgesConfiguration badgesConfiguration,
PostPolicyGenerator policyGenerator,
PolicySigner policySigner,
ServerSecretParams serverSecretParams,
ServerZkProfileOperations zkProfileOperations,
Executor batchIdentityCheckExecutor) {
@ -150,7 +146,6 @@ public class ProfileController {
this.serverSecretParams = serverSecretParams;
this.zkProfileOperations = zkProfileOperations;
this.policyGenerator = policyGenerator;
this.policySigner = policySigner;
this.batchIdentityCheckExecutor = Preconditions.checkNotNull(batchIdentityCheckExecutor);
}
@ -181,14 +176,8 @@ public class ProfileController {
final Optional<VersionedProfileV1> currentProfile =
profilesManager.getV1(auth.accountIdentifier(), request.version());
if (request.paymentAddress() != null && request.paymentAddress().length != 0) {
final boolean hasDisallowedPrefix =
dynamicConfigurationManager.getConfiguration().getPaymentsConfiguration().getDisallowedPrefixes().stream()
.anyMatch(prefix -> account.getNumber().startsWith(prefix));
if (hasDisallowedPrefix && currentProfile.map(VersionedProfileV1::paymentAddress).isEmpty()) {
return Response.status(Response.Status.FORBIDDEN).build();
}
if (request.paymentAddress() != null && request.paymentAddress().length != 0 && ProfileHelper.isPaymentAddressUpdateForbidden(account, Optional.empty(), currentProfile, dynamicConfigurationManager)) {
return Response.status(Response.Status.FORBIDDEN).build();
}
final Optional<String> currentAvatar = ProfileHelper.getCurrentAvatar(currentProfile);
@ -216,7 +205,7 @@ public class ProfileController {
.orElseGet(a::getBadges);
a.setBadges(clock, updatedBadges);
a.setCurrentProfileVersion(request.version());
a.setCurrentProfileVersion(HexFormat.of().parseHex(request.version()));
});
if (request.getAvatarChange() == CreateProfileRequest.AvatarChange.UPDATE) {
@ -436,7 +425,7 @@ public class ProfileController {
final ExpiringProfileKeyCredentialResponse expiringProfileKeyCredentialResponse;
if (account.getCurrentProfileVersion().map(version::equals).orElse(false)) {
if (account.getCurrentProfileVersion().map(v -> HexFormat.of().formatHex(v).equals(version)).orElse(false)) {
expiringProfileKeyCredentialResponse = profilesManager.getV1(account.getUuid(), version)
.map(profile -> {
final ExpiringProfileKeyCredentialResponse profileKeyCredentialResponse;
@ -488,7 +477,7 @@ public class ProfileController {
// Allow requests where either the version matches the latest version on Account or the latest version on Account
// is empty to read the payment address.
final byte[] paymentAddress = maybeProfile
.filter(p -> account.getCurrentProfileVersion().map(v -> v.equals(p.version())).orElse(true))
.filter(p -> account.getCurrentProfileVersion().map(v -> HexFormat.of().formatHex(v).equals(p.version())).orElse(true))
.map(VersionedProfileV1::paymentAddress)
.orElse(null);
@ -559,15 +548,13 @@ public class ProfileController {
return maybeTargetAccount.get();
}
private ProfileAvatarUploadAttributes generateAvatarUploadForm(
final String objectName) {
ZonedDateTime now = ZonedDateTime.now(clock);
Pair<String, String> policy = policyGenerator.createFor(now, objectName, ProfileHelper.MAX_PROFILE_AVATAR_SIZE_BYTES);
String signature = policySigner.getSignature(now, policy.second());
private ProfileAvatarUploadAttributes generateAvatarUploadForm(final String objectName) {
final PostPolicyGenerator.SignedPostPolicy signedPostPolicy =
policyGenerator.createFor(objectName, ProfileHelper.MAX_PROFILE_AVATAR_SIZE_BYTES, clock.instant());
return new ProfileAvatarUploadAttributes(objectName, policy.first(),
"private", "AWS4-HMAC-SHA256",
now.format(PostPolicyGenerator.AWS_DATE_TIME), policy.second(), signature);
return new ProfileAvatarUploadAttributes(objectName, signedPostPolicy.credential(),
PostPolicyGenerator.ACL, PostPolicyGenerator.ALGORITHM,
signedPostPolicy.formattedTimestamp(), signedPostPolicy.encodedPolicy(), signedPostPolicy.signature());
}
private static Map<String, Boolean> getAccountCapabilities(final Account account) {

View File

@ -26,13 +26,10 @@ import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.util.Arrays;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
@ -61,9 +58,7 @@ public class RemoteConfigController {
.map(p -> p.name().toLowerCase())
.collect(Collectors.toSet());
public RemoteConfigController(RemoteConfigsManager remoteConfigsManager,
Map<String, String> globalConfig,
final Clock clock) {
public RemoteConfigController(RemoteConfigsManager remoteConfigsManager, Map<String, String> globalConfig) {
this.remoteConfigsManager = remoteConfigsManager;
this.globalConfig = globalConfig;
}

View File

@ -6,6 +6,7 @@
package org.whispersystems.textsecuregcm.controllers;
import io.dropwizard.auth.Auth;
import io.dropwizard.util.DataSize;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
@ -15,8 +16,8 @@ import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.security.SecureRandom;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.Clock;
import java.time.Instant;
import java.util.HexFormat;
import java.util.LinkedList;
import java.util.List;
@ -24,23 +25,25 @@ import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.entities.StickerPackFormUploadAttributes;
import org.whispersystems.textsecuregcm.entities.StickerPackFormUploadAttributes.StickerPackFormUploadItem;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.s3.PolicySigner;
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
import org.whispersystems.textsecuregcm.util.Constants;
import org.whispersystems.textsecuregcm.util.Pair;
@Path("/v1/sticker")
@Tag(name = "Stickers")
public class StickerController {
private final RateLimiters rateLimiters;
private final PolicySigner policySigner;
private final PostPolicyGenerator policyGenerator;
private final Clock clock;
public StickerController(RateLimiters rateLimiters, String accessKey, String accessSecret, String region, String bucket) {
public static final int MAXIMUM_STICKER_SIZE_BYTES = (int) DataSize.kibibytes(300 + 1).toBytes(); // add 1 kiB for encryption overhead
public static final int MAXIMUM_STICKER_MANIFEST_SIZE_BYTES = (int) DataSize.kibibytes(10).toBytes();
public StickerController(final RateLimiters rateLimiters,
final PostPolicyGenerator postPolicyGenerator,
final Clock clock) {
this.rateLimiters = rateLimiters;
this.policySigner = new PolicySigner(accessSecret, region);
this.policyGenerator = new PostPolicyGenerator(region, bucket, accessKey);
this.policyGenerator = postPolicyGenerator;
this.clock = clock;
}
@GET
@ -51,33 +54,32 @@ public class StickerController {
throws RateLimitExceededException {
rateLimiters.getStickerPackLimiter().validate(auth.accountIdentifier());
ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
String packId = generatePackId();
String packLocation = "stickers/" + packId;
String manifestKey = packLocation + "/manifest.proto";
Pair<String, String> manifestPolicy = policyGenerator.createFor(now, manifestKey,
Constants.MAXIMUM_STICKER_MANIFEST_SIZE_BYTES);
String manifestSignature = policySigner.getSignature(now, manifestPolicy.second());
StickerPackFormUploadItem manifest = new StickerPackFormUploadItem(-1, manifestKey, manifestPolicy.first(),
"private", "AWS4-HMAC-SHA256",
now.format(PostPolicyGenerator.AWS_DATE_TIME), manifestPolicy.second(), manifestSignature);
final Instant currentTime = clock.instant();
final String packId = generatePackId();
final String packLocation = "stickers/" + packId;
final String manifestKey = packLocation + "/manifest.proto";
final PostPolicyGenerator.SignedPostPolicy manifestPolicy =
policyGenerator.createFor(manifestKey, MAXIMUM_STICKER_MANIFEST_SIZE_BYTES, currentTime);
List<StickerPackFormUploadItem> stickers = new LinkedList<>();
final StickerPackFormUploadItem manifest = new StickerPackFormUploadItem(-1, manifestKey, manifestPolicy.credential(),
PostPolicyGenerator.ACL, PostPolicyGenerator.ALGORITHM,
manifestPolicy.formattedTimestamp(), manifestPolicy.encodedPolicy(), manifestPolicy.signature());
final List<StickerPackFormUploadItem> stickers = new LinkedList<>();
for (int i = 0; i < stickerCount; i++) {
String stickerKey = packLocation + "/full/" + i;
Pair<String, String> stickerPolicy = policyGenerator.createFor(now, stickerKey,
Constants.MAXIMUM_STICKER_SIZE_BYTES);
String stickerSignature = policySigner.getSignature(now, stickerPolicy.second());
stickers.add(new StickerPackFormUploadItem(i, stickerKey, stickerPolicy.first(), "private", "AWS4-HMAC-SHA256",
now.format(PostPolicyGenerator.AWS_DATE_TIME), stickerPolicy.second(), stickerSignature));
final String stickerKey = packLocation + "/full/" + i;
final PostPolicyGenerator.SignedPostPolicy stickerPolicy =
policyGenerator.createFor(stickerKey, MAXIMUM_STICKER_SIZE_BYTES, currentTime);
stickers.add(new StickerPackFormUploadItem(i, stickerKey, stickerPolicy.credential(), PostPolicyGenerator.ACL, PostPolicyGenerator.ALGORITHM,
manifestPolicy.formattedTimestamp(), stickerPolicy.encodedPolicy(), stickerPolicy.signature()));
}
return new StickerPackFormUploadAttributes(packId, manifest, stickers);
}
private String generatePackId() {
byte[] object = new byte[16];
final byte[] object = new byte[16];
new SecureRandom().nextBytes(object);
return HexFormat.of().formatHex(object);

View File

@ -5,6 +5,11 @@
package org.whispersystems.textsecuregcm.controllers;
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.buildCurrencyConfiguration;
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.buildDonationLevelsConfiguration;
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.getClientPlatform;
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.getPayPalLocale;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.google.common.annotations.VisibleForTesting;
@ -15,6 +20,7 @@ import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.Tags;
import io.swagger.v3.oas.annotations.ExternalDocumentation;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.headers.Header;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
@ -36,6 +42,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.MediaType;
@ -44,34 +51,31 @@ import jakarta.ws.rs.core.Response.Status;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Instant;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import org.glassfish.jersey.server.ManagedAsync;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.DonationPermitHeader;
import org.whispersystems.textsecuregcm.badges.BadgeTranslator;
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationCurrencyConfiguration;
import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;
import org.whispersystems.textsecuregcm.configuration.SubscriptionLevelConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.entities.Badge;
import org.whispersystems.textsecuregcm.entities.PurchasableBadge;
import org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil;
import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.mappers.SubscriptionExceptionMapper;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.storage.DonationPermitsManager;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.storage.PaymentTime;
import org.whispersystems.textsecuregcm.storage.SubscriberCredentials;
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
import org.whispersystems.textsecuregcm.storage.Subscriptions;
@ -80,8 +84,10 @@ import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator;
import org.whispersystems.textsecuregcm.subscriptions.BankTransferType;
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure;
import org.whispersystems.textsecuregcm.subscriptions.CurrencyConfiguration;
import org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor;
import org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager;
import org.whispersystems.textsecuregcm.subscriptions.LevelConfiguration;
import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
@ -92,9 +98,6 @@ import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidLevelEx
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentRequiresActionException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionReceiptRequestedForOpenPaymentException;
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;
@Path("/v1/subscription")
@io.swagger.v3.oas.annotations.tags.Tag(name = "Subscriptions")
@ -110,6 +113,7 @@ public class SubscriptionController {
private final AppleAppStoreManager appleAppStoreManager;
private final BadgeTranslator badgeTranslator;
private final BankMandateTranslator bankMandateTranslator;
private final DonationPermitsManager donationPermitsManager;
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
static final String RECEIPT_ISSUED_COUNTER_NAME = MetricsUtil.name(SubscriptionController.class, "receiptIssued");
static final String PROCESSOR_TAG_NAME = "processor";
@ -117,17 +121,18 @@ public class SubscriptionController {
private static final String SUBSCRIPTION_TYPE_TAG_NAME = "subscriptionType";
public SubscriptionController(
@Nonnull Clock clock,
@Nonnull SubscriptionConfiguration subscriptionConfiguration,
@Nonnull OneTimeDonationConfiguration oneTimeDonationConfiguration,
@Nonnull SubscriptionManager subscriptionManager,
@Nonnull StripeManager stripeManager,
@Nonnull BraintreeManager braintreeManager,
@Nonnull GooglePlayBillingManager googlePlayBillingManager,
@Nonnull AppleAppStoreManager appleAppStoreManager,
@Nonnull BadgeTranslator badgeTranslator,
@Nonnull BankMandateTranslator bankMandateTranslator,
@NotNull DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {
Clock clock,
SubscriptionConfiguration subscriptionConfiguration,
OneTimeDonationConfiguration oneTimeDonationConfiguration,
SubscriptionManager subscriptionManager,
StripeManager stripeManager,
BraintreeManager braintreeManager,
GooglePlayBillingManager googlePlayBillingManager,
AppleAppStoreManager appleAppStoreManager,
BadgeTranslator badgeTranslator,
BankMandateTranslator bankMandateTranslator,
DonationPermitsManager donationPermitsManager,
DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {
this.subscriptionManager = subscriptionManager;
this.clock = Objects.requireNonNull(clock);
this.subscriptionConfiguration = Objects.requireNonNull(subscriptionConfiguration);
@ -138,78 +143,14 @@ public class SubscriptionController {
this.appleAppStoreManager = appleAppStoreManager;
this.badgeTranslator = Objects.requireNonNull(badgeTranslator);
this.bankMandateTranslator = Objects.requireNonNull(bankMandateTranslator);
this.dynamicConfigurationManager = dynamicConfigurationManager;
this.donationPermitsManager = donationPermitsManager;
this.dynamicConfigurationManager = Objects.requireNonNull(dynamicConfigurationManager);
}
private Map<String, CurrencyConfiguration> buildCurrencyConfiguration() {
final List<CustomerAwareSubscriptionPaymentProcessor> subscriptionPaymentProcessors = List.of(stripeManager, braintreeManager);
return oneTimeDonationConfiguration.currencies()
.entrySet().stream()
.collect(Collectors.toMap(Entry::getKey, currencyAndConfig -> {
final String currency = currencyAndConfig.getKey();
final OneTimeDonationCurrencyConfiguration currencyConfig = currencyAndConfig.getValue();
final Map<String, List<BigDecimal>> oneTimeLevelsToSuggestedAmounts = Map.of(
String.valueOf(oneTimeDonationConfiguration.boost().level()), currencyConfig.boosts(),
String.valueOf(oneTimeDonationConfiguration.gift().level()), List.of(currencyConfig.gift())
);
final Function<Map<Long, ? extends SubscriptionLevelConfiguration>, Map<String, BigDecimal>> extractSubscriptionAmounts = levels ->
levels.entrySet().stream()
.filter(levelIdAndConfig -> levelIdAndConfig.getValue().prices().containsKey(currency))
.collect(Collectors.toMap(
levelIdAndConfig -> String.valueOf(levelIdAndConfig.getKey()),
levelIdAndConfig -> levelIdAndConfig.getValue().prices().get(currency).amount()));
final List<String> supportedPaymentMethods = Arrays.stream(PaymentMethod.values())
.filter(paymentMethod -> subscriptionPaymentProcessors.stream()
.anyMatch(manager -> manager.supportsPaymentMethod(paymentMethod)
&& manager.getSupportedCurrenciesForPaymentMethod(paymentMethod).contains(currency)))
.map(PaymentMethod::name)
.collect(Collectors.toList());
if (supportedPaymentMethods.isEmpty()) {
throw new RuntimeException("Configuration has currency with no processor support: " + currency);
}
return new CurrencyConfiguration(
currencyConfig.minimum(),
oneTimeLevelsToSuggestedAmounts,
extractSubscriptionAmounts.apply(subscriptionConfiguration.getDonationLevels()),
extractSubscriptionAmounts.apply(subscriptionConfiguration.getBackupLevels()),
supportedPaymentMethods);
}));
}
@VisibleForTesting
GetSubscriptionConfigurationResponse buildGetSubscriptionConfigurationResponse(
final List<Locale> acceptableLanguages) {
final Map<String, LevelConfiguration> donationLevels = new HashMap<>();
subscriptionConfiguration.getDonationLevels().forEach((levelId, levelConfig) -> {
final LevelConfiguration levelConfiguration = new LevelConfiguration(
"" /* deprecated and unused */,
badgeTranslator.translate(acceptableLanguages, levelConfig.badge()));
donationLevels.put(String.valueOf(levelId), levelConfiguration);
});
final Badge boostBadge = badgeTranslator.translate(acceptableLanguages,
oneTimeDonationConfiguration.boost().badge());
donationLevels.put(String.valueOf(oneTimeDonationConfiguration.boost().level()),
new LevelConfiguration(
"" /* deprecated and unused */,
// NB: the one-time badges are PurchasableBadge, which has a `duration` field
new PurchasableBadge(
boostBadge,
oneTimeDonationConfiguration.boost().expiration())));
final Badge giftBadge = badgeTranslator.translate(acceptableLanguages, oneTimeDonationConfiguration.gift().badge());
donationLevels.put(String.valueOf(oneTimeDonationConfiguration.gift().level()),
new LevelConfiguration(
"" /* deprecated and unused */,
new PurchasableBadge(
giftBadge,
oneTimeDonationConfiguration.gift().expiration())));
final long maxTotalBackupMediaBytes =
dynamicConfigurationManager.getConfiguration().getBackupConfiguration().maxTotalMediaSize();
@ -222,7 +163,11 @@ public class SubscriptionController {
e.getValue().playProductId(),
e.getValue().mediaTtl().toDays())));
return new GetSubscriptionConfigurationResponse(buildCurrencyConfiguration(), donationLevels,
return new GetSubscriptionConfigurationResponse(
buildCurrencyConfiguration(List.of(stripeManager, braintreeManager), oneTimeDonationConfiguration,
subscriptionConfiguration),
buildDonationLevelsConfiguration(subscriptionConfiguration, oneTimeDonationConfiguration, badgeTranslator,
acceptableLanguages),
new BackupConfiguration(backupLevels, subscriptionConfiguration.getbackupFreeTierMediaDuration().toDays()),
oneTimeDonationConfiguration.sepaMaximumEuros());
}
@ -265,15 +210,35 @@ public class SubscriptionController {
period of time will result in the subscription being canceled.
""")
@ApiResponse(responseCode = "200", description = "The subscriber was successfully created or refreshed")
@ApiResponse(responseCode = "401", description = "Donation permit was invalid or already spent")
@ApiResponse(responseCode = "403", description = "subscriberId authentication failure OR account authentication is present")
@ApiResponse(responseCode = "404", description = "subscriberId is malformed")
@ManagedAsync
public Response updateSubscriber(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@Parameter(description="A base64-encoded donation permit retrieved from POST /v1/donation/permit. Not required if the subscriber already exists.")
@HeaderParam(HeaderUtils.DONATION_PERMIT)
final Optional<DonationPermitHeader> donationPermitHeader,
@HeaderParam(HttpHeaders.USER_AGENT) @Nullable final String userAgent,
@PathParam("subscriberId") String subscriberId) throws SubscriptionException {
SubscriberCredentials subscriberCredentials =
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
subscriptionManager.updateSubscriber(subscriberCredentials);
SubscriptionsUtil.recordDonationPermitPresent(donationPermitHeader.isPresent(), "putSubscriber", userAgent);
final boolean creationPermitted = donationPermitHeader.map(
permitHeader -> {
try {
return SubscriptionsUtil.verifyAndSpendDonationPermit(permitHeader.permit(), donationPermitsManager, clock);
} catch (VerificationFailedException e) {
return false;
}
})
.orElse(true);
subscriptionManager.updateSubscriber(subscriberCredentials, creationPermitted);
return Response.ok().build();
}
@ -286,8 +251,14 @@ public class SubscriptionController {
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@ManagedAsync
@RateLimitedByIp(RateLimiters.For.ADD_SUBSCRIPTION_PAYMENT_METHOD)
public CreatePaymentMethodResponse createPaymentMethod(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@Parameter(description="A base64-encoded donation permit retrieved from POST /v1/donation/permit")
@HeaderParam(HeaderUtils.DONATION_PERMIT)
final Optional<DonationPermitHeader> donationPermitHeader,
@PathParam("subscriberId") String subscriberId,
@QueryParam("type") @DefaultValue("CARD") PaymentMethod paymentMethodType,
@HeaderParam(HttpHeaders.USER_AGENT) @Nullable final String userAgentString) throws SubscriptionException {
@ -305,6 +276,21 @@ public class SubscriptionController {
case UNKNOWN -> throw new BadRequestException("Invalid payment method");
};
SubscriptionsUtil.recordDonationPermitPresent(donationPermitHeader.isPresent(), "createPaymentMethod", userAgentString);
final boolean spendSuccessful = donationPermitHeader.map(
permitHeader -> {
try {
return SubscriptionsUtil.verifyAndSpendDonationPermit(permitHeader.permit(), donationPermitsManager, clock);
} catch (VerificationFailedException e) {
return false;
}
})
.orElse(true);
if (!spendSuccessful) {
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
}
final String token = subscriptionManager.addPaymentMethodToCustomer(
subscriberCredentials,
customerAwareSubscriptionPaymentProcessor,
@ -323,6 +309,7 @@ public class SubscriptionController {
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@ManagedAsync
@RateLimitedByIp(RateLimiters.For.ADD_SUBSCRIPTION_PAYMENT_METHOD)
public CreatePayPalBillingAgreementResponse createPayPalPaymentMethod(
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@PathParam("subscriberId") String subscriberId,
@ -332,10 +319,7 @@ public class SubscriptionController {
final SubscriberCredentials subscriberCredentials =
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
final Locale locale = HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext).stream()
.filter(l -> !"*".equals(l.getLanguage()))
.findFirst()
.orElse(Locale.US);
final Locale locale = getPayPalLocale(HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext));
final BraintreeManager.PayPalBillingAgreementApprovalDetails billingAgreementApprovalDetails = subscriptionManager.addPaymentMethodToCustomer(
subscriberCredentials,
@ -538,33 +522,12 @@ public class SubscriptionController {
@Schema(description = "A map of lower-cased ISO 3 currency codes to minimums and level-specific scalar amounts")
Map<String, CurrencyConfiguration> currencies,
@Schema(description = "A map of numeric donation level IDs to level-specific badge configuration")
Map<String, LevelConfiguration> levels,
Map<Long, LevelConfiguration> levels,
@Schema(description = "Backup specific configuration")
BackupConfiguration backup,
@Schema(description = "The maximum value of a one-time donation SEPA transaction")
BigDecimal sepaMaximumEuros) {}
@Schema(description = "Configuration for a currency - use to present appropriate client interfaces")
public record CurrencyConfiguration(
@Schema(description = "The minimum amount that may be submitted for a one-time donation in the currency")
BigDecimal minimum,
@Schema(description = "A map of numeric one-time donation level IDs to the list of default amounts to be presented")
Map<String, List<BigDecimal>> oneTime,
@Schema(description = "A map of numeric subscription level IDs to the amount charged for that level")
Map<String, BigDecimal> subscription,
@Schema(description = "A map of numeric backup level IDs to the amount charged for that level")
Map<String, BigDecimal> backupSubscription,
@Schema(description = "The payment methods that support the given currency")
List<String> supportedPaymentMethods) {}
@Schema(description = "Configuration for a donation level - use to present appropriate client interfaces")
public record LevelConfiguration(
@Deprecated(forRemoval = true) // may be removed after 2025-01-28
@Schema(description = "The localized name for the level")
String name,
@Schema(description = "The displayable badge associated with the level")
Badge badge) {}
public record BackupConfiguration(
@Schema(description = "A map of numeric backup level IDs to level-specific backup configuration")
Map<String, BackupLevelConfiguration> levels,
@ -764,7 +727,8 @@ public class SubscriptionController {
SubscriberCredentials subscriberCredentials = SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
try {
final SubscriptionManager.ReceiptResult receiptCredential = subscriptionManager.createReceiptCredentials(
subscriberCredentials, request, this::receiptExpirationWithGracePeriod);
subscriberCredentials, request.receiptCredentialRequest(),
r -> SubscriptionsUtil.receiptExpirationWithGracePeriod(subscriptionConfiguration, r));
final ReceiptCredentialResponse receiptCredentialResponse = receiptCredential.receiptCredentialResponse();
final CustomerAwareSubscriptionPaymentProcessor.ReceiptItem receipt = receiptCredential.receiptItem();
@ -819,19 +783,6 @@ public class SubscriptionController {
}
}
private Instant receiptExpirationWithGracePeriod(CustomerAwareSubscriptionPaymentProcessor.ReceiptItem receiptItem) {
final PaymentTime paymentTime = receiptItem.paymentTime();
return switch (subscriptionConfiguration.getSubscriptionLevel(receiptItem.level()).type()) {
case DONATION -> paymentTime.receiptExpiration(
subscriptionConfiguration.getBadgeExpiration(),
subscriptionConfiguration.getBadgeGracePeriod());
case BACKUP -> paymentTime.receiptExpiration(
subscriptionConfiguration.getBackupExpiration(),
subscriptionConfiguration.getBackupGracePeriod());
};
}
private String getSubscriptionTemplateId(long level, String currency, PaymentProvider processor) {
final SubscriptionLevelConfiguration config = subscriptionConfiguration.getSubscriptionLevel(level);
if (config == null) {
@ -850,13 +801,4 @@ public class SubscriptionController {
SetSubscriptionLevelErrorResponse.Error.Type.UNSUPPORTED_CURRENCY, null))))
.build()));
}
@Nullable
private static ClientPlatform getClientPlatform(@Nullable final String userAgentString) {
try {
return UserAgentUtil.parseUserAgentString(userAgentString).platform();
} catch (final UnrecognizedUserAgentException e) {
return null;
}
}
}

View File

@ -7,6 +7,7 @@ package org.whispersystems.textsecuregcm.controllers;
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
import com.google.common.annotations.VisibleForTesting;
import com.google.i18n.phonenumbers.NumberParseException;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import com.google.i18n.phonenumbers.Phonenumber;
@ -58,14 +59,12 @@ import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletionException;
import java.util.concurrent.TimeUnit;
import org.apache.commons.lang3.Strings;
import org.apache.http.HttpStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.captcha.AssessmentResult;
import org.whispersystems.textsecuregcm.captcha.InvalidCaptchaArgumentException;
import org.whispersystems.textsecuregcm.captcha.RegistrationCaptchaManager;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.entities.CreateVerificationSessionRequest;
@ -74,12 +73,15 @@ import org.whispersystems.textsecuregcm.entities.SubmitVerificationCodeRequest;
import org.whispersystems.textsecuregcm.entities.UpdateVerificationSessionRequest;
import org.whispersystems.textsecuregcm.entities.VerificationCodeRequest;
import org.whispersystems.textsecuregcm.entities.VerificationSessionResponse;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.filters.RemoteAddressFilter;
import org.whispersystems.textsecuregcm.identity.IdentityType;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.mappers.RegistrationServiceSenderExceptionMapper;
import org.whispersystems.textsecuregcm.metrics.CaptchaMetrics;
import org.whispersystems.textsecuregcm.metrics.DevicePlatformUtil;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.push.NotPushRegisteredException;
import org.whispersystems.textsecuregcm.push.PushNotification;
import org.whispersystems.textsecuregcm.push.PushNotificationManager;
import org.whispersystems.textsecuregcm.registration.ClientType;
@ -87,7 +89,6 @@ import org.whispersystems.textsecuregcm.registration.MessageTransport;
import org.whispersystems.textsecuregcm.registration.RegistrationFraudException;
import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient;
import org.whispersystems.textsecuregcm.registration.RegistrationServiceException;
import org.whispersystems.textsecuregcm.registration.RegistrationServiceSenderException;
import org.whispersystems.textsecuregcm.registration.TransportNotAllowedException;
import org.whispersystems.textsecuregcm.registration.VerificationSession;
import org.whispersystems.textsecuregcm.spam.RegistrationFraudChecker;
@ -101,7 +102,6 @@ import org.whispersystems.textsecuregcm.storage.VerificationSessionManager;
import org.whispersystems.textsecuregcm.telephony.CarrierData;
import org.whispersystems.textsecuregcm.telephony.CarrierDataException;
import org.whispersystems.textsecuregcm.telephony.CarrierDataProvider;
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
import org.whispersystems.textsecuregcm.util.ObsoletePhoneNumberFormatException;
import org.whispersystems.textsecuregcm.util.Pair;
import org.whispersystems.textsecuregcm.util.Util;
@ -113,7 +113,6 @@ public class VerificationController {
private static final Logger logger = LoggerFactory.getLogger(VerificationController.class);
private static final Duration REGISTRATION_RPC_TIMEOUT = Duration.ofSeconds(15);
private static final Duration DYNAMODB_TIMEOUT = Duration.ofSeconds(5);
private static final SecureRandom RANDOM = new SecureRandom();
@ -133,6 +132,9 @@ public class VerificationController {
private static final String EXISTING_ACCOUNT_PLATFORM = "existingAccountPlatform";
private static final String EXISTING_ACCOUNT_RECENTLY_SEEN_TAG_NAME = "existingAccountRecentlySeen";
@VisibleForTesting
static final String VERIFICATION_CODE_PUSH_NOTIFICATION_EXPERIMENT_NAME = "verificationCodePushNotification";
private final RegistrationServiceClient registrationServiceClient;
private final VerificationSessionManager verificationSessionManager;
private final PushNotificationManager pushNotificationManager;
@ -144,6 +146,7 @@ public class VerificationController {
private final CarrierDataProvider carrierDataProvider;
private final RegistrationFraudChecker registrationFraudChecker;
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
private final ExperimentEnrollmentManager experimentEnrollmentManager;
private final Clock clock;
public VerificationController(final RegistrationServiceClient registrationServiceClient,
@ -157,6 +160,7 @@ public class VerificationController {
final CarrierDataProvider carrierDataProvider,
final RegistrationFraudChecker registrationFraudChecker,
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
final ExperimentEnrollmentManager experimentEnrollmentManager,
final Clock clock) {
this.registrationServiceClient = registrationServiceClient;
this.verificationSessionManager = verificationSessionManager;
@ -169,6 +173,7 @@ public class VerificationController {
this.carrierDataProvider = carrierDataProvider;
this.registrationFraudChecker = registrationFraudChecker;
this.dynamicConfigurationManager = dynamicConfigurationManager;
this.experimentEnrollmentManager = experimentEnrollmentManager;
this.clock = clock;
}
@ -216,27 +221,13 @@ public class VerificationController {
maybeCarrierData = Optional.empty();
}
final RegistrationServiceSession registrationServiceSession;
try {
final String sourceHost = (String) requestContext.getProperty(RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME);
registrationServiceSession = registrationServiceClient.createRegistrationSession(phoneNumber,
sourceHost,
accountsManager.getByE164(request.number()).isPresent(),
maybeCarrierData.flatMap(CarrierData::mcc).orElse(null),
maybeCarrierData.flatMap(CarrierData::mnc).orElse(null),
REGISTRATION_RPC_TIMEOUT).join();
} catch (final CancellationException e) {
throw new ServerErrorException("registration service unavailable", Response.Status.SERVICE_UNAVAILABLE);
} catch (final CompletionException e) {
if (ExceptionUtils.unwrap(e) instanceof RateLimitExceededException re) {
throw re;
}
throw new ServerErrorException(Response.Status.INTERNAL_SERVER_ERROR, e);
}
final RegistrationServiceSession registrationServiceSession =
registrationServiceClient.createRegistrationSession(phoneNumber,
(String) requestContext.getProperty(RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME),
accountsManager.getByE164(request.number()).isPresent(),
maybeCarrierData.flatMap(CarrierData::mcc).orElse(null),
maybeCarrierData.flatMap(CarrierData::mnc).orElse(null),
REGISTRATION_RPC_TIMEOUT);
VerificationSession verificationSession = new VerificationSession(registrationServiceSession.encodedSessionId(),
null,
@ -255,7 +246,7 @@ public class VerificationController {
// if a push challenge sent in `handlePushToken` doesn't arrive in time
verificationSession.requestedInformation().add(VerificationSession.Information.CAPTCHA);
storeVerificationSession(verificationSession);
verificationSessionManager.insert(verificationSession);
return buildResponse(registrationServiceSession, verificationSession);
}
@ -328,24 +319,12 @@ public class VerificationController {
} finally {
// Each of the handle* methods may update requestedInformation, submittedInformation, and allowedToRequestCode,
// and we want to be sure to store a changes, even if a later method throws
updateStoredVerificationSession(verificationSession);
verificationSessionManager.update(verificationSession);
}
return buildResponse(registrationServiceSession, verificationSession);
}
private void storeVerificationSession(final VerificationSession verificationSession) {
verificationSessionManager.insert(verificationSession)
.orTimeout(DYNAMODB_TIMEOUT.toSeconds(), TimeUnit.SECONDS)
.join();
}
private void updateStoredVerificationSession(final VerificationSession verificationSession) {
verificationSessionManager.update(verificationSession)
.orTimeout(DYNAMODB_TIMEOUT.toSeconds(), TimeUnit.SECONDS)
.join();
}
/**
* If {@code pushTokenAndType} values are not {@code null}, sends a push challenge. If there is no existing push
* challenge in the session, one will be created, set on the returned session record, and
@ -501,6 +480,8 @@ public class VerificationController {
} catch (final IOException e) {
logger.error("error assessing captcha during registration verification", e);
throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE, e);
} catch (InvalidCaptchaArgumentException e) {
throw new BadRequestException(e);
}
if (assessmentResult.isValid(captchaScoreThreshold)) {
@ -655,49 +636,44 @@ public class VerificationController {
clientType,
acceptLanguage.orElse(null),
senderOverride,
REGISTRATION_RPC_TIMEOUT).join();
} catch (final CancellationException e) {
throw new ServerErrorException("registration service unavailable", Response.Status.SERVICE_UNAVAILABLE);
} catch (final CompletionException e) {
final Throwable unwrappedException = ExceptionUtils.unwrap(e);
switch (unwrappedException) {
case RateLimitExceededException rateLimitExceededException -> {
if (rateLimitExceededException instanceof VerificationSessionRateLimitExceededException ve) {
final Response response = buildResponseForRateLimitExceeded(verificationSession,
ve.getRegistrationSession(),
ve.getRetryDuration());
throw new ClientErrorException(response);
}
REGISTRATION_RPC_TIMEOUT);
} catch (final VerificationSessionRateLimitExceededException e) {
throw new ClientErrorException(buildResponseForRateLimitExceeded(verificationSession,
e.getRegistrationSession(),
e.getRetryDuration()));
} catch (final RegistrationServiceException registrationServiceException) {
throw registrationServiceException.getRegistrationSession()
.map(s -> buildResponse(s, verificationSession))
.map(verificationSessionResponse -> {
final Response response = registrationServiceException instanceof TransportNotAllowedException
? Response.status(418).entity(verificationSessionResponse).build()
: Response.status(Response.Status.CONFLICT).entity(verificationSessionResponse).build();
throw new RateLimitExceededException(rateLimitExceededException.getRetryDuration().orElse(null));
}
case RegistrationServiceException registrationServiceException ->
throw registrationServiceException.getRegistrationSession()
.map(s -> buildResponse(s, verificationSession))
.map(verificationSessionResponse -> {
final Response response = registrationServiceException instanceof TransportNotAllowedException
? Response.status(418).entity(verificationSessionResponse).build()
: Response.status(Response.Status.CONFLICT).entity(verificationSessionResponse).build();
return new ClientErrorException(response);
})
.orElseGet(NotFoundException::new);
case RegistrationFraudException _ -> {
if (dynamicConfigurationManager.getConfiguration().getRegistrationConfiguration()
.squashDeclinedAttemptErrors()) {
return buildResponse(registrationServiceSession, verificationSession);
} else {
throw unwrappedException.getCause();
}
}
case RegistrationServiceSenderException _ -> throw unwrappedException;
case null, default -> {
logger.error("Registration service failure", unwrappedException);
throw new ServerErrorException(Response.Status.INTERNAL_SERVER_ERROR);
}
return new ClientErrorException(response);
})
.orElseGet(NotFoundException::new);
} catch (final RegistrationFraudException e) {
if (dynamicConfigurationManager.getConfiguration().getRegistrationConfiguration()
.squashDeclinedAttemptErrors()) {
return buildResponse(registrationServiceSession, verificationSession);
} else {
throw e.getCause();
}
} catch (final RuntimeException e) {
logger.error("Registration service failure", e);
throw new ServerErrorException(Response.Status.INTERNAL_SERVER_ERROR);
}
accountsManager.getByE164(registrationServiceSession.number())
.filter(existingAccount ->
experimentEnrollmentManager.isEnrolled(existingAccount.getIdentifier(IdentityType.ACI), VERIFICATION_CODE_PUSH_NOTIFICATION_EXPERIMENT_NAME))
.ifPresent(existingAccount -> {
try {
pushNotificationManager.sendVerificationCodeRequestedNotifications(existingAccount, clock.instant());
} catch (final NotPushRegisteredException _) {
}
});
Metrics.counter(CODE_REQUESTED_COUNTER_NAME, Tags.of(
UserAgentTagUtil.getPlatformTag(userAgent),
Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(registrationServiceSession.number())),
@ -737,8 +713,7 @@ public class VerificationController {
schema = @Schema(implementation = Integer.class)))
public VerificationSessionResponse verifyCode(@PathParam("sessionId") final String encodedSessionId,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
@NotNull @Valid final SubmitVerificationCodeRequest submitVerificationCodeRequest)
throws RateLimitExceededException {
@NotNull @Valid final SubmitVerificationCodeRequest submitVerificationCodeRequest) {
final RegistrationServiceSession registrationServiceSession = retrieveRegistrationServiceSession(encodedSessionId);
final VerificationSession verificationSession = retrieveVerificationSession(registrationServiceSession);
@ -755,35 +730,17 @@ public class VerificationController {
try {
resultSession = registrationServiceClient.checkVerificationCode(registrationServiceSession.id(),
submitVerificationCodeRequest.code(),
REGISTRATION_RPC_TIMEOUT)
.join();
} catch (final CancellationException e) {
logger.warn("Unexpected cancellation from registration service", e);
throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE);
} catch (final CompletionException e) {
final Throwable unwrappedException = ExceptionUtils.unwrap(e);
if (unwrappedException instanceof RateLimitExceededException rateLimitExceededException) {
if (rateLimitExceededException instanceof VerificationSessionRateLimitExceededException ve) {
final Response response = buildResponseForRateLimitExceeded(verificationSession, ve.getRegistrationSession(),
ve.getRetryDuration());
throw new ClientErrorException(response);
}
throw new RateLimitExceededException(rateLimitExceededException.getRetryDuration().orElse(null));
} else if (unwrappedException instanceof RegistrationServiceException registrationServiceException) {
throw registrationServiceException.getRegistrationSession()
.map(s -> buildResponse(s, verificationSession))
.map(verificationSessionResponse -> new ClientErrorException(
Response.status(Response.Status.CONFLICT).entity(verificationSessionResponse).build()))
.orElseGet(NotFoundException::new);
} else {
logger.error("Registration service failure", unwrappedException);
throw new ServerErrorException(Response.Status.INTERNAL_SERVER_ERROR);
}
REGISTRATION_RPC_TIMEOUT);
} catch (final VerificationSessionRateLimitExceededException e) {
throw new ClientErrorException(buildResponseForRateLimitExceeded(verificationSession,
e.getRegistrationSession(),
e.getRetryDuration()));
} catch (final RegistrationServiceException e) {
throw e.getRegistrationSession()
.map(s -> buildResponse(s, verificationSession))
.map(verificationSessionResponse -> new ClientErrorException(
Response.status(Response.Status.CONFLICT).entity(verificationSessionResponse).build()))
.orElseGet(NotFoundException::new);
}
boolean existingRRP = false;
@ -792,7 +749,7 @@ public class VerificationController {
// the RRP. It's possible the client will not actually be able to register (e.g. failed reglock challenge), and
// so we will have removed the RRP unnecessarily. The impact of this is low, since the owner of the RRP
// can always just fallback to session-based verification.
existingRRP = registrationRecoveryPasswordsManager.remove(phoneNumberIdentifiers.getPhoneNumberIdentifier(registrationServiceSession.number()).join()).join();
existingRRP = registrationRecoveryPasswordsManager.remove(phoneNumberIdentifiers.getPhoneNumberIdentifier(registrationServiceSession.number()).join());
}
Optional<Account> maybeExistingAccount;
@ -859,28 +816,29 @@ public class VerificationController {
}
try {
final RegistrationServiceSession registrationServiceSession = registrationServiceClient.getSession(sessionId,
REGISTRATION_RPC_TIMEOUT).join()
.orElseThrow(NotFoundException::new);
final RegistrationServiceSession registrationServiceSession =
registrationServiceClient.getSession(sessionId, REGISTRATION_RPC_TIMEOUT).orElseThrow(NotFoundException::new);
if (registrationServiceSession.verified()) {
// Since there is a valid verification session we invalidate the other possible verification mechanism,
// the RRP. It's possible the client will not actually be able to register (e.g. failed reglock challenge), and
// so we will have removed the RRP unnecessarily. The impact of this is low, since the owner of the RRP
// can always just fallback to session-based verification.
registrationRecoveryPasswordsManager.remove(phoneNumberIdentifiers.getPhoneNumberIdentifier(registrationServiceSession.number()).join()).join();
registrationRecoveryPasswordsManager.remove(phoneNumberIdentifiers.getPhoneNumberIdentifier(registrationServiceSession.number()).join());
}
return registrationServiceSession;
} catch (final CompletionException | CancellationException e) {
final Throwable unwrapped = ExceptionUtils.unwrap(e);
if (unwrapped instanceof StatusRuntimeException grpcRuntimeException) {
if (grpcRuntimeException.getStatus().getCode() == Status.Code.INVALID_ARGUMENT) {
throw new BadRequestException();
}
} catch (final StatusRuntimeException e) {
if (e.getStatus().getCode() == Status.Code.INVALID_ARGUMENT) {
throw new BadRequestException();
}
logger.error("Registration service failure", e);
throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE, e);
} catch (final WebApplicationException e) {
throw e;
} catch (final RuntimeException e) {
logger.error("Registration service failure", e);
throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE, e);
}
@ -892,8 +850,7 @@ public class VerificationController {
private VerificationSession retrieveVerificationSession(final RegistrationServiceSession registrationServiceSession) {
return verificationSessionManager.findForId(registrationServiceSession.encodedSessionId())
.orTimeout(5, TimeUnit.SECONDS)
.join().orElseThrow(NotFoundException::new);
.orElseThrow(NotFoundException::new);
}
/**

View File

@ -0,0 +1,20 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
public record CreateDonationPermitResponse(
@Schema(description = "A serialized DonationPermitResponse")
@NotEmpty
@NotNull
@Valid
byte[] permitResponse) {
}

View File

@ -0,0 +1,18 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
public record CreateDonationPermitsRequest(
@Schema(description = "A serialized DonationPermitRequest")
@NotEmpty
@NotNull
byte[] permitRequest) {
}

View File

@ -50,7 +50,7 @@ public record IncomingMessage(int type,
.setType(MessageProtos.Envelope.Type.forNumber(type))
.setClientTimestamp(timestamp)
.setServerTimestamp(clock.millis())
.setDestinationServiceId(destinationIdentifier.toServiceIdentifierString())
.setDestinationServiceId(destinationIdentifier.toCompactByteString())
.setEphemeral(ephemeral)
.setUrgent(urgent);
@ -61,7 +61,7 @@ public record IncomingMessage(int type,
if (sourceServiceIdentifier != null && sourceDeviceId != null) {
envelopeBuilder
.setSourceServiceId(sourceServiceIdentifier.toServiceIdentifierString())
.setSourceServiceId(sourceServiceIdentifier.toCompactByteString())
.setSourceDevice(sourceDeviceId.intValue());
}

View File

@ -1,124 +0,0 @@
/*
* Copyright 2013-2020 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
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;
import java.util.UUID;
import javax.annotation.Nullable;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
import org.whispersystems.textsecuregcm.util.ServiceIdentifierAdapter;
public record OutgoingMessageEntity(UUID guid,
int type,
long timestamp,
@JsonSerialize(using = ServiceIdentifierAdapter.ServiceIdentifierSerializer.class)
@JsonDeserialize(using = ServiceIdentifierAdapter.ServiceIdentifierDeserializer.class)
@Nullable
ServiceIdentifier sourceUuid,
int sourceDevice,
@JsonSerialize(using = ServiceIdentifierAdapter.ServiceIdentifierSerializer.class)
@JsonDeserialize(using = ServiceIdentifierAdapter.ServiceIdentifierDeserializer.class)
ServiceIdentifier destinationUuid,
@Nullable UUID updatedPni,
byte[] content,
long serverTimestamp,
boolean urgent,
boolean story,
@Nullable byte[] reportSpamToken) {
@VisibleForTesting
MessageProtos.Envelope toEnvelope() {
final MessageProtos.Envelope.Builder builder = MessageProtos.Envelope.newBuilder()
.setType(MessageProtos.Envelope.Type.forNumber(type()))
.setClientTimestamp(timestamp())
.setServerTimestamp(serverTimestamp())
.setDestinationServiceId(destinationUuid().toServiceIdentifierString())
.setServerGuid(guid().toString())
.setUrgent(urgent);
if (story) {
// Avoid sending this field if it's false.
builder.setStory(true);
}
if (sourceUuid() != null) {
builder.setSourceServiceId(sourceUuid().toServiceIdentifierString());
builder.setSourceDevice(sourceDevice());
}
if (content() != null) {
builder.setContent(ByteString.copyFrom(content()));
}
if (updatedPni() != null) {
builder.setUpdatedPni(updatedPni().toString());
}
if (reportSpamToken != null) {
builder.setReportSpamToken(ByteString.copyFrom(reportSpamToken));
}
return builder.build();
}
public static OutgoingMessageEntity fromEnvelope(final MessageProtos.Envelope envelope) {
ByteString token = envelope.getReportSpamToken();
return new OutgoingMessageEntity(
UUID.fromString(envelope.getServerGuid()),
envelope.getType().getNumber(),
envelope.getClientTimestamp(),
envelope.hasSourceServiceId() ? ServiceIdentifier.valueOf(envelope.getSourceServiceId()) : null,
envelope.getSourceDevice(),
envelope.hasDestinationServiceId() ? ServiceIdentifier.valueOf(envelope.getDestinationServiceId()) : null,
envelope.hasUpdatedPni() ? UUID.fromString(envelope.getUpdatedPni()) : null,
envelope.getContent().toByteArray(),
envelope.getServerTimestamp(),
envelope.getUrgent(),
envelope.getStory(),
token.isEmpty() ? null : token.toByteArray());
}
@Override
public boolean equals(final Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
final OutgoingMessageEntity that = (OutgoingMessageEntity) o;
return guid.equals(that.guid) &&
type == that.type &&
timestamp == that.timestamp &&
Objects.equals(sourceUuid, that.sourceUuid) &&
sourceDevice == that.sourceDevice &&
destinationUuid.equals(that.destinationUuid) &&
Objects.equals(updatedPni, that.updatedPni) &&
Arrays.equals(content, that.content) &&
serverTimestamp == that.serverTimestamp &&
urgent == that.urgent &&
story == that.story &&
Arrays.equals(reportSpamToken, that.reportSpamToken);
}
@Override
public int hashCode() {
int result = Objects.hash(
guid, type, timestamp, sourceUuid, sourceDevice, destinationUuid, updatedPni, serverTimestamp, urgent, story);
result = 31 * result + Arrays.hashCode(content);
result = 71 * result + Arrays.hashCode(reportSpamToken);
return result;
}
}

View File

@ -1,11 +0,0 @@
/*
* Copyright 2013-2020 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
import java.util.List;
public record OutgoingMessageEntityList(List<OutgoingMessageEntity> messages, boolean more) {
}

View File

@ -1,45 +0,0 @@
/*
* Copyright 2013-2020 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import java.time.Instant;
import java.util.List;
import io.swagger.v3.oas.annotations.media.Schema;
import org.whispersystems.textsecuregcm.util.InstantAdapter;
public class UserRemoteConfigList {
@JsonProperty
@Schema(description = "List of remote configurations applicable to the user")
private List<UserRemoteConfig> config;
@JsonProperty
@JsonSerialize(using = InstantAdapter.EpochSecondSerializer.class)
@JsonFormat(shape = JsonFormat.Shape.NUMBER_INT)
@Schema(description = """
Timestamp when the configuration was generated. Deprecated in favor of `X-Signal-Timestamp` response header.
""", deprecated = true)
@Deprecated
private Instant serverEpochTime;
public UserRemoteConfigList() {}
public UserRemoteConfigList(List<UserRemoteConfig> config, Instant serverEpochTime) {
this.config = config;
this.serverEpochTime = serverEpochTime;
}
public List<UserRemoteConfig> getConfig() {
return config;
}
public Instant getServerEpochTime() {
return serverEpochTime;
}
}

View File

@ -71,6 +71,10 @@ public class Experiment {
this.experimentNullMismatchTimer = experimentNullMismatchTimer;
}
public <T> void compareResult(final T expected, final T experimentResult) {
recordResult(expected, experimentResult, Timer.start());
}
public <T> void compareMonoResult(final T expected, final Mono<T> experimentMono) {
final Timer.Sample sample = Timer.start();

View File

@ -0,0 +1,82 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.filters;
import jakarta.servlet.DispatcherType;
import jakarta.servlet.Filter;
import jakarta.servlet.ServletContext;
import java.util.EnumSet;
import java.util.Objects;
import org.eclipse.jetty.ee10.servlet.FilterHolder;
import org.eclipse.jetty.ee10.servlet.FilterMapping;
import org.eclipse.jetty.ee10.servlet.ServletContextHandler;
import org.eclipse.jetty.ee10.servlet.ServletHandler;
import org.eclipse.jetty.server.handler.ContextHandler;
import org.eclipse.jetty.util.component.LifeCycle;
public class PriorityFilter {
private PriorityFilter() {}
private static FilterHolder getFilter(ServletContext servletContext, final Class<? extends Filter> filterClass) {
final ContextHandler contextHandler = Objects.requireNonNull(ServletContextHandler.getServletContextHandler(servletContext));
final ServletHandler servletHandler = contextHandler.getDescendant(ServletHandler.class);
return servletHandler.getFilter(filterClass.getName());
}
/**
* Ensure a filter is available on the provided ServletContext, a new filter will added if one does not already
* exist.
* <p>
* If a new filter is added, it will be added before all other filters.
* <p>
* Modeled after {@link org.eclipse.jetty.ee10.websocket.servlet.WebSocketUpgradeFilter#ensureFilter(ServletContext)},
* since its use of {@link org.eclipse.jetty.ee10.servlet.ServletHandler#prependFilter(FilterHolder)} is what makes
* this necessary.
*/
public static void ensureFilter(final ServletContext servletContext, final Filter filter) {
FilterHolder existingFilter = getFilter(servletContext, filter.getClass());
if (existingFilter != null) {
return;
}
final ContextHandler contextHandler = ServletContextHandler.getServletContextHandler(servletContext);
final ServletHandler servletHandler = contextHandler.getDescendant(ServletHandler.class);
final String pathSpec = "/*";
final FilterHolder holder = new FilterHolder(filter);
holder.setName(filter.getClass().getName());
holder.setAsyncSupported(true);
final FilterMapping mapping = new FilterMapping();
mapping.setFilterName(holder.getName());
mapping.setPathSpec(pathSpec);
mapping.setDispatcherTypes(EnumSet.of(DispatcherType.REQUEST));
// Add as the first filter in the list.
servletHandler.prependFilter(holder);
servletHandler.prependFilterMapping(mapping);
// If we create the filter we must also make sure it is removed if the context is stopped.
contextHandler.addEventListener(new LifeCycle.Listener()
{
@Override
public void lifeCycleStopping(LifeCycle event)
{
servletHandler.removeFilterHolder(holder);
servletHandler.removeFilterMapping(mapping);
contextHandler.removeEventListener(this);
}
@Override
public String toString()
{
return String.format("%sCleanupListener", filter.getClass().getSimpleName());
}
});
}
}

View File

@ -26,10 +26,6 @@ public class RemoteAddressFilter implements Filter {
public static final String REMOTE_ADDRESS_ATTRIBUTE_NAME = RemoteAddressFilter.class.getName() + ".remoteAddress";
private static final Logger logger = LoggerFactory.getLogger(RemoteAddressFilter.class);
public RemoteAddressFilter() {
}
@Override
public void doFilter(final ServletRequest request, final ServletResponse response, final FilterChain chain)
throws ServletException, IOException {

View File

@ -0,0 +1,71 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.filters;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.nio.ByteBuffer;
import org.eclipse.jetty.ee10.servlet.ServletContextRequest;
import org.eclipse.jetty.http.HttpFields;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.http.HttpVersion;
import org.eclipse.jetty.http.MetaData;
import org.eclipse.jetty.server.HttpStream;
import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.util.Callback;
/// Our current version of jetty (12.1.5) has a bug where it includes content-length:0 on
/// CONNECT websocket upgrade requests. Providing an HTTP/2 header frame with a
/// content-length that does not match the sum of the lengths of the data frames is technically
/// a malformed HTTP/2 stream and our netty-based reverse proxy implementation rejects it. This
/// filter strips out the superfluous content-length at stream-send time. It can be removed once
/// we update to a jetty version that fixes [jetty/jetty.project#15074](https://github.com/jetty/jetty.project/issues/15074)
public class StripContentLengthOnConnectFilter implements Filter {
@Override
public void doFilter(final ServletRequest request, final ServletResponse response, final FilterChain chain)
throws IOException, ServletException {
if (request instanceof HttpServletRequest hsr &&
HttpVersion.HTTP_2.is(hsr.getProtocol()) &&
HttpMethod.CONNECT.is(hsr.getMethod())) {
final Request coreRequest = ServletContextRequest.getServletContextRequest(hsr);
if (coreRequest != null) {
coreRequest.addHttpStreamWrapper(StripContentLengthStream::new);
}
}
chain.doFilter(request, response);
}
private static class StripContentLengthStream extends HttpStream.Wrapper {
StripContentLengthStream(final HttpStream wrapped) {
super(wrapped);
}
@Override
public void send(MetaData.Request request, MetaData.Response response, boolean last, ByteBuffer content,
Callback callback) {
if (response != null && response.getStatus() == 200 && response.getHttpFields()
.contains(HttpHeader.CONTENT_LENGTH)) {
final HttpFields fieldsWithoutContentLengthHeader =
HttpFields.build(response.getHttpFields()).remove(HttpHeader.CONTENT_LENGTH);
response = new MetaData.Response(
response.getStatus(),
response.getReason(),
response.getHttpVersion(),
fieldsWithoutContentLengthHeader,
-1,
response.getTrailersSupplier());
}
super.send(request, response, last, content, callback);
}
}
}

View File

@ -38,7 +38,7 @@ public class AccountsAnonymousGrpcService extends SimpleAccountsAnonymousGrpc.Ac
throws RateLimitExceededException {
final ServiceIdentifier serviceIdentifier =
ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getServiceIdentifier());
GrpcServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getServiceIdentifier());
RateLimitUtil.rateLimitByRemoteAddress(rateLimiters.getCheckAccountExistenceLimiter());
@ -55,7 +55,7 @@ public class AccountsAnonymousGrpcService extends SimpleAccountsAnonymousGrpc.Ac
return accountsManager.getByUsernameHash(request.getUsernameHash().toByteArray()).join()
.map(account -> LookupUsernameHashResponse.newBuilder()
.setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(account.getUuid())))
.setServiceIdentifier(GrpcServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(account.getUuid())))
.build())
.orElseGet(() -> LookupUsernameHashResponse.newBuilder().setNotFound(NotFound.getDefaultInstance()).build());
}

View File

@ -6,10 +6,12 @@
package org.whispersystems.textsecuregcm.grpc;
import com.google.protobuf.ByteString;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import org.signal.chat.account.ClearRegistrationLockRequest;
import org.signal.chat.account.ClearRegistrationLockResponse;
@ -46,7 +48,6 @@ import org.whispersystems.textsecuregcm.auth.SaltedTokenHash;
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;
import org.whispersystems.textsecuregcm.controllers.AccountController;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
import org.whispersystems.textsecuregcm.identity.IdentityType;
@ -62,6 +63,8 @@ import org.whispersystems.textsecuregcm.util.UsernameHashZkProofVerifier;
public class AccountsGrpcService extends SimpleAccountsGrpc.AccountsImplBase {
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private final AccountsManager accountsManager;
private final RateLimiters rateLimiters;
private final UsernameHashZkProofVerifier usernameHashZkProofVerifier;
@ -83,8 +86,8 @@ public class AccountsGrpcService extends SimpleAccountsGrpc.AccountsImplBase {
final Account account = getAuthenticatedAccount();
final AccountIdentifiers.Builder accountIdentifiersBuilder = AccountIdentifiers.newBuilder()
.addServiceIdentifiers(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(account.getUuid())))
.addServiceIdentifiers(ServiceIdentifierUtil.toGrpcServiceIdentifier(new PniServiceIdentifier(account.getPhoneNumberIdentifier())))
.addServiceIdentifiers(GrpcServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(account.getUuid())))
.addServiceIdentifiers(GrpcServiceIdentifierUtil.toGrpcServiceIdentifier(new PniServiceIdentifier(account.getPhoneNumberIdentifier())))
.setE164(account.getNumber());
account.getUsernameHash().ifPresent(usernameHash ->
@ -132,11 +135,6 @@ public class AccountsGrpcService extends SimpleAccountsGrpc.AccountsImplBase {
final List<byte[]> usernameHashes = new ArrayList<>(request.getUsernameHashesCount());
for (final ByteString usernameHash : request.getUsernameHashesList()) {
if (usernameHash.size() != AccountController.USERNAME_HASH_LENGTH) {
throw GrpcExceptions.fieldViolation("username_hashes",
String.format("Username hash length must be %d bytes, but was actually %d",
AccountController.USERNAME_HASH_LENGTH, usernameHash.size()));
}
usernameHashes.add(usernameHash.toByteArray());
}
@ -164,7 +162,7 @@ public class AccountsGrpcService extends SimpleAccountsGrpc.AccountsImplBase {
try {
usernameHashZkProofVerifier.verifyProof(request.getZkProof().toByteArray(), request.getUsernameHash().toByteArray());
} catch (final BaseUsernameException e) {
throw GrpcExceptions.constraintViolation("Could not verify proof");
throw GrpcExceptions.invalidArguments("Could not verify proof");
}
rateLimiters.getUsernameSetLimiter().validate(authenticatedDevice.accountIdentifier());
@ -263,8 +261,7 @@ public class AccountsGrpcService extends SimpleAccountsGrpc.AccountsImplBase {
@Override
public SetRegistrationRecoveryPasswordResponse setRegistrationRecoveryPassword(final SetRegistrationRecoveryPasswordRequest request) {
registrationRecoveryPasswordsManager.store(getAuthenticatedAccount().getIdentifier(IdentityType.PNI),
request.getRegistrationRecoveryPassword().toByteArray())
.join();
request.getRegistrationRecoveryPassword().toByteArray());
return SetRegistrationRecoveryPasswordResponse.getDefaultInstance();
}
@ -277,14 +274,24 @@ public class AccountsGrpcService extends SimpleAccountsGrpc.AccountsImplBase {
final byte[] zkCredentialKey = request.getPublicKey().toByteArray();
if (Arrays.equals(authenticatedAccount.getZkCredentialKey(), zkCredentialKey)) {
return SetZkCredentialKeyResponse.getDefaultInstance();
return SetZkCredentialKeyResponse.newBuilder()
.setRotationId(Objects.requireNonNull(authenticatedAccount.getZkCredentialKeyRotationId()))
.build();
}
rateLimiters.getSetZkCredentialKeyLimiter().validate(authenticatedDevice.accountIdentifier());
accountsManager.update(authenticatedDevice.accountIdentifier(), account -> account.setZkCredentialKey(zkCredentialKey));
// It is technically fine from the credential's perspective if it is zero, but it's clearer to never have the default value
final long rotationId = SECURE_RANDOM.nextLong(1, Long.MAX_VALUE);
return SetZkCredentialKeyResponse.getDefaultInstance();
accountsManager.update(authenticatedDevice.accountIdentifier(), account -> {
account.setZkCredentialKey(zkCredentialKey);
account.setZkCredentialKeyRotationId(rotationId);
});
return SetZkCredentialKeyResponse.newBuilder()
.setRotationId(rotationId)
.build();
}
private Account getAuthenticatedAccount() {

View File

@ -6,10 +6,21 @@
package org.whispersystems.textsecuregcm.grpc;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Instant;
import java.util.HexFormat;
import java.util.Map;
import java.util.stream.IntStream;
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.chat.attachments.GetStickerUploadFormRequest;
import org.signal.chat.attachments.GetStickerUploadFormResponse;
import org.signal.chat.attachments.GetUploadFormRequest;
import org.signal.chat.attachments.GetUploadFormResponse;
import org.signal.chat.attachments.SimpleAttachmentsGrpc;
import org.signal.chat.common.S3UploadForm;
import org.signal.chat.common.UploadForm;
import org.signal.chat.errors.FailedPrecondition;
import org.whispersystems.textsecuregcm.attachments.AttachmentGenerator;
@ -18,30 +29,51 @@ import org.whispersystems.textsecuregcm.attachments.GcsAttachmentGenerator;
import org.whispersystems.textsecuregcm.attachments.TusAttachmentGenerator;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV4;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.controllers.StickerController;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
public class AttachmentsGrpcService extends SimpleAttachmentsGrpc.AttachmentsImplBase {
private final ExperimentEnrollmentManager experimentEnrollmentManager;
private final RateLimiter countRateLimiter;
private final RateLimiter bytesRateLimiter;
private final RateLimiter stickerPackLimiter;
private final long maxUploadLength;
private final Map<Integer, AttachmentGenerator> attachmentGenerators;
private final PostPolicyGenerator stickerPolicyGenerator;
private final Clock clock;
private final SecureRandom secureRandom;
private static final S3UploadForm PROTOTYPE_STICKER_UPLOAD_FORM = S3UploadForm.newBuilder()
.setAcl(PostPolicyGenerator.ACL)
.setAlgorithm(PostPolicyGenerator.ALGORITHM)
.build();
private static final String ATTACHMENT_SIZE_NAME =
MetricsUtil.name(AttachmentsGrpcService.class, "attachmentSize");
public AttachmentsGrpcService(
final ExperimentEnrollmentManager experimentEnrollmentManager,
final RateLimiters rateLimiters,
final GcsAttachmentGenerator gcsAttachmentGenerator,
final TusAttachmentGenerator tusAttachmentGenerator,
final long maxUploadLength) {
final PostPolicyGenerator stickerPolicyGenerator,
final long maxUploadLength,
final Clock clock) {
this.experimentEnrollmentManager = experimentEnrollmentManager;
this.countRateLimiter = rateLimiters.getAttachmentLimiter();
this.bytesRateLimiter = rateLimiters.getAttachmentBytesLimiter();
this.stickerPackLimiter = rateLimiters.getStickerPackLimiter();
this.stickerPolicyGenerator = stickerPolicyGenerator;
this.maxUploadLength = maxUploadLength;
this.clock = clock;
this.secureRandom = new SecureRandom();
this.attachmentGenerators = Map.of(
2, gcsAttachmentGenerator,
@ -67,6 +99,11 @@ public class AttachmentsGrpcService extends SimpleAttachmentsGrpc.AttachmentsImp
throw e;
}
DistributionSummary.builder(ATTACHMENT_SIZE_NAME)
.tags(Tags.of(UserAgentTagUtil.getPlatformTag(RequestAttributesUtil.getUserAgent().orElse(null))))
.register(Metrics.globalRegistry)
.record(request.getUploadLength());
final String key = AttachmentUtil.generateAttachmentKey(secureRandom);
final boolean useCdn3 = this.experimentEnrollmentManager.isEnrolled(auth.accountIdentifier(),
AttachmentUtil.CDN3_EXPERIMENT_NAME);
@ -80,4 +117,59 @@ public class AttachmentsGrpcService extends SimpleAttachmentsGrpc.AttachmentsImp
.setSignedUploadLocation(descriptor.signedUploadLocation()))
.build();
}
@Override
public GetStickerUploadFormResponse getStickerUploadForm(final GetStickerUploadFormRequest request)
throws RateLimitExceededException {
stickerPackLimiter.validate(AuthenticationUtil.requireAuthenticatedDevice().accountIdentifier());
final Instant currentTime = clock.instant();
final String packId;
{
final byte[] packIdBytes = new byte[16];
secureRandom.nextBytes(packIdBytes);
packId = HexFormat.of().formatHex(packIdBytes);
}
final String packLocation = "stickers/" + packId;
final GetStickerUploadFormResponse.Builder responseBuilder = GetStickerUploadFormResponse.newBuilder()
.setPackId(packId);
{
final String manifestKey = packLocation + "/manifest.proto";
final PostPolicyGenerator.SignedPostPolicy manifestPolicy =
stickerPolicyGenerator.createFor(manifestKey, StickerController.MAXIMUM_STICKER_MANIFEST_SIZE_BYTES, currentTime);
responseBuilder
.setManifestUploadForm(PROTOTYPE_STICKER_UPLOAD_FORM.toBuilder()
.setKey(manifestKey)
.setCredential(manifestPolicy.credential())
.setDate(manifestPolicy.formattedTimestamp())
.setPolicy(manifestPolicy.encodedPolicy())
.setSignature(manifestPolicy.signature())
.build());
}
IntStream.range(0, request.getStickerCount())
.mapToObj(i -> {
final String stickerKey = packLocation + "/full/" + i;
final PostPolicyGenerator.SignedPostPolicy stickerPolicy =
stickerPolicyGenerator.createFor(stickerKey, StickerController.MAXIMUM_STICKER_SIZE_BYTES, currentTime);
return PROTOTYPE_STICKER_UPLOAD_FORM.toBuilder()
.setKey(stickerKey)
.setCredential(stickerPolicy.credential())
.setDate(stickerPolicy.formattedTimestamp())
.setPolicy(stickerPolicy.encodedPolicy())
.setSignature(stickerPolicy.signature())
.build();
})
.forEach(responseBuilder::addStickerUploadForms);
return responseBuilder.build();
}
}

View File

@ -8,6 +8,7 @@ import com.google.protobuf.ByteString;
import com.google.protobuf.Empty;
import java.util.Optional;
import java.util.concurrent.Flow;
import org.signal.chat.backup.BackupStreamClosed;
import org.signal.chat.backup.CopyMediaRequest;
import org.signal.chat.backup.CopyMediaResponse;
import org.signal.chat.backup.DeleteAllRequest;
@ -62,11 +63,13 @@ public class BackupsAnonymousGrpcService extends SimpleBackupsAnonymousGrpc.Back
private final BackupManager backupManager;
private final BackupMetrics backupMetrics;
private final long maxAttachmentSize;
private final long maxMessageBackupSize;
public BackupsAnonymousGrpcService(final BackupManager backupManager, final BackupMetrics backupMetrics, final long maxAttachmentSize) {
public BackupsAnonymousGrpcService(final BackupManager backupManager, final BackupMetrics backupMetrics, final long maxAttachmentSize, final long maxMessageBackupSize) {
this.backupManager = backupManager;
this.backupMetrics = backupMetrics;
this.maxAttachmentSize = maxAttachmentSize;
this.maxMessageBackupSize = maxMessageBackupSize;
}
@Override
@ -157,16 +160,19 @@ public class BackupsAnonymousGrpcService extends SimpleBackupsAnonymousGrpc.Back
}
@Override
public SetPublicKeyResponse setPublicKey(final SetPublicKeyRequest request)
throws BackupFailedZkAuthenticationException {
public SetPublicKeyResponse setPublicKey(final SetPublicKeyRequest request) {
final ECPublicKey publicKey = deserialize(ECPublicKey::new, request.getPublicKey().toByteArray());
final BackupAuthCredentialPresentation presentation = deserialize(
BackupAuthCredentialPresentation::new,
request.getSignedPresentation().getPresentation().toByteArray());
final byte[] signature = request.getSignedPresentation().getPresentationSignature().toByteArray();
backupManager.setPublicKey(presentation, signature, publicKey);
return SetPublicKeyResponse.newBuilder().setSuccess(Empty.getDefaultInstance()).build();
try {
backupManager.setPublicKey(presentation, signature, publicKey);
return SetPublicKeyResponse.newBuilder().setSuccess(Empty.getDefaultInstance()).build();
} catch (BackupFailedZkAuthenticationException e) {
return SetPublicKeyResponse.newBuilder().setFailedAuthentication(FailedZkAuthentication.getDefaultInstance()).build();
}
}
@ -182,18 +188,21 @@ public class BackupsAnonymousGrpcService extends SimpleBackupsAnonymousGrpc.Back
.build();
}
final long uploadLength = request.getUploadLength();
if (uploadLength > maxAttachmentSize) {
if (request.getUploadTypeCase() == GetUploadFormRequest.UploadTypeCase.MESSAGES) {
backupMetrics.updateMessageBackupSizeDistribution(backupUser, true, Optional.of(uploadLength));
final boolean oversize = switch (request.getUploadTypeCase()) {
case MEDIA -> uploadLength > maxAttachmentSize;
case MESSAGES -> {
backupMetrics.updateMessageBackupSizeDistribution(backupUser, uploadLength > maxMessageBackupSize, Optional.of(uploadLength));
yield uploadLength > maxMessageBackupSize;
}
case UPLOADTYPE_NOT_SET -> throw GrpcExceptions.fieldViolation("upload_type", "Must set upload_type");
};
if (oversize) {
return GetUploadFormResponse.newBuilder().setExceedsMaxUploadLength(FailedPrecondition.getDefaultInstance()).build();
}
final BackupUploadDescriptor uploadDescriptor = switch (request.getUploadTypeCase()) {
case MESSAGES -> {
backupMetrics.updateMessageBackupSizeDistribution(backupUser, false, Optional.of(uploadLength));
yield backupManager.createMessageBackupUploadDescriptor(backupUser, uploadLength);
}
case MESSAGES -> backupManager.createMessageBackupUploadDescriptor(backupUser, uploadLength);
case MEDIA -> backupManager.createTemporaryAttachmentUploadDescriptor(backupUser, uploadLength);
case UPLOADTYPE_NOT_SET -> throw GrpcExceptions.fieldViolation("upload_type", "Must set upload_type");
};
@ -215,15 +224,16 @@ public class BackupsAnonymousGrpcService extends SimpleBackupsAnonymousGrpc.Back
copyQuota = backupManager.getCopyQuota(backupUser,
request.getItemsList().stream().map(item -> new CopyParameters(
item.getSourceAttachmentCdn(), item.getSourceKey(),
// uint32 in proto, make sure it fits in a signed int
fromUnsignedExact(item.getObjectLength()),
item.getObjectLength(),
new MediaEncryptionParameters(item.getEncryptionKey().toByteArray(), item.getHmacKey().toByteArray()),
item.getMediaId().toByteArray())).toList(), maxAttachmentSize);
} catch (BackupFailedZkAuthenticationException e) {
return JdkFlowAdapter.publisherToFlowPublisher(Mono.just(CopyMediaResponse
.newBuilder()
.setFailedAuthentication(FailedZkAuthentication.newBuilder().setDescription(e.getMessage()))
.build()));
return JdkFlowAdapter.publisherToFlowPublisher(
Mono.error(GrpcExceptions.streamClosed(BackupStreamClosed.newBuilder()
.setFailedAuthentication(FailedZkAuthentication.newBuilder()
.setDescription(e.getMessage())
.build())
.build())));
}
return JdkFlowAdapter.publisherToFlowPublisher(backupManager.copyToBackup(copyQuota)
.doOnNext(result -> backupMetrics.updateCopyCounter(
@ -300,10 +310,12 @@ public class BackupsAnonymousGrpcService extends SimpleBackupsAnonymousGrpc.Back
.map(item -> new BackupManager.StorageDescriptor(item.getCdn(), item.getMediaId().toByteArray()))
.toList());
} catch (BackupFailedZkAuthenticationException e) {
return JdkFlowAdapter.publisherToFlowPublisher(Mono.just(DeleteMediaResponse
.newBuilder()
.setFailedAuthentication(FailedZkAuthentication.newBuilder().setDescription(e.getMessage()))
.build()));
return JdkFlowAdapter.publisherToFlowPublisher(
Mono.error(GrpcExceptions.streamClosed(BackupStreamClosed.newBuilder()
.setFailedAuthentication(FailedZkAuthentication.newBuilder()
.setDescription(e.getMessage())
.build())
.build())));
}
return JdkFlowAdapter.publisherToFlowPublisher(deleteItems
.map(storageDescriptor -> DeleteMediaResponse.newBuilder()
@ -338,17 +350,6 @@ public class BackupsAnonymousGrpcService extends SimpleBackupsAnonymousGrpc.Back
}
}
/**
* Convert an int from a proto uint32 to a signed positive integer, throwing if the value exceeds
* {@link Integer#MAX_VALUE}. To convert to a long, see {@link Integer#toUnsignedLong(int)}
*/
private static int fromUnsignedExact(final int i) {
if (i < 0) {
throw GrpcExceptions.invalidArguments("integer length too large");
}
return i;
}
private interface Deserializer<T> {
T deserialize(byte[] bytes) throws InvalidInputException, InvalidKeyException;

View File

@ -0,0 +1,74 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc;
import java.io.IOException;
import org.apache.commons.lang3.StringUtils;
import org.signal.chat.calling.GetCallingRelaysRequest;
import org.signal.chat.calling.GetCallingRelaysResponse;
import org.signal.chat.calling.SimpleCallingGrpc;
import org.whispersystems.textsecuregcm.auth.CloudflareTurnCredentialsManager;
import org.whispersystems.textsecuregcm.auth.TurnToken;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
public class CallingGrpcService extends SimpleCallingGrpc.CallingImplBase {
private final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager;
private final RateLimiters rateLimiters;
public CallingGrpcService(final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager,
final RateLimiters rateLimiters) {
this.cloudflareTurnCredentialsManager = cloudflareTurnCredentialsManager;
this.rateLimiters = rateLimiters;
}
@Override
public GetCallingRelaysResponse getCallingRelays(final GetCallingRelaysRequest request)
throws RateLimitExceededException, IOException {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
rateLimiters.getCallEndpointLimiter().validate(authenticatedDevice.accountIdentifier());
final TurnToken turnToken =
cloudflareTurnCredentialsManager.retrieveFromCloudflare(authenticatedDevice.accountIdentifier());
final GetCallingRelaysResponse.Relay.Builder relayBuilder = GetCallingRelaysResponse.Relay.newBuilder()
.setUsername(turnToken.username())
.setPassword(turnToken.password())
.setCredentialTtlSeconds(turnToken.ttlSeconds());
if (!turnToken.urls().isEmpty()) {
relayBuilder.setHostnameUrls(turnToken.urls().stream()
.collect(GetCallingRelaysResponse.HostnameUrlList::newBuilder,
GetCallingRelaysResponse.HostnameUrlList.Builder::addUrls,
(a, b) -> a.mergeFrom(b.build())));
}
if (!turnToken.urlsWithIps().isEmpty()) {
relayBuilder.setIpUrls(turnToken.urlsWithIps().stream()
.collect(() -> {
final GetCallingRelaysResponse.IpUrlList.Builder builder =
GetCallingRelaysResponse.IpUrlList.newBuilder();
if (StringUtils.isNotBlank(turnToken.hostname())) {
builder.setHostname(turnToken.hostname());
}
return builder;
},
GetCallingRelaysResponse.IpUrlList.Builder::addUrls,
(a, b) -> a.mergeFrom(b.build())));
}
return GetCallingRelaysResponse.newBuilder()
.addRelays(relayBuilder.build())
.build();
}
}

View File

@ -0,0 +1,63 @@
package org.whispersystems.textsecuregcm.grpc;
import java.io.IOException;
import org.signal.chat.challenge.AnswerChallengeRequest;
import org.signal.chat.challenge.AnswerChallengeResponse;
import org.signal.chat.challenge.SimpleChallengeGrpc;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;
import org.whispersystems.textsecuregcm.captcha.InvalidCaptchaArgumentException;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.limits.RateLimitChallengeManager;
import org.whispersystems.textsecuregcm.spam.ChallengeConstraintChecker;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
public class ChallengeGrpcService extends SimpleChallengeGrpc.ChallengeImplBase {
private final AccountsManager accountsManager;
private final RateLimitChallengeManager rateLimitChallengeManager;
private final ChallengeConstraintChecker challengeConstraintChecker;
public ChallengeGrpcService(final AccountsManager accountsManager,
final RateLimitChallengeManager rateLimitChallengeManager,
final ChallengeConstraintChecker challengeConstraintChecker) {
this.accountsManager = accountsManager;
this.rateLimitChallengeManager = rateLimitChallengeManager;
this.challengeConstraintChecker = challengeConstraintChecker;
}
@Override
public AnswerChallengeResponse handleChallengeResponse(final AnswerChallengeRequest request)
throws RateLimitExceededException, IOException {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())
.orElseThrow(() -> GrpcExceptions.invalidCredentials("invalid credentials"));
final ChallengeConstraintChecker.ChallengeConstraints constraints = challengeConstraintChecker.challengeConstraintsGrpc(
account);
final boolean success = switch (request.getRequestCase()) {
case PUSH -> constraints.pushPermitted() && rateLimitChallengeManager.answerPushChallenge(account,
request.getPush().getChallenge());
case CAPTCHA -> {
try {
yield rateLimitChallengeManager.answerCaptchaChallenge(
account,
request.getCaptcha().getCaptcha(),
RequestAttributesUtil.getRemoteAddress().getHostAddress(),
RequestAttributesUtil.getUserAgent().orElse(null),
constraints.captchaScoreThreshold());
} catch (InvalidCaptchaArgumentException e) {
throw GrpcExceptions.invalidArguments(e.getMessage());
}
}
case REQUEST_NOT_SET -> throw GrpcExceptions.fieldViolation("request", "Must set request type");
};
return AnswerChallengeResponse.newBuilder()
.setSuccess(success)
.build();
}
}

View File

@ -1,53 +1,35 @@
/*
* Copyright 2023 Signal Messenger, LLC
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc;
import static java.util.Objects.requireNonNull;
import com.google.common.annotations.VisibleForTesting;
import java.time.Clock;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import org.signal.chat.credentials.AuthCheckResult;
import org.signal.chat.credentials.CheckSvrCredentialsRequest;
import org.signal.chat.credentials.CheckSvrCredentialsResponse;
import org.signal.chat.credentials.SimpleExternalServiceCredentialsAnonymousGrpc;
import org.whispersystems.textsecuregcm.WhisperServerConfiguration;
import org.signal.chat.credentials.SimpleCredentialsAnonymousGrpc;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsSelector;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
public class ExternalServiceCredentialsAnonymousGrpcService extends
SimpleExternalServiceCredentialsAnonymousGrpc.ExternalServiceCredentialsAnonymousImplBase {
public class CredentialsAnonymousGrpcService extends SimpleCredentialsAnonymousGrpc.CredentialsAnonymousImplBase {
private final AccountsManager accountsManager;
private final ExternalServiceCredentialsGenerator svrCredentialsGenerator;
private static final long MAX_SVR_PASSWORD_AGE_SECONDS = TimeUnit.DAYS.toSeconds(30);
private final ExternalServiceCredentialsGenerator svrCredentialsGenerator;
private final AccountsManager accountsManager;
public static ExternalServiceCredentialsAnonymousGrpcService create(
final AccountsManager accountsManager,
final WhisperServerConfiguration chatConfiguration) {
return new ExternalServiceCredentialsAnonymousGrpcService(
accountsManager,
ExternalServiceDefinitions.SVR.generatorFactory().apply(chatConfiguration, Clock.systemUTC())
);
}
@VisibleForTesting
ExternalServiceCredentialsAnonymousGrpcService(
final AccountsManager accountsManager,
public CredentialsAnonymousGrpcService(final AccountsManager accountsManager,
final ExternalServiceCredentialsGenerator svrCredentialsGenerator) {
this.accountsManager = requireNonNull(accountsManager);
this.svrCredentialsGenerator = requireNonNull(svrCredentialsGenerator);
this.svrCredentialsGenerator = svrCredentialsGenerator;
this.accountsManager = accountsManager;
}
@Override
@ -57,14 +39,18 @@ public class ExternalServiceCredentialsAnonymousGrpcService extends
tokens,
svrCredentialsGenerator,
MAX_SVR_PASSWORD_AGE_SECONDS);
// the username associated with the provided number
final Optional<String> maybeUsername = accountsManager.getByE164(request.getNumber())
.map(Account::getUuid)
.map(svrCredentialsGenerator::generateForUuid)
.map(ExternalServiceCredentials::username);
final CheckSvrCredentialsResponse.Builder builder = CheckSvrCredentialsResponse.newBuilder();
for (ExternalServiceCredentialsSelector.CredentialInfo credentialInfo : credentials) {
final AuthCheckResult authCheckResult;
if (!credentialInfo.valid()) {
authCheckResult = AuthCheckResult.AUTH_CHECK_RESULT_INVALID;
} else {
@ -74,8 +60,10 @@ public class ExternalServiceCredentialsAnonymousGrpcService extends
? AuthCheckResult.AUTH_CHECK_RESULT_MATCH
: AuthCheckResult.AUTH_CHECK_RESULT_NO_MATCH;
}
builder.putMatches(credentialInfo.token(), authCheckResult);
}
return builder.build();
}
}

View File

@ -0,0 +1,168 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc;
import com.google.protobuf.ByteString;
import java.time.Clock;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Map;
import java.util.UUID;
import org.signal.chat.credentials.ExternalServiceType;
import org.signal.chat.credentials.GetCreateCallLinkCredentialsRequest;
import org.signal.chat.credentials.GetCreateCallLinkCredentialsResponse;
import org.signal.chat.credentials.GetDeliveryCertificateRequest;
import org.signal.chat.credentials.GetDeliveryCertificateResponse;
import org.signal.chat.credentials.GetExternalServiceCredentialsRequest;
import org.signal.chat.credentials.GetExternalServiceCredentialsResponse;
import org.signal.chat.credentials.GetGroupCredentialsRequest;
import org.signal.chat.credentials.GetGroupCredentialsResponse;
import org.signal.chat.credentials.SimpleCredentialsGrpc;
import org.signal.libsignal.protocol.ServiceId;
import org.signal.libsignal.zkgroup.GenericServerSecretParams;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.auth.ServerZkAuthOperations;
import org.signal.libsignal.zkgroup.calllinks.CallLinkAuthCredentialResponse;
import org.signal.libsignal.zkgroup.calllinks.CreateCallLinkCredentialRequest;
import org.whispersystems.textsecuregcm.auth.CertificateGenerator;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.auth.RedemptionRange;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.identity.IdentityType;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.util.UUIDUtil;
public class CredentialsGrpcService extends SimpleCredentialsGrpc.CredentialsImplBase {
private final AccountsManager accountsManager;
private final CertificateGenerator certificateGenerator;
private final ServerZkAuthOperations serverZkAuthOperations;
private final GenericServerSecretParams serverSecretParams;
private final RateLimiters rateLimiters;
private final Clock clock;
private final Map<ExternalServiceType, ExternalServiceCredentialsGenerator> credentialsGeneratorByType;
public CredentialsGrpcService(final AccountsManager accountsManager,
final CertificateGenerator certificateGenerator,
final ServerZkAuthOperations serverZkAuthOperations,
final GenericServerSecretParams serverSecretParams,
final RateLimiters rateLimiters,
final Clock clock,
final Map<ExternalServiceType, ExternalServiceCredentialsGenerator> credentialsGeneratorByType) {
this.accountsManager = accountsManager;
this.certificateGenerator = certificateGenerator;
this.serverZkAuthOperations = serverZkAuthOperations;
this.serverSecretParams = serverSecretParams;
this.rateLimiters = rateLimiters;
this.clock = clock;
this.credentialsGeneratorByType = credentialsGeneratorByType;
}
@Override
public GetExternalServiceCredentialsResponse getExternalServiceCredentials(final GetExternalServiceCredentialsRequest request)
throws RateLimitExceededException {
final ExternalServiceCredentialsGenerator credentialsGenerator = this.credentialsGeneratorByType
.get(request.getExternalService());
if (credentialsGenerator == null) {
throw GrpcExceptions.fieldViolation("externalService", "Invalid external service type");
}
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
rateLimiters.forDescriptor(RateLimiters.For.EXTERNAL_SERVICE_CREDENTIALS).validate(authenticatedDevice.accountIdentifier());
final ExternalServiceCredentials externalServiceCredentials = credentialsGenerator
.generateForUuid(authenticatedDevice.accountIdentifier());
return GetExternalServiceCredentialsResponse.newBuilder()
.setUsername(externalServiceCredentials.username())
.setPassword(externalServiceCredentials.password())
.build();
}
@Override
public GetDeliveryCertificateResponse getDeliveryCertificate(final GetDeliveryCertificateRequest request) {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())
.orElseThrow(() -> GrpcExceptions.invalidCredentials("invalid credentials"));
return GetDeliveryCertificateResponse.newBuilder()
.setCertificateWithE164(ByteString.copyFrom(
certificateGenerator.createFor(account, authenticatedDevice.deviceId(), true)))
.setCertificateWithoutE164(ByteString.copyFrom(
certificateGenerator.createFor(account, authenticatedDevice.deviceId(), false)))
.build();
}
@Override
public GetGroupCredentialsResponse getGroupCredentials(final GetGroupCredentialsRequest request) {
final RedemptionRange redemptionRange;
try {
redemptionRange = RedemptionRange.inclusive(clock,
Instant.ofEpochSecond(request.getRedemptionStartSeconds()),
Instant.ofEpochSecond(request.getRedemptionEndSeconds()));
} catch (final IllegalArgumentException e) {
throw GrpcExceptions.invalidArguments(e.getMessage());
}
final Account account =
accountsManager.getByAccountIdentifier(AuthenticationUtil.requireAuthenticatedDevice().accountIdentifier())
.orElseThrow(() -> GrpcExceptions.invalidCredentials("invalid credentials"));
final ServiceId.Aci aci = new ServiceId.Aci(account.getIdentifier(IdentityType.ACI));
final ServiceId.Pni pni = new ServiceId.Pni(account.getIdentifier(IdentityType.PNI));
final GetGroupCredentialsResponse.Builder responseBuilder = GetGroupCredentialsResponse.newBuilder()
.setPni(UUIDUtil.toByteString(pni.getRawUUID()));
for (final Instant redemption : redemptionRange) {
responseBuilder.addGroupCredentials(GetGroupCredentialsResponse.CredentialAndRedemptionTime.newBuilder()
.setRedemptionTimeSeconds(redemption.getEpochSecond())
.setCredential(ByteString.copyFrom(
serverZkAuthOperations.issueAuthCredentialWithPniZkc(aci, pni, redemption).serialize()))
.build());
responseBuilder.addCallLinkAuthCredentials(GetGroupCredentialsResponse.CredentialAndRedemptionTime.newBuilder()
.setRedemptionTimeSeconds(redemption.getEpochSecond())
.setCredential(ByteString.copyFrom(
CallLinkAuthCredentialResponse.issueCredential(aci, redemption, serverSecretParams).serialize()))
.build());
}
return responseBuilder.build();
}
@Override
public GetCreateCallLinkCredentialsResponse getCreateCallLinkCredentials(final GetCreateCallLinkCredentialsRequest request)
throws RateLimitExceededException {
final UUID accountIdentifier = AuthenticationUtil.requireAuthenticatedDevice().accountIdentifier();
rateLimiters.getCreateCallLinkLimiter().validate(accountIdentifier);
final Instant truncatedDayTimestamp = clock.instant().truncatedTo(ChronoUnit.DAYS);
try {
final CreateCallLinkCredentialRequest createCallLinkCredentialRequest =
new CreateCallLinkCredentialRequest(request.getCredentialRequest().toByteArray());
return GetCreateCallLinkCredentialsResponse.newBuilder()
.setRedemptionTimeSeconds(truncatedDayTimestamp.getEpochSecond())
.setCredential(ByteString.copyFrom(createCallLinkCredentialRequest.issueCredential(
new ServiceId.Aci(accountIdentifier),
truncatedDayTimestamp,
serverSecretParams)
.serialize()))
.build();
} catch (final InvalidInputException e) {
throw GrpcExceptions.invalidArguments("Invalid 'create call link credential' request");
}
}
}

View File

@ -19,6 +19,7 @@ public class DeviceCapabilityUtil {
case DEVICE_CAPABILITY_ATTACHMENT_BACKFILL -> DeviceCapability.ATTACHMENT_BACKFILL;
case DEVICE_CAPABILITY_SPARSE_POST_QUANTUM_RATCHET -> DeviceCapability.SPARSE_POST_QUANTUM_RATCHET;
case DEVICE_CAPABILITY_PROFILES_V2 -> DeviceCapability.PROFILES_V2;
case DEVICE_CAPABILITY_USERNAME_CHANGE_SYNC_MESSAGE -> DeviceCapability.USERNAME_CHANGE_SYNC_MESSAGE;
case DEVICE_CAPABILITY_UNSPECIFIED, UNRECOGNIZED ->
throw GrpcExceptions.invalidArguments("unrecognized device capability");
};
@ -31,6 +32,7 @@ public class DeviceCapabilityUtil {
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;
case PROFILES_V2 -> org.signal.chat.common.DeviceCapability.DEVICE_CAPABILITY_PROFILES_V2;
case USERNAME_CHANGE_SYNC_MESSAGE -> org.signal.chat.common.DeviceCapability.DEVICE_CAPABILITY_USERNAME_CHANGE_SYNC_MESSAGE;
};
}
}

View File

@ -0,0 +1,136 @@
package org.whispersystems.textsecuregcm.grpc;
import com.google.protobuf.ByteString;
import com.google.protobuf.Empty;
import java.time.Clock;
import java.time.Instant;
import org.signal.chat.donations.CreateDonationPermitRequest;
import org.signal.chat.donations.CreateDonationPermitResponse;
import org.signal.chat.donations.RedeemReceiptRequest;
import org.signal.chat.donations.RedeemReceiptResponse;
import org.signal.chat.donations.SimpleDonationsGrpc;
import org.signal.chat.errors.FailedPrecondition;
import org.signal.chat.errors.FailedZkAuthentication;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.donation.DonationPermitRequest;
import org.signal.libsignal.zkgroup.donation.DonationPermitResponse;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;
import org.signal.libsignal.zkgroup.receipts.ReceiptSerial;
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.storage.AccountBadge;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.DonationPermitsManager;
import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;
import org.whispersystems.textsecuregcm.subscriptions.ReceiptCredentialPresentationFactory;
public class DonationsGrpcService extends SimpleDonationsGrpc.DonationsImplBase {
private final Clock clock;
private final ServerZkReceiptOperations serverZkReceiptOperations;
private final RedeemedReceiptsManager redeemedReceiptsManager;
private final AccountsManager accountsManager;
private final BadgesConfiguration badgesConfiguration;
private final ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory;
private final DonationPermitsManager donationPermitsManager;
private final RateLimiters rateLimiters;
private static final Logger LOGGER = LoggerFactory.getLogger(DonationsGrpcService.class);
public DonationsGrpcService(
final Clock clock,
final ServerZkReceiptOperations serverZkReceiptOperations,
final RedeemedReceiptsManager redeemedReceiptsManager,
final AccountsManager accountsManager,
final BadgesConfiguration badgesConfiguration,
final ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory,
final DonationPermitsManager donationPermitsManager,
final RateLimiters rateLimiters) {
this.clock = clock;
this.serverZkReceiptOperations = serverZkReceiptOperations;
this.redeemedReceiptsManager = redeemedReceiptsManager;
this.accountsManager = accountsManager;
this.badgesConfiguration = badgesConfiguration;
this.receiptCredentialPresentationFactory = receiptCredentialPresentationFactory;
this.donationPermitsManager = donationPermitsManager;
this.rateLimiters = rateLimiters;
}
@Override
public RedeemReceiptResponse redeemReceipt(final RedeemReceiptRequest request) {
try {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
final ReceiptCredentialPresentation receiptCredentialPresentation = receiptCredentialPresentationFactory
.build(request.getReceiptCredentialPresentation().toByteArray());
serverZkReceiptOperations.verifyReceiptCredentialPresentation(receiptCredentialPresentation);
final ReceiptSerial receiptSerial = receiptCredentialPresentation.getReceiptSerial();
final Instant receiptExpiration = Instant.ofEpochSecond(receiptCredentialPresentation.getReceiptExpirationTime());
final long receiptLevel = receiptCredentialPresentation.getReceiptLevel();
final String badgeId = badgesConfiguration.getReceiptLevels().get(receiptLevel);
if (badgeId == null) {
// Since the receipt presentation checked out, the server messed up because it doesn't recognize a receipt level it previously issued.
LOGGER.error("Server doesn't recognize previously issued receipt level; please check badgesConfiguration for issues");
throw GrpcExceptions.unavailable("server does not recognize the requested receipt level");
}
final boolean receiptMatched = redeemedReceiptsManager.put(
receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, authenticatedDevice.accountIdentifier());
if (!receiptMatched) {
return RedeemReceiptResponse.newBuilder()
.setAlreadyRedeemed(FailedPrecondition.newBuilder()
.setDescription("receipt has already been redeemed")
.build())
.build();
}
accountsManager.update(authenticatedDevice.accountIdentifier(), a -> {
a.addBadge(clock, new AccountBadge(badgeId, receiptExpiration, request.getVisible()));
if (request.getPrimary()) {
a.makeBadgePrimaryIfExists(clock, badgeId);
}
});
return RedeemReceiptResponse.newBuilder()
.setSuccess(Empty.getDefaultInstance())
.build();
} catch (final InvalidInputException e) {
return RedeemReceiptResponse.newBuilder()
.setFailedAuthentication(FailedZkAuthentication.newBuilder()
.setDescription("invalid receipt credential presentation")
.build())
.build();
} catch (final VerificationFailedException e) {
return RedeemReceiptResponse.newBuilder()
.setFailedAuthentication(FailedZkAuthentication.newBuilder()
.setDescription("receipt credential presentation verification failed")
.build())
.build();
}
}
@Override
public CreateDonationPermitResponse createDonationPermit(final CreateDonationPermitRequest request) throws Exception {
final DonationPermitRequest permitRequest;
try {
permitRequest = new DonationPermitRequest(request.getDonationPermitRequest().toByteArray());
} catch (InvalidInputException e) {
throw GrpcExceptions.invalidArguments("invalid permit request");
}
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
rateLimiters.getCreateDonationPermitLimiter().validate(authenticatedDevice.accountIdentifier(), permitRequest.getPermitCount());
final DonationPermitResponse permitResponse = donationPermitsManager.issue(permitRequest);
return CreateDonationPermitResponse.newBuilder()
.setDonationPermitResponse(ByteString.copyFrom(permitResponse.serialize()))
.build();
}
}

View File

@ -39,7 +39,7 @@ public class ErrorConformanceInterceptor implements ServerInterceptor {
}
switch (status.getCode()) {
case UNAUTHENTICATED, UNAVAILABLE, INVALID_ARGUMENT, RESOURCE_EXHAUSTED -> {
case UNAUTHENTICATED, UNAVAILABLE, INVALID_ARGUMENT, RESOURCE_EXHAUSTED, ABORTED -> {
}
default -> {
log.error("Intercepted call {} returned illegal application status {}: {}",

View File

@ -53,11 +53,11 @@ public class ErrorMappingInterceptor implements ServerInterceptor {
case ConvertibleToGrpcStatus e -> e.toStatusRuntimeException();
case UncheckedIOException e -> {
log.warn("RPC {} encountered UncheckedIOException", call.getMethodDescriptor().getFullMethodName(), e.getCause());
yield GrpcExceptions.unavailable(e.getCause().getMessage());
yield GrpcExceptions.unavailable();
}
case IOException e -> {
log.warn("RPC {} encountered IOException", call.getMethodDescriptor().getFullMethodName(), e);
yield GrpcExceptions.unavailable(e.getMessage());
yield GrpcExceptions.unavailable();
}
case null -> {
log.error("RPC {} finished with status UNKNOWN: {}",

View File

@ -1,66 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc;
import static java.util.Objects.requireNonNull;
import com.google.common.annotations.VisibleForTesting;
import java.time.Clock;
import java.util.Map;
import org.signal.chat.credentials.ExternalServiceType;
import org.signal.chat.credentials.GetExternalServiceCredentialsRequest;
import org.signal.chat.credentials.GetExternalServiceCredentialsResponse;
import org.signal.chat.credentials.SimpleExternalServiceCredentialsGrpc;
import org.whispersystems.textsecuregcm.WhisperServerConfiguration;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
public class ExternalServiceCredentialsGrpcService extends SimpleExternalServiceCredentialsGrpc.ExternalServiceCredentialsImplBase {
private final Map<ExternalServiceType, ExternalServiceCredentialsGenerator> credentialsGeneratorByType;
private final RateLimiters rateLimiters;
public static ExternalServiceCredentialsGrpcService createForAllExternalServices(
final WhisperServerConfiguration chatConfiguration,
final RateLimiters rateLimiters) {
return new ExternalServiceCredentialsGrpcService(
ExternalServiceDefinitions.createExternalServiceList(chatConfiguration, Clock.systemUTC()),
rateLimiters
);
}
@VisibleForTesting
ExternalServiceCredentialsGrpcService(
final Map<ExternalServiceType, ExternalServiceCredentialsGenerator> credentialsGeneratorByType,
final RateLimiters rateLimiters) {
this.credentialsGeneratorByType = requireNonNull(credentialsGeneratorByType);
this.rateLimiters = requireNonNull(rateLimiters);
}
@Override
public GetExternalServiceCredentialsResponse getExternalServiceCredentials(final GetExternalServiceCredentialsRequest request)
throws RateLimitExceededException {
final ExternalServiceCredentialsGenerator credentialsGenerator = this.credentialsGeneratorByType
.get(request.getExternalService());
if (credentialsGenerator == null) {
throw GrpcExceptions.fieldViolation("externalService", "Invalid external service type");
}
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
rateLimiters.forDescriptor(RateLimiters.For.EXTERNAL_SERVICE_CREDENTIALS).validate(authenticatedDevice.accountIdentifier());
final ExternalServiceCredentials externalServiceCredentials = credentialsGenerator
.generateForUuid(authenticatedDevice.accountIdentifier());
return GetExternalServiceCredentialsResponse.newBuilder()
.setUsername(externalServiceCredentials.username())
.setPassword(externalServiceCredentials.password())
.build();
}
}

View File

@ -20,7 +20,7 @@ import org.whispersystems.textsecuregcm.configuration.DirectoryV2ClientConfigura
import org.whispersystems.textsecuregcm.configuration.PaymentsServiceConfiguration;
import org.whispersystems.textsecuregcm.configuration.SecureValueRecoveryConfiguration;
enum ExternalServiceDefinitions {
public enum ExternalServiceDefinitions {
DIRECTORY(ExternalServiceType.EXTERNAL_SERVICE_TYPE_DIRECTORY, (chatConfig, clock) -> {
final DirectoryV2ClientConfiguration cfg = chatConfig.getDirectoryV2Configuration().getDirectoryV2ClientConfiguration();
return ExternalServiceCredentialsGenerator
@ -30,7 +30,7 @@ enum ExternalServiceDefinitions {
.withClock(clock)
.build();
}),
PAYMENTS(ExternalServiceType.EXTERNAL_SERVICE_TYPE_PAYMENTS, (chatConfig, clock) -> {
PAYMENTS(ExternalServiceType.EXTERNAL_SERVICE_TYPE_PAYMENTS, (chatConfig, _) -> {
final PaymentsServiceConfiguration cfg = chatConfig.getPaymentsServiceConfiguration();
return ExternalServiceCredentialsGenerator
.builder(cfg.userAuthenticationTokenSharedSecret())
@ -47,7 +47,7 @@ enum ExternalServiceDefinitions {
.withClock(clock)
.build();
}),
STORAGE(ExternalServiceType.EXTERNAL_SERVICE_TYPE_STORAGE, (chatConfig, clock) -> {
STORAGE(ExternalServiceType.EXTERNAL_SERVICE_TYPE_STORAGE, (chatConfig, _) -> {
final PaymentsServiceConfiguration cfg = chatConfig.getPaymentsServiceConfiguration();
return ExternalServiceCredentialsGenerator
.builder(cfg.userAuthenticationTokenSharedSecret())

View File

@ -39,6 +39,11 @@ public class GrpcExceptions {
.setReason("BAD_AUTHENTICATION")
.build());
private static final Any ERROR_INFO_STREAM_CLOSED = Any.pack(ErrorInfo.newBuilder()
.setDomain(DOMAIN)
.setReason("STREAM_CLOSED")
.build());
private static final com.google.rpc.Status UPGRADE_REQUIRED = com.google.rpc.Status.newBuilder()
.setCode(Status.Code.INVALID_ARGUMENT.value())
.setMessage("Upgrade required")
@ -93,20 +98,6 @@ public class GrpcExceptions {
.build());
}
/// The RPC argument violated a constraint that was annotated or documented in the service definition. It is always
/// possible to check this constraint without communicating with the chat server. This always represents a client bug
/// or out of date client.
///
/// @param message Additional context about the constraint violation
/// @return A [StatusRuntimeException] encoding the error
public static StatusRuntimeException constraintViolation(@Nullable final String message) {
return StatusProto.toStatusRuntimeException(com.google.rpc.Status.newBuilder()
.setCode(Status.Code.INVALID_ARGUMENT.value())
.setMessage(messageOrDefault(message, Status.Code.INVALID_ARGUMENT))
.addDetails(ERROR_INFO_CONSTRAINT_VIOLATED)
.build());
}
/// The request has incorrectly set authentication credentials for the RPC. This represents a client bug where the
/// authorization header is not correct for the RPC. For example,
///
@ -158,6 +149,24 @@ public class GrpcExceptions {
return StatusProto.toStatusRuntimeException(builder.build());
}
/// The server terminated the stream non-successfully for a reason specific to the RPC. The server must provide
/// additional information about the stream closure reason via the `streamClosedMessage`.
///
/// @param streamClosedMessage The additional domain-specific proto message that indicates the closure reason
/// @return A [StatusRuntimeException] encoding the error
public static <T extends com.google.protobuf.Message> StatusRuntimeException streamClosed(
final T streamClosedMessage) {
return StatusProto.toStatusRuntimeException(com.google.rpc.Status.newBuilder()
.setCode(Status.Code.ABORTED.value())
.addDetails(ERROR_INFO_STREAM_CLOSED)
.addDetails(Any.pack(streamClosedMessage))
.build());
}
public static StatusRuntimeException unavailable() {
return unavailable(null);
}
/// There was an internal error processing the RPC. The client should retry the request with exponential backoff.
///
/// @return A [StatusRuntimeException] encoding the error

View File

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

View File

@ -15,8 +15,10 @@ import org.signal.keytransparency.client.E164MonitorRequest;
import org.signal.keytransparency.client.E164SearchRequest;
import org.signal.keytransparency.client.MonitorRequest;
import org.signal.keytransparency.client.MonitorResponse;
import org.signal.keytransparency.client.MonitorResponseV2;
import org.signal.keytransparency.client.SearchRequest;
import org.signal.keytransparency.client.SearchResponse;
import org.signal.keytransparency.client.SearchResponseV2;
import org.signal.keytransparency.client.SimpleKeyTransparencyQueryServiceGrpc;
import org.signal.keytransparency.client.UsernameHashMonitorRequest;
import org.whispersystems.textsecuregcm.controllers.AccountController;
@ -39,102 +41,51 @@ public class KeyTransparencyGrpcService extends
}
@Override
public SearchResponse search(final SearchRequest request) throws RateLimitExceededException {
public SearchResponseV2 searchV2(final SearchRequest request) throws RateLimitExceededException {
rateLimiters.getKeyTransparencySearchLimiter().validate(RequestAttributesUtil.getRemoteAddress().getHostAddress());
return client.search(validateSearchRequest(request));
}
@Override
public MonitorResponse monitor(final MonitorRequest request) throws RateLimitExceededException {
public MonitorResponseV2 monitorV2(final MonitorRequest request) throws RateLimitExceededException {
rateLimiters.getKeyTransparencyMonitorLimiter().validate(RequestAttributesUtil.getRemoteAddress().getHostAddress());
return client.monitor(validateMonitorRequest(request));
}
@Override
public DistinguishedResponse distinguished(final DistinguishedRequest request) throws RateLimitExceededException {
public DistinguishedResponse distinguishedV2(final DistinguishedRequest request) throws RateLimitExceededException {
rateLimiters.getKeyTransparencyDistinguishedLimiter().validate(RequestAttributesUtil.getRemoteAddress().getHostAddress());
// A client's very first distinguished request will not have a "last" parameter
if (request.hasLast() && request.getLast() <= 0) {
throw Status.INVALID_ARGUMENT.withDescription("Last tree head size must be positive").asRuntimeException();
}
return client.distinguished(request);
}
private SearchRequest validateSearchRequest(final SearchRequest request) {
validateAci(request.getAci().toByteArray());
if (request.hasE164SearchRequest()) {
final E164SearchRequest e164SearchRequest = request.getE164SearchRequest();
if (e164SearchRequest.getUnidentifiedAccessKey().isEmpty() != e164SearchRequest.getE164().isEmpty()) {
throw Status.INVALID_ARGUMENT.withDescription("Unidentified access key and E164 must be provided together or not at all").asRuntimeException();
throw GrpcExceptions.fieldViolation("e164_search_request", "Unidentified access key and E164 must be provided together or not at all");
}
}
if (!request.getConsistency().hasDistinguished()) {
throw Status.INVALID_ARGUMENT.withDescription("Must provide distinguished tree head size").asRuntimeException();
}
validateConsistencyParameters(request.getConsistency());
return request;
}
private void validateAci(final byte[] aci) {
try {
AciServiceIdentifier.fromBytes(aci);
} catch (IllegalArgumentException e) {
throw GrpcExceptions.fieldViolation("aci", "Invalid ACI");
}
}
private MonitorRequest validateMonitorRequest(final MonitorRequest request) {
final AciMonitorRequest aciMonitorRequest = request.getAci();
validateAci(request.getAci().getAci().toByteArray());
try {
AciServiceIdentifier.fromBytes(aciMonitorRequest.getAci().toByteArray());
} catch (IllegalArgumentException e) {
throw Status.INVALID_ARGUMENT.withDescription("Invalid ACI").asRuntimeException();
}
if (aciMonitorRequest.getEntryPosition() <= 0) {
throw Status.INVALID_ARGUMENT.withDescription("Aci entry position must be positive").asRuntimeException();
}
if (aciMonitorRequest.getCommitmentIndex().size() != COMMITMENT_INDEX_LENGTH) {
throw Status.INVALID_ARGUMENT.withDescription("Aci commitment index must be 32 bytes").asRuntimeException();
if (!request.getConsistency().hasLast()) {
throw GrpcExceptions.fieldViolation("consistency_last", "Must provide distinguished and last tree head sizes");
}
if (request.hasUsernameHash()) {
final UsernameHashMonitorRequest usernameHashMonitorRequest = request.getUsernameHash();
if (usernameHashMonitorRequest.getUsernameHash().isEmpty()) {
throw Status.INVALID_ARGUMENT.withDescription("Username hash cannot be empty").asRuntimeException();
}
if (usernameHashMonitorRequest.getUsernameHash().size() != AccountController.USERNAME_HASH_LENGTH) {
throw Status.INVALID_ARGUMENT.withDescription("Invalid username hash length").asRuntimeException();
}
if (usernameHashMonitorRequest.getEntryPosition() <= 0) {
throw Status.INVALID_ARGUMENT.withDescription("Username hash entry position must be positive").asRuntimeException();
}
if (usernameHashMonitorRequest.getCommitmentIndex().size() != COMMITMENT_INDEX_LENGTH) {
throw Status.INVALID_ARGUMENT.withDescription("Username hash commitment index must be 32 bytes").asRuntimeException();
}
}
if (request.hasE164()) {
final E164MonitorRequest e164MonitorRequest = request.getE164();
if (e164MonitorRequest.getE164().isEmpty()) {
throw Status.INVALID_ARGUMENT.withDescription("E164 cannot be empty").asRuntimeException();
}
if (e164MonitorRequest.getEntryPosition() <= 0) {
throw Status.INVALID_ARGUMENT.withDescription("E164 entry position must be positive").asRuntimeException();
}
if (e164MonitorRequest.getCommitmentIndex().size() != COMMITMENT_INDEX_LENGTH) {
throw Status.INVALID_ARGUMENT.withDescription("E164 commitment index must be 32 bytes").asRuntimeException();
}
}
if (!request.getConsistency().hasDistinguished() || !request.getConsistency().hasLast()) {
throw Status.INVALID_ARGUMENT.withDescription("Must provide distinguished and last tree head sizes").asRuntimeException();
}
validateConsistencyParameters(request.getConsistency());
return request;
}
private static void validateConsistencyParameters(final ConsistencyParameters consistency) {
if (consistency.getDistinguished() <= 0) {
throw Status.INVALID_ARGUMENT.withDescription("Distinguished tree head size must be positive").asRuntimeException();
}
if (consistency.hasLast() && consistency.getLast() <= 0) {
throw Status.INVALID_ARGUMENT.withDescription("Last tree head size must be positive").asRuntimeException();
}
}
}

View File

@ -13,7 +13,6 @@ import java.util.Arrays;
import java.util.concurrent.Flow;
import org.signal.chat.errors.FailedUnidentifiedAuthorization;
import org.signal.chat.errors.NotFound;
import org.signal.chat.keys.AccountPreKeyBundles;
import org.signal.chat.keys.CheckIdentityKeyRequest;
import org.signal.chat.keys.CheckIdentityKeyResponse;
import org.signal.chat.keys.GetPreKeysAnonymousRequest;
@ -46,7 +45,7 @@ public class KeysAnonymousGrpcService extends SimpleKeysAnonymousGrpc.KeysAnonym
@Override
public GetPreKeysAnonymousResponse getPreKeys(final GetPreKeysAnonymousRequest request) {
final ServiceIdentifier serviceIdentifier =
ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getRequest().getTargetIdentifier());
GrpcServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getRequest().getTargetIdentifier());
final byte deviceId = request.getRequest().hasDeviceId()
? DeviceIdUtil.validate(request.getRequest().getDeviceId())
@ -91,7 +90,7 @@ public class KeysAnonymousGrpcService extends SimpleKeysAnonymousGrpc.KeysAnonym
@Override
public Flow.Publisher<CheckIdentityKeyResponse> checkIdentityKeys(final Flow.Publisher<CheckIdentityKeyRequest> requests) {
return JdkFlowAdapter.publisherToFlowPublisher(JdkFlowAdapter.flowPublisherToFlux(requests)
.map(request -> Tuples.of(ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getTargetIdentifier()),
.map(request -> Tuples.of(GrpcServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getTargetIdentifier()),
request.getFingerprint().toByteArray()))
.flatMap(serviceIdentifierAndFingerprint -> Mono.fromFuture(
() -> accountsManager.getByServiceIdentifierAsync(serviceIdentifierAndFingerprint.getT1()))
@ -100,7 +99,7 @@ public class KeysAnonymousGrpcService extends SimpleKeysAnonymousGrpc.KeysAnonym
.identityType()), serviceIdentifierAndFingerprint.getT2()))
.map(account -> CheckIdentityKeyResponse.newBuilder()
.setTargetIdentifier(
ServiceIdentifierUtil.toGrpcServiceIdentifier(serviceIdentifierAndFingerprint.getT1()))
GrpcServiceIdentifierUtil.toGrpcServiceIdentifier(serviceIdentifierAndFingerprint.getT1()))
.setIdentityKey(ByteString.copyFrom(account.getIdentityKey(serviceIdentifierAndFingerprint.getT1()
.identityType()).serialize()))
.build())));

View File

@ -96,7 +96,7 @@ public class KeysGrpcService extends SimpleKeysGrpc.KeysImplBase {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
final ServiceIdentifier targetIdentifier =
ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getTargetIdentifier());
GrpcServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getTargetIdentifier());
final Optional<Account> maybeTargetAccount = accountsManager.getByServiceIdentifier(targetIdentifier);

View File

@ -0,0 +1,268 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc;
import com.google.common.annotations.VisibleForTesting;
import com.google.protobuf.Empty;
import io.grpc.StatusRuntimeException;
import io.micrometer.core.instrument.Metrics;
import java.time.Duration;
import java.util.UUID;
import javax.annotation.Nullable;
import org.signal.chat.messages.GetMessagesResponse;
import org.signal.chat.messages.GetMessagesStreamClosed;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.DisconnectionRequestListener;
import org.whispersystems.textsecuregcm.auth.DisconnectionRequestManager;
import org.whispersystems.textsecuregcm.entities.MessageProtos;
import org.whispersystems.textsecuregcm.identity.IdentityType;
import org.whispersystems.textsecuregcm.limits.MessageDeliveryLoopMonitor;
import org.whispersystems.textsecuregcm.metrics.MessageMetrics;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.push.PushNotificationManager;
import org.whispersystems.textsecuregcm.push.PushNotificationScheduler;
import org.whispersystems.textsecuregcm.push.ReceiptSender;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.ClientReleaseManager;
import org.whispersystems.textsecuregcm.storage.ConflictingMessageConsumerException;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.MessageStream;
import org.whispersystems.textsecuregcm.storage.MessageStreamEntry;
import org.whispersystems.textsecuregcm.storage.MessagesManager;
import org.whispersystems.textsecuregcm.util.UUIDUtil;
import org.whispersystems.textsecuregcm.util.ua.UserAgent;
import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
import reactor.adapter.JdkFlowAdapter;
import reactor.core.observability.micrometer.Micrometer;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.publisher.Signal;
import reactor.core.publisher.Sinks;
/// A MessageDispatcher is used to stream messages to an authenticated device and coordinates message acknowledgement,
/// backpressure, disconnection signals, and receipts.
public class MessageDispatcher {
private static final Logger log = LoggerFactory.getLogger(MessageDispatcher.class);
@VisibleForTesting
static final int MAX_UNACKED_MESSAGES = 256;
@VisibleForTesting
static final Duration CLOSE_WITH_PENDING_MESSAGES_NOTIFICATION_DELAY = Duration.ofMinutes(1);
private static final int MESSAGE_PUBLISHER_LIMIT_RATE = 100;
public static final GetMessagesResponse QUEUE_EMPTY_RESPONSE =
GetMessagesResponse.newBuilder().setQueueEmpty(Empty.getDefaultInstance()).build();
private static final StatusRuntimeException CONFLICTING_STREAM_EXCEPTION =
GrpcExceptions.streamClosed(GetMessagesStreamClosed.newBuilder()
.setConflictingStream(Empty.getDefaultInstance())
.build());
private static final String SEND_MESSAGES_FLUX_NAME = MetricsUtil.name(MessageDispatcher.class, "sendMessages");
private final ReceiptSender receiptSender;
private final MessagesManager messagesManager;
private final MessageMetrics messageMetrics;
private final PushNotificationManager pushNotificationManager;
private final PushNotificationScheduler pushNotificationScheduler;
private final ClientReleaseManager clientReleaseManager;
private final MessageDeliveryLoopMonitor messageDeliveryLoopMonitor;
private final DisconnectionRequestManager disconnectionRequestManager;
public MessageDispatcher(final ReceiptSender receiptSender,
final MessagesManager messagesManager,
final MessageMetrics messageMetrics,
final PushNotificationManager pushNotificationManager,
final PushNotificationScheduler pushNotificationScheduler,
final MessageDeliveryLoopMonitor messageDeliveryLoopMonitor,
final DisconnectionRequestManager disconnectionRequestManager,
final ClientReleaseManager clientReleaseManager) {
this.receiptSender = receiptSender;
this.messagesManager = messagesManager;
this.messageMetrics = messageMetrics;
this.pushNotificationManager = pushNotificationManager;
this.pushNotificationScheduler = pushNotificationScheduler;
this.messageDeliveryLoopMonitor = messageDeliveryLoopMonitor;
this.disconnectionRequestManager = disconnectionRequestManager;
this.clientReleaseManager = clientReleaseManager;
}
/// Retrieve messages for the device.
///
/// ## Termination
/// The returned message stream is potentially infinite. It will terminate if the device acknowledgement stream
/// terminates for any reason. The error from the client will be propagated to the returned flux. The stream may also
/// terminate with any other error encountered retrieving messages or processing acknowledgements.
///
/// The returned flux will error with a [GrpcExceptions#streamClosed] non-ok status exception if:
/// - A disconnection request (typically indicating a change in authorization credentials) is received
/// - A client requests messages for the same account/device while this stream is still active
///
/// When the stream terminates for any reason and the client has messages remaining in their queue, a push
/// notification is scheduled to encourage the client to wake up and try again after a delay.
///
/// ## Queue Empty
/// The returned flux will emit a queue empty indicator when the device has successfully drained their waiting queue
/// and acknowledged all of them. A queue empty response guarantees all messages that were waiting for the client when
/// they first connected have been delivered and removed from storage, though it's possible the queue empty response
/// also waits for messages that arrived in the intervening period.
///
/// ## Acknowledgement
/// When the device acknowledges a message GUID it is removed from storage. If applicable, a delivery receipt is sent
/// to the original sender of the message.
///
///
/// @param shouldDropStories if true, stories will not be delivered in the response stream (and will be
/// removed from persistent storage)
/// @param userAgentString the userAgent of the requesting device
/// @param account the account to retrieve messages for
/// @param device the device to retrieve messages for
/// @param acknowledgedMessageGuids A flux that should emit the server guid of messages when they have been
/// acknowledged by the device
/// @return The message stream
public Flux<GetMessagesResponse> getMessages(
final boolean shouldDropStories,
@Nullable final String userAgentString,
final Account account,
final Device device,
final Flux<UUID> acknowledgedMessageGuids) {
final MessageStream messageStream = messagesManager.getMessages(account.getIdentifier(IdentityType.ACI), device);
@Nullable final UserAgent userAgent = UserAgentUtil.maybeParseUserAgentString(userAgentString);
final PendingAcknowledgementTracker pendingAcknowledgementTracker = new PendingAcknowledgementTracker(messageMetrics, userAgent);
pushNotificationManager.handleMessagesRetrieved(account, device, userAgentString);
final Flux<GetMessagesResponse> messages = JdkFlowAdapter.flowPublisherToFlux(messageStream.getMessages())
.name(SEND_MESSAGES_FLUX_NAME)
.tap(Micrometer.metrics(Metrics.globalRegistry))
.limitRate(MESSAGE_PUBLISHER_LIMIT_RATE)
// Check the first message we send for message-delivery loops
.switchOnFirst((firstEntry, flux) -> {
recordDeliveryAttempt(account, device, userAgentString, firstEntry);
return flux;
})
.flatMapSequential(entry -> switch (entry) {
case MessageStreamEntry.Envelope(final MessageProtos.Envelope envelope) -> {
final UUID serverGuid = UUIDUtil.fromByteString(envelope.getServerGuid());
if (envelope.getStory() && shouldDropStories) {
// Just immediately delete stories if the device doesn't want them
yield Mono.fromFuture(() -> messageStream.acknowledgeMessage(serverGuid, envelope.getServerTimestamp()))
.then(Mono.empty());
}
pendingAcknowledgementTracker.addUnacknowledgedEnvelope(envelope);
final MessageProtos.Envelope stripped = envelope.toBuilder().clearEphemeral().build();
messageMetrics.measureAccountEnvelopeUuidMismatches(account, envelope);
messageMetrics.measureMessageSent(stripped.getSerializedSize());
yield Mono.just(GetMessagesResponse.newBuilder().setEnvelope(stripped).build());
}
case MessageStreamEntry.QueueEmpty _ -> {
pendingAcknowledgementTracker.markEndOfQueue();
yield Mono.empty();
}
}, MAX_UNACKED_MESSAGES)
.onErrorMap(ConflictingMessageConsumerException.class, _ -> {
messageMetrics.measureMessageStreamDisplaced(MessageMetrics.GRPC_CHANNEL, userAgent, true);
return CONFLICTING_STREAM_EXCEPTION;
});
// Only emit envelopes when the permit flux emits a value. Initially it is seeded MAX_UNACKED_MESSAGES permits,
// after that a permit is emitted every time we receive an ack.
final Sinks.Many<Boolean> ackPermits = Sinks.many().unicast().onBackpressureBuffer();
final Flux<Boolean> permits = Flux.range(0, MAX_UNACKED_MESSAGES)
.map(_ -> true)
.concatWith(ackPermits.asFlux());
final Mono<GetMessagesResponse> ackCompletions = acknowledgedMessageGuids
.flatMap(guid -> {
final PendingAcknowledgementTracker.UnacknowledgedEnvelope unacked = pendingAcknowledgementTracker.takeUnacknowledgedEnvelope(guid);
if (unacked == null) {
// This is fine, the client may have sent a duplicate-ack
return Mono.empty();
}
messageMetrics.measureOutgoingMessageLatency(
unacked.getServerTimestamp(), MessageMetrics.GRPC_CHANNEL, device.isPrimary(),
unacked.isUrgent(), unacked.isEphemeral(), userAgent, clientReleaseManager);
maybeSendDeliveryReceipt(device, unacked);
return Mono.fromFuture(() -> messageStream.acknowledgeMessage(unacked.getServerGuid(), unacked.getServerTimestamp()))
.doOnSuccess(_ -> unacked.handleMessageAcknowledged())
// Just have to emit some value that indicates we can release a permit
.thenReturn(true);
}, MAX_UNACKED_MESSAGES)
.doOnNext(_ -> ackPermits.tryEmitNext(true))
.ignoreElements()
.cast(GetMessagesResponse.class);
return Flux.merge(
// Emit messages, but only when a permit is available
messages.zipWith(permits, (resp, _) -> resp),
// Will only emit errors, but those should terminate the stream
ackCompletions,
// Emits a queue empty once all messages prior to the queueEmpty signal have been acked
pendingAcknowledgementTracker.queueDrained(),
// Emit an invalid credentials error if we receive a disconnection request
disconnectionSignal(account, device, userAgent))
.doFinally(_ -> maybeSchedulePush(account, device));
}
/// If the device potentially has more messages available, schedule a push notification.
private void maybeSchedulePush(final Account account, final Device device) {
messagesManager.mayHaveMessages(account.getIdentifier(IdentityType.ACI), device)
.thenAccept(mayHaveMessages -> {
if (mayHaveMessages) {
pushNotificationScheduler.scheduleDelayedNotification(account, device,
CLOSE_WITH_PENDING_MESSAGES_NOTIFICATION_DELAY);
}
});
}
/// Watch for a disconnection signal from the [DisconnectionRequestManager]
///
/// @return Mono that completes with an `InvalidCredentials` status if the
/// device receives a disconnection request
private Mono<GetMessagesResponse> disconnectionSignal(final Account account, final Device device, final UserAgent userAgent) {
return Mono.create(sink -> {
final DisconnectionRequestListener listener = () -> {
messageMetrics.measureMessageStreamDisplaced(MessageMetrics.GRPC_CHANNEL, userAgent, false);
sink.error(GrpcExceptions.invalidCredentials("reauthentication required"));
};
disconnectionRequestManager.addListener(account.getUuid(), device.getId(), listener);
sink.onDispose(() -> disconnectionRequestManager.removeListener(account.getUuid(), device.getId(), listener));
});
}
private void recordDeliveryAttempt(final Account account, final Device device, final String userAgent,
final Signal<? extends MessageStreamEntry> firstEntry) {
if (firstEntry.get() instanceof MessageStreamEntry.Envelope(final MessageProtos.Envelope message)) {
messageDeliveryLoopMonitor.recordDeliveryAttempt(account.getIdentifier(IdentityType.ACI),
device.getId(),
UUIDUtil.fromByteString(message.getServerGuid()),
userAgent,
MessageMetrics.GRPC_CHANNEL);
}
}
private void maybeSendDeliveryReceipt(
final Device device,
final PendingAcknowledgementTracker.UnacknowledgedEnvelope unacknowledgedEnvelope) {
if (unacknowledgedEnvelope.getSourceServiceId() == null) {
return;
}
try {
receiptSender.sendReceipt(
unacknowledgedEnvelope.getDestinationServiceId(),
device.getId(),
unacknowledgedEnvelope.getSourceServiceId(),
unacknowledgedEnvelope.getClientTimestamp());
} catch (final Exception e) {
log.warn("Failed to send receipt", e);
}
}
}

View File

@ -6,18 +6,18 @@
package org.whispersystems.textsecuregcm.grpc;
import com.google.protobuf.ByteString;
import com.google.protobuf.Empty;
import java.time.Clock;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import com.google.protobuf.Empty;
import org.signal.chat.errors.FailedUnidentifiedAuthorization;
import org.signal.chat.errors.NotFound;
import org.signal.chat.messages.IndividualRecipientMessageBundle;
import org.signal.chat.messages.SendMessageType;
import org.signal.chat.messages.MultiRecipientMismatchedDevices;
import org.signal.chat.messages.MultiRecipientSuccess;
import org.signal.chat.messages.SendMessageResponse;
import org.signal.chat.messages.SendMessageType;
import org.signal.chat.messages.SendMultiRecipientMessageRequest;
import org.signal.chat.messages.SendMultiRecipientMessageResponse;
import org.signal.chat.messages.SendMultiRecipientStoryRequest;
@ -28,6 +28,7 @@ import org.signal.libsignal.protocol.InvalidMessageException;
import org.signal.libsignal.protocol.InvalidVersionException;
import org.signal.libsignal.protocol.SealedSenderMultiRecipientMessage;
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;
import org.whispersystems.textsecuregcm.controllers.MessageDeliveryNotAllowedException;
import org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException;
import org.whispersystems.textsecuregcm.controllers.MultiRecipientMismatchedDevicesException;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
@ -83,7 +84,7 @@ public class MessagesAnonymousGrpcService extends SimpleMessagesAnonymousGrpc.Me
throws RateLimitExceededException {
final ServiceIdentifier destinationServiceIdentifier =
ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getDestination());
GrpcServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getDestination());
final Optional<Account> maybeDestination = accountsManager.getByServiceIdentifier(destinationServiceIdentifier);
@ -131,7 +132,7 @@ public class MessagesAnonymousGrpcService extends SimpleMessagesAnonymousGrpc.Me
throws RateLimitExceededException {
final ServiceIdentifier destinationServiceIdentifier =
ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getDestination());
GrpcServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getDestination());
final Optional<Account> maybeDestination = accountsManager.getByServiceIdentifier(destinationServiceIdentifier);
@ -192,7 +193,7 @@ public class MessagesAnonymousGrpcService extends SimpleMessagesAnonymousGrpc.Me
.setType(MessageProtos.Envelope.Type.UNIDENTIFIED_SENDER)
.setClientTimestamp(messages.getTimestamp())
.setServerTimestamp(clock.millis())
.setDestinationServiceId(destinationServiceIdentifier.toServiceIdentifierString())
.setDestinationServiceId(destinationServiceIdentifier.toCompactByteString())
.setEphemeral(ephemeral)
.setUrgent(urgent)
.setContent(entry.getValue().getPayload());
@ -229,6 +230,8 @@ public class MessagesAnonymousGrpcService extends SimpleMessagesAnonymousGrpc.Me
.build();
} catch (final MessageTooLargeException e) {
throw GrpcExceptions.invalidArguments("message too large");
} catch (final MessageDeliveryNotAllowedException e) {
throw GrpcExceptions.unavailable();
}
}
@ -309,7 +312,7 @@ public class MessagesAnonymousGrpcService extends SimpleMessagesAnonymousGrpc.Me
final MultiRecipientSuccess.Builder responseBuilder = MultiRecipientSuccess.newBuilder();
MessageUtil.getUnresolvedRecipients(multiRecipientMessage, resolvedRecipients).stream()
.map(ServiceIdentifierUtil::toGrpcServiceIdentifier)
.map(GrpcServiceIdentifierUtil::toGrpcServiceIdentifier)
.forEach(responseBuilder::addUnresolvedRecipients);
return SendMultiRecipientMessageResponse.newBuilder().setSuccess(responseBuilder).build();
@ -325,6 +328,8 @@ public class MessagesAnonymousGrpcService extends SimpleMessagesAnonymousGrpc.Me
return SendMultiRecipientMessageResponse.newBuilder()
.setMismatchedDevices(mismatchedDevicesBuilder)
.build();
} catch (final MessageDeliveryNotAllowedException e) {
throw GrpcExceptions.unavailable();
}
}

View File

@ -23,7 +23,7 @@ public class MessagesGrpcHelper {
final org.whispersystems.textsecuregcm.controllers.MismatchedDevices mismatchedDevices) {
final MismatchedDevices.Builder mismatchedDevicesBuilder = MismatchedDevices.newBuilder()
.setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(serviceIdentifier));
.setServiceIdentifier(GrpcServiceIdentifierUtil.toGrpcServiceIdentifier(serviceIdentifier));
mismatchedDevices.missingDeviceIds().forEach(mismatchedDevicesBuilder::addMissingDevices);
mismatchedDevices.extraDeviceIds().forEach(mismatchedDevicesBuilder::addExtraDevices);

View File

@ -5,21 +5,29 @@
package org.whispersystems.textsecuregcm.grpc;
import static org.whispersystems.textsecuregcm.grpc.MessagesGrpcHelper.buildMismatchedDevices;
import com.google.protobuf.ByteString;
import com.google.protobuf.Empty;
import java.time.Clock;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.Flow;
import java.util.stream.Collectors;
import com.google.protobuf.Empty;
import io.grpc.StatusRuntimeException;
import org.signal.chat.errors.NotFound;
import org.signal.chat.messages.SendMessageType;
import org.signal.chat.messages.GetMessagesRequest;
import org.signal.chat.messages.GetMessagesResponse;
import org.signal.chat.messages.IndividualRecipientMessageBundle;
import org.signal.chat.messages.SendAuthenticatedSenderMessageRequest;
import org.signal.chat.messages.SendMessageAuthenticatedSenderResponse;
import org.signal.chat.messages.SendMessageType;
import org.signal.chat.messages.SendSyncMessageRequest;
import org.signal.chat.messages.SimpleMessagesGrpc;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;
import org.whispersystems.textsecuregcm.controllers.MessageDeliveryNotAllowedException;
import org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.entities.MessageProtos;
@ -35,8 +43,11 @@ import org.whispersystems.textsecuregcm.spam.SpamCheckResult;
import org.whispersystems.textsecuregcm.spam.SpamChecker;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import static org.whispersystems.textsecuregcm.grpc.MessagesGrpcHelper.buildMismatchedDevices;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.util.UUIDUtil;
import reactor.adapter.JdkFlowAdapter;
import reactor.core.publisher.Flux;
import javax.annotation.Nullable;
public class MessagesGrpcService extends SimpleMessagesGrpc.MessagesImplBase {
@ -45,6 +56,7 @@ public class MessagesGrpcService extends SimpleMessagesGrpc.MessagesImplBase {
private final MessageSender messageSender;
private final CardinalityEstimator messageByteLimitEstimator;
private final SpamChecker spamChecker;
private final MessageDispatcher messageDispatcher;
private final Clock clock;
private static final SendMessageAuthenticatedSenderResponse SEND_MESSAGE_SUCCESS_RESPONSE =
@ -55,6 +67,7 @@ public class MessagesGrpcService extends SimpleMessagesGrpc.MessagesImplBase {
final MessageSender messageSender,
final CardinalityEstimator messageByteLimitEstimator,
final SpamChecker spamChecker,
final MessageDispatcher messageDispatcher,
final Clock clock) {
this.accountsManager = accountsManager;
@ -62,9 +75,49 @@ public class MessagesGrpcService extends SimpleMessagesGrpc.MessagesImplBase {
this.messageSender = messageSender;
this.messageByteLimitEstimator = messageByteLimitEstimator;
this.spamChecker = spamChecker;
this.messageDispatcher = messageDispatcher;
this.clock = clock;
}
@Override
public Flow.Publisher<GetMessagesResponse> getMessages(final Flow.Publisher<GetMessagesRequest> request) throws Exception {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
final Account account = accountsManager
.getByAccountIdentifier(authenticatedDevice.accountIdentifier())
.orElseThrow(() -> GrpcExceptions.invalidCredentials("invalid credentials"));
final Device device = account.getDevice(authenticatedDevice.deviceId())
.orElseThrow(() -> GrpcExceptions.invalidCredentials("invalid credentials"));
final String userAgent = RequestAttributesUtil.getUserAgent().orElse(null);
final Flux<GetMessagesRequest> requestFlux = JdkFlowAdapter.flowPublisherToFlux(request);
return JdkFlowAdapter.publisherToFlowPublisher(requestFlux.switchOnFirst((firstSignal, flux) -> {
@Nullable final GetMessagesRequest streamRequest = firstSignal.get();
if (streamRequest == null) {
// Just forward the error or completion signal
return flux.then().cast(GetMessagesResponse.class);
}
if (!streamRequest.hasOptions()) {
throw GrpcExceptions.fieldViolation("request", "the first request must be GetMessageOptions");
}
final boolean dropStories = streamRequest.getOptions().getDropStories();
return messageDispatcher.getMessages(dropStories, userAgent, account, device,
flux.skip(1).map(MessagesGrpcService::extractAckGuid));
}));
}
private static UUID extractAckGuid(final GetMessagesRequest ack) throws StatusRuntimeException {
if (!ack.hasServerGuidAck()) {
throw GrpcExceptions.fieldViolation("request", "All non-initial GetMessageRequests must contain a server_guid_ack");
}
try {
return UUIDUtil.fromByteString(ack.getServerGuidAck());
} catch (final IllegalArgumentException e) {
throw GrpcExceptions.fieldViolation("server_guid_ack", "invalid server_guid_ack");
}
}
@Override
public SendMessageAuthenticatedSenderResponse sendMessage(final SendAuthenticatedSenderMessageRequest request)
throws RateLimitExceededException {
@ -75,7 +128,7 @@ public class MessagesGrpcService extends SimpleMessagesGrpc.MessagesImplBase {
.orElseThrow(() -> GrpcExceptions.invalidCredentials("invalid credentials"));
final ServiceIdentifier destinationServiceIdentifier =
ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getDestination());
GrpcServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getDestination());
if (sender.isIdentifiedBy(destinationServiceIdentifier)) {
throw GrpcExceptions.invalidArguments("use `sendSyncMessage` to send messages to own account");
@ -158,8 +211,8 @@ public class MessagesGrpcService extends SimpleMessagesGrpc.MessagesImplBase {
.setType(getEnvelopeType(entry.getValue().getType()))
.setClientTimestamp(messages.getTimestamp())
.setServerTimestamp(clock.millis())
.setDestinationServiceId(destinationServiceIdentifier.toServiceIdentifierString())
.setSourceServiceId(new AciServiceIdentifier(sender.accountIdentifier()).toServiceIdentifierString())
.setDestinationServiceId(destinationServiceIdentifier.toCompactByteString())
.setSourceServiceId(new AciServiceIdentifier(sender.accountIdentifier()).toCompactByteString())
.setSourceDevice(sender.deviceId())
.setEphemeral(ephemeral)
.setUrgent(urgent)
@ -192,6 +245,8 @@ public class MessagesGrpcService extends SimpleMessagesGrpc.MessagesImplBase {
.build();
} catch (final MessageTooLargeException e) {
throw GrpcExceptions.invalidArguments("message too large");
} catch (final MessageDeliveryNotAllowedException e) {
throw GrpcExceptions.unavailable();
}
}

View File

@ -0,0 +1,130 @@
package org.whispersystems.textsecuregcm.grpc;
import java.math.BigDecimal;
import java.time.Duration;
import java.util.List;
import java.util.Locale;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
import org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor;
import org.whispersystems.textsecuregcm.subscriptions.PayPalDonationsTranslator;
import org.whispersystems.textsecuregcm.subscriptions.PaymentDetails;
import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionCurrencyUtil;
public class OneTimeDonationUtil {
private static final String EURO_CURRENCY_CODE = "EUR";
private static final Logger LOGGER = LoggerFactory.getLogger(OneTimeDonationUtil.class);
/// Thrown if a one time donation level cannot be parsed or if it is not found in configuration
public static class InvalidLevelException extends Exception {
public InvalidLevelException(final String message) {
super(message);
}
}
public record LocalizedPayPalDonationLineItem(Locale locale, String itemName){}
public record DonationLevelDetails(long level, Duration levelExpiration){}
public sealed interface OneTimeDonationRequestValidationResult permits OneTimeDonationRequestValidationResult.Success,
OneTimeDonationRequestValidationResult.UnsupportedCurrency,
OneTimeDonationRequestValidationResult.UnsupportedLevel,
OneTimeDonationRequestValidationResult.AmountBelowMinimum,
OneTimeDonationRequestValidationResult.AmountAboveSepaLimit {
record Success() implements OneTimeDonationRequestValidationResult {}
record UnsupportedCurrency() implements OneTimeDonationRequestValidationResult {}
record UnsupportedLevel() implements OneTimeDonationRequestValidationResult {}
record AmountBelowMinimum(BigDecimal minimum) implements OneTimeDonationRequestValidationResult {}
record AmountAboveSepaLimit(BigDecimal maximum) implements OneTimeDonationRequestValidationResult {}
}
public static OneTimeDonationRequestValidationResult validateOneTimeDonationRequest(
final String currency,
final BigDecimal amount,
final long level,
final PaymentMethod paymentMethod,
final OneTimeDonationConfiguration oneTimeDonationConfiguration,
final CustomerAwareSubscriptionPaymentProcessor manager
) {
if (!(level == oneTimeDonationConfiguration.gift().level()
|| level == oneTimeDonationConfiguration.boost().level())) {
return new OneTimeDonationRequestValidationResult.UnsupportedLevel();
}
if (!manager.getSupportedCurrenciesForPaymentMethod(paymentMethod)
.contains(currency.toLowerCase(Locale.ROOT))) {
return new OneTimeDonationRequestValidationResult.UnsupportedCurrency();
}
final BigDecimal minCurrencyAmountMajorUnits = oneTimeDonationConfiguration.currencies()
.get(currency.toLowerCase(Locale.ROOT)).minimum();
final BigDecimal minCurrencyAmountMinorUnits = SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount(
currency,
minCurrencyAmountMajorUnits);
if (minCurrencyAmountMinorUnits.compareTo(amount) > 0) {
return new OneTimeDonationRequestValidationResult.AmountBelowMinimum(minCurrencyAmountMajorUnits);
}
if (paymentMethod == PaymentMethod.SEPA_DEBIT &&
amount.compareTo(SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount(
EURO_CURRENCY_CODE,
oneTimeDonationConfiguration.sepaMaximumEuros())) > 0) {
return new OneTimeDonationRequestValidationResult.AmountAboveSepaLimit(
oneTimeDonationConfiguration.sepaMaximumEuros());
}
return new OneTimeDonationRequestValidationResult.Success();
}
public static LocalizedPayPalDonationLineItem localizePayPalDonationLineItem(
final PayPalDonationsTranslator payPalDonationsTranslator, final List<Locale> acceptableLocales) {
// These two localizations are a best-effort, and it's possible that the first `locale` and the localized line
// item name will not match. We could try to align with the locales PayPal documents <https://developer.paypal.com/reference/locale-codes/#supported-locale-codes>
// but that's a moving target, and we can hopefully have one of them be better for the user by selecting
// independently.
final Locale locale = SubscriptionsUtil.getPayPalLocale(acceptableLocales);
final String localizedLineItemName = payPalDonationsTranslator.translate(acceptableLocales,
org.whispersystems.textsecuregcm.subscriptions.PayPalDonationsTranslator.ONE_TIME_DONATION_LINE_ITEM_KEY);
return new LocalizedPayPalDonationLineItem(locale, localizedLineItemName);
}
public static DonationLevelDetails getLevelDetails(final PaymentDetails paymentDetails,
final OneTimeDonationConfiguration oneTimeDonationConfiguration)
throws InvalidLevelException {
long level = oneTimeDonationConfiguration.boost().level();
if (paymentDetails.customMetadata() != null) {
final String levelMetadata = paymentDetails.customMetadata()
.getOrDefault("level", Long.toString(oneTimeDonationConfiguration.boost().level()));
try {
level = Long.parseLong(levelMetadata);
} catch (final NumberFormatException e) {
LOGGER.error("failed to parse level metadata ({}) on payment intent {}", levelMetadata,
paymentDetails.id(), e);
throw new InvalidLevelException("failed to parse level metadata");
}
}
final Duration levelExpiration;
if (level == oneTimeDonationConfiguration.boost().level()) {
levelExpiration = oneTimeDonationConfiguration.boost().expiration();
} else if (level == oneTimeDonationConfiguration.gift().level()) {
levelExpiration = oneTimeDonationConfiguration.gift().expiration();
} else {
LOGGER.error("level ({}) returned from payment intent that is unknown to the server", level);
throw new InvalidLevelException("unrecognized level");
}
return new DonationLevelDetails(level, levelExpiration);
}
}

View File

@ -0,0 +1,326 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc;
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.getClientPlatform;
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.toChargeFailure;
import com.google.protobuf.ByteString;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.Tags;
import org.signal.chat.errors.FailedPrecondition;
import org.signal.chat.errors.FailedZkAuthentication;
import org.signal.chat.errors.NotFound;
import org.signal.chat.one_time_donations.AmountAboveSepaLimitError;
import org.signal.chat.one_time_donations.AmountBelowMinimumError;
import org.signal.chat.one_time_donations.ConfirmPayPalBoostRequest;
import org.signal.chat.one_time_donations.ConfirmPayPalBoostResponse;
import org.signal.chat.one_time_donations.CreateBoostReceiptCredentialsRequest;
import org.signal.chat.one_time_donations.CreateBoostReceiptCredentialsResponse;
import org.signal.chat.one_time_donations.CreateBoostRequest;
import org.signal.chat.one_time_donations.CreateBoostResponse;
import org.signal.chat.one_time_donations.CreatePayPalBoostRequest;
import org.signal.chat.one_time_donations.CreatePayPalBoostResponse;
import org.signal.chat.one_time_donations.SimpleOneTimeDonationsGrpc;
import org.signal.chat.subscriptions.PaymentRequired;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.donation.DonationPermit;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse;
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.storage.DonationPermitsManager;
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
import org.whispersystems.textsecuregcm.storage.OneTimeDonationsManager;
import org.whispersystems.textsecuregcm.storage.WriteConflictException;
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
import org.whispersystems.textsecuregcm.subscriptions.PayPalDonationsTranslator;
import org.whispersystems.textsecuregcm.subscriptions.PaymentDetails;
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
import org.whispersystems.textsecuregcm.subscriptions.PaymentStatus;
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
public class OneTimeDonationsGrpcService extends SimpleOneTimeDonationsGrpc.OneTimeDonationsImplBase {
private final OneTimeDonationConfiguration oneTimeDonationConfiguration;
private final StripeManager stripeManager;
private final BraintreeManager braintreeManager;
private final PayPalDonationsTranslator payPalDonationsTranslator;
private final OneTimeDonationsManager oneTimeDonationsManager;
private final IssuedReceiptsManager issuedReceiptsManager;
private final ServerZkReceiptOperations zkReceiptOperations;
private final Clock clock;
private final RateLimiters rateLimiters;
private final DonationPermitsManager donationPermitsManager;
public OneTimeDonationsGrpcService(
final OneTimeDonationConfiguration oneTimeDonationConfiguration,
final StripeManager stripeManager,
final BraintreeManager braintreeManager,
final PayPalDonationsTranslator payPalDonationsTranslator,
final OneTimeDonationsManager oneTimeDonationsManager,
final IssuedReceiptsManager issuedReceiptsManager,
final ServerZkReceiptOperations zkReceiptOperations,
final Clock clock,
final RateLimiters rateLimiters,
final DonationPermitsManager donationPermitsManager) {
this.oneTimeDonationConfiguration = oneTimeDonationConfiguration;
this.stripeManager = stripeManager;
this.braintreeManager = braintreeManager;
this.payPalDonationsTranslator = payPalDonationsTranslator;
this.oneTimeDonationsManager = oneTimeDonationsManager;
this.issuedReceiptsManager = issuedReceiptsManager;
this.zkReceiptOperations = zkReceiptOperations;
this.clock = clock;
this.rateLimiters = rateLimiters;
this.donationPermitsManager = donationPermitsManager;
}
@Override
public CreateBoostResponse createBoost(final CreateBoostRequest request) throws RateLimitExceededException {
RateLimitUtil.rateLimitByRemoteAddress(rateLimiters.forDescriptor(RateLimiters.For.ONE_TIME_DONATION));
try {
final DonationPermit donationPermit = new DonationPermit(request.getDonationPermit().toByteArray());
if (!donationPermitsManager.spend(donationPermit)) {
return CreateBoostResponse.newBuilder()
.setPermitRejected(FailedZkAuthentication.newBuilder()
.setDescription("donation permit rejected")
.build())
.build();
}
} catch (InvalidInputException | VerificationFailedException _) {
return CreateBoostResponse.newBuilder()
.setPermitRejected(FailedZkAuthentication.newBuilder()
.setDescription("donation permit rejected")
.build())
.build();
}
final org.whispersystems.textsecuregcm.subscriptions.PaymentMethod paymentMethod =
switch (request.getPaymentMethod()) {
case PAYMENT_METHOD_CARD -> org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.CARD;
case PAYMENT_METHOD_SEPA_DEBIT -> org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.SEPA_DEBIT;
case PAYMENT_METHOD_IDEAL -> org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.IDEAL;
default -> throw GrpcExceptions.fieldViolation("payment_method", "Unsupported payment method");
};
final OneTimeDonationUtil.OneTimeDonationRequestValidationResult validationResult =
OneTimeDonationUtil.validateOneTimeDonationRequest(
request.getCurrency(),
BigDecimal.valueOf(request.getAmount()),
request.getLevel(),
paymentMethod,
oneTimeDonationConfiguration,
stripeManager);
return switch (validationResult) {
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedLevel _ ->
CreateBoostResponse.newBuilder()
.setUnsupportedLevel(FailedPrecondition.getDefaultInstance()).build();
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedCurrency _ ->
CreateBoostResponse.newBuilder()
.setUnsupportedCurrency(FailedPrecondition.getDefaultInstance()).build();
case final OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountBelowMinimum r ->
CreateBoostResponse.newBuilder()
.setAmountBelowMinimum(AmountBelowMinimumError.newBuilder()
.setMinimum(r.minimum().toString()).build()).build();
case final OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountAboveSepaLimit r ->
CreateBoostResponse.newBuilder()
.setAmountAboveSepaLimit(AmountAboveSepaLimitError.newBuilder()
.setMaximum(r.maximum().toString()).build()).build();
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.Success _ -> {
final com.stripe.model.PaymentIntent paymentIntent = stripeManager.createPaymentIntent(
request.getCurrency(), request.getAmount(), request.getLevel(),
getClientPlatform(RequestAttributesUtil.getUserAgent().orElse(null))).join();
yield CreateBoostResponse.newBuilder().setClientSecret(paymentIntent.getClientSecret()).build();
}
};
}
@Override
public CreatePayPalBoostResponse createPayPalBoost(final CreatePayPalBoostRequest request) {
final OneTimeDonationUtil.OneTimeDonationRequestValidationResult validationResult =
OneTimeDonationUtil.validateOneTimeDonationRequest(
request.getCurrency(),
BigDecimal.valueOf(request.getAmount()),
request.getLevel(),
org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.PAYPAL,
oneTimeDonationConfiguration,
braintreeManager);
return switch (validationResult) {
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedLevel _ ->
CreatePayPalBoostResponse.newBuilder()
.setUnsupportedLevel(FailedPrecondition.getDefaultInstance()).build();
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedCurrency _ ->
CreatePayPalBoostResponse.newBuilder()
.setUnsupportedCurrency(FailedPrecondition.getDefaultInstance()).build();
case final OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountBelowMinimum r ->
CreatePayPalBoostResponse.newBuilder()
.setAmountBelowMinimum(AmountBelowMinimumError.newBuilder()
.setMinimum(r.minimum().toString()).build()).build();
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountAboveSepaLimit _ ->
throw new IllegalStateException("SEPA limit should not trigger for PayPal");
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.Success _ -> {
final List<Locale> acceptableLocales = RequestAttributesUtil.getAvailableAcceptedLocales();
final OneTimeDonationUtil.LocalizedPayPalDonationLineItem localizedLineItem = OneTimeDonationUtil.localizePayPalDonationLineItem(
payPalDonationsTranslator, acceptableLocales);
final BraintreeManager.PayPalOneTimePaymentApprovalDetails approvalDetails =
braintreeManager.createOneTimePayment(
request.getCurrency().toUpperCase(Locale.ROOT), request.getAmount(),
localizedLineItem.locale().toLanguageTag(), request.getReturnUrl(), request.getCancelUrl(),
localizedLineItem.itemName()).join();
yield CreatePayPalBoostResponse.newBuilder()
.setResult(CreatePayPalBoostResponse.CreatePayPalBoostResult.newBuilder()
.setApprovalUrl(approvalDetails.approvalUrl())
.setPaymentId(approvalDetails.paymentId()).build()).build();
}
};
}
@Override
public ConfirmPayPalBoostResponse confirmPayPalBoost(final ConfirmPayPalBoostRequest request)
throws RateLimitExceededException {
RateLimitUtil.rateLimitByRemoteAddress(rateLimiters.forDescriptor(RateLimiters.For.ONE_TIME_DONATION));
final OneTimeDonationUtil.OneTimeDonationRequestValidationResult validationResult =
OneTimeDonationUtil.validateOneTimeDonationRequest(
request.getCurrency(),
BigDecimal.valueOf(request.getAmount()),
request.getLevel(),
org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.PAYPAL,
oneTimeDonationConfiguration,
braintreeManager);
return switch (validationResult) {
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedLevel _ ->
ConfirmPayPalBoostResponse.newBuilder()
.setUnsupportedLevel(FailedPrecondition.getDefaultInstance()).build();
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedCurrency _ ->
ConfirmPayPalBoostResponse.newBuilder()
.setUnsupportedCurrency(FailedPrecondition.getDefaultInstance()).build();
case final OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountBelowMinimum r ->
ConfirmPayPalBoostResponse.newBuilder()
.setAmountBelowMinimum(AmountBelowMinimumError.newBuilder()
.setMinimum(r.minimum().toString()).build()).build();
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountAboveSepaLimit _ ->
throw new IllegalStateException("SEPA limit should not trigger for PayPal");
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.Success _ -> {
final BraintreeManager.PayPalChargeSuccessDetails chargeSuccessDetails =
braintreeManager.captureOneTimePayment(
request.getPayerId(), request.getPaymentId(), request.getPaymentToken(),
request.getCurrency(), request.getAmount(), request.getLevel(),
getClientPlatform(RequestAttributesUtil.getUserAgent().orElse(null))).join();
oneTimeDonationsManager.putPaidAt(chargeSuccessDetails.paymentId(), clock.instant());
yield ConfirmPayPalBoostResponse.newBuilder()
.setResult(ConfirmPayPalBoostResponse.ConfirmPayPalBoostResult.newBuilder()
.setPaymentId(chargeSuccessDetails.paymentId()).build()).build();
}
};
}
@Override
public CreateBoostReceiptCredentialsResponse createBoostReceiptCredentials(
final CreateBoostReceiptCredentialsRequest request) {
final PaymentProvider processor;
final Optional<PaymentDetails> maybePaymentDetails;
switch (request.getProcessor()) {
case PAYMENT_PROVIDER_STRIPE -> {
processor = PaymentProvider.STRIPE;
maybePaymentDetails = stripeManager.getPaymentDetails(request.getPaymentIntentId()).join();
}
case PAYMENT_PROVIDER_BRAINTREE -> {
processor = PaymentProvider.BRAINTREE;
maybePaymentDetails = braintreeManager.getPaymentDetails(request.getPaymentIntentId()).join();
}
default -> throw GrpcExceptions.fieldViolation("processor", "Unsupported payment processor");
}
if (maybePaymentDetails.isEmpty()) {
return CreateBoostReceiptCredentialsResponse.newBuilder()
.setPaymentNotFound(NotFound.getDefaultInstance()).build();
}
final PaymentDetails paymentDetails = maybePaymentDetails.get();
if (paymentDetails.status() == PaymentStatus.PROCESSING) {
return CreateBoostReceiptCredentialsResponse.newBuilder()
.setPaymentStillProcessing(FailedPrecondition.getDefaultInstance()).build();
}
if (paymentDetails.status() != PaymentStatus.SUCCEEDED) {
final PaymentRequired.Builder paymentRequiredBuilder = PaymentRequired.newBuilder();
if (paymentDetails.chargeFailure() != null) {
paymentRequiredBuilder.setChargeFailure(toChargeFailure(processor, paymentDetails.chargeFailure()));
}
return CreateBoostReceiptCredentialsResponse.newBuilder()
.setPaymentRequired(paymentRequiredBuilder).build();
}
final OneTimeDonationUtil.DonationLevelDetails levelDetails;
try {
levelDetails = OneTimeDonationUtil.getLevelDetails(paymentDetails, oneTimeDonationConfiguration);
} catch (final OneTimeDonationUtil.InvalidLevelException e) {
throw GrpcExceptions.unavailable(e.getMessage());
}
final ReceiptCredentialRequest receiptCredentialRequest;
try {
receiptCredentialRequest = new ReceiptCredentialRequest(
request.getReceiptCredentialRequest().toByteArray());
} catch (final InvalidInputException e) {
throw GrpcExceptions.fieldViolation("receipt_credential_request", "invalid receipt credential request");
}
try {
issuedReceiptsManager.recordIssuance(
paymentDetails.id(), processor, receiptCredentialRequest, clock.instant());
} catch (final WriteConflictException e) {
return CreateBoostReceiptCredentialsResponse.newBuilder()
.setReceiptAlreadyIssued(FailedPrecondition.getDefaultInstance()).build();
}
final Instant paidAt = oneTimeDonationsManager.getPaidAt(paymentDetails.id(), paymentDetails.created());
final Instant expiration = paidAt
.plus(levelDetails.levelExpiration())
.truncatedTo(ChronoUnit.DAYS)
.plus(1, ChronoUnit.DAYS);
final ReceiptCredentialResponse receiptCredentialResponse;
try {
receiptCredentialResponse = zkReceiptOperations.issueReceiptCredential(
receiptCredentialRequest, expiration.getEpochSecond(), levelDetails.level());
} catch (final VerificationFailedException e) {
throw GrpcExceptions.fieldViolation("receipt_credential_request",
"receipt credential request failed verification");
}
Metrics.counter(SubscriptionsGrpcService.RECEIPT_ISSUED_COUNTER_NAME,
Tags.of(
Tag.of(SubscriptionsGrpcService.PROCESSOR_TAG_NAME, processor.toString()),
Tag.of(SubscriptionsGrpcService.TYPE_TAG_NAME, "boost"),
UserAgentTagUtil.getPlatformTag(RequestAttributesUtil.getUserAgent().orElse(null))))
.increment();
return CreateBoostReceiptCredentialsResponse.newBuilder()
.setResult(CreateBoostReceiptCredentialsResponse.CreateBoostReceiptCredentialsResult.newBuilder()
.setReceiptCredentialResponse(ByteString.copyFrom(receiptCredentialResponse.serialize()))
.build())
.build();
}
}

View File

@ -0,0 +1,171 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc;
import io.micrometer.core.instrument.Timer;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import javax.annotation.Nullable;
import org.signal.chat.messages.GetMessagesResponse;
import org.whispersystems.textsecuregcm.entities.MessageProtos;
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
import org.whispersystems.textsecuregcm.metrics.MessageMetrics;
import org.whispersystems.textsecuregcm.util.ClosableEpoch;
import org.whispersystems.textsecuregcm.util.UUIDUtil;
import org.whispersystems.textsecuregcm.util.ua.UserAgent;
import reactor.core.publisher.Mono;
import reactor.core.publisher.Sinks;
/// Track messages while they are in-flight so they can later be retrieved for acknowledgement, as well as when
/// acknowledgement completes.
///
/// # Example Usage
/// ```java
/// PendingAcknowledgement tracker = new PendingAcknowledgementTracker(...);
/// tracker.addUnacknowledgedEnvelope(envelope);
/// sendEnvelopeToDevice(envelope);
///
/// // Later, when the device acklowedges the envelope
/// UnacknowledgedEnvelope envelope = takeUnacknowledgedEnvelope(uuid);
/// removeFromStorage(envelope);
/// envelope.handleMessageAcknowledged();
/// ```
class PendingAcknowledgementTracker {
private final ConcurrentHashMap<UUID, UnacknowledgedEnvelope> unacknowledgedEnvelopes = new ConcurrentHashMap<>();
private final AtomicLong sentMessageCounter = new AtomicLong();
private final Sinks.One<GetMessagesResponse> queueDrained = Sinks.one();
private final ClosableEpoch queueDrainEpoch = new ClosableEpoch(() ->
queueDrained.tryEmitValue(MessageDispatcher.QUEUE_EMPTY_RESPONSE));
private final Timer.Sample queueDrainStart = Timer.start();
private final MessageMetrics messageMetrics;
@Nullable private final UserAgent userAgent;
PendingAcknowledgementTracker(final MessageMetrics messageMetrics, @Nullable final UserAgent userAgent) {
this.messageMetrics = messageMetrics;
this.userAgent = userAgent;
}
/// Store an envelope so it can be retrieved later via [#takeUnacknowledgedEnvelope].
///
/// @param envelope An envelope that has been sent to a device and is waiting to be acknowledged
void addUnacknowledgedEnvelope(final MessageProtos.Envelope envelope) {
sentMessageCounter.incrementAndGet();
final UUID messageGuid = UUIDUtil.fromByteString(envelope.getServerGuid());
// If the envelope has a source, and it is not a server delivery receipt, it will be an ACI.
final AciServiceIdentifier sourceId = envelope.hasSourceServiceId() && envelope.getType() != MessageProtos.Envelope.Type.SERVER_DELIVERY_RECEIPT
? AciServiceIdentifier.fromByteString(envelope.getSourceServiceId())
: null;
final ServiceIdentifier destinationId = ServiceIdentifier.fromByteString(envelope.getDestinationServiceId());
final UnacknowledgedEnvelope unacknowledgedEnvelope = new UnacknowledgedEnvelope(messageGuid,
envelope.getServerTimestamp(), envelope.getClientTimestamp(),
envelope.getUrgent(), envelope.getEphemeral(),
destinationId, sourceId);
unacknowledgedEnvelopes.put(messageGuid, unacknowledgedEnvelope);
}
/// Take a previously stored envelope so it can be acknowledged. When the message is successfully removed from
/// storage, call [UnacknowledgedEnvelope#handleMessageAcknowledged()].
///
/// @param guid The server GUID of the envelope previously stored with [#addUnacknowledgedEnvelope(MessageProtos.Envelope)].
/// @return The previously stored envelope, or null if no envelope with the provided guid was stored
@Nullable
UnacknowledgedEnvelope takeUnacknowledgedEnvelope(final UUID guid) {
return unacknowledgedEnvelopes.remove(guid);
}
/// Signal that we've observed the end of the initial message queue. The Mono returned by [#queueDrained] will emit a
/// value when all current envelopes tracked with [#addUnacknowledgedEnvelope] have been marked [UnacknowledgedEnvelope#handleMessageAcknowledged()]
void markEndOfQueue() {
messageMetrics
.measureQueueDrain(MessageMetrics.GRPC_CHANNEL, userAgent, sentMessageCounter.get(), queueDrainStart);
queueDrainEpoch.close();
}
/// Determine when a queue has been drained.
///
/// @return A mono that completes with a QueueEmpty response when all messages tracked before [#markEndOfQueue] have
/// been acknowledged.
Mono<GetMessagesResponse> queueDrained() {
return queueDrained.asMono();
}
class UnacknowledgedEnvelope {
private final UUID serverGuid;
private final long serverTimestamp;
private final Timer.Sample sample;
private final boolean inDrainEpoch;
private final long clientTimestamp;
private final boolean urgent;
private final boolean ephemeral;
private final ServiceIdentifier destinationServiceId;
@Nullable private final AciServiceIdentifier sourceServiceId;
private UnacknowledgedEnvelope(
final UUID serverGuid,
final long serverTimestamp,
final long clientTimestamp,
final boolean urgent,
final boolean ephemeral,
final ServiceIdentifier destinationServiceId,
@Nullable final AciServiceIdentifier sourceServiceId) {
this.serverGuid = serverGuid;
this.serverTimestamp = serverTimestamp;
this.clientTimestamp = clientTimestamp;
this.urgent = urgent;
this.ephemeral = ephemeral;
this.destinationServiceId = destinationServiceId;
this.sample = Timer.start();
this.inDrainEpoch = queueDrainEpoch.tryArrive();
this.sourceServiceId = sourceServiceId;
}
void handleMessageAcknowledged() {
messageMetrics.measureSendMessageDuration(MessageMetrics.GRPC_CHANNEL, userAgent, sample);
if (inDrainEpoch) {
queueDrainEpoch.depart();
}
}
public UUID getServerGuid() {
return serverGuid;
}
public long getServerTimestamp() {
return serverTimestamp;
}
public long getClientTimestamp() {
return clientTimestamp;
}
public boolean isUrgent() {
return urgent;
}
public boolean isEphemeral() {
return ephemeral;
}
public ServiceIdentifier getDestinationServiceId() {
return destinationServiceId;
}
/// Get the sender of this message
///
/// @return the ServiceIdentifier of the source of this message. `null` if the message sender was not identified or
/// if the envelope was for a server delivery receipt
@Nullable
public AciServiceIdentifier getSourceServiceId() {
return sourceServiceId;
}
}
}

View File

@ -63,7 +63,7 @@ public class ProfileAnonymousGrpcService extends SimpleProfileAnonymousGrpc.Prof
@Override
public GetUnversionedProfileAnonymousResponse getUnversionedProfile(final GetUnversionedProfileAnonymousRequest request) {
final ServiceIdentifier targetIdentifier =
ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getRequest().getServiceIdentifier());
GrpcServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getRequest().getServiceIdentifier());
// Callers must be authenticated to request unversioned profiles by PNI
if (targetIdentifier.identityType() == IdentityType.PNI) {
@ -77,7 +77,7 @@ public class ProfileAnonymousGrpcService extends SimpleProfileAnonymousGrpc.Prof
case UNIDENTIFIED_ACCESS_KEY ->
targetAccount.map(a -> UnidentifiedAccessUtil.checkUnidentifiedAccess(a, request.getUnidentifiedAccessKey().toByteArray()))
.orElse(false);
default -> throw GrpcExceptions.constraintViolation("invalid authentication");
default -> throw GrpcExceptions.invalidArguments("invalid authentication");
};
if (!authorized) {
@ -100,7 +100,7 @@ public class ProfileAnonymousGrpcService extends SimpleProfileAnonymousGrpc.Prof
@Override
public GetVersionedProfileAnonymousResponse getVersionedProfile(final GetVersionedProfileAnonymousRequest request) {
final ServiceIdentifier targetIdentifier = ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getRequest().getAccountIdentifier());
final ServiceIdentifier targetIdentifier = GrpcServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getRequest().getAccountIdentifier());
final Optional<Account> targetAccount = getTargetAccountAndValidateUnidentifiedAccess(targetIdentifier, request.getUnidentifiedAccessKey().toByteArray());
@ -123,10 +123,10 @@ public class ProfileAnonymousGrpcService extends SimpleProfileAnonymousGrpc.Prof
@Override
public GetExpiringProfileKeyCredentialAnonymousResponse getExpiringProfileKeyCredential(
final GetExpiringProfileKeyCredentialAnonymousRequest request) {
final ServiceIdentifier targetIdentifier = ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getRequest().getAccountIdentifier());
final ServiceIdentifier targetIdentifier = GrpcServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getRequest().getAccountIdentifier());
if (request.getRequest().getCredentialType() != CredentialType.CREDENTIAL_TYPE_EXPIRING_PROFILE_KEY) {
throw GrpcExceptions.constraintViolation("invalid credential type");
throw GrpcExceptions.invalidArguments("invalid credential type");
}
final Optional<Account> maybeAccount = getTargetAccountAndValidateUnidentifiedAccess(

View File

@ -15,8 +15,8 @@ import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.signal.chat.profile.Badge;
import org.signal.chat.profile.BadgeSvg;
import org.signal.chat.common.Badge;
import org.signal.chat.common.BadgeSvg;
import org.signal.chat.profile.DataEtag;
import org.signal.chat.profile.GetExpiringProfileKeyCredentialResult;
import org.signal.chat.profile.GetUnversionedProfileResult;
@ -72,7 +72,7 @@ public class ProfileGrpcHelper {
} else {
responseBuilder.setDataEtag(DataEtag.newBuilder()
.setData(ByteString.copyFrom(p.data()))
.setEtag(ByteString.copyFrom(p.dataHash()))
.setEtagSha256(ByteString.copyFrom(p.dataHash()))
.build());
}
});
@ -95,7 +95,7 @@ public class ProfileGrpcHelper {
// Include payment address if the version matches the latest version on Account or the latest version on Account
// is empty
if (account.getCurrentProfileVersion().map(v -> v.equals(HexFormat.of().formatHex(requestVersion))).orElse(true)) {
if (account.getCurrentProfileVersion().map(v -> Arrays.equals(v, requestVersion)).orElse(true)) {
final Optional<byte []> paymentAddress = profile.map(VersionedProfile::paymentAddress).or(() -> v1Profile.map(VersionedProfileV1::paymentAddress));
if (paymentAddress.isPresent()) {
@ -110,7 +110,7 @@ public class ProfileGrpcHelper {
} else {
responseBuilder.setPaymentAddressDataEtag(DataEtag.newBuilder()
.setData(ByteString.copyFrom(paymentAddress.get()))
.setEtag(ByteString.copyFrom(
.setEtagSha256(ByteString.copyFrom(
profile.map(VersionedProfile::paymentAddressHash).orElseGet(() -> hash(paymentAddress.get()))))
.build());
}
@ -208,7 +208,7 @@ public class ProfileGrpcHelper {
profileKeyCredentialResponse = ProfileHelper.getExpiringProfileKeyCredential(new ProfileKeyCredentialRequest(encodedCredentialRequest),
new ProfileKeyCommitment(commitment), new ServiceId.Aci(account.getUuid()), zkProfileOperations);
} catch (VerificationFailedException | InvalidInputException e) {
throw GrpcExceptions.constraintViolation("invalid credential request");
throw GrpcExceptions.invalidArguments("invalid credential request");
}
return GetExpiringProfileKeyCredentialResult.newBuilder()

View File

@ -5,9 +5,8 @@
package org.whispersystems.textsecuregcm.grpc;
import com.google.protobuf.ByteString;
import java.time.Clock;
import java.time.ZonedDateTime;
import java.util.Arrays;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
@ -15,6 +14,7 @@ import java.util.Optional;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.signal.chat.common.S3UploadForm;
import org.signal.chat.errors.FailedPrecondition;
import org.signal.chat.errors.NotFound;
import org.signal.chat.profile.GetUnversionedProfileRequest;
@ -22,7 +22,6 @@ import org.signal.chat.profile.GetUnversionedProfileResponse;
import org.signal.chat.profile.GetVersionedProfileRequest;
import org.signal.chat.profile.GetVersionedProfileResponse;
import org.signal.chat.profile.PaymentsForbiddenInRegion;
import org.signal.chat.profile.ProfileAvatarUploadAttributes;
import org.signal.chat.profile.ProfilesV2CapabilityRequired;
import org.signal.chat.profile.SetProfileRequest;
import org.signal.chat.profile.SetProfileResponse;
@ -39,7 +38,6 @@ import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.identity.IdentityType;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.s3.PolicySigner;
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountBadge;
@ -50,7 +48,6 @@ import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import org.whispersystems.textsecuregcm.storage.VersionedProfile;
import org.whispersystems.textsecuregcm.storage.VersionedProfileV1;
import org.whispersystems.textsecuregcm.storage.WriteConflictException;
import org.whispersystems.textsecuregcm.util.Pair;
import org.whispersystems.textsecuregcm.util.ProfileHelper;
public class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {
@ -61,13 +58,17 @@ public class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
private final Map<String, BadgeConfiguration> badgeConfigurationMap;
private final PostPolicyGenerator policyGenerator;
private final PolicySigner policySigner;
private final ProfileBadgeConverter profileBadgeConverter;
private final RateLimiters rateLimiters;
private static final S3UploadForm PROTOTYPE_AVATAR_UPLOAD_FORM = S3UploadForm.newBuilder()
.setAcl(PostPolicyGenerator.ACL)
.setAlgorithm(PostPolicyGenerator.ALGORITHM)
.build();
private record AvatarData(Optional<String> currentAvatar,
Optional<String> finalAvatar,
Optional<ProfileAvatarUploadAttributes> uploadAttributes) {}
Optional<S3UploadForm> uploadAttributes) {}
public ProfileGrpcService(
final Clock clock,
@ -76,7 +77,6 @@ public class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
final BadgesConfiguration badgesConfiguration,
final PostPolicyGenerator policyGenerator,
final PolicySigner policySigner,
final ProfileBadgeConverter profileBadgeConverter,
final RateLimiters rateLimiters) {
this.clock = clock;
@ -86,7 +86,6 @@ public class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {
this.badgeConfigurationMap = badgesConfiguration.getBadges().stream().collect(Collectors.toMap(
BadgeConfiguration::getId, Function.identity()));
this.policyGenerator = policyGenerator;
this.policySigner = policySigner;
this.profileBadgeConverter = profileBadgeConverter;
this.rateLimiters = rateLimiters;
}
@ -107,14 +106,11 @@ public class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {
validateRequest(request);
final String expectedCurrentVersionHex = HexFormat.of().formatHex(request.getExpectedCurrentVersion().toByteArray());
final boolean currentVersionMatchesExpected = account.getCurrentProfileVersion().isEmpty()
? request.getExpectedCurrentVersion().isEmpty()
: account.getCurrentProfileVersion().get().equals(expectedCurrentVersionHex);
final byte[] expectedCurrentVersion = request.getExpectedCurrentVersion().toByteArray();
final boolean currentVersionMatchesExpected = Arrays.equals(account.getCurrentProfileVersion().orElse(new byte[0]), expectedCurrentVersion);
if (!currentVersionMatchesExpected) {
return SetProfileResponse.newBuilder().setWriteConflict(FailedPrecondition.newBuilder()
return SetProfileResponse.newBuilder().setExpectedVersionWriteConflict(FailedPrecondition.newBuilder()
.setDescription("current and expected profile versions must match")
.build()).build();
}
@ -154,7 +150,7 @@ public class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {
avatarData.finalAvatar().orElse(null),
request.getV1Request().getAboutEmoji().toByteArray(),
request.getV1Request().getAbout().toByteArray(),
request.getPaymentAddress().toByteArray(),
request.getPaymentAddress().isEmpty() ? null : request.getPaymentAddress().toByteArray(),
request.getV1Request().getPhoneNumberSharing().toByteArray(),
commitment);
@ -170,14 +166,14 @@ public class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {
} catch (WriteConflictException _) {
return SetProfileResponse.newBuilder()
.setWriteConflict(FailedPrecondition.newBuilder()
.setExpectedDataWriteConflict(FailedPrecondition.newBuilder()
.setDescription("current and expected data hash mismatch")
.build())
.build();
}
try {
accountsManager.updateCurrentProfileVersion(account.getIdentifier(IdentityType.ACI), version, expectedCurrentVersionHex, a -> {
accountsManager.updateCurrentProfileVersion(account.getIdentifier(IdentityType.ACI), version, expectedCurrentVersion, a -> {
final List<AccountBadge> updatedBadges = Optional.of(request.getBadgeIdsList())
.map(badges -> ProfileHelper.mergeBadgeIdsWithExistingAccountBadges(clock, badgeConfigurationMap, badges,
@ -189,7 +185,7 @@ public class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {
} catch (final WriteConflictException _) {
return SetProfileResponse.newBuilder()
.setWriteConflict(FailedPrecondition.newBuilder()
.setExpectedVersionWriteConflict(FailedPrecondition.newBuilder()
.setDescription("current and expected version mismatch")
.build())
.build();
@ -213,7 +209,7 @@ public class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {
public GetUnversionedProfileResponse getUnversionedProfile(final GetUnversionedProfileRequest request) throws RateLimitExceededException {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
final ServiceIdentifier targetIdentifier =
ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getServiceIdentifier());
GrpcServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getServiceIdentifier());
final Optional<Account> maybeAccount = validateRateLimitAndGetAccount(authenticatedDevice.accountIdentifier(), targetIdentifier);
return maybeAccount.map(account -> GetUnversionedProfileResponse.newBuilder()
@ -230,7 +226,7 @@ public class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {
public GetVersionedProfileResponse getVersionedProfile(final GetVersionedProfileRequest request) throws RateLimitExceededException {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
final ServiceIdentifier targetIdentifier =
ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getAccountIdentifier());
GrpcServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getAccountIdentifier());
final Optional<Account> maybeAccount = validateRateLimitAndGetAccount(authenticatedDevice.accountIdentifier(), targetIdentifier);
@ -259,28 +255,25 @@ public class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {
private void validateRequest(final SetProfileRequest request) {
if (request.getExpectedCurrentDataHash().isEmpty() && request.getCommitment().isEmpty()) {
throw GrpcExceptions.constraintViolation("At least one of expected current data hash and commitment is required");
throw GrpcExceptions.invalidArguments("At least one of expected current data hash and commitment is required");
}
// v1 -> v2 migration
if (request.getCommitment().isEmpty()) {
throw GrpcExceptions.constraintViolation("Request must include commitment during migration");
throw GrpcExceptions.invalidArguments("Request must include commitment during migration");
}
}
private ProfileAvatarUploadAttributes generateAvatarUploadForm(final String objectName) {
final ZonedDateTime now = ZonedDateTime.now(clock);
final Pair<String, String> policy = policyGenerator.createFor(now, objectName, ProfileHelper.MAX_PROFILE_AVATAR_SIZE_BYTES);
final String signature = policySigner.getSignature(now, policy.second());
private S3UploadForm generateAvatarUploadForm(final String objectName) {
final PostPolicyGenerator.SignedPostPolicy policy =
policyGenerator.createFor(objectName, ProfileHelper.MAX_PROFILE_AVATAR_SIZE_BYTES, clock.instant());
return ProfileAvatarUploadAttributes.newBuilder()
.setPath(objectName)
.setCredential(policy.first())
.setAcl("private")
.setAlgorithm("AWS4-HMAC-SHA256")
.setDate(now.format(PostPolicyGenerator.AWS_DATE_TIME))
.setPolicy(policy.second())
.setSignature(ByteString.copyFrom(signature.getBytes()))
return PROTOTYPE_AVATAR_UPLOAD_FORM.toBuilder()
.setKey(objectName)
.setCredential(policy.credential())
.setDate(policy.formattedTimestamp())
.setPolicy(policy.encodedPolicy())
.setSignature(policy.signature())
.build();
}
}

View File

@ -48,6 +48,8 @@ public class RequestAttributesInterceptor implements ServerInterceptor {
final String acceptLanguageHeader = headers.get(ACCEPT_LANG_KEY);
final String xForwardedForHeader = headers.get(X_FORWARDED_FOR_KEY);
// This assumes that X-Forwarded-For has been set by a trusted intermediate proxy. For example, this may be set by
// OmnibusH2Server which itself sets X-Forwarded-For using a PPv2 header that comes from a trusted load-balancer.
final Optional<InetAddress> remoteAddress = getMostRecentProxy(xForwardedForHeader)
.flatMap(mostRecentProxy -> {
try {

View File

@ -0,0 +1,610 @@
package org.whispersystems.textsecuregcm.grpc;
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.getClientPlatform;
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.getPayPalLocale;
import com.google.protobuf.ByteString;
import com.google.protobuf.Empty;
import java.math.BigDecimal;
import java.time.Clock;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.Tags;
import org.signal.chat.errors.FailedPrecondition;
import org.signal.chat.errors.FailedUnidentifiedAuthorization;
import org.signal.chat.errors.FailedZkAuthentication;
import org.signal.chat.errors.NotFound;
import org.signal.chat.subscriptions.CreatePayPalPaymentMethodRequest;
import org.signal.chat.subscriptions.CreatePayPalPaymentMethodResponse;
import org.signal.chat.subscriptions.CreatePaymentMethodRequest;
import org.signal.chat.subscriptions.CreatePaymentMethodResponse;
import org.signal.chat.subscriptions.DeleteSubscriberRequest;
import org.signal.chat.subscriptions.DeleteSubscriberResponse;
import org.signal.chat.subscriptions.GetBankMandateRequest;
import org.signal.chat.subscriptions.GetBankMandateResponse;
import org.signal.chat.subscriptions.GetConfigurationRequest;
import org.signal.chat.subscriptions.GetConfigurationResponse;
import org.signal.chat.subscriptions.GetReceiptCredentialsRequest;
import org.signal.chat.subscriptions.GetReceiptCredentialsResponse;
import org.signal.chat.subscriptions.GetSubscriptionInformationRequest;
import org.signal.chat.subscriptions.GetSubscriptionInformationResponse;
import org.signal.chat.subscriptions.PaymentMethod;
import org.signal.chat.subscriptions.PaymentRequired;
import org.signal.chat.subscriptions.SetDefaultPaymentMethodRequest;
import org.signal.chat.subscriptions.SetDefaultPaymentMethodResponse;
import org.signal.chat.subscriptions.SetIapSubscriptionRequest;
import org.signal.chat.subscriptions.SetIapSubscriptionResponse;
import org.signal.chat.subscriptions.SetSubscriptionLevelRequest;
import org.signal.chat.subscriptions.SetSubscriptionLevelResponse;
import org.signal.chat.subscriptions.SimpleSubscriptionsGrpc;
import org.signal.chat.subscriptions.UpdateSubscriberRequest;
import org.signal.chat.subscriptions.UpdateSubscriberResponse;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.donation.DonationPermit;
import org.whispersystems.textsecuregcm.badges.BadgeTranslator;
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;
import org.whispersystems.textsecuregcm.configuration.SubscriptionLevelConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.entities.Badge;
import org.whispersystems.textsecuregcm.entities.PurchasableBadge;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.storage.DonationPermitsManager;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.storage.SubscriberCredentials;
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
import org.whispersystems.textsecuregcm.storage.Subscriptions;
import org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreManager;
import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator;
import org.whispersystems.textsecuregcm.subscriptions.BankTransferType;
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
import org.whispersystems.textsecuregcm.subscriptions.CurrencyConfiguration;
import org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor;
import org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager;
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
import org.whispersystems.textsecuregcm.subscriptions.SubscriberIdCreationNotPermittedException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionChargeFailurePaymentRequiredException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionForbiddenException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInformation;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidArgumentsException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidIdempotencyKeyException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidLevelException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionNotFoundException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentRequiredException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentRequiresActionException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorConflictException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionReceiptAlreadyRedeemedException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionReceiptRequestedForOpenPaymentException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionStatus;
public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.SubscriptionsImplBase {
private final Clock clock;
private final SubscriptionConfiguration subscriptionConfiguration;
private final OneTimeDonationConfiguration oneTimeDonationConfiguration;
private final SubscriptionManager subscriptionManager;
private final DonationPermitsManager donationPermitsManager;
private final StripeManager stripeManager;
private final BraintreeManager braintreeManager;
private final GooglePlayBillingManager googlePlayBillingManager;
private final AppleAppStoreManager appleAppStoreManager;
private final BadgeTranslator badgeTranslator;
private final BankMandateTranslator bankMandateTranslator;
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
static final String RECEIPT_ISSUED_COUNTER_NAME = MetricsUtil.name(SubscriptionsGrpcService.class, "receiptIssued");
static final String PROCESSOR_TAG_NAME = "processor";
static final String TYPE_TAG_NAME = "type";
private static final String SUBSCRIPTION_TYPE_TAG_NAME = "subscriptionType";
public SubscriptionsGrpcService(final Clock clock, final SubscriptionConfiguration subscriptionConfiguration,
final OneTimeDonationConfiguration oneTimeDonationConfiguration, final SubscriptionManager subscriptionManager,
final DonationPermitsManager donationPermitsManager, final StripeManager stripeManager,
final BraintreeManager braintreeManager, final GooglePlayBillingManager googlePlayBillingManager,
final AppleAppStoreManager appleAppStoreManager, final BadgeTranslator badgeTranslator,
final BankMandateTranslator bankMandateTranslator,
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {
this.clock = clock;
this.subscriptionConfiguration = subscriptionConfiguration;
this.oneTimeDonationConfiguration = oneTimeDonationConfiguration;
this.subscriptionManager = subscriptionManager;
this.donationPermitsManager = donationPermitsManager;
this.stripeManager = stripeManager;
this.braintreeManager = braintreeManager;
this.googlePlayBillingManager = googlePlayBillingManager;
this.appleAppStoreManager = appleAppStoreManager;
this.badgeTranslator = badgeTranslator;
this.bankMandateTranslator = bankMandateTranslator;
this.dynamicConfigurationManager = dynamicConfigurationManager;
}
@Override
public UpdateSubscriberResponse updateSubscriber(final UpdateSubscriberRequest request) {
try {
final SubscriberCredentials subscriberCredentials = SubscriberCredentials.process(
request.getSubscriberId().toByteArray(), clock);
final boolean creationPermitted;
try {
if (request.getDonationPermit().isEmpty()) {
creationPermitted = false;
} else {
final DonationPermit permit = new DonationPermit(request.getDonationPermit().toByteArray());
creationPermitted = SubscriptionsUtil.verifyAndSpendDonationPermit(permit, donationPermitsManager, clock);
}
} catch (final InvalidInputException _) {
throw GrpcExceptions.invalidArguments("invalid donation permit");
} catch (final VerificationFailedException _) {
return UpdateSubscriberResponse.newBuilder()
.setPermitRejected(FailedZkAuthentication.newBuilder()
.setDescription("donation permit failed verification")
.build())
.build();
}
subscriptionManager.updateSubscriber(subscriberCredentials, creationPermitted);
return UpdateSubscriberResponse.newBuilder().setSuccess(Empty.getDefaultInstance()).build();
} catch (final SubscriptionForbiddenException e) {
return UpdateSubscriberResponse.newBuilder().setSubscriberIdMismatch(
FailedUnidentifiedAuthorization.newBuilder().setDescription(e.errorDetail().orElse("")).build()).build();
} catch (SubscriberIdCreationNotPermittedException _) {
if (request.getDonationPermit().isEmpty()) {
throw GrpcExceptions.invalidArguments("donation permit is required to create a subscriber ID");
}
return UpdateSubscriberResponse.newBuilder()
.setPermitRejected(FailedZkAuthentication.newBuilder()
.setDescription("donation permit was not valid")
.build())
.build();
}
}
@Override
public DeleteSubscriberResponse deleteSubscriber(final DeleteSubscriberRequest request)
throws RateLimitExceededException {
try {
final SubscriberCredentials subscriberCredentials = SubscriberCredentials.process(
request.getSubscriberId().toByteArray(), clock);
subscriptionManager.deleteSubscriber(subscriberCredentials);
return DeleteSubscriberResponse.newBuilder().setSuccess(Empty.getDefaultInstance()).build();
} catch (final SubscriptionNotFoundException e) {
return DeleteSubscriberResponse.newBuilder().setSubscriberNotFound(NotFound.newBuilder().build()).build();
} catch (final SubscriptionInvalidArgumentsException e) {
return DeleteSubscriberResponse.newBuilder().setCannotCancelSubscription(
FailedPrecondition.newBuilder().setDescription(e.errorDetail().orElse("")).build()).build();
}
}
@Override
public CreatePaymentMethodResponse createPaymentMethod(final CreatePaymentMethodRequest request) {
final SubscriberCredentials subscriberCredentials = SubscriberCredentials.process(
request.getSubscriberId().toByteArray(), clock);
final CustomerAwareSubscriptionPaymentProcessor customerAwareSubscriptionPaymentProcessor = switch (request.getPaymentMethod()) {
case PAYMENT_METHOD_CARD, PAYMENT_METHOD_SEPA_DEBIT, PAYMENT_METHOD_IDEAL -> stripeManager;
default -> throw GrpcExceptions.fieldViolation("payment_method", "Unsupported payment method");
};
try {
final DonationPermit permit = new DonationPermit(request.getDonationPermit().toByteArray());
if (!SubscriptionsUtil.verifyAndSpendDonationPermit(permit, donationPermitsManager, clock)) {
return CreatePaymentMethodResponse.newBuilder()
.setPermitRejected(FailedZkAuthentication.getDefaultInstance())
.build();
}
} catch (final InvalidInputException _) {
throw GrpcExceptions.invalidArguments("invalid donation permit");
} catch (final VerificationFailedException _) {
return CreatePaymentMethodResponse.newBuilder()
.setPermitRejected(FailedZkAuthentication.getDefaultInstance())
.build();
}
try {
final String token = subscriptionManager.addPaymentMethodToCustomer(subscriberCredentials,
customerAwareSubscriptionPaymentProcessor,
getClientPlatform(RequestAttributesUtil.getUserAgent().orElse(null)),
CustomerAwareSubscriptionPaymentProcessor::createPaymentMethodSetupToken);
return CreatePaymentMethodResponse.newBuilder().setResult(
CreatePaymentMethodResponse.CreatePaymentMethodResult.newBuilder().setClientSecret(token)
.setPaymentProvider(customerAwareSubscriptionPaymentProcessor.getProvider().toProto()).build()).build();
} catch (final SubscriptionNotFoundException e) {
return CreatePaymentMethodResponse.newBuilder().setSubscriberNotFound(NotFound.newBuilder()).build();
} catch (final SubscriptionForbiddenException e) {
return CreatePaymentMethodResponse.newBuilder().setSubscriberIdMismatch(
FailedUnidentifiedAuthorization.newBuilder().setDescription(e.errorDetail().orElse("")).build()).build();
} catch (final SubscriptionProcessorConflictException e) {
return CreatePaymentMethodResponse.newBuilder().setSubscriptionProcessorConflict(
FailedPrecondition.newBuilder().setDescription(e.errorDetail().orElse("")).build()).build();
}
}
@Override
public CreatePayPalPaymentMethodResponse createPayPalPaymentMethod(final CreatePayPalPaymentMethodRequest request) {
final SubscriberCredentials subscriberCredentials = SubscriberCredentials.process(
request.getSubscriberId().toByteArray(), clock);
final Locale locale = getPayPalLocale(RequestAttributesUtil.getAvailableAcceptedLocales());
try {
final BraintreeManager.PayPalBillingAgreementApprovalDetails details = subscriptionManager.addPaymentMethodToCustomer(
subscriberCredentials, braintreeManager, getClientPlatform(RequestAttributesUtil.getUserAgent().orElse(null)),
(mgr, _) -> mgr.createPayPalBillingAgreement(request.getReturnUrl(), request.getCancelUrl(),
locale.toLanguageTag())).join();
return CreatePayPalPaymentMethodResponse.newBuilder().setResult(
CreatePayPalPaymentMethodResponse.CreatePayPalPaymentMethodResult.newBuilder()
.setApprovalUrl(details.approvalUrl()).setToken(details.billingAgreementToken()).build()).build();
} catch (final SubscriptionNotFoundException e) {
return CreatePayPalPaymentMethodResponse.newBuilder().setSubscriberNotFound(NotFound.newBuilder().build())
.build();
} catch (final SubscriptionForbiddenException e) {
return CreatePayPalPaymentMethodResponse.newBuilder().setSubscriberIdMismatch(
FailedUnidentifiedAuthorization.newBuilder().setDescription(e.errorDetail().orElse("")).build()).build();
} catch (final SubscriptionProcessorConflictException e) {
return CreatePayPalPaymentMethodResponse.newBuilder().setSubscriptionProcessorConflict(
FailedPrecondition.newBuilder().setDescription(e.errorDetail().orElse("")).build()).build();
}
}
@Override
public SetDefaultPaymentMethodResponse setDefaultPaymentMethod(final SetDefaultPaymentMethodRequest request) {
final SubscriberCredentials subscriberCredentials = SubscriberCredentials.process(
request.getSubscriberId().toByteArray(), clock);
final CustomerAwareSubscriptionPaymentProcessor manager;
final String paymentMethodId = switch (request.getRequestCase()) {
case STRIPE -> {
manager = stripeManager;
yield request.getStripe().getPaymentMethodToken();
}
case BRAINTREE -> {
manager = braintreeManager;
yield request.getBraintree().getPaymentMethodToken();
}
case SEPA -> {
manager = stripeManager;
yield stripeManager.getGeneratedSepaIdFromSetupIntent(request.getSepa().getSetupIntentId()).join();
}
default -> throw GrpcExceptions.fieldViolation("request", "No payment method specified");
};
try {
final Subscriptions.Record record = subscriptionManager.getSubscriber(subscriberCredentials);
return record.getProcessorCustomer().map(
processorCustomer -> setDefaultPaymentMethodForCustomer(manager, processorCustomer, paymentMethodId,
record.subscriptionId)).orElseGet(() ->
// a missing customer ID indicates the client made requests out of order,
// and needs to call create_payment_method to create a customer for the given payment method
SetDefaultPaymentMethodResponse.newBuilder().setPaymentMethodNotSetUp(FailedPrecondition.newBuilder().build())
.build());
} catch (final SubscriptionNotFoundException e) {
return SetDefaultPaymentMethodResponse.newBuilder().setSubscriberNotFound(NotFound.newBuilder().build()).build();
} catch (final SubscriptionForbiddenException e) {
return SetDefaultPaymentMethodResponse.newBuilder().setSubscriberIdMismatch(
FailedUnidentifiedAuthorization.newBuilder().setDescription(e.errorDetail().orElse("")).build()).build();
}
}
private static SetDefaultPaymentMethodResponse setDefaultPaymentMethodForCustomer(
final CustomerAwareSubscriptionPaymentProcessor processor, final ProcessorCustomer processorCustomer,
final String paymentMethodId, final String subscriptionId) {
try {
processor.setDefaultPaymentMethodForCustomer(processorCustomer.customerId(), paymentMethodId, subscriptionId);
return SetDefaultPaymentMethodResponse.newBuilder().setSuccess(Empty.getDefaultInstance()).build();
} catch (final SubscriptionInvalidArgumentsException e) {
// Here, invalid arguments must mean that the client has made requests out of order, and needs to finish
// setting up the paymentMethod first
return SetDefaultPaymentMethodResponse.newBuilder()
.setPaymentMethodNotSetUp(FailedPrecondition.newBuilder().setDescription(e.errorDetail().orElse("")).build())
.build();
}
}
@Override
public SetSubscriptionLevelResponse setSubscriptionLevel(final SetSubscriptionLevelRequest request) {
final SubscriberCredentials subscriberCredentials = SubscriberCredentials.process(
request.getSubscriberId().toByteArray(), clock);
try {
final Subscriptions.Record record = subscriptionManager.getSubscriber(subscriberCredentials);
return record.getProcessorCustomer().map(
processorCustomer -> setSubscriptionLevelForCustomer(processorCustomer, subscriberCredentials, record,
request)).orElseGet(() -> SetSubscriptionLevelResponse.newBuilder()
.setPaymentMethodNotSetUp(FailedPrecondition.newBuilder().build()).build());
} catch (final SubscriptionNotFoundException e) {
return SetSubscriptionLevelResponse.newBuilder().setSubscriberNotFound(NotFound.newBuilder().build()).build();
} catch (final SubscriptionForbiddenException e) {
return SetSubscriptionLevelResponse.newBuilder().setSubscriberIdMismatch(
FailedUnidentifiedAuthorization.newBuilder().setDescription(e.errorDetail().orElse("")).build()).build();
}
}
private SetSubscriptionLevelResponse setSubscriptionLevelForCustomer(final ProcessorCustomer processorCustomer,
final SubscriberCredentials subscriberCredentials, final Subscriptions.Record subscriptionRecord,
final SetSubscriptionLevelRequest request) {
final SubscriptionLevelConfiguration config = subscriptionConfiguration.getSubscriptionLevel(request.getLevel());
if (config == null) {
return SetSubscriptionLevelResponse.newBuilder().setUnsupportedLevel(FailedPrecondition.newBuilder().build())
.build();
}
final Optional<String> templateId = Optional.ofNullable(
config.prices().get(request.getCurrency().toLowerCase(Locale.ROOT)))
.map(priceConfiguration -> priceConfiguration.processorIds().get(processorCustomer.processor()));
if (templateId.isEmpty()) {
return SetSubscriptionLevelResponse.newBuilder().setUnsupportedCurrency(FailedPrecondition.newBuilder().build())
.build();
}
final CustomerAwareSubscriptionPaymentProcessor manager;
switch (processorCustomer.processor()) {
case STRIPE -> manager = stripeManager;
case BRAINTREE -> manager = braintreeManager;
default -> {
return SetSubscriptionLevelResponse.newBuilder().setUnsupportedOperation(FailedPrecondition.newBuilder()
.setDescription(
"Operation cannot be performed with the '" + processorCustomer.processor() + "' payment provider")
.build()).build();
}
}
try {
subscriptionManager.updateSubscriptionLevelForCustomer(subscriberCredentials, subscriptionRecord, manager,
request.getLevel(), request.getCurrency(), request.getIdempotencyKey(), templateId.get(),
(l1, l2) -> SubscriptionsUtil.subscriptionsAreSameType(subscriptionConfiguration, l1, l2));
return SetSubscriptionLevelResponse.newBuilder().setSuccess(
SetSubscriptionLevelResponse.SetSubscriptionLevelResult.newBuilder().setLevel(request.getLevel()).build())
.build();
} catch (final SubscriptionInvalidIdempotencyKeyException e) {
return SetSubscriptionLevelResponse.newBuilder()
.setInvalidIdempotencyKey(FailedPrecondition.newBuilder().setDescription(e.errorDetail().orElse("")).build())
.build();
} catch (final SubscriptionProcessorException e) {
return SetSubscriptionLevelResponse.newBuilder()
.setChargeFailure(SubscriptionsUtil.toChargeFailure(e.getProcessor(), e.getChargeFailure())).build();
} catch (final SubscriptionPaymentRequiresActionException e) {
return SetSubscriptionLevelResponse.newBuilder().setPaymentRequiresAction(FailedPrecondition.newBuilder().build())
.build();
} catch (final SubscriptionInvalidLevelException e) {
return SetSubscriptionLevelResponse.newBuilder()
.setInvalidLevelTransition(FailedPrecondition.newBuilder().build()).build();
} catch (final SubscriptionProcessorConflictException e) {
return SetSubscriptionLevelResponse.newBuilder().setSubscriptionProcessorConflict(
FailedPrecondition.newBuilder().setDescription(e.errorDetail().orElse("")).build()).build();
}
}
@Override
public SetIapSubscriptionResponse setIapSubscription(final SetIapSubscriptionRequest request)
throws RateLimitExceededException {
final SubscriberCredentials subscriberCredentials = SubscriberCredentials.process(
request.getSubscriberId().toByteArray(), clock);
try {
final long level = switch (request.getRequestCase()) {
case APP_STORE -> subscriptionManager.updateAppStoreTransactionId(subscriberCredentials, appleAppStoreManager,
request.getAppStore().getOriginalTransactionId());
case PLAY_BILLING ->
subscriptionManager.updatePlayBillingPurchaseToken(subscriberCredentials, googlePlayBillingManager,
request.getPlayBilling().getPurchaseToken());
default -> throw GrpcExceptions.fieldViolation("request", "must set request type");
};
return SetIapSubscriptionResponse.newBuilder()
.setSuccess(SetIapSubscriptionResponse.SetIapSubscriptionResult.newBuilder().setLevel(level).build()).build();
} catch (final SubscriptionNotFoundException e) {
return SetIapSubscriptionResponse.newBuilder().setSubscriberNotFound(NotFound.newBuilder().build()).build();
} catch (final SubscriptionForbiddenException e) {
return SetIapSubscriptionResponse.newBuilder().setSubscriberIdMismatch(
FailedUnidentifiedAuthorization.newBuilder().setDescription(e.errorDetail().orElse("")).build()).build();
} catch (final SubscriptionProcessorConflictException e) {
return SetIapSubscriptionResponse.newBuilder().setSubscriptionProcessorConflict(
FailedPrecondition.newBuilder().setDescription(e.errorDetail().orElse("")).build()).build();
} catch (final SubscriptionPaymentRequiredException e) {
return SetIapSubscriptionResponse.newBuilder().setPaymentRequired(FailedPrecondition.newBuilder().build())
.build();
} catch (final SubscriptionInvalidArgumentsException e) {
return SetIapSubscriptionResponse.newBuilder()
.setInvalidTransaction(FailedPrecondition.newBuilder().setDescription(e.errorDetail().orElse("")).build())
.build();
}
}
@Override
public GetSubscriptionInformationResponse getSubscriptionInformation(final GetSubscriptionInformationRequest request)
throws RateLimitExceededException {
final SubscriberCredentials subscriberCredentials = SubscriberCredentials.process(
request.getSubscriberId().toByteArray(), clock);
try {
return subscriptionManager.getSubscriptionInformation(subscriberCredentials)
.map(SubscriptionsGrpcService::buildSubscriptionInformationResponse).orElseGet(
() -> GetSubscriptionInformationResponse.newBuilder().setNoSubscription(Empty.getDefaultInstance())
.build());
} catch (final SubscriptionNotFoundException e) {
return GetSubscriptionInformationResponse.newBuilder().setSubscriberNotFound(NotFound.newBuilder().build())
.build();
} catch (final SubscriptionForbiddenException e) {
return GetSubscriptionInformationResponse.newBuilder().setSubscriberIdMismatch(
FailedUnidentifiedAuthorization.newBuilder().setDescription(e.errorDetail().orElse("")).build()).build();
}
}
private static GetSubscriptionInformationResponse buildSubscriptionInformationResponse(
final SubscriptionInformation info) {
final GetSubscriptionInformationResponse.Subscription.Builder subscription = GetSubscriptionInformationResponse.Subscription.newBuilder()
.setLevel(info.level()).setEndOfCurrentPeriod(info.endOfCurrentPeriod().getEpochSecond())
.setActive(info.active()).setCancelAtPeriodEnd(info.cancelAtPeriodEnd()).setCurrency(info.price().currency())
.setAmount(info.price().amount().longValue()).setStatus(toProtoSubscriptionStatus(info.status()))
.setProcessor(info.paymentProvider().toProto()).setPaymentMethod(toProtoPaymentMethod(info.paymentMethod()))
.setPaymentProcessing(info.paymentProcessing());
if (info.billingCycleAnchor() != null) {
subscription.setBillingCycleAnchor(info.billingCycleAnchor().getEpochSecond());
}
if (info.chargeFailure() != null) {
subscription.setChargeFailure(SubscriptionsUtil.toChargeFailure(info.paymentProvider(), info.chargeFailure()));
}
return GetSubscriptionInformationResponse.newBuilder().setSuccess(subscription.build()).build();
}
@Override
public GetReceiptCredentialsResponse getReceiptCredentials(final GetReceiptCredentialsRequest request)
throws RateLimitExceededException {
final SubscriberCredentials subscriberCredentials = SubscriberCredentials.process(
request.getSubscriberId().toByteArray(), clock);
try {
final SubscriptionManager.ReceiptResult result = subscriptionManager.createReceiptCredentials(
subscriberCredentials, request.getReceiptCredentialRequest().toByteArray(),
r -> SubscriptionsUtil.receiptExpirationWithGracePeriod(subscriptionConfiguration, r));
Metrics.counter(RECEIPT_ISSUED_COUNTER_NAME,
Tags.of(
Tag.of(PROCESSOR_TAG_NAME, result.paymentProvider().toString()),
Tag.of(TYPE_TAG_NAME, "subscription"),
Tag.of(SUBSCRIPTION_TYPE_TAG_NAME,
subscriptionConfiguration.getSubscriptionLevel(result.receiptItem().level()).type().name()
.toLowerCase(Locale.ROOT)),
UserAgentTagUtil.getPlatformTag(RequestAttributesUtil.getUserAgent().orElse(null))))
.increment();
return GetReceiptCredentialsResponse.newBuilder().setSuccess(
GetReceiptCredentialsResponse.GetReceiptCredentialsResult.newBuilder()
.setReceiptCredentialResponse(ByteString.copyFrom(result.receiptCredentialResponse().serialize()))
.build()).build();
} catch (final SubscriptionReceiptRequestedForOpenPaymentException e) {
return GetReceiptCredentialsResponse.newBuilder().setNoPaidInvoice(FailedPrecondition.newBuilder().build())
.build();
} catch (final SubscriptionChargeFailurePaymentRequiredException e) {
return GetReceiptCredentialsResponse.newBuilder().setPaymentRequired(
PaymentRequired.newBuilder()
.setChargeFailure(SubscriptionsUtil.toChargeFailure(e.getProcessor(), e.getChargeFailure())).build()).build();
} catch (final SubscriptionPaymentRequiredException e) {
return GetReceiptCredentialsResponse.newBuilder()
.setPaymentRequired(PaymentRequired.newBuilder().build()).build();
} catch (final SubscriptionInvalidArgumentsException e) {
throw GrpcExceptions.invalidArguments(e.errorDetail().orElse(""));
} catch (final SubscriptionReceiptAlreadyRedeemedException e) {
return GetReceiptCredentialsResponse.newBuilder().setAlreadyRedeemed(FailedPrecondition.newBuilder().build())
.build();
} catch (final SubscriptionNotFoundException e) {
return GetReceiptCredentialsResponse.newBuilder().setSubscriberNotFound(NotFound.newBuilder().build()).build();
} catch (final SubscriptionForbiddenException e) {
return GetReceiptCredentialsResponse.newBuilder().setSubscriberIdMismatch(
FailedUnidentifiedAuthorization.newBuilder().setDescription(e.errorDetail().orElse("")).build()).build();
}
}
private static org.signal.chat.subscriptions.SubscriptionStatus toProtoSubscriptionStatus(
final SubscriptionStatus status) {
return switch (status) {
case ACTIVE -> org.signal.chat.subscriptions.SubscriptionStatus.SUBSCRIPTION_STATUS_ACTIVE;
case INCOMPLETE -> org.signal.chat.subscriptions.SubscriptionStatus.SUBSCRIPTION_STATUS_INCOMPLETE;
case PAST_DUE -> org.signal.chat.subscriptions.SubscriptionStatus.SUBSCRIPTION_STATUS_PAST_DUE;
case CANCELED -> org.signal.chat.subscriptions.SubscriptionStatus.SUBSCRIPTION_STATUS_CANCELED;
case UNPAID -> org.signal.chat.subscriptions.SubscriptionStatus.SUBSCRIPTION_STATUS_UNPAID;
case UNKNOWN -> org.signal.chat.subscriptions.SubscriptionStatus.SUBSCRIPTION_STATUS_UNKNOWN;
};
}
private static PaymentMethod toProtoPaymentMethod(
final org.whispersystems.textsecuregcm.subscriptions.PaymentMethod paymentMethod) {
if (paymentMethod == null) {
return PaymentMethod.PAYMENT_METHOD_UNKNOWN;
}
return switch (paymentMethod) {
case CARD -> PaymentMethod.PAYMENT_METHOD_CARD;
case SEPA_DEBIT -> PaymentMethod.PAYMENT_METHOD_SEPA_DEBIT;
case IDEAL -> PaymentMethod.PAYMENT_METHOD_IDEAL;
case PAYPAL -> PaymentMethod.PAYMENT_METHOD_PAYPAL;
case GOOGLE_PLAY_BILLING -> PaymentMethod.PAYMENT_METHOD_GOOGLE_PLAY_BILLING;
case APPLE_APP_STORE -> PaymentMethod.PAYMENT_METHOD_APPLE_APP_STORE;
case UNKNOWN -> PaymentMethod.PAYMENT_METHOD_UNKNOWN;
};
}
@Override
public GetConfigurationResponse getConfiguration(final GetConfigurationRequest request) {
final long maxBackupBytes = dynamicConfigurationManager.getConfiguration().getBackupConfiguration()
.maxTotalMediaSize();
final Map<Long, GetConfigurationResponse.BackupLevelConfiguration> backupLevels = subscriptionConfiguration.getBackupLevels()
.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey,
e -> GetConfigurationResponse.BackupLevelConfiguration.newBuilder().setStorageAllowanceBytes(maxBackupBytes)
.setPlayProductId(e.getValue().playProductId()).setMediaTtlDays(e.getValue().mediaTtl().toDays())
.build()));
return GetConfigurationResponse.newBuilder().putAllCurrencies(buildCurrencyConfigurations())
.putAllLevels(buildLevelConfigurations()).setBackup(
GetConfigurationResponse.BackupConfiguration.newBuilder().putAllLevels(backupLevels)
.setFreeTierMediaDays(subscriptionConfiguration.getbackupFreeTierMediaDuration().toDays()).build())
.setSepaMaximumEuros(oneTimeDonationConfiguration.sepaMaximumEuros().toString()).build();
}
private Map<String, GetConfigurationResponse.CurrencyConfiguration> buildCurrencyConfigurations() {
return SubscriptionsUtil.buildCurrencyConfiguration(List.of(stripeManager, braintreeManager),
oneTimeDonationConfiguration, subscriptionConfiguration).entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey, e -> toProtoCurrencyConfiguration(e.getKey(), e.getValue())));
}
private Map<Long, GetConfigurationResponse.LevelConfiguration> buildLevelConfigurations() {
return SubscriptionsUtil.buildDonationLevelsConfiguration(subscriptionConfiguration, oneTimeDonationConfiguration,
badgeTranslator, RequestAttributesUtil.getAvailableAcceptedLocales()).entrySet().stream().collect(
Collectors.toMap(Map.Entry::getKey, entry -> GetConfigurationResponse.LevelConfiguration.newBuilder()
.setBadge(toProtoBadge(entry.getValue().badge())).build()));
}
private static GetConfigurationResponse.CurrencyConfiguration toProtoCurrencyConfiguration(final String currency,
final CurrencyConfiguration config) {
final GetConfigurationResponse.CurrencyConfiguration.Builder builder = GetConfigurationResponse.CurrencyConfiguration.newBuilder()
.setMinimum(config.minimum().toString())
.addAllSupportedPaymentMethods(
config.supportedPaymentMethods().stream().map(SubscriptionsGrpcService::toProtoPaymentMethod).toList());
config.oneTime().forEach((levelId, amounts) -> builder.putOneTime(levelId,
GetConfigurationResponse.AmountList.newBuilder()
.addAllAmounts(
amounts.stream().map(BigDecimal::toString).toList())
.build()));
config.subscription()
.forEach((levelId, amount) -> builder.putSubscription(levelId,
amount.toString()));
config.backupSubscription()
.forEach((levelId, amount) -> builder.putBackupSubscription(levelId,
amount.toString()));
return builder.build();
}
private static GetConfigurationResponse.Badge toProtoBadge(final Badge badge) {
final org.signal.chat.common.Badge commonBadge = org.signal.chat.common.Badge.newBuilder().setId(badge.getId())
.setCategory(badge.getCategory()).setName(badge.getName()).setDescription(badge.getDescription())
.addAllSprites6(badge.getSprites6()).setSvg(badge.getSvg()).addAllSvgs(badge.getSvgs().stream()
.map(s -> org.signal.chat.common.BadgeSvg.newBuilder().setLight(s.getLight()).setDark(s.getDark()).build())
.toList()).build();
final GetConfigurationResponse.Badge.Builder builder = GetConfigurationResponse.Badge.newBuilder()
.setBadge(commonBadge);
if (badge instanceof final PurchasableBadge purchasableBadge) {
builder.setDurationSeconds(purchasableBadge.getDuration().toSeconds());
}
return builder.build();
}
@Override
public GetBankMandateResponse getBankMandate(final GetBankMandateRequest request) {
final BankTransferType bankTransferType = switch (request.getBankTransferType()) {
case BANK_TRANSFER_TYPE_SEPA_DEBIT -> BankTransferType.SEPA_DEBIT;
default -> throw GrpcExceptions.fieldViolation("bank_transfer_type", "Unsupported bank transfer type");
};
final String mandate = bankMandateTranslator.translate(RequestAttributesUtil.getAvailableAcceptedLocales(),
bankTransferType);
return GetBankMandateResponse.newBuilder().setMandate(mandate).build();
}
}

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