Compare commits
1 Commits
main
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6cea201b5 |
24
.github/workflows/documentation.yml
vendored
24
.github/workflows/documentation.yml
vendored
@ -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
|
||||
|
||||
4
.github/workflows/integration-tests.yml
vendored
4
.github/workflows/integration-tests.yml
vendored
@ -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 }}
|
||||
|
||||
16
.github/workflows/test.yml
vendored
16
.github/workflows/test.yml
vendored
@ -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
4
.gitignore
vendored
@ -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/
|
||||
|
||||
@ -1,2 +0,0 @@
|
||||
main-page-content-file = "../../service/src/main/proto/org/signal/chat/README.md"
|
||||
markdown-extensions = ["fenced_code", "tables"]
|
||||
@ -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>
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
27
pom.xml
@ -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>
|
||||
|
||||
@ -100,5 +100,3 @@ tlsKeyStore.password: unset
|
||||
|
||||
hlrLookup.apiKey: AAAAAAAAAAA
|
||||
hlrLookup.apiSecret: AAAAAAAAAAA
|
||||
|
||||
foundationDbMessages.versionstampCipherKey.0: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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()),
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,7 +35,7 @@ public interface RemoteStorageManager {
|
||||
CompletionStage<Void> copy(
|
||||
int sourceCdn,
|
||||
String sourceKey,
|
||||
long expectedSourceLength,
|
||||
int expectedSourceLength,
|
||||
MediaEncryptionParameters encryptionParameters,
|
||||
String dstKey);
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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) {
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
}
|
||||
@ -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() {
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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")))
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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 {
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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) {
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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());
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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 {}: {}",
|
||||
|
||||
@ -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: {}",
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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())));
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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
Loading…
Reference in New Issue
Block a user