Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
b6cea201b5
Bump aws-actions/configure-aws-credentials
Some checks failed
Service CI / build (push) Has been cancelled
Bumps the minor-actions-dependencies group with 1 update in the / directory: [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials).


Updates `aws-actions/configure-aws-credentials` from 6.1.0 to 6.1.1
- [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...d979d5b3a7)

---
updated-dependencies:
- dependency-name: aws-actions/configure-aws-credentials
  dependency-version: 6.1.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-05-06 18:51:39 +00:00
386 changed files with 5852 additions and 18474 deletions

View File

@ -11,35 +11,23 @@ jobs:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
distribution: 'temurin'
java-version-file: .java-version
cache: 'maven'
- name: Install gRPC documentation tooling
run: |
sudo apt-get update
sudo apt-get install -y protobuf-compiler
pip install sabledocs
- name: Generate OpenAPI documentation
- name: Compile and Build OpenAPI file
run: ./mvnw compile
- 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
- name: Update Documentation
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 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"
cp /tmp/signal-server-openapi.yaml .
git diff --quiet || git commit -a -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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
distribution: 'temurin'
java-version-file: .java-version
cache: 'maven'
- uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0
- uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1
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; no need to
# bump it purely to match the CLIENT version
image: foundationdb/foundationdb:7.3.68
# Note: this should generally match the version of the FoundationDB SERVER deployed in production; it's okay if
# it's a little behind the CLIENT version.
image: foundationdb/foundationdb:7.3.62
options: --name foundationdb0
foundationdb1:
# 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
# Note: this should generally match the version of the FoundationDB SERVER deployed in production; it's okay if
# it's a little behind the CLIENT version.
image: foundationdb/foundationdb:7.3.62
options: --name foundationdb1
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- 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.so /usr/lib/libfdb_c.x86_64.so
cp service/target/jib-extra/usr/lib/libfdb_c.x86_64.so /usr/lib/libfdb_c.x86_64.so
ldconfig
- name: Build with Maven
run: ./mvnw -e -B clean verify -DfoundationDb.serviceContainerNamePrefix=foundationdb

4
.gitignore vendored
View File

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

View File

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

View File

@ -10,10 +10,6 @@
<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>
@ -25,22 +21,6 @@
<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,22 +9,19 @@ import java.time.Clock;
import java.time.Duration;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.CompletableFuture;
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 {
@ -34,7 +31,6 @@ public class IntegrationTools {
private final PhoneNumberIdentifiers phoneNumberIdentifiers;
private final ChangeNumberWaitingPeriods changeNumberWaitingPeriods;
public static IntegrationTools create(final Config config) {
final AwsCredentialsProvider credentialsProvider = DefaultCredentialsProvider.builder().build();
@ -42,49 +38,37 @@ 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), dynamoDbClient, Clock.systemUTC());
config.dynamoDbTables().registrationRecovery(), Duration.ofDays(1), dynamoDbAsyncClient, Clock.systemUTC());
final VerificationSessions verificationSessions = new VerificationSessions(
dynamoDbClient, config.dynamoDbTables().verificationSessions(), Clock.systemUTC());
dynamoDbAsyncClient, config.dynamoDbTables().verificationSessions(), Clock.systemUTC());
return new IntegrationTools(
new RegistrationRecoveryPasswordsManager(registrationRecoveryPasswords),
new VerificationSessionManager(verificationSessions),
new PhoneNumberIdentifiers(dynamoDbAsyncClient, config.dynamoDbTables().phoneNumberIdentifiers()),
new ChangeNumberWaitingPeriods(config.dynamoDbTables().changeNumberWaitingPeriods(), dynamoDbClient)
new PhoneNumberIdentifiers(dynamoDbAsyncClient, config.dynamoDbTables().phoneNumberIdentifiers())
);
}
private IntegrationTools(
final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager,
final VerificationSessionManager verificationSessionManager,
final PhoneNumberIdentifiers phoneNumberIdentifiers,
final ChangeNumberWaitingPeriods changeNumberWaitingPeriods) {
final PhoneNumberIdentifiers phoneNumberIdentifiers) {
this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager;
this.verificationSessionManager = verificationSessionManager;
this.phoneNumberIdentifiers = phoneNumberIdentifiers;
this.changeNumberWaitingPeriods = changeNumberWaitingPeriods;
}
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<Void> populateRecoveryPassword(final String phoneNumber, final byte[] password) {
return phoneNumberIdentifiers
.getPhoneNumberIdentifier(phoneNumber)
.thenCompose(pni -> registrationRecoveryPasswordsManager.store(pni, password))
.thenRun(Util.NOOP);
}
public Optional<String> peekVerificationSessionPushChallenge(final String sessionId) {
return verificationSessionManager.findForId(sessionId).map(VerificationSession::pushChallenge);
}
public void clearChangeNumberWaitingPeriod(TestUser user) {
changeNumberWaitingPeriods.delete(user.aciUuid());
public CompletableFuture<Optional<String>> peekVerificationSessionPushChallenge(final String sessionId) {
return verificationSessionManager.findForId(sessionId)
.thenApply(maybeSession -> maybeSession.map(VerificationSession::pushChallenge));
}
}

View File

@ -20,28 +20,18 @@ 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;
@ -59,7 +49,6 @@ 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;
@ -76,8 +65,6 @@ public final class Operations {
private static final FaultTolerantHttpClient CLIENT = buildClient();
private static final WebSocketClient WEB_SOCKET_CLIENT = buildWebSocketClient();
private Operations() {
// utility class
@ -130,21 +117,17 @@ public final class Operations {
}
public static String peekVerificationSessionPushChallenge(final String sessionId) {
return INTEGRATION_TOOLS.peekVerificationSessionPushChallenge(sessionId)
return INTEGRATION_TOOLS.peekVerificationSessionPushChallenge(sessionId).join()
.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);
INTEGRATION_TOOLS.populateRecoveryPassword(number, recoveryPassword).join();
return recoveryPassword;
}
public static void clearChangeNumberWaitingPeriod(final TestUser user) {
INTEGRATION_TOOLS.clearChangeNumberWaitingPeriod(user);
}
public static <T> T sendEmptyRequestAuthenticated(
final String endpoint,
final String method,
@ -231,10 +214,14 @@ public final class Operations {
}
private static <R> RequestBuilder withJsonBody(final String endpoint, final String method, final R input) {
final byte[] body = encodeJsonBody(input);
return new RequestBuilder(HttpRequest.newBuilder()
.header(HttpHeaders.CONTENT_TYPE, "application/json")
.method(method, HttpRequest.BodyPublishers.ofByteArray(body)), endpoint);
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);
}
}
public RequestBuilder authorized(final TestUser user) {
@ -314,7 +301,6 @@ public final class Operations {
})
.join();
}
}
private static FaultTolerantHttpClient buildClient() {
@ -327,53 +313,6 @@ 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);
@ -402,12 +341,4 @@ 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

@ -1,150 +0,0 @@
/*
* 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,6 +9,5 @@ import jakarta.validation.constraints.NotBlank;
public record DynamoDbTables(@NotBlank String registrationRecovery,
@NotBlank String verificationSessions,
@NotBlank String phoneNumberIdentifiers,
@NotBlank String changeNumberWaitingPeriods) {
@NotBlank String phoneNumberIdentifiers) {
}

View File

@ -62,20 +62,14 @@ public class AccountTest {
Map.of(Device.PRIMARY_ID, Operations.generateSignedKEMPreKey(2, pniIdentityKeyPair)),
Map.of(Device.PRIMARY_ID, 17));
try {
Operations.clearChangeNumberWaitingPeriod(user);
final AccountIdentityResponse accountIdentityResponse =
Operations.apiPut("/v2/accounts/number", changeNumberRequest)
.authorized(user)
.executeExpectSuccess(AccountIdentityResponse.class);
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);
}
assertEquals(user.aciUuid(), accountIdentityResponse.uuid());
assertNotEquals(user.pniUuid(), accountIdentityResponse.pni());
assertEquals(targetNumber, accountIdentityResponse.number());
}
@Test

View File

@ -6,72 +6,43 @@
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 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.apache.commons.lang3.tuple.Pair;
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.MessageProtos;
import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntityList;
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() throws IOException {
public void testSendMessageUnsealed() {
final TestUser userA = Operations.newRegisteredUser("+19995550102");
final TestUser userB = Operations.newRegisteredUser("+19995550103");
try {
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());
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));
Operations
.apiPut("/v1/messages/%s".formatted(userB.aciUuid().toString()), messages)
.authorized(userA)
.execute(SendMessageResponse.class);
final WebsocketClientSession websocketB = Operations.authenticatedWebsocket(userB, Device.PRIMARY_ID);
assertTimeoutPreemptively(Duration.ofSeconds(5), websocketB::waitForQueueEmpty);
final Pair<Integer, OutgoingMessageEntityList> receiveMessages = Operations.apiGet("/v1/messages")
.authorized(userB)
.execute(OutgoingMessageEntityList.class);
assertEquals(1, websocketB.getReceivedEnvelopes().size());
final MessageProtos.Envelope envelope = websocketB.getReceivedEnvelopes().getFirst();
assertArrayEquals(expectedContent, envelope.getContent().toByteArray());
websocketB.close(1000);
websocketA.close(1000);
final byte[] actualContent = receiveMessages.getRight().messages().getFirst().content();
assertArrayEquals(expectedContent, actualContent);
} finally {
Operations.deleteUser(userA);
Operations.deleteUser(userB);
}
}
}

27
pom.xml
View File

@ -49,13 +49,14 @@
<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>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>
<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>
<foundationdb.api-version>730</foundationdb.api-version>
<foundationdb.client-library-sha256>8c96a1f7ab561cd38e16e4c269c5e50ae0fd8063854e0d72c89339a7ac1b6873</foundationdb.client-library-sha256>
<foundationdb.client-library-sha256>bfed237b787fae3cde1222676e6bfbb0d218fc27bf9e903397a7a7aa96fb2d33</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>
@ -69,13 +70,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>7.5.1.RELEASE</lettuce.version>
<lettuce.version>6.8.2.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.2.13.Final</netty.version>
<netty.version>4.1.127.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>
@ -252,6 +253,12 @@
<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>
@ -286,7 +293,7 @@
<dependency>
<groupId>org.signal</groupId>
<artifactId>libsignal-server</artifactId>
<version>0.96.2</version>
<version>0.86.6</version>
</dependency>
<dependency>
<groupId>org.signal</groupId>
@ -354,7 +361,7 @@
</dependency>
<dependency>
<groupId>org.wiremock</groupId>
<artifactId>wiremock-jetty12</artifactId>
<artifactId>wiremock</artifactId>
<version>3.13.1</version>
<scope>test</scope>
</dependency>

View File

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

View File

@ -92,17 +92,12 @@ 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
@ -151,6 +146,8 @@ dynamoDbTables:
expiration: P7D
subscriptions:
tableName: Example_Subscriptions
clientPublicKeys:
tableName: Example_ClientPublicKeys
verificationSessions:
tableName: Example_VerificationSessions
@ -237,8 +234,7 @@ messageCache: # Redis server configuration for message store cache
configurationUri: redis://redis.example.com:6379/
attachments:
maxAttachmentUploadSizeInBytes: 1024
maxMessageBackupUploadSizeInBytes: 1024
maxUploadSizeInBytes: 1024
gcpAttachments: # GCP Storage configuration
domain: example.com
@ -520,7 +516,6 @@ idlePrimaryDeviceReminder:
grpc:
port: 50051
websocketPort: 8080
asnTable:
s3Region: a-region
@ -541,27 +536,3 @@ 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,7 +23,6 @@
<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>
@ -138,12 +137,6 @@
<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>
@ -249,15 +242,15 @@
<dependency>
<groupId>org.eclipse.jetty.websocket</groupId>
<artifactId>jetty-websocket-jetty-api</artifactId>
<artifactId>websocket-jetty-api</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.ee10</groupId>
<artifactId>jetty-ee10-servlets</artifactId>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-servlets</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.websocket</groupId>
<artifactId>jetty-websocket-jetty-client</artifactId>
<artifactId>websocket-jetty-client</artifactId>
<scope>test</scope>
</dependency>
@ -446,59 +439,17 @@
<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>
@ -617,7 +568,7 @@
<plugin>
<groupId>io.github.download-maven-plugin</groupId>
<artifactId>download-maven-plugin</artifactId>
<version>2.1.0</version>
<version>2.0.0</version>
<executions>
<execution>
@ -632,7 +583,6 @@
<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,7 +22,6 @@ 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;
@ -34,7 +33,6 @@ 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;
@ -357,16 +355,6 @@ 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;
}
@ -603,11 +591,4 @@ public class WhisperServerConfiguration extends Configuration {
return hlrLookup;
}
public ChangeNumberConfiguration getChangeNumber() {
return changeNumber;
}
public FoundationDbMessagesConfiguration getFoundationDbMessagesConfiguration() {
return foundationDbMessages;
}
}

View File

@ -7,8 +7,6 @@ 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;
@ -33,31 +31,24 @@ 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;
@ -68,12 +59,10 @@ 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;
@ -146,12 +135,10 @@ 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;
@ -159,35 +146,25 @@ 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.ExternalServiceDefinitions;
import org.whispersystems.textsecuregcm.grpc.ExternalServiceCredentialsAnonymousGrpcService;
import org.whispersystems.textsecuregcm.grpc.ExternalServiceCredentialsGrpcService;
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.OmnibusH2Server;
import org.whispersystems.textsecuregcm.grpc.net.OmnibusRouter;
import org.whispersystems.textsecuregcm.grpc.net.SniMapper;
import org.whispersystems.textsecuregcm.grpc.net.ManagedNioEventLoopGroup;
import org.whispersystems.textsecuregcm.jetty.JettyHttpConfigurationCustomizer;
import org.whispersystems.textsecuregcm.keytransparency.KeyTransparencyServiceClient;
import org.whispersystems.textsecuregcm.limits.CardinalityEstimator;
@ -216,7 +193,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.MetricsHttpEventHandler;
import org.whispersystems.textsecuregcm.metrics.MetricsHttpChannelListener;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.metrics.MicrometerAwsSdkMetricPublisher;
import org.whispersystems.textsecuregcm.metrics.ReportedMessageMetricsListener;
@ -235,6 +212,7 @@ 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;
@ -248,14 +226,9 @@ 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;
@ -286,8 +259,6 @@ 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;
@ -316,7 +287,6 @@ 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;
@ -389,7 +359,6 @@ 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",
@ -412,6 +381,7 @@ 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();
@ -472,39 +442,6 @@ 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)
@ -556,7 +493,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
MessagesDynamoDb messagesDynamoDb = new MessagesDynamoDb(dynamoDbClient, dynamoDbAsyncClient,
config.getDynamoDbTables().getMessages().getTableName(),
config.getDynamoDbTables().getMessages().getExpiration(),
messageDeletionAsyncExecutor);
messageDeletionAsyncExecutor, experimentEnrollmentManager);
RemoteConfigs remoteConfigs = new RemoteConfigs(dynamoDbClient,
config.getDynamoDbTables().getRemoteConfig().getTableName());
PushChallengeDynamoDb pushChallengeDynamoDb = new PushChallengeDynamoDb(dynamoDbClient,
@ -567,10 +504,10 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
RegistrationRecoveryPasswords registrationRecoveryPasswords = new RegistrationRecoveryPasswords(
config.getDynamoDbTables().getRegistrationRecovery().getTableName(),
config.getDynamoDbTables().getRegistrationRecovery().getExpiration(),
dynamoDbClient,
dynamoDbAsyncClient,
clock);
final VerificationSessions verificationSessions = new VerificationSessions(dynamoDbClient,
final VerificationSessions verificationSessions = new VerificationSessions(dynamoDbAsyncClient,
config.getDynamoDbTables().getVerificationSessions().getTableName(), clock);
final ClientResources sharedClientResources = ClientResources.builder()
@ -636,6 +573,10 @@ 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)
@ -676,9 +617,10 @@ 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 ManagedEventLoopGroup<NioEventLoopGroup> dnsResolutionEventLoopGroup = new ManagedEventLoopGroup<>(new NioEventLoopGroup());
final DnsNameResolver cloudflareDnsResolver = new DnsNameResolverBuilder(dnsResolutionEventLoopGroup.getEventLoopGroup().next())
final ManagedNioEventLoopGroup dnsResolutionEventLoopGroup = new ManagedNioEventLoopGroup();
final DnsNameResolver cloudflareDnsResolver = new DnsNameResolverBuilder(dnsResolutionEventLoopGroup.next())
.resolvedAddressTypes(ResolvedAddressTypes.IPV6_PREFERRED)
.completeOncePreferredResolved(false)
.channelType(NioDatagramChannel.class)
@ -717,7 +659,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
retryExecutor);
RegistrationServiceClient registrationServiceClient = config.getRegistrationServiceConfiguration()
.build(environment, registrationIdentityTokenRefreshExecutor);
.build(environment, registrationCallbackExecutor, registrationIdentityTokenRefreshExecutor);
KeyTransparencyServiceClient keyTransparencyServiceClient = new KeyTransparencyServiceClient(
config.getKeyTransparencyServiceConfiguration().host(),
config.getKeyTransparencyServiceConfiguration().port(),
@ -743,12 +685,7 @@ 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);
final FoundationDbMessageStore foundationDbMessageStore = new FoundationDbMessageStore(messageDatabasesByEpoch,
config.getFoundationDbMessagesConfiguration().activeEpoch(),
new VersionstampUUIDCipher(config.getFoundationDbMessagesConfiguration().currentVersionstampCipherKey(),
config.getFoundationDbMessagesConfiguration().versionstampCipherKeys().get(config.getFoundationDbMessagesConfiguration().currentVersionstampCipherKey()).value()),
Clock.systemUTC());
messageDeletionAsyncExecutor, retryExecutor, clock, experimentEnrollmentManager);
ClientReleaseManager clientReleaseManager = new ClientReleaseManager(clientReleases,
recurringJobExecutor,
config.getClientReleaseConfiguration().refreshInterval(),
@ -757,22 +694,17 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
config.getReportMessageConfiguration().getCounterTtl());
RedisMessageAvailabilityManager redisMessageAvailabilityManager =
new RedisMessageAvailabilityManager(messagesCluster, clientEventExecutor, asyncOperationQueueingExecutor);
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);
MessagesManager messagesManager = new MessagesManager(messagesDynamoDb, messagesCache, redisMessageAvailabilityManager,
reportMessageManager, messageDeletionAsyncExecutor, Clock.systemUTC());
AccountLockManager accountLockManager = new AccountLockManager(dynamoDbClient,
config.getDynamoDbTables().getDeletedAccountsLock().getTableName());
AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,
pubsubClient, accountLockManager, keysManager, messagesManager, profilesManager,
changeNumberWaitingPeriodManager, secureStorageClient, secureValueRecovery2Client, disconnectionRequestManager,
secureStorageClient, secureValueRecovery2Client, disconnectionRequestManager,
registrationRecoveryPasswordsManager, accountLockExecutor, messagePollExecutor,
retryExecutor, clock, config.getLinkDeviceSecretConfiguration().secret().value());
RemoteConfigsManager remoteConfigsManager = new RemoteConfigsManager(remoteConfigs);
APNSender apnSender = new APNSender(apnSenderExecutor, Clock.systemUTC(), config.getApnConfiguration());
APNSender apnSender = new APNSender(apnSenderExecutor, config.getApnConfiguration());
FcmSender fcmSender = new FcmSender(fcmSenderExecutor, config.getFcmConfiguration().credentials().value());
PushNotificationScheduler pushNotificationScheduler = new PushNotificationScheduler(pushSchedulerCluster,
apnSender, fcmSender, accountsManager, 0, 0, retryExecutor);
@ -783,19 +715,17 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
IssuedReceiptsManager issuedReceiptsManager = new IssuedReceiptsManager(
config.getDynamoDbTables().getIssuedReceipts().getTableName(),
config.getDynamoDbTables().getIssuedReceipts().getExpiration(),
dynamoDbClient,
dynamoDbAsyncClient,
config.getDynamoDbTables().getIssuedReceipts().getGenerator(),
config.getDynamoDbTables().getIssuedReceipts().getmaxIssuedReceiptsPerPaymentId());
OneTimeDonationsManager oneTimeDonationsManager = new OneTimeDonationsManager(
config.getDynamoDbTables().getOnetimeDonations().getTableName(), config.getDynamoDbTables().getOnetimeDonations().getExpiration(), dynamoDbClient);
DonationPermits donationPermits = new DonationPermits(
config.getDynamoDbTables().getDonationPermits().getTableName(), config.getDynamoDbTables().getDonationPermits().getExpiration(), dynamoDbClient);
config.getDynamoDbTables().getOnetimeDonations().getTableName(), config.getDynamoDbTables().getOnetimeDonations().getExpiration(), dynamoDbAsyncClient);
RedeemedReceiptsManager redeemedReceiptsManager = new RedeemedReceiptsManager(clock,
config.getDynamoDbTables().getRedeemedReceipts().getTableName(),
dynamoDbClient,
dynamoDbAsyncClient,
config.getDynamoDbTables().getRedeemedReceipts().getExpiration());
Subscriptions subscriptions = new Subscriptions(
config.getDynamoDbTables().getSubscriptions().getTableName(), dynamoDbClient);
config.getDynamoDbTables().getSubscriptions().getTableName(), dynamoDbAsyncClient);
MessageDeliveryLoopMonitor messageDeliveryLoopMonitor =
config.logMessageDeliveryLoops() ? new RedisMessageDeliveryLoopMonitor(rateLimitersCluster) : new NoopMessageDeliveryLoopMonitor();
CallQualitySurveyManager callQualitySurveyManager = new CallQualitySurveyManager(asnInfoProviderSupplier,
@ -813,7 +743,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
final AccountAuthenticator accountAuthenticator = new AccountAuthenticator(accountsManager);
final MessageSender messageSender = new MessageSender(messagesManager, pushNotificationManager, dynamicConfigurationManager);
final MessageSender messageSender = new MessageSender(messagesManager, pushNotificationManager);
final ReceiptSender receiptSender = new ReceiptSender(accountsManager, messageSender, receiptSenderExecutor);
final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager = new CloudflareTurnCredentialsManager(
config.getTurnConfiguration().cloudflare().apiToken().value(),
@ -898,15 +828,11 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
config.getGcpAttachmentsConfiguration().pathPrefix(),
config.getGcpAttachmentsConfiguration().rsaSigningKey().value());
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());
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());
ServerSecretParams zkSecretParams = new ServerSecretParams(config.getZkConfig().serverSecret().value());
GenericServerSecretParams callingGenericZkSecretParams = new GenericServerSecretParams(config.getCallingZkConfig().serverSecret().value());
@ -954,13 +880,6 @@ 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)
@ -1048,32 +967,17 @@ 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),
new CallingGrpcService(cloudflareTurnCredentialsManager, rateLimiters),
new CredentialsGrpcService(accountsManager, certificateGenerator, zkAuthOperations, callingGenericZkSecretParams, rateLimiters, Clock.systemUTC(), ExternalServiceDefinitions.createExternalServiceList(config, Clock.systemUTC())),
ExternalServiceCredentialsGrpcService.createForAllExternalServices(config, rateLimiters),
new KeysGrpcService(accountsManager, keysManager, rateLimiters),
new ProfileGrpcService(clock, accountsManager, profilesManager, dynamicConfigurationManager, config.getBadges(), profileCdnPolicyGenerator, profileBadgeConverter, rateLimiters),
new MessagesGrpcService(accountsManager, rateLimiters, messageSender, messageByteLimitCardinalityEstimator, spamChecker, messageDispatcher, Clock.systemUTC()),
new ProfileGrpcService(clock, accountsManager, profilesManager, dynamicConfigurationManager, config.getBadges(), profileCdnPolicyGenerator, profileCdnPolicySigner, profileBadgeConverter, rateLimiters),
new MessagesGrpcService(accountsManager, rateLimiters, messageSender, messageByteLimitCardinalityEstimator, spamChecker, Clock.systemUTC()),
new BackupsGrpcService(accountsManager, backupAuthManager, backupMetrics),
new DevicesGrpcService(accountsManager),
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))
new AttachmentsGrpcService(experimentEnrollmentManager, rateLimiters,
gcsAttachmentGenerator, tusAttachmentGenerator, config.getAttachments().maxUploadSizeInBytes()))
.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!
@ -1091,15 +995,10 @@ 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().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))
new BackupsAnonymousGrpcService(backupManager, backupMetrics, config.getAttachments().maxUploadSizeInBytes()),
ExternalServiceCredentialsAnonymousGrpcService.create(accountsManager, config))
.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!
@ -1114,39 +1013,24 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
prohibitAuthenticationInterceptor))
.toList();
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());
final ServerBuilder<?> serverBuilder =
NettyServerBuilder.forAddress(new InetSocketAddress(config.getGrpc().bindAddress(), config.getGrpc().port()));
authenticatedServices.forEach(serverBuilder::addService);
unauthenticatedServices.forEach(serverBuilder::addService);
final ManagedGrpcServer localGrpcServer = new ManagedGrpcServer(serverBuilder.build());
final ManagedGrpcServer exposedGrpcServer = new ManagedGrpcServer(serverBuilder.build());
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());
environment.lifecycle().manage(exposedGrpcServer);
environment.lifecycle().manage(omnibusLocalEventLoopGroup);
environment.lifecycle().manage(omnibusNioEventLoopGroup);
environment.lifecycle().manage(localGrpcServer);
environment.lifecycle().manage(omnibusH2Server);
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, "/*");
}
if (!config.getExternalRequestFilterConfiguration().paths().isEmpty()) {
environment.servlets().addFilter(ExternalRequestFilter.class.getSimpleName(),
@ -1164,7 +1048,10 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
final String websocketServletPath = "/v1/websocket/";
final String provisioningWebsocketServletPath = "/v1/websocket/provisioning/";
MetricsHttpEventHandler.configure(environment, Metrics.globalRegistry, clientReleaseManager, Set.of(websocketServletPath, provisioningWebsocketServletPath, "/health-check"));
final MetricsHttpChannelListener metricsHttpChannelListener = new MetricsHttpChannelListener(clientReleaseManager,
Set.of(websocketServletPath, provisioningWebsocketServletPath, "/health-check"));
metricsHttpChannelListener.configure(environment);
final MessageMetrics messageMetrics = new MessageMetrics();
// 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
@ -1209,20 +1096,21 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
phoneNumberIdentifiers, registrationServiceClient, registrationRecoveryPasswordsManager, registrationRecoveryChecker);
final ChangeNumberManager changeNumberManager = new ChangeNumberManager(messageSender, accountsManager,
phoneVerificationTokenManager, registrationLockVerificationManager, rateLimiters,
changeNumberWaitingPeriodManager, Clock.systemUTC());
phoneVerificationTokenManager, registrationLockVerificationManager, rateLimiters, 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().maxAttachmentUploadSizeInBytes()),
new ArchiveController(accountsManager, backupAuthManager, backupManager, backupMetrics, config.getAttachments().maxAttachmentUploadSizeInBytes(), config.getAttachments().maxMessageBackupUploadSizeInBytes()),
experimentEnrollmentManager, config.getAttachments().maxUploadSizeInBytes()),
new ArchiveController(accountsManager, backupAuthManager, backupManager, backupMetrics, config.getAttachments().maxUploadSizeInBytes()),
new CallRoutingControllerV2(rateLimiters, cloudflareTurnCredentialsManager),
new CallLinkController(rateLimiters, callingGenericZkSecretParams),
new CallQualitySurveyController(callQualitySurveyManager),
new CertificateController(accountsManager, certificateGenerator, zkAuthOperations, callingGenericZkSecretParams, clock),
new CertificateController(accountsManager, new CertificateGenerator(config.getDeliveryCertificate().certificate(),
config.getDeliveryCertificate().ecPrivateKey(), config.getDeliveryCertificate().expiresDays(), config.getDeliveryCertificate().embedSigner()),
zkAuthOperations, callingGenericZkSecretParams, clock),
new ChallengeController(accountsManager, rateLimitChallengeManager, challengeConstraintChecker),
new DeviceController(accountsManager, rateLimiters, persistentTimer),
new DeviceCheckController(clock, accountsManager, backupAuthManager, appleDeviceCheckManager, rateLimiters,
@ -1230,33 +1118,42 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
config.getDeviceCheck().backupRedemptionDuration()),
new DirectoryV2Controller(directoryV2CredentialsGenerator),
new DonationController(clock, zkReceiptOperations, redeemedReceiptsManager, accountsManager, config.getBadges(),
ReceiptCredentialPresentation::new, donationPermitsManager, rateLimiters),
ReceiptCredentialPresentation::new),
new KeysController(rateLimiters, keysManager, accountsManager, zkSecretParams, Clock.systemUTC()),
new KeyTransparencyController(keyTransparencyServiceClient),
new MessageController(rateLimiters, messageByteLimitCardinalityEstimator, messageSender, accountsManager,
phoneNumberIdentifiers, reportMessageManager, zkSecretParams, spamChecker, Clock.systemUTC()),
new MessageController(rateLimiters, messageByteLimitCardinalityEstimator, messageSender,
accountsManager, messagesManager, phoneNumberIdentifiers, pushNotificationManager, pushNotificationScheduler,
reportMessageManager, messageDeliveryScheduler, clientReleaseManager,
zkSecretParams, spamChecker, messageMetrics, messageDeliveryLoopMonitor,
Clock.systemUTC()),
new PaymentsController(currencyManager, paymentsCredentialsGenerator),
new ProfileController(clock, rateLimiters, accountsManager, profilesManager, dynamicConfigurationManager,
profileBadgeConverter, config.getBadges(), profileCdnPolicyGenerator,
profileBadgeConverter, config.getBadges(), profileCdnPolicyGenerator, profileCdnPolicySigner,
zkSecretParams, zkProfileOperations, batchIdentityCheckExecutor),
new ProvisioningController(rateLimiters, provisioningManager),
new RegistrationController(accountsManager, phoneVerificationTokenManager, registrationLockVerificationManager,
rateLimiters, registrationFraudChecker),
new RemoteConfigController(remoteConfigsManager, config.getRemoteConfigConfiguration().globalConfig()),
new RemoteConfigController(remoteConfigsManager, config.getRemoteConfigConfiguration().globalConfig(), clock),
new SecureStorageController(storageCredentialsGenerator),
new SecureValueRecovery2Controller(svr2CredentialsGenerator, accountsManager),
new StickerController(rateLimiters, stickerPolicyGenerator, Clock.systemUTC()),
new StickerController(rateLimiters, config.getCdnConfiguration().credentials().accessKeyId().value(),
config.getCdnConfiguration().credentials().secretAccessKey().value(), config.getCdnConfiguration().region(),
config.getCdnConfiguration().bucket()),
new VerificationController(registrationServiceClient, new VerificationSessionManager(verificationSessions),
pushNotificationManager, registrationCaptchaManager, registrationRecoveryPasswordsManager,
phoneNumberIdentifiers, rateLimiters, accountsManager, carrierDataProvider, registrationFraudChecker,
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)
dynamicConfigurationManager, clock)
);
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);
@ -1276,36 +1173,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, RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME);
webSocketEnvironment, AuthenticatedDevice.class, config.getWebSocketConfiguration(),
RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME);
WebSocketResourceProviderFactory<AuthenticatedDevice> provisioningServlet = new WebSocketResourceProviderFactory<>(
provisioningEnvironment, AuthenticatedDevice.class, RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME);
provisioningEnvironment, AuthenticatedDevice.class, config.getWebSocketConfiguration(),
RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME);
JettyWebSocketServletContainerInitializer.configure(environment.getApplicationContext(),
(servletContext, container) -> {
container.addMapping(websocketServletPath, webSocketServlet);
container.addMapping(provisioningWebsocketServletPath, provisioningServlet);
ServletRegistration.Dynamic websocket = environment.servlets().addServlet("WebSocket", webSocketServlet);
ServletRegistration.Dynamic provisioning = environment.servlets().addServlet("Provisioning", provisioningServlet);
PriorityFilter.ensureFilter(servletContext, new StripContentLengthOnConnectFilter());
PriorityFilter.ensureFilter(servletContext, new TimestampResponseFilter());
PriorityFilter.ensureFilter(servletContext, new RemoteAddressFilter());
PriorityFilter.ensureFilter(servletContext, remoteDeprecationFilter);
websocket.addMapping(websocketServletPath);
websocket.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);
}
});
provisioning.addMapping(provisioningWebsocketServletPath);
provisioning.setAsyncSupported(true);
environment.admin().addTask(new SetRequestLoggingEnabledTask());
}
private void registerExceptionMappers(Environment environment,

View File

@ -1,24 +0,0 @@
/*
* 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,9 +10,10 @@ 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.ee10.websocket.server.JettyServerUpgradeRequest;
import org.eclipse.jetty.ee10.websocket.server.JettyServerUpgradeResponse;
import org.eclipse.jetty.websocket.server.JettyServerUpgradeRequest;
import org.eclipse.jetty.websocket.server.JettyServerUpgradeResponse;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.websocket.auth.AuthenticatedWebSocketUpgradeFilter;

View File

@ -15,11 +15,10 @@ import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.core.Response;
import java.security.MessageDigest;
import java.time.Duration;
import java.util.UUID;
import java.util.concurrent.CancellationException;
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;
@ -28,6 +27,7 @@ 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,10 +89,11 @@ public class PhoneVerificationTokenManager {
return verificationType;
}
private void verifyBySessionId(final String number, final byte[] sessionId) {
private void verifyBySessionId(final String number, final byte[] sessionId) throws InterruptedException {
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())) {
@ -101,11 +102,19 @@ public class PhoneVerificationTokenManager {
if (!session.verified()) {
throw new NotAuthorizedException("session not verified");
}
} catch (final StatusRuntimeException e) {
if (e.getStatus().getCode() == Status.Code.INVALID_ARGUMENT) {
throw new BadRequestException();
} catch (final ExecutionException e) {
if (e.getCause() instanceof StatusRuntimeException grpcRuntimeException) {
if (grpcRuntimeException.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);
}
@ -120,11 +129,10 @@ public class PhoneVerificationTokenManager {
}
try {
final UUID phoneNumberIdentifier = phoneNumberIdentifiers.getPhoneNumberIdentifier(number)
final boolean verified = phoneNumberIdentifiers.getPhoneNumberIdentifier(number)
.thenCompose(phoneNumberIdentifier -> registrationRecoveryPasswordsManager.verify(phoneNumberIdentifier, recoveryPassword))
.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));
registrationRecoveryPasswordsManager.remove(updatedAccount.getIdentifier(IdentityType.PNI)).join();
}
final List<Byte> deviceIds = updatedAccount.getDevices().stream().map(Device::getId).toList();

View File

@ -266,7 +266,8 @@ public class BackupAuthManager {
}
boolean receiptAllowed = redeemedReceiptsManager
.put(receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, account.getUuid());
.put(receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, account.getUuid())
.join();
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 long expectedSourceLength,
final int 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, long expectedSourceLength,
SourceDescriptor source, int expectedSourceLength,
String dst) {
Cdn3CopyRequest(MediaEncryptionParameters parameters, SourceDescriptor source, long expectedSourceLength,
Cdn3CopyRequest(MediaEncryptionParameters parameters, SourceDescriptor source, int 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,
long sourceLength,
int sourceLength,
MediaEncryptionParameters encryptionParameters,
byte[] destinationMediaId) {
@ -35,17 +35,16 @@ public record CopyParameters(
///
/// @return the size, in bytes, of the ciphertext of a media object with the given `inputSize`
@VisibleForTesting
static long destinationObjectSize(final long inputSize) {
static long destinationObjectSize(final int 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 = Math.addExact(inputSize, 16L) / 16;
final long cipherTextLength = Math.multiplyExact(numBlocks, 16);
final long numBlocks = ((long) inputSize + 16) / 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 Math.addExact(cipherTextLength, 16L + 32);
return 16 + (numBlocks * 16) + 32;
}
}

View File

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

View File

@ -16,7 +16,6 @@ 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;
@ -53,20 +52,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 InvalidCaptchaArgumentException if input is not in the expected format
* @throws IOException if there is an error validating the captcha with the underlying service
* @throws BadRequestException if input is not in the expected format
*/
public AssessmentResult verify(
final Optional<UUID> maybeAci,
final Action expectedAction,
final String input,
final String ip,
@Nullable final String userAgent) throws IOException, InvalidCaptchaArgumentException {
final String userAgent) throws IOException {
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 InvalidCaptchaArgumentException("too few parts");
throw new BadRequestException("too few parts");
}
final String prefix = parts[0];
@ -79,30 +78,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 InvalidCaptchaArgumentException("invalid shortcode"));
token = shortCodeExpander.retrieve(token).orElseThrow(() -> new BadRequestException("invalid shortcode"));
}
final CaptchaClient client = this.captchaClientSupplier.apply(provider);
if (client == null) {
throw new InvalidCaptchaArgumentException("invalid captcha scheme");
throw new BadRequestException("invalid captcha scheme");
}
final Action parsedAction = Action.parse(action)
.orElseThrow(() -> {
Metrics.counter(INVALID_ACTION_COUNTER_NAME).increment();
return new InvalidCaptchaArgumentException("invalid captcha action");
Metrics.counter(INVALID_ACTION_COUNTER_NAME, "action", action).increment();
return new BadRequestException("invalid captcha action");
});
if (!parsedAction.equals(expectedAction)) {
Metrics.counter(INVALID_ACTION_COUNTER_NAME, "action", action).increment();
throw new InvalidCaptchaArgumentException("invalid captcha action");
throw new BadRequestException("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 InvalidCaptchaArgumentException("invalid captcha site-key");
throw new BadRequestException("invalid captcha site-key");
}
final AssessmentResult result = client.verify(maybeAci, siteKey, parsedAction, token, ip, userAgent);

View File

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

View File

@ -1,15 +0,0 @@
/*
* 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, InvalidCaptchaArgumentException{
throws IOException {
return captcha.isPresent()
? Optional.of(captchaChecker.verify(aci, Action.REGISTRATION, captcha.get(), sourceHost, userAgent))
: Optional.empty();

View File

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

View File

@ -1,11 +0,0 @@
/*
* 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,13 +49,12 @@ 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;
@ -79,13 +78,12 @@ 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,
@ -108,13 +106,12 @@ 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;
@ -148,12 +145,6 @@ public class DynamoDbTables {
return appleDeviceChecks;
}
@NotNull
@Valid
public Table getChangeNumberWaitingPeriods() {
return changeNumberWaitingPeriods;
}
@NotNull
@Valid
public Table getAppleDeviceCheckPublicKeys() {
@ -166,6 +157,12 @@ public class DynamoDbTables {
return backups;
}
@NotNull
@Valid
public Table getClientPublicKeys() {
return clientPublicKeys;
}
@NotNull
@Valid
public Table getClientReleases() {
@ -184,12 +181,6 @@ public class DynamoDbTables {
return deletedAccountsLock;
}
@NotNull
@Valid
public TableWithExpiration getDonationPermits() {
return donationPermits;
}
@NotNull
@Valid
public IssuedReceiptsTableConfiguration getIssuedReceipts() {

View File

@ -1,65 +0,0 @@
/*
* 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

@ -1,18 +0,0 @@
/*
* 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

@ -1,62 +0,0 @@
/*
* 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,33 +5,11 @@
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,5 +15,6 @@ import java.util.concurrent.ScheduledExecutorService;
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type", defaultImpl = RegistrationServiceConfiguration.class)
public interface RegistrationServiceClientFactory extends Discoverable {
RegistrationServiceClient build(Environment environment, ScheduledExecutorService identityRefreshExecutor);
RegistrationServiceClient build(Environment environment, Executor callbackExecutor,
ScheduledExecutorService identityRefreshExecutor);
}

View File

@ -5,6 +5,7 @@ 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;
@ -20,16 +21,16 @@ public record RegistrationServiceConfiguration(@NotBlank String host,
RegistrationServiceClientFactory {
@Override
public RegistrationServiceClient build(final Environment environment,
public RegistrationServiceClient build(final Environment environment, final Executor callbackExecutor,
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());
return new RegistrationServiceClient(host, port, callCredentials, registrationCaCertificate, collationKeySalt.value(),
identityRefreshExecutor);
} catch (IOException e) {
throw new RuntimeException(e);
}

View File

@ -7,9 +7,6 @@ package org.whispersystems.textsecuregcm.configuration;
import jakarta.validation.constraints.NotNull;
import org.whispersystems.textsecuregcm.configuration.secrets.SecretString;
import javax.annotation.Nullable;
public record TlsKeyStoreConfiguration(
@Nullable String path,
@NotNull SecretString password) {
public record TlsKeyStoreConfiguration(@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,10 +45,6 @@ public class DynamicConfiguration {
@Valid
DynamicMessagePersisterConfiguration messagePersister = new DynamicMessagePersisterConfiguration();
@JsonProperty
@Valid
DynamicMessageDeliveryConfiguration messageDelivery = new DynamicMessageDeliveryConfiguration();
@JsonProperty
@Valid
DynamicRegistrationConfiguration registrationConfiguration = new DynamicRegistrationConfiguration(false);
@ -81,10 +77,6 @@ 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();
@ -119,10 +111,6 @@ public class DynamicConfiguration {
return messagePersister;
}
public DynamicMessageDeliveryConfiguration getMessageDeliveryConfiguration() {
return messageDelivery;
}
public DynamicRegistrationConfiguration getRegistrationConfiguration() {
return registrationConfiguration;
}
@ -155,10 +143,6 @@ public class DynamicConfiguration {
return grpcAllowList;
}
public DynamicOmnibusConfiguration getOmnibus() {
return omnibus;
}
public DynamicTurnConfiguration getTurnConfiguration() {
return turn;
}

View File

@ -1,18 +0,0 @@
/*
* 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

@ -1,13 +0,0 @@
/*
* 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,7 +244,8 @@ 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);
.store(updatedAccount.getIdentifier(IdentityType.PNI), registrationRecoveryPassword)
.join();
Metrics.counter(RECOVERY_PASSWORD_SET_COUNTER_NAME, Tags.of(
UserAgentTagUtil.getPlatformTag(userAgent),
Tag.of("outcome", rrpCreated ? "created" : "updated")))

View File

@ -22,7 +22,6 @@ 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;
@ -76,7 +75,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, a positive integer indicating the number of seconds before a subsequent attempt could succeed"))
description = "If present, an 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,
@ -122,8 +121,6 @@ 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,22 +103,19 @@ 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 maxMessageBackupSize) {
final long maxAttachmentSize) {
this.accountsManager = accountsManager;
this.backupAuthManager = backupAuthManager;
this.backupManager = backupManager;
this.backupMetrics = backupMetrics;
this.maxAttachmentSize = maxAttachmentSize;
this.maxMessageBackupSize = maxMessageBackupSize;
}
public record SetBackupIdRequest(
@ -605,7 +602,7 @@ public class ArchiveController {
backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent);
final boolean oversize = uploadLength
.map(length -> length > maxMessageBackupSize)
.map(length -> length > maxAttachmentSize)
.orElse(false);
backupMetrics.updateMessageBackupSizeDistribution(backupUser, oversize, uploadLength);
@ -613,7 +610,7 @@ public class ArchiveController {
throw new ClientErrorException("exceeded maximum uploadLength", Response.Status.REQUEST_ENTITY_TOO_LARGE);
}
final BackupUploadDescriptor uploadDescriptor =
backupManager.createMessageBackupUploadDescriptor(backupUser, uploadLength.orElse(maxMessageBackupSize));
backupManager.createMessageBackupUploadDescriptor(backupUser, uploadLength.orElse(maxAttachmentSize));
return new UploadDescriptorResponse(
uploadDescriptor.cdn(),
uploadDescriptor.key(),

View File

@ -5,21 +5,16 @@
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;
@ -29,7 +24,6 @@ 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;
@ -39,8 +33,6 @@ import org.whispersystems.textsecuregcm.entities.AttachmentDescriptorV3;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
/**
@ -48,7 +40,7 @@ import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
* (message attachments) to a remote storage location. The location may be selected by the server at runtime.
*/
@Path("/v4/attachments")
@io.swagger.v3.oas.annotations.tags.Tag(name = "Attachments")
@Tag(name = "Attachments")
public class AttachmentControllerV4 {
private final ExperimentEnrollmentManager experimentEnrollmentManager;
@ -58,9 +50,6 @@ 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;
@ -102,8 +91,7 @@ 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,
@HeaderParam(HttpHeaders.USER_AGENT) @Nullable final String userAgent)
@QueryParam("uploadLength") final @Valid Optional<@Positive Long> maybeUploadLength)
throws RateLimitExceededException {
final long uploadLength = maybeUploadLength.orElse(maxUploadLength);
@ -123,12 +111,6 @@ 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,6 +20,7 @@ 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;
@ -75,7 +76,8 @@ public class CertificateController {
@Produces(MediaType.APPLICATION_JSON)
@Path("/delivery")
public DeliveryCertificate getDeliveryCertificate(@Auth AuthenticatedDevice auth,
@QueryParam("includeE164") @DefaultValue("true") boolean includeE164) {
@QueryParam("includeE164") @DefaultValue("true") boolean includeE164)
throws InvalidKeyException {
Metrics.counter(GENERATE_DELIVERY_CERTIFICATE_COUNTER_NAME, INCLUDE_E164_TAG_NAME, String.valueOf(includeE164))
.increment();

View File

@ -34,7 +34,6 @@ 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;
@ -98,7 +97,7 @@ public class ChallengeController {
Tags tags = Tags.of(UserAgentTagUtil.getPlatformTag(userAgent));
final ChallengeConstraints constraints = challengeConstraintChecker.challengeConstraintsHttp(
final ChallengeConstraints constraints = challengeConstraintChecker.challengeConstraints(
requestContext, account);
try {
if (answerRequest instanceof final AnswerPushChallengeRequest pushChallengeRequest) {
@ -127,8 +126,6 @@ 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();
@ -188,7 +185,7 @@ public class ChallengeController {
final Account account = accountsManager.getByAccountIdentifier(auth.accountIdentifier())
.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));
final ChallengeConstraints constraints = challengeConstraintChecker.challengeConstraintsHttp(requestContext, account);
final ChallengeConstraints constraints = challengeConstraintChecker.challengeConstraints(requestContext, account);
if (!constraints.pushPermitted()) {
return Response.status(429).build();
}

View File

@ -11,7 +11,6 @@ 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;
@ -22,56 +21,48 @@ 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(
final Clock clock,
final ServerZkReceiptOperations serverZkReceiptOperations,
final RedeemedReceiptsManager redeemedReceiptsManager,
final AccountsManager accountsManager,
final BadgesConfiguration badgesConfiguration,
final ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory,
final DonationPermitsManager donationPermitsManager,
final RateLimiters rateLimiters) {
@Nonnull final Clock clock,
@Nonnull final ServerZkReceiptOperations serverZkReceiptOperations,
@Nonnull final RedeemedReceiptsManager redeemedReceiptsManager,
@Nonnull final AccountsManager accountsManager,
@Nonnull final BadgesConfiguration badgesConfiguration,
@Nonnull final ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory) {
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
@ -126,7 +117,7 @@ public class DonationController {
}
final boolean receiptMatched = redeemedReceiptsManager.put(
receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, auth.accountIdentifier());
receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, auth.accountIdentifier()).join();
if (!receiptMatched) {
return Response.status(Status.BAD_REQUEST)
.entity("receipt serial is already redeemed")
@ -143,34 +134,4 @@ 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,8 +33,6 @@ 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;
@ -103,23 +101,15 @@ public class KeyTransparencyController {
.build()
));
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());
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());
} catch (final StatusRuntimeException exception) {
handleKeyTransparencyServiceError(exception);
}
@ -173,22 +163,13 @@ public class KeyTransparencyController {
.setCommitmentIndex(ByteString.copyFrom(e164.commitmentIndex()))
.build());
final MonitorResponseV2 monitorResponse = keyTransparencyServiceClient.monitor(
return new KeyTransparencyMonitorResponse(keyTransparencyServiceClient.monitor(
aciMonitorRequest,
usernameHashMonitorRequest,
e164MonitorRequest,
request.lastNonDistinguishedTreeHeadSize(),
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());
request.lastDistinguishedTreeHeadSize())
.toByteArray());
} catch (final StatusRuntimeException exception) {
handleKeyTransparencyServiceError(exception);
}

View File

@ -4,6 +4,8 @@
*/
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;
@ -20,6 +22,7 @@ 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;
@ -30,7 +33,6 @@ 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;
@ -38,6 +40,7 @@ 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;
@ -48,6 +51,7 @@ 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;
@ -70,6 +74,8 @@ 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;
@ -78,22 +84,31 @@ 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")
@ -106,14 +121,23 @@ 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;
@ -141,6 +165,8 @@ 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());
@ -149,19 +175,33 @@ 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;
}
@ -431,8 +471,6 @@ public class MessageController {
}
} catch (final MessageTooLargeException e) {
throw new WebApplicationException(Status.REQUEST_ENTITY_TOO_LARGE);
} catch (final MessageDeliveryNotAllowedException e) {
throw new ServiceUnavailableException();
}
}
@ -686,8 +724,6 @@ public class MessageController {
.type(MediaType.APPLICATION_JSON)
.entity(accountStaleDevices)
.build());
} catch (final MessageDeliveryNotAllowedException e) {
throw new ServiceUnavailableException();
}
}
@ -726,6 +762,84 @@ 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

@ -1,11 +0,0 @@
/*
* 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,8 +4,6 @@
*/
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;
@ -13,7 +11,6 @@ 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;
@ -39,13 +36,14 @@ 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;
@ -54,17 +52,10 @@ 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;
@ -74,8 +65,13 @@ 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)
@ -90,6 +86,8 @@ 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;
@ -98,18 +96,16 @@ public class OneTimeDonationController {
private final ServerZkReceiptOperations zkReceiptOperations;
private final IssuedReceiptsManager issuedReceiptsManager;
private final OneTimeDonationsManager oneTimeDonationsManager;
private final DonationPermitsManager donationPermitsManager;
public OneTimeDonationController(
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) {
@Nonnull Clock clock,
@Nonnull OneTimeDonationConfiguration oneTimeDonationConfiguration,
@Nonnull StripeManager stripeManager,
@Nonnull BraintreeManager braintreeManager,
@Nonnull PayPalDonationsTranslator payPalDonationsTranslator,
@Nonnull ServerZkReceiptOperations zkReceiptOperations,
@Nonnull IssuedReceiptsManager issuedReceiptsManager,
@Nonnull OneTimeDonationsManager oneTimeDonationsManager) {
this.clock = Objects.requireNonNull(clock);
this.oneTimeDonationConfiguration = Objects.requireNonNull(oneTimeDonationConfiguration);
this.stripeManager = Objects.requireNonNull(stripeManager);
@ -118,7 +114,6 @@ 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 {
@ -149,10 +144,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 = """
@ -166,35 +161,15 @@ 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 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,
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@NotNull @Valid 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,
@ -203,34 +178,47 @@ 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) {
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()) {
if (!(request.level == oneTimeDonationConfiguration.gift().level()
|| request.level == oneTimeDonationConfiguration.boost().level())) {
throw new BadRequestException(
Response.status(Response.Status.BAD_REQUEST).entity(errorBody).build());
Response.status(Response.Status.BAD_REQUEST).entity(Map.of("error", "invalid_level")).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 {
@ -252,9 +240,9 @@ public class OneTimeDonationController {
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public CompletableFuture<Response> createPayPalBoost(
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
@NotNull @Valid final CreatePayPalBoostRequest request,
@Context final ContainerRequestContext containerRequestContext) {
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@NotNull @Valid CreatePayPalBoostRequest request,
@Context ContainerRequestContext containerRequestContext) {
if (authenticatedAccount.isPresent()) {
throw new ForbiddenException("must not use authenticated connection for one-time donation operations");
@ -265,11 +253,21 @@ public class OneTimeDonationController {
.thenCompose(_ -> {
final List<Locale> acceptableLanguages =
HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext);
final OneTimeDonationUtil.LocalizedPayPalDonationLineItem localizedLineItem = OneTimeDonationUtil.localizePayPalDonationLineItem(
payPalDonationsTranslator, acceptableLanguages);
// 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);
return braintreeManager.createOneTimePayment(request.currency.toUpperCase(Locale.ROOT), request.amount,
localizedLineItem.locale().toLanguageTag(),
request.returnUrl, request.cancelUrl, localizedLineItem.itemName());
locale.toLanguageTag(),
request.returnUrl, request.cancelUrl, localizedLineItemName);
})
.thenApply(approvalDetails -> Response.ok(
new CreatePayPalBoostResponse(approvalDetails.approvalUrl(), approvalDetails.paymentId())).build());
@ -296,8 +294,8 @@ public class OneTimeDonationController {
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public CompletableFuture<Response> confirmPayPalBoost(
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
@NotNull @Valid final ConfirmPayPalBoostRequest request,
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@NotNull @Valid ConfirmPayPalBoostRequest request,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) {
if (authenticatedAccount.isPresent()) {
@ -308,11 +306,10 @@ 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)))
.thenApply(chargeSuccessDetails -> {
oneTimeDonationsManager.putPaidAt(chargeSuccessDetails.paymentId(), Instant.now());
return Response.ok(
new ConfirmPayPalBoostResponse(chargeSuccessDetails.paymentId())).build();
});
.thenCompose(
chargeSuccessDetails -> oneTimeDonationsManager.putPaidAt(chargeSuccessDetails.paymentId(), Instant.now()))
.thenApply(paymentId -> Response.ok(
new ConfirmPayPalBoostResponse(paymentId)).build());
}
public static class CreateBoostReceiptCredentialsRequest {
@ -340,7 +337,7 @@ public class OneTimeDonationController {
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public CompletableFuture<Response> createBoostReceiptCredentials(
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
@NotNull @Valid final CreateBoostReceiptCredentialsRequest request,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) {
@ -348,68 +345,87 @@ public class OneTimeDonationController {
throw new ForbiddenException("must not use authenticated connection for one-time donation operations");
}
final CompletableFuture<Optional<PaymentDetails>> paymentDetailsFut = switch (request.processor) {
final CompletableFuture<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.thenApply(maybePaymentDetails -> {
if (maybePaymentDetails.isEmpty()) {
return paymentDetailsFut.thenCompose(paymentDetails -> {
if (paymentDetails == null) {
throw new WebApplicationException(Response.Status.NOT_FOUND);
}
final PaymentDetails paymentDetails = maybePaymentDetails.get();
if (paymentDetails.status() == PaymentStatus.PROCESSING) {
return Response.noContent().build();
}
if (paymentDetails.status() != PaymentStatus.SUCCEEDED) {
} else if (paymentDetails.status() == PaymentStatus.PROCESSING) {
return CompletableFuture.completedFuture(Response.noContent().build());
} else 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
final OneTimeDonationUtil.DonationLevelDetails levelDetails;
try {
levelDetails = OneTimeDonationUtil.getLevelDetails(paymentDetails, oneTimeDonationConfiguration);
} catch (OneTimeDonationUtil.InvalidLevelException _) {
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);
throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
}
final ReceiptCredentialRequest receiptCredentialRequest;
ReceiptCredentialRequest receiptCredentialRequest;
try {
receiptCredentialRequest = new ReceiptCredentialRequest(request.receiptCredentialRequest);
} catch (final InvalidInputException e) {
} catch (InvalidInputException e) {
throw new BadRequestException("invalid receipt credential request", e);
}
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();
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();
});
});
}
@Nullable
private static ClientPlatform getClientPlatform(@Nullable final String userAgentString) {
try {
return UserAgentUtil.parseUserAgentString(userAgentString).platform();
} catch (final UnrecognizedUserAgentException e) {
return null;
}
}
}

View File

@ -39,6 +39,7 @@ 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;
@ -87,6 +88,7 @@ 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;
@ -112,6 +114,7 @@ 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;
@ -132,6 +135,7 @@ public class ProfileController {
ProfileBadgeConverter profileBadgeConverter,
BadgesConfiguration badgesConfiguration,
PostPolicyGenerator policyGenerator,
PolicySigner policySigner,
ServerSecretParams serverSecretParams,
ServerZkProfileOperations zkProfileOperations,
Executor batchIdentityCheckExecutor) {
@ -146,6 +150,7 @@ public class ProfileController {
this.serverSecretParams = serverSecretParams;
this.zkProfileOperations = zkProfileOperations;
this.policyGenerator = policyGenerator;
this.policySigner = policySigner;
this.batchIdentityCheckExecutor = Preconditions.checkNotNull(batchIdentityCheckExecutor);
}
@ -176,8 +181,14 @@ public class ProfileController {
final Optional<VersionedProfileV1> currentProfile =
profilesManager.getV1(auth.accountIdentifier(), request.version());
if (request.paymentAddress() != null && request.paymentAddress().length != 0 && ProfileHelper.isPaymentAddressUpdateForbidden(account, Optional.empty(), currentProfile, dynamicConfigurationManager)) {
return Response.status(Response.Status.FORBIDDEN).build();
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();
}
}
final Optional<String> currentAvatar = ProfileHelper.getCurrentAvatar(currentProfile);
@ -205,7 +216,7 @@ public class ProfileController {
.orElseGet(a::getBadges);
a.setBadges(clock, updatedBadges);
a.setCurrentProfileVersion(HexFormat.of().parseHex(request.version()));
a.setCurrentProfileVersion(request.version());
});
if (request.getAvatarChange() == CreateProfileRequest.AvatarChange.UPDATE) {
@ -425,7 +436,7 @@ public class ProfileController {
final ExpiringProfileKeyCredentialResponse expiringProfileKeyCredentialResponse;
if (account.getCurrentProfileVersion().map(v -> HexFormat.of().formatHex(v).equals(version)).orElse(false)) {
if (account.getCurrentProfileVersion().map(version::equals).orElse(false)) {
expiringProfileKeyCredentialResponse = profilesManager.getV1(account.getUuid(), version)
.map(profile -> {
final ExpiringProfileKeyCredentialResponse profileKeyCredentialResponse;
@ -477,7 +488,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 -> HexFormat.of().formatHex(v).equals(p.version())).orElse(true))
.filter(p -> account.getCurrentProfileVersion().map(v -> v.equals(p.version())).orElse(true))
.map(VersionedProfileV1::paymentAddress)
.orElse(null);
@ -548,13 +559,15 @@ public class ProfileController {
return maybeTargetAccount.get();
}
private ProfileAvatarUploadAttributes generateAvatarUploadForm(final String objectName) {
final PostPolicyGenerator.SignedPostPolicy signedPostPolicy =
policyGenerator.createFor(objectName, ProfileHelper.MAX_PROFILE_AVATAR_SIZE_BYTES, clock.instant());
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());
return new ProfileAvatarUploadAttributes(objectName, signedPostPolicy.credential(),
PostPolicyGenerator.ACL, PostPolicyGenerator.ALGORITHM,
signedPostPolicy.formattedTimestamp(), signedPostPolicy.encodedPolicy(), signedPostPolicy.signature());
return new ProfileAvatarUploadAttributes(objectName, policy.first(),
"private", "AWS4-HMAC-SHA256",
now.format(PostPolicyGenerator.AWS_DATE_TIME), policy.second(), signature);
}
private static Map<String, Boolean> getAccountCapabilities(final Account account) {

View File

@ -26,10 +26,13 @@ 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;
@ -58,7 +61,9 @@ public class RemoteConfigController {
.map(p -> p.name().toLowerCase())
.collect(Collectors.toSet());
public RemoteConfigController(RemoteConfigsManager remoteConfigsManager, Map<String, String> globalConfig) {
public RemoteConfigController(RemoteConfigsManager remoteConfigsManager,
Map<String, String> globalConfig,
final Clock clock) {
this.remoteConfigsManager = remoteConfigsManager;
this.globalConfig = globalConfig;
}

View File

@ -6,7 +6,6 @@
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;
@ -16,8 +15,8 @@ import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.HexFormat;
import java.util.LinkedList;
import java.util.List;
@ -25,25 +24,23 @@ 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 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) {
public StickerController(RateLimiters rateLimiters, String accessKey, String accessSecret, String region, String bucket) {
this.rateLimiters = rateLimiters;
this.policyGenerator = postPolicyGenerator;
this.clock = clock;
this.policySigner = new PolicySigner(accessSecret, region);
this.policyGenerator = new PostPolicyGenerator(region, bucket, accessKey);
}
@GET
@ -54,32 +51,33 @@ public class StickerController {
throws RateLimitExceededException {
rateLimiters.getStickerPackLimiter().validate(auth.accountIdentifier());
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);
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 StickerPackFormUploadItem manifest = new StickerPackFormUploadItem(-1, manifestKey, manifestPolicy.credential(),
PostPolicyGenerator.ACL, PostPolicyGenerator.ALGORITHM,
manifestPolicy.formattedTimestamp(), manifestPolicy.encodedPolicy(), manifestPolicy.signature());
final List<StickerPackFormUploadItem> stickers = new LinkedList<>();
List<StickerPackFormUploadItem> stickers = new LinkedList<>();
for (int i = 0; i < stickerCount; i++) {
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()));
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));
}
return new StickerPackFormUploadAttributes(packId, manifest, stickers);
}
private String generatePackId() {
final byte[] object = new byte[16];
byte[] object = new byte[16];
new SecureRandom().nextBytes(object);
return HexFormat.of().formatHex(object);

View File

@ -5,11 +5,6 @@
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;
@ -20,7 +15,6 @@ 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;
@ -42,7 +36,6 @@ 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;
@ -51,31 +44,34 @@ 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.grpc.SubscriptionsUtil;
import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.entities.Badge;
import org.whispersystems.textsecuregcm.entities.PurchasableBadge;
import org.whispersystems.textsecuregcm.mappers.SubscriptionExceptionMapper;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.storage.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;
@ -84,10 +80,8 @@ 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;
@ -98,6 +92,9 @@ 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")
@ -113,7 +110,6 @@ 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";
@ -121,18 +117,17 @@ public class SubscriptionController {
private static final String SUBSCRIPTION_TYPE_TAG_NAME = "subscriptionType";
public SubscriptionController(
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) {
@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) {
this.subscriptionManager = subscriptionManager;
this.clock = Objects.requireNonNull(clock);
this.subscriptionConfiguration = Objects.requireNonNull(subscriptionConfiguration);
@ -143,14 +138,78 @@ public class SubscriptionController {
this.appleAppStoreManager = appleAppStoreManager;
this.badgeTranslator = Objects.requireNonNull(badgeTranslator);
this.bankMandateTranslator = Objects.requireNonNull(bankMandateTranslator);
this.donationPermitsManager = donationPermitsManager;
this.dynamicConfigurationManager = Objects.requireNonNull(dynamicConfigurationManager);
this.dynamicConfigurationManager = 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();
@ -163,11 +222,7 @@ public class SubscriptionController {
e.getValue().playProductId(),
e.getValue().mediaTtl().toDays())));
return new GetSubscriptionConfigurationResponse(
buildCurrencyConfiguration(List.of(stripeManager, braintreeManager), oneTimeDonationConfiguration,
subscriptionConfiguration),
buildDonationLevelsConfiguration(subscriptionConfiguration, oneTimeDonationConfiguration, badgeTranslator,
acceptableLanguages),
return new GetSubscriptionConfigurationResponse(buildCurrencyConfiguration(), donationLevels,
new BackupConfiguration(backupLevels, subscriptionConfiguration.getbackupFreeTierMediaDuration().toDays()),
oneTimeDonationConfiguration.sepaMaximumEuros());
}
@ -210,35 +265,15 @@ 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);
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);
subscriptionManager.updateSubscriber(subscriberCredentials);
return Response.ok().build();
}
@ -251,14 +286,8 @@ 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 {
@ -276,21 +305,6 @@ 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,
@ -309,7 +323,6 @@ 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,
@ -319,7 +332,10 @@ public class SubscriptionController {
final SubscriberCredentials subscriberCredentials =
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
final Locale locale = getPayPalLocale(HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext));
final Locale locale = HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext).stream()
.filter(l -> !"*".equals(l.getLanguage()))
.findFirst()
.orElse(Locale.US);
final BraintreeManager.PayPalBillingAgreementApprovalDetails billingAgreementApprovalDetails = subscriptionManager.addPaymentMethodToCustomer(
subscriberCredentials,
@ -522,12 +538,33 @@ 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<Long, LevelConfiguration> levels,
Map<String, 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,
@ -727,8 +764,7 @@ public class SubscriptionController {
SubscriberCredentials subscriberCredentials = SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
try {
final SubscriptionManager.ReceiptResult receiptCredential = subscriptionManager.createReceiptCredentials(
subscriberCredentials, request.receiptCredentialRequest(),
r -> SubscriptionsUtil.receiptExpirationWithGracePeriod(subscriptionConfiguration, r));
subscriberCredentials, request, this::receiptExpirationWithGracePeriod);
final ReceiptCredentialResponse receiptCredentialResponse = receiptCredential.receiptCredentialResponse();
final CustomerAwareSubscriptionPaymentProcessor.ReceiptItem receipt = receiptCredential.receiptItem();
@ -783,6 +819,19 @@ 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) {
@ -801,4 +850,13 @@ 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,7 +7,6 @@ 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;
@ -59,12 +58,14 @@ 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;
@ -73,15 +74,12 @@ 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;
@ -89,6 +87,7 @@ 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;
@ -102,6 +101,7 @@ 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,6 +113,7 @@ 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();
@ -132,9 +133,6 @@ 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;
@ -146,7 +144,6 @@ 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,
@ -160,7 +157,6 @@ 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;
@ -173,7 +169,6 @@ public class VerificationController {
this.carrierDataProvider = carrierDataProvider;
this.registrationFraudChecker = registrationFraudChecker;
this.dynamicConfigurationManager = dynamicConfigurationManager;
this.experimentEnrollmentManager = experimentEnrollmentManager;
this.clock = clock;
}
@ -221,13 +216,27 @@ public class VerificationController {
maybeCarrierData = Optional.empty();
}
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);
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);
}
VerificationSession verificationSession = new VerificationSession(registrationServiceSession.encodedSessionId(),
null,
@ -246,7 +255,7 @@ public class VerificationController {
// if a push challenge sent in `handlePushToken` doesn't arrive in time
verificationSession.requestedInformation().add(VerificationSession.Information.CAPTCHA);
verificationSessionManager.insert(verificationSession);
storeVerificationSession(verificationSession);
return buildResponse(registrationServiceSession, verificationSession);
}
@ -319,12 +328,24 @@ 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
verificationSessionManager.update(verificationSession);
updateStoredVerificationSession(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
@ -480,8 +501,6 @@ 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)) {
@ -636,43 +655,48 @@ public class VerificationController {
clientType,
acceptLanguage.orElse(null),
senderOverride,
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();
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 _) {
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);
}
});
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);
}
}
}
Metrics.counter(CODE_REQUESTED_COUNTER_NAME, Tags.of(
UserAgentTagUtil.getPlatformTag(userAgent),
@ -713,7 +737,8 @@ 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) {
@NotNull @Valid final SubmitVerificationCodeRequest submitVerificationCodeRequest)
throws RateLimitExceededException {
final RegistrationServiceSession registrationServiceSession = retrieveRegistrationServiceSession(encodedSessionId);
final VerificationSession verificationSession = retrieveVerificationSession(registrationServiceSession);
@ -730,17 +755,35 @@ public class VerificationController {
try {
resultSession = registrationServiceClient.checkVerificationCode(registrationServiceSession.id(),
submitVerificationCodeRequest.code(),
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);
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);
}
}
boolean existingRRP = false;
@ -749,7 +792,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());
existingRRP = registrationRecoveryPasswordsManager.remove(phoneNumberIdentifiers.getPhoneNumberIdentifier(registrationServiceSession.number()).join()).join();
}
Optional<Account> maybeExistingAccount;
@ -816,29 +859,28 @@ public class VerificationController {
}
try {
final RegistrationServiceSession registrationServiceSession =
registrationServiceClient.getSession(sessionId, REGISTRATION_RPC_TIMEOUT).orElseThrow(NotFoundException::new);
final RegistrationServiceSession registrationServiceSession = registrationServiceClient.getSession(sessionId,
REGISTRATION_RPC_TIMEOUT).join()
.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());
registrationRecoveryPasswordsManager.remove(phoneNumberIdentifiers.getPhoneNumberIdentifier(registrationServiceSession.number()).join()).join();
}
return registrationServiceSession;
} catch (final StatusRuntimeException e) {
if (e.getStatus().getCode() == Status.Code.INVALID_ARGUMENT) {
throw new BadRequestException();
}
} catch (final CompletionException | CancellationException e) {
final Throwable unwrapped = ExceptionUtils.unwrap(e);
logger.error("Registration service failure", e);
throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE, e);
} catch (final WebApplicationException e) {
throw e;
} catch (final RuntimeException e) {
if (unwrapped instanceof StatusRuntimeException grpcRuntimeException) {
if (grpcRuntimeException.getStatus().getCode() == Status.Code.INVALID_ARGUMENT) {
throw new BadRequestException();
}
}
logger.error("Registration service failure", e);
throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE, e);
}
@ -850,7 +892,8 @@ public class VerificationController {
private VerificationSession retrieveVerificationSession(final RegistrationServiceSession registrationServiceSession) {
return verificationSessionManager.findForId(registrationServiceSession.encodedSessionId())
.orElseThrow(NotFoundException::new);
.orTimeout(5, TimeUnit.SECONDS)
.join().orElseThrow(NotFoundException::new);
}
/**

View File

@ -1,20 +0,0 @@
/*
* 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

@ -1,18 +0,0 @@
/*
* 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.toCompactByteString())
.setDestinationServiceId(destinationIdentifier.toServiceIdentifierString())
.setEphemeral(ephemeral)
.setUrgent(urgent);
@ -61,7 +61,7 @@ public record IncomingMessage(int type,
if (sourceServiceIdentifier != null && sourceDeviceId != null) {
envelopeBuilder
.setSourceServiceId(sourceServiceIdentifier.toCompactByteString())
.setSourceServiceId(sourceServiceIdentifier.toServiceIdentifierString())
.setSourceDevice(sourceDeviceId.intValue());
}

View File

@ -0,0 +1,124 @@
/*
* 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

@ -0,0 +1,11 @@
/*
* 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

@ -0,0 +1,45 @@
/*
* 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,10 +71,6 @@ 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

@ -1,82 +0,0 @@
/*
* 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,6 +26,10 @@ 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

@ -1,71 +0,0 @@
/*
* 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 =
GrpcServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getServiceIdentifier());
ServiceIdentifierUtil.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(GrpcServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(account.getUuid())))
.setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(account.getUuid())))
.build())
.orElseGet(() -> LookupUsernameHashResponse.newBuilder().setNotFound(NotFound.getDefaultInstance()).build());
}

View File

@ -6,12 +6,10 @@
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;
@ -48,6 +46,7 @@ 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;
@ -63,8 +62,6 @@ 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;
@ -86,8 +83,8 @@ public class AccountsGrpcService extends SimpleAccountsGrpc.AccountsImplBase {
final Account account = getAuthenticatedAccount();
final AccountIdentifiers.Builder accountIdentifiersBuilder = AccountIdentifiers.newBuilder()
.addServiceIdentifiers(GrpcServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(account.getUuid())))
.addServiceIdentifiers(GrpcServiceIdentifierUtil.toGrpcServiceIdentifier(new PniServiceIdentifier(account.getPhoneNumberIdentifier())))
.addServiceIdentifiers(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(account.getUuid())))
.addServiceIdentifiers(ServiceIdentifierUtil.toGrpcServiceIdentifier(new PniServiceIdentifier(account.getPhoneNumberIdentifier())))
.setE164(account.getNumber());
account.getUsernameHash().ifPresent(usernameHash ->
@ -135,6 +132,11 @@ 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());
}
@ -162,7 +164,7 @@ public class AccountsGrpcService extends SimpleAccountsGrpc.AccountsImplBase {
try {
usernameHashZkProofVerifier.verifyProof(request.getZkProof().toByteArray(), request.getUsernameHash().toByteArray());
} catch (final BaseUsernameException e) {
throw GrpcExceptions.invalidArguments("Could not verify proof");
throw GrpcExceptions.constraintViolation("Could not verify proof");
}
rateLimiters.getUsernameSetLimiter().validate(authenticatedDevice.accountIdentifier());
@ -261,7 +263,8 @@ public class AccountsGrpcService extends SimpleAccountsGrpc.AccountsImplBase {
@Override
public SetRegistrationRecoveryPasswordResponse setRegistrationRecoveryPassword(final SetRegistrationRecoveryPasswordRequest request) {
registrationRecoveryPasswordsManager.store(getAuthenticatedAccount().getIdentifier(IdentityType.PNI),
request.getRegistrationRecoveryPassword().toByteArray());
request.getRegistrationRecoveryPassword().toByteArray())
.join();
return SetRegistrationRecoveryPasswordResponse.getDefaultInstance();
}
@ -274,24 +277,14 @@ public class AccountsGrpcService extends SimpleAccountsGrpc.AccountsImplBase {
final byte[] zkCredentialKey = request.getPublicKey().toByteArray();
if (Arrays.equals(authenticatedAccount.getZkCredentialKey(), zkCredentialKey)) {
return SetZkCredentialKeyResponse.newBuilder()
.setRotationId(Objects.requireNonNull(authenticatedAccount.getZkCredentialKeyRotationId()))
.build();
return SetZkCredentialKeyResponse.getDefaultInstance();
}
rateLimiters.getSetZkCredentialKeyLimiter().validate(authenticatedDevice.accountIdentifier());
// 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);
accountsManager.update(authenticatedDevice.accountIdentifier(), account -> account.setZkCredentialKey(zkCredentialKey));
accountsManager.update(authenticatedDevice.accountIdentifier(), account -> {
account.setZkCredentialKey(zkCredentialKey);
account.setZkCredentialKeyRotationId(rotationId);
});
return SetZkCredentialKeyResponse.newBuilder()
.setRotationId(rotationId)
.build();
return SetZkCredentialKeyResponse.getDefaultInstance();
}
private Account getAuthenticatedAccount() {

View File

@ -6,21 +6,10 @@
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;
@ -29,51 +18,30 @@ 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 PostPolicyGenerator stickerPolicyGenerator,
final long maxUploadLength,
final Clock clock) {
final long maxUploadLength) {
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,
@ -99,11 +67,6 @@ 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);
@ -117,59 +80,4 @@ 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,7 +8,6 @@ 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;
@ -63,13 +62,11 @@ 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, final long maxMessageBackupSize) {
public BackupsAnonymousGrpcService(final BackupManager backupManager, final BackupMetrics backupMetrics, final long maxAttachmentSize) {
this.backupManager = backupManager;
this.backupMetrics = backupMetrics;
this.maxAttachmentSize = maxAttachmentSize;
this.maxMessageBackupSize = maxMessageBackupSize;
}
@Override
@ -160,19 +157,16 @@ public class BackupsAnonymousGrpcService extends SimpleBackupsAnonymousGrpc.Back
}
@Override
public SetPublicKeyResponse setPublicKey(final SetPublicKeyRequest request) {
public SetPublicKeyResponse setPublicKey(final SetPublicKeyRequest request)
throws BackupFailedZkAuthenticationException {
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();
try {
backupManager.setPublicKey(presentation, signature, publicKey);
return SetPublicKeyResponse.newBuilder().setSuccess(Empty.getDefaultInstance()).build();
} catch (BackupFailedZkAuthenticationException e) {
return SetPublicKeyResponse.newBuilder().setFailedAuthentication(FailedZkAuthentication.getDefaultInstance()).build();
}
backupManager.setPublicKey(presentation, signature, publicKey);
return SetPublicKeyResponse.newBuilder().setSuccess(Empty.getDefaultInstance()).build();
}
@ -188,21 +182,18 @@ public class BackupsAnonymousGrpcService extends SimpleBackupsAnonymousGrpc.Back
.build();
}
final long uploadLength = request.getUploadLength();
final boolean oversize = switch (request.getUploadTypeCase()) {
case MEDIA -> uploadLength > maxAttachmentSize;
case MESSAGES -> {
backupMetrics.updateMessageBackupSizeDistribution(backupUser, uploadLength > maxMessageBackupSize, Optional.of(uploadLength));
yield uploadLength > maxMessageBackupSize;
if (uploadLength > maxAttachmentSize) {
if (request.getUploadTypeCase() == GetUploadFormRequest.UploadTypeCase.MESSAGES) {
backupMetrics.updateMessageBackupSizeDistribution(backupUser, true, Optional.of(uploadLength));
}
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 -> backupManager.createMessageBackupUploadDescriptor(backupUser, uploadLength);
case MESSAGES -> {
backupMetrics.updateMessageBackupSizeDistribution(backupUser, false, Optional.of(uploadLength));
yield backupManager.createMessageBackupUploadDescriptor(backupUser, uploadLength);
}
case MEDIA -> backupManager.createTemporaryAttachmentUploadDescriptor(backupUser, uploadLength);
case UPLOADTYPE_NOT_SET -> throw GrpcExceptions.fieldViolation("upload_type", "Must set upload_type");
};
@ -224,16 +215,15 @@ public class BackupsAnonymousGrpcService extends SimpleBackupsAnonymousGrpc.Back
copyQuota = backupManager.getCopyQuota(backupUser,
request.getItemsList().stream().map(item -> new CopyParameters(
item.getSourceAttachmentCdn(), item.getSourceKey(),
item.getObjectLength(),
// uint32 in proto, make sure it fits in a signed int
fromUnsignedExact(item.getObjectLength()),
new MediaEncryptionParameters(item.getEncryptionKey().toByteArray(), item.getHmacKey().toByteArray()),
item.getMediaId().toByteArray())).toList(), maxAttachmentSize);
} catch (BackupFailedZkAuthenticationException e) {
return JdkFlowAdapter.publisherToFlowPublisher(
Mono.error(GrpcExceptions.streamClosed(BackupStreamClosed.newBuilder()
.setFailedAuthentication(FailedZkAuthentication.newBuilder()
.setDescription(e.getMessage())
.build())
.build())));
return JdkFlowAdapter.publisherToFlowPublisher(Mono.just(CopyMediaResponse
.newBuilder()
.setFailedAuthentication(FailedZkAuthentication.newBuilder().setDescription(e.getMessage()))
.build()));
}
return JdkFlowAdapter.publisherToFlowPublisher(backupManager.copyToBackup(copyQuota)
.doOnNext(result -> backupMetrics.updateCopyCounter(
@ -310,12 +300,10 @@ public class BackupsAnonymousGrpcService extends SimpleBackupsAnonymousGrpc.Back
.map(item -> new BackupManager.StorageDescriptor(item.getCdn(), item.getMediaId().toByteArray()))
.toList());
} catch (BackupFailedZkAuthenticationException e) {
return JdkFlowAdapter.publisherToFlowPublisher(
Mono.error(GrpcExceptions.streamClosed(BackupStreamClosed.newBuilder()
.setFailedAuthentication(FailedZkAuthentication.newBuilder()
.setDescription(e.getMessage())
.build())
.build())));
return JdkFlowAdapter.publisherToFlowPublisher(Mono.just(DeleteMediaResponse
.newBuilder()
.setFailedAuthentication(FailedZkAuthentication.newBuilder().setDescription(e.getMessage()))
.build()));
}
return JdkFlowAdapter.publisherToFlowPublisher(deleteItems
.map(storageDescriptor -> DeleteMediaResponse.newBuilder()
@ -350,6 +338,17 @@ 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

@ -1,74 +0,0 @@
/*
* 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

@ -1,63 +0,0 @@
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,168 +0,0 @@
/*
* 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,7 +19,6 @@ 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");
};
@ -32,7 +31,6 @@ 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

@ -1,136 +0,0 @@
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, ABORTED -> {
case UNAUTHENTICATED, UNAVAILABLE, INVALID_ARGUMENT, RESOURCE_EXHAUSTED -> {
}
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();
yield GrpcExceptions.unavailable(e.getCause().getMessage());
}
case IOException e -> {
log.warn("RPC {} encountered IOException", call.getMethodDescriptor().getFullMethodName(), e);
yield GrpcExceptions.unavailable();
yield GrpcExceptions.unavailable(e.getMessage());
}
case null -> {
log.error("RPC {} finished with status UNKNOWN: {}",

View File

@ -1,35 +1,53 @@
/*
* Copyright 2026 Signal Messenger, LLC
* 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.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.SimpleCredentialsAnonymousGrpc;
import org.signal.chat.credentials.SimpleExternalServiceCredentialsAnonymousGrpc;
import org.whispersystems.textsecuregcm.WhisperServerConfiguration;
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 CredentialsAnonymousGrpcService extends SimpleCredentialsAnonymousGrpc.CredentialsAnonymousImplBase {
private final AccountsManager accountsManager;
private final ExternalServiceCredentialsGenerator svrCredentialsGenerator;
public class ExternalServiceCredentialsAnonymousGrpcService extends
SimpleExternalServiceCredentialsAnonymousGrpc.ExternalServiceCredentialsAnonymousImplBase {
private static final long MAX_SVR_PASSWORD_AGE_SECONDS = TimeUnit.DAYS.toSeconds(30);
public CredentialsAnonymousGrpcService(final AccountsManager accountsManager,
final ExternalServiceCredentialsGenerator svrCredentialsGenerator) {
private final ExternalServiceCredentialsGenerator svrCredentialsGenerator;
this.svrCredentialsGenerator = svrCredentialsGenerator;
this.accountsManager = accountsManager;
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,
final ExternalServiceCredentialsGenerator svrCredentialsGenerator) {
this.accountsManager = requireNonNull(accountsManager);
this.svrCredentialsGenerator = requireNonNull(svrCredentialsGenerator);
}
@Override
@ -39,18 +57,14 @@ public class CredentialsAnonymousGrpcService extends SimpleCredentialsAnonymousG
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 {
@ -60,10 +74,8 @@ public class CredentialsAnonymousGrpcService extends SimpleCredentialsAnonymousG
? AuthCheckResult.AUTH_CHECK_RESULT_MATCH
: AuthCheckResult.AUTH_CHECK_RESULT_NO_MATCH;
}
builder.putMatches(credentialInfo.token(), authCheckResult);
}
return builder.build();
}
}

View File

@ -0,0 +1,66 @@
/*
* 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;
public enum ExternalServiceDefinitions {
enum ExternalServiceDefinitions {
DIRECTORY(ExternalServiceType.EXTERNAL_SERVICE_TYPE_DIRECTORY, (chatConfig, clock) -> {
final DirectoryV2ClientConfiguration cfg = chatConfig.getDirectoryV2Configuration().getDirectoryV2ClientConfiguration();
return ExternalServiceCredentialsGenerator
@ -30,7 +30,7 @@ public enum ExternalServiceDefinitions {
.withClock(clock)
.build();
}),
PAYMENTS(ExternalServiceType.EXTERNAL_SERVICE_TYPE_PAYMENTS, (chatConfig, _) -> {
PAYMENTS(ExternalServiceType.EXTERNAL_SERVICE_TYPE_PAYMENTS, (chatConfig, clock) -> {
final PaymentsServiceConfiguration cfg = chatConfig.getPaymentsServiceConfiguration();
return ExternalServiceCredentialsGenerator
.builder(cfg.userAuthenticationTokenSharedSecret())
@ -47,7 +47,7 @@ public enum ExternalServiceDefinitions {
.withClock(clock)
.build();
}),
STORAGE(ExternalServiceType.EXTERNAL_SERVICE_TYPE_STORAGE, (chatConfig, _) -> {
STORAGE(ExternalServiceType.EXTERNAL_SERVICE_TYPE_STORAGE, (chatConfig, clock) -> {
final PaymentsServiceConfiguration cfg = chatConfig.getPaymentsServiceConfiguration();
return ExternalServiceCredentialsGenerator
.builder(cfg.userAuthenticationTokenSharedSecret())

View File

@ -39,11 +39,6 @@ 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")
@ -98,6 +93,20 @@ 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,
///
@ -149,24 +158,6 @@ 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

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

View File

@ -13,6 +13,7 @@ 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;
@ -45,7 +46,7 @@ public class KeysAnonymousGrpcService extends SimpleKeysAnonymousGrpc.KeysAnonym
@Override
public GetPreKeysAnonymousResponse getPreKeys(final GetPreKeysAnonymousRequest request) {
final ServiceIdentifier serviceIdentifier =
GrpcServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getRequest().getTargetIdentifier());
ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getRequest().getTargetIdentifier());
final byte deviceId = request.getRequest().hasDeviceId()
? DeviceIdUtil.validate(request.getRequest().getDeviceId())
@ -90,7 +91,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(GrpcServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getTargetIdentifier()),
.map(request -> Tuples.of(ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getTargetIdentifier()),
request.getFingerprint().toByteArray()))
.flatMap(serviceIdentifierAndFingerprint -> Mono.fromFuture(
() -> accountsManager.getByServiceIdentifierAsync(serviceIdentifierAndFingerprint.getT1()))
@ -99,7 +100,7 @@ public class KeysAnonymousGrpcService extends SimpleKeysAnonymousGrpc.KeysAnonym
.identityType()), serviceIdentifierAndFingerprint.getT2()))
.map(account -> CheckIdentityKeyResponse.newBuilder()
.setTargetIdentifier(
GrpcServiceIdentifierUtil.toGrpcServiceIdentifier(serviceIdentifierAndFingerprint.getT1()))
ServiceIdentifierUtil.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 =
GrpcServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getTargetIdentifier());
ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getTargetIdentifier());
final Optional<Account> maybeTargetAccount = accountsManager.getByServiceIdentifier(targetIdentifier);

View File

@ -1,268 +0,0 @@
/*
* 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,7 +28,6 @@ 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;
@ -84,7 +83,7 @@ public class MessagesAnonymousGrpcService extends SimpleMessagesAnonymousGrpc.Me
throws RateLimitExceededException {
final ServiceIdentifier destinationServiceIdentifier =
GrpcServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getDestination());
ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getDestination());
final Optional<Account> maybeDestination = accountsManager.getByServiceIdentifier(destinationServiceIdentifier);
@ -132,7 +131,7 @@ public class MessagesAnonymousGrpcService extends SimpleMessagesAnonymousGrpc.Me
throws RateLimitExceededException {
final ServiceIdentifier destinationServiceIdentifier =
GrpcServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getDestination());
ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getDestination());
final Optional<Account> maybeDestination = accountsManager.getByServiceIdentifier(destinationServiceIdentifier);
@ -193,7 +192,7 @@ public class MessagesAnonymousGrpcService extends SimpleMessagesAnonymousGrpc.Me
.setType(MessageProtos.Envelope.Type.UNIDENTIFIED_SENDER)
.setClientTimestamp(messages.getTimestamp())
.setServerTimestamp(clock.millis())
.setDestinationServiceId(destinationServiceIdentifier.toCompactByteString())
.setDestinationServiceId(destinationServiceIdentifier.toServiceIdentifierString())
.setEphemeral(ephemeral)
.setUrgent(urgent)
.setContent(entry.getValue().getPayload());
@ -230,8 +229,6 @@ public class MessagesAnonymousGrpcService extends SimpleMessagesAnonymousGrpc.Me
.build();
} catch (final MessageTooLargeException e) {
throw GrpcExceptions.invalidArguments("message too large");
} catch (final MessageDeliveryNotAllowedException e) {
throw GrpcExceptions.unavailable();
}
}
@ -312,7 +309,7 @@ public class MessagesAnonymousGrpcService extends SimpleMessagesAnonymousGrpc.Me
final MultiRecipientSuccess.Builder responseBuilder = MultiRecipientSuccess.newBuilder();
MessageUtil.getUnresolvedRecipients(multiRecipientMessage, resolvedRecipients).stream()
.map(GrpcServiceIdentifierUtil::toGrpcServiceIdentifier)
.map(ServiceIdentifierUtil::toGrpcServiceIdentifier)
.forEach(responseBuilder::addUnresolvedRecipients);
return SendMultiRecipientMessageResponse.newBuilder().setSuccess(responseBuilder).build();
@ -328,8 +325,6 @@ 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(GrpcServiceIdentifierUtil.toGrpcServiceIdentifier(serviceIdentifier));
.setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(serviceIdentifier));
mismatchedDevices.missingDeviceIds().forEach(mismatchedDevicesBuilder::addMissingDevices);
mismatchedDevices.extraDeviceIds().forEach(mismatchedDevicesBuilder::addExtraDevices);

View File

@ -5,29 +5,21 @@
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 io.grpc.StatusRuntimeException;
import com.google.protobuf.Empty;
import org.signal.chat.errors.NotFound;
import org.signal.chat.messages.GetMessagesRequest;
import org.signal.chat.messages.GetMessagesResponse;
import org.signal.chat.messages.SendMessageType;
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;
@ -43,11 +35,8 @@ 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.Device;
import org.whispersystems.textsecuregcm.util.UUIDUtil;
import reactor.adapter.JdkFlowAdapter;
import reactor.core.publisher.Flux;
import javax.annotation.Nullable;
import static org.whispersystems.textsecuregcm.grpc.MessagesGrpcHelper.buildMismatchedDevices;
public class MessagesGrpcService extends SimpleMessagesGrpc.MessagesImplBase {
@ -56,7 +45,6 @@ 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 =
@ -67,7 +55,6 @@ public class MessagesGrpcService extends SimpleMessagesGrpc.MessagesImplBase {
final MessageSender messageSender,
final CardinalityEstimator messageByteLimitEstimator,
final SpamChecker spamChecker,
final MessageDispatcher messageDispatcher,
final Clock clock) {
this.accountsManager = accountsManager;
@ -75,49 +62,9 @@ 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 {
@ -128,7 +75,7 @@ public class MessagesGrpcService extends SimpleMessagesGrpc.MessagesImplBase {
.orElseThrow(() -> GrpcExceptions.invalidCredentials("invalid credentials"));
final ServiceIdentifier destinationServiceIdentifier =
GrpcServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getDestination());
ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getDestination());
if (sender.isIdentifiedBy(destinationServiceIdentifier)) {
throw GrpcExceptions.invalidArguments("use `sendSyncMessage` to send messages to own account");
@ -211,8 +158,8 @@ public class MessagesGrpcService extends SimpleMessagesGrpc.MessagesImplBase {
.setType(getEnvelopeType(entry.getValue().getType()))
.setClientTimestamp(messages.getTimestamp())
.setServerTimestamp(clock.millis())
.setDestinationServiceId(destinationServiceIdentifier.toCompactByteString())
.setSourceServiceId(new AciServiceIdentifier(sender.accountIdentifier()).toCompactByteString())
.setDestinationServiceId(destinationServiceIdentifier.toServiceIdentifierString())
.setSourceServiceId(new AciServiceIdentifier(sender.accountIdentifier()).toServiceIdentifierString())
.setSourceDevice(sender.deviceId())
.setEphemeral(ephemeral)
.setUrgent(urgent)
@ -245,8 +192,6 @@ 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

@ -1,130 +0,0 @@
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

@ -1,326 +0,0 @@
/*
* 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

@ -1,171 +0,0 @@
/*
* 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 =
GrpcServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getRequest().getServiceIdentifier());
ServiceIdentifierUtil.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.invalidArguments("invalid authentication");
default -> throw GrpcExceptions.constraintViolation("invalid authentication");
};
if (!authorized) {
@ -100,7 +100,7 @@ public class ProfileAnonymousGrpcService extends SimpleProfileAnonymousGrpc.Prof
@Override
public GetVersionedProfileAnonymousResponse getVersionedProfile(final GetVersionedProfileAnonymousRequest request) {
final ServiceIdentifier targetIdentifier = GrpcServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getRequest().getAccountIdentifier());
final ServiceIdentifier targetIdentifier = ServiceIdentifierUtil.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 = GrpcServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getRequest().getAccountIdentifier());
final ServiceIdentifier targetIdentifier = ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getRequest().getAccountIdentifier());
if (request.getRequest().getCredentialType() != CredentialType.CREDENTIAL_TYPE_EXPIRING_PROFILE_KEY) {
throw GrpcExceptions.invalidArguments("invalid credential type");
throw GrpcExceptions.constraintViolation("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.common.Badge;
import org.signal.chat.common.BadgeSvg;
import org.signal.chat.profile.Badge;
import org.signal.chat.profile.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()))
.setEtagSha256(ByteString.copyFrom(p.dataHash()))
.setEtag(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 -> Arrays.equals(v, requestVersion)).orElse(true)) {
if (account.getCurrentProfileVersion().map(v -> v.equals(HexFormat.of().formatHex(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()))
.setEtagSha256(ByteString.copyFrom(
.setEtag(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.invalidArguments("invalid credential request");
throw GrpcExceptions.constraintViolation("invalid credential request");
}
return GetExpiringProfileKeyCredentialResult.newBuilder()

View File

@ -5,8 +5,9 @@
package org.whispersystems.textsecuregcm.grpc;
import com.google.protobuf.ByteString;
import java.time.Clock;
import java.util.Arrays;
import java.time.ZonedDateTime;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
@ -14,7 +15,6 @@ 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,6 +22,7 @@ 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;
@ -38,6 +39,7 @@ 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;
@ -48,6 +50,7 @@ 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 {
@ -58,17 +61,13 @@ 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<S3UploadForm> uploadAttributes) {}
Optional<ProfileAvatarUploadAttributes> uploadAttributes) {}
public ProfileGrpcService(
final Clock clock,
@ -77,6 +76,7 @@ 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,6 +86,7 @@ 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;
}
@ -106,11 +107,14 @@ public class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {
validateRequest(request);
final byte[] expectedCurrentVersion = request.getExpectedCurrentVersion().toByteArray();
final boolean currentVersionMatchesExpected = Arrays.equals(account.getCurrentProfileVersion().orElse(new byte[0]), expectedCurrentVersion);
final String expectedCurrentVersionHex = HexFormat.of().formatHex(request.getExpectedCurrentVersion().toByteArray());
final boolean currentVersionMatchesExpected = account.getCurrentProfileVersion().isEmpty()
? request.getExpectedCurrentVersion().isEmpty()
: account.getCurrentProfileVersion().get().equals(expectedCurrentVersionHex);
if (!currentVersionMatchesExpected) {
return SetProfileResponse.newBuilder().setExpectedVersionWriteConflict(FailedPrecondition.newBuilder()
return SetProfileResponse.newBuilder().setWriteConflict(FailedPrecondition.newBuilder()
.setDescription("current and expected profile versions must match")
.build()).build();
}
@ -150,7 +154,7 @@ public class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {
avatarData.finalAvatar().orElse(null),
request.getV1Request().getAboutEmoji().toByteArray(),
request.getV1Request().getAbout().toByteArray(),
request.getPaymentAddress().isEmpty() ? null : request.getPaymentAddress().toByteArray(),
request.getPaymentAddress().toByteArray(),
request.getV1Request().getPhoneNumberSharing().toByteArray(),
commitment);
@ -166,14 +170,14 @@ public class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {
} catch (WriteConflictException _) {
return SetProfileResponse.newBuilder()
.setExpectedDataWriteConflict(FailedPrecondition.newBuilder()
.setWriteConflict(FailedPrecondition.newBuilder()
.setDescription("current and expected data hash mismatch")
.build())
.build();
}
try {
accountsManager.updateCurrentProfileVersion(account.getIdentifier(IdentityType.ACI), version, expectedCurrentVersion, a -> {
accountsManager.updateCurrentProfileVersion(account.getIdentifier(IdentityType.ACI), version, expectedCurrentVersionHex, a -> {
final List<AccountBadge> updatedBadges = Optional.of(request.getBadgeIdsList())
.map(badges -> ProfileHelper.mergeBadgeIdsWithExistingAccountBadges(clock, badgeConfigurationMap, badges,
@ -185,7 +189,7 @@ public class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {
} catch (final WriteConflictException _) {
return SetProfileResponse.newBuilder()
.setExpectedVersionWriteConflict(FailedPrecondition.newBuilder()
.setWriteConflict(FailedPrecondition.newBuilder()
.setDescription("current and expected version mismatch")
.build())
.build();
@ -209,7 +213,7 @@ public class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {
public GetUnversionedProfileResponse getUnversionedProfile(final GetUnversionedProfileRequest request) throws RateLimitExceededException {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
final ServiceIdentifier targetIdentifier =
GrpcServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getServiceIdentifier());
ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getServiceIdentifier());
final Optional<Account> maybeAccount = validateRateLimitAndGetAccount(authenticatedDevice.accountIdentifier(), targetIdentifier);
return maybeAccount.map(account -> GetUnversionedProfileResponse.newBuilder()
@ -226,7 +230,7 @@ public class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {
public GetVersionedProfileResponse getVersionedProfile(final GetVersionedProfileRequest request) throws RateLimitExceededException {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
final ServiceIdentifier targetIdentifier =
GrpcServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getAccountIdentifier());
ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getAccountIdentifier());
final Optional<Account> maybeAccount = validateRateLimitAndGetAccount(authenticatedDevice.accountIdentifier(), targetIdentifier);
@ -255,25 +259,28 @@ public class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {
private void validateRequest(final SetProfileRequest request) {
if (request.getExpectedCurrentDataHash().isEmpty() && request.getCommitment().isEmpty()) {
throw GrpcExceptions.invalidArguments("At least one of expected current data hash and commitment is required");
throw GrpcExceptions.constraintViolation("At least one of expected current data hash and commitment is required");
}
// v1 -> v2 migration
if (request.getCommitment().isEmpty()) {
throw GrpcExceptions.invalidArguments("Request must include commitment during migration");
throw GrpcExceptions.constraintViolation("Request must include commitment during migration");
}
}
private S3UploadForm generateAvatarUploadForm(final String objectName) {
final PostPolicyGenerator.SignedPostPolicy policy =
policyGenerator.createFor(objectName, ProfileHelper.MAX_PROFILE_AVATAR_SIZE_BYTES, clock.instant());
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());
return PROTOTYPE_AVATAR_UPLOAD_FORM.toBuilder()
.setKey(objectName)
.setCredential(policy.credential())
.setDate(policy.formattedTimestamp())
.setPolicy(policy.encodedPolicy())
.setSignature(policy.signature())
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()))
.build();
}
}

View File

@ -48,8 +48,6 @@ 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

@ -5,15 +5,17 @@
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 GrpcServiceIdentifierUtil {
public class ServiceIdentifierUtil {
private GrpcServiceIdentifierUtil() {
private ServiceIdentifierUtil() {
}
public static ServiceIdentifier fromGrpcServiceIdentifier(final org.signal.chat.common.ServiceIdentifier serviceIdentifier) {
@ -40,4 +42,12 @@ public class GrpcServiceIdentifierUtil {
.setUuid(UUIDUtil.toByteString(serviceIdentifier.uuid()))
.build();
}
public static ByteString toCompactByteString(final ServiceIdentifier serviceIdentifier) {
return ByteString.copyFrom(serviceIdentifier.toCompactByteArray());
}
public static ServiceIdentifier fromByteString(final ByteString byteString) {
return ServiceIdentifier.fromBytes(byteString.toByteArray());
}
}

View File

@ -1,610 +0,0 @@
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