diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 4a44e14fe..e3f657516 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -152,6 +152,7 @@ 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; @@ -996,6 +997,7 @@ public class WhisperServerService extends Application 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())), new KeysGrpcService(accountsManager, keysManager, rateLimiters), new ProfileGrpcService(clock, accountsManager, profilesManager, dynamicConfigurationManager, config.getBadges(), profileCdnPolicyGenerator, profileCdnPolicySigner, profileBadgeConverter, rateLimiters), diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/CallingGrpcService.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/CallingGrpcService.java new file mode 100644 index 000000000..ebfa98a3b --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/CallingGrpcService.java @@ -0,0 +1,74 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.grpc; + +import java.io.IOException; +import org.apache.commons.lang3.StringUtils; +import org.signal.chat.calling.GetCallingRelaysRequest; +import org.signal.chat.calling.GetCallingRelaysResponse; +import org.signal.chat.calling.SimpleCallingGrpc; +import org.whispersystems.textsecuregcm.auth.CloudflareTurnCredentialsManager; +import org.whispersystems.textsecuregcm.auth.TurnToken; +import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice; +import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil; +import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; +import org.whispersystems.textsecuregcm.limits.RateLimiters; + +public class CallingGrpcService extends SimpleCallingGrpc.CallingImplBase { + + private final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager; + private final RateLimiters rateLimiters; + + public CallingGrpcService(final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager, + final RateLimiters rateLimiters) { + + this.cloudflareTurnCredentialsManager = cloudflareTurnCredentialsManager; + this.rateLimiters = rateLimiters; + } + + @Override + public GetCallingRelaysResponse getCallingRelays(final GetCallingRelaysRequest request) + throws RateLimitExceededException, IOException { + + final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice(); + rateLimiters.getCallEndpointLimiter().validate(authenticatedDevice.accountIdentifier()); + + final TurnToken turnToken = + cloudflareTurnCredentialsManager.retrieveFromCloudflare(authenticatedDevice.accountIdentifier()); + + final GetCallingRelaysResponse.Relay.Builder relayBuilder = GetCallingRelaysResponse.Relay.newBuilder() + .setUsername(turnToken.username()) + .setPassword(turnToken.password()) + .setCredentialTtlSeconds(turnToken.ttlSeconds()); + + if (!turnToken.urls().isEmpty()) { + relayBuilder.setHostnameUrls(turnToken.urls().stream() + .collect(GetCallingRelaysResponse.HostnameUrlList::newBuilder, + GetCallingRelaysResponse.HostnameUrlList.Builder::addUrls, + (a, b) -> a.mergeFrom(b.build()))); + } + + if (!turnToken.urlsWithIps().isEmpty()) { + relayBuilder.setIpUrls(turnToken.urlsWithIps().stream() + .collect(() -> { + final GetCallingRelaysResponse.IpUrlList.Builder builder = + GetCallingRelaysResponse.IpUrlList.newBuilder(); + + if (StringUtils.isNotBlank(turnToken.hostname())) { + builder.setHostname(turnToken.hostname()); + } + + return builder; + }, + GetCallingRelaysResponse.IpUrlList.Builder::addUrls, + (a, b) -> a.mergeFrom(b.build()))); + } + + return GetCallingRelaysResponse.newBuilder() + .addRelays(relayBuilder.build()) + .build(); + } +} diff --git a/service/src/main/proto/org/signal/chat/calling.proto b/service/src/main/proto/org/signal/chat/calling.proto index 7b566a4f1..0079f322e 100644 --- a/service/src/main/proto/org/signal/chat/calling.proto +++ b/service/src/main/proto/org/signal/chat/calling.proto @@ -9,23 +9,53 @@ option java_multiple_files = true; package org.signal.chat.calling; -// Provides methods for getting credentials for one-on-one and group calls. +// Provides methods for getting credentials and relay options for one-on-one +// calls. service Calling { - // Generates and returns TURN credentials for the caller. - rpc GetTurnCredentials(GetTurnCredentialsRequest) returns (GetTurnCredentialsResponse) {} + // Retrieves TURN credentials and relay options for one-on-one calls. + rpc GetCallingRelays(GetCallingRelaysRequest) returns (GetCallingRelaysResponse) {} } -message GetTurnCredentialsRequest {} +message GetCallingRelaysRequest {} -message GetTurnCredentialsResponse { - // A username that can be presented to authenticate with a TURN server. - string username = 1; +message GetCallingRelaysResponse { + message HostnameUrlList { + // A collection of hostname-based TURN, TURNS, or STUN URLs a client can use + // to connect to a relay. + repeated string urls = 1; + } - // A password that can be presented to authenticate with a TURN server. - string password = 2; + message IpUrlList { + // A collection of IP-based TURN, TURNS, or STUN URLs a client can use to + // connect to a relay. + repeated string urls = 1; - // A list of TURN (or TURNS or STUN) servers where the provided credentials - // may be used. - repeated string urls = 3; + // A hostname clients must use to validate the relay's TLS certificate when + // connecting via an IP-based TURNS URL. May not be specified if `urls` + // contains no TURNS URLs. + optional string hostname = 2; + } + + message Relay { + // A username that can be presented to authenticate with a TURN server. + string username = 1; + + // A password that can be presented to authenticate with a TURN server. + string password = 2; + + // The duration, in seconds, after which the included username and password + // will no longer be valid. + uint64 credential_ttl_seconds = 3; + + // A collection of hostname-based URLs clients may use to connect to this + // relay. + optional HostnameUrlList hostname_urls = 4; + + // A collection of IP-based URLs clients may use to connect to this relay. + optional IpUrlList ip_urls = 5; + } + + // A collection of calling relays a client may use for one-on-one calls. + repeated Relay relays = 1; } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/CallingGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/CallingGrpcServiceTest.java new file mode 100644 index 000000000..62dd595ae --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/CallingGrpcServiceTest.java @@ -0,0 +1,103 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.grpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.grpc.Status; +import java.io.IOException; +import java.time.Duration; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.signal.chat.calling.CallingGrpc; +import org.signal.chat.calling.GetCallingRelaysRequest; +import org.signal.chat.calling.GetCallingRelaysResponse; +import org.whispersystems.textsecuregcm.auth.CloudflareTurnCredentialsManager; +import org.whispersystems.textsecuregcm.auth.TurnToken; +import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; +import org.whispersystems.textsecuregcm.limits.RateLimiter; +import org.whispersystems.textsecuregcm.limits.RateLimiters; + +class CallingGrpcServiceTest extends SimpleBaseGrpcTest { + + @Mock + private CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager; + + @Mock + private RateLimiter rateLimiter; + + @Override + protected CallingGrpcService createServiceBeforeEachTest() { + final RateLimiters rateLimiters = mock(RateLimiters.class); + when(rateLimiters.getCallEndpointLimiter()).thenReturn(rateLimiter); + + return new CallingGrpcService(cloudflareTurnCredentialsManager, rateLimiters); + } + + @Test + void getCallingRelays() throws IOException { + final String username = "username"; + final String password = "password"; + final long credentialTtlSeconds = 73; + final String hostnameTurnUrl = "turn:example.com:443"; + final String ipTurnUrl = "turn:127.0.0.1:80"; + final String ipTurnsUrl = "turns:127.0.0.1:443"; + final String hostname = "example.com"; + + when(cloudflareTurnCredentialsManager.retrieveFromCloudflare(any())) + .thenReturn(new TurnToken(username, + password, + credentialTtlSeconds, + List.of(hostnameTurnUrl), + List.of(ipTurnUrl, ipTurnsUrl), + hostname)); + + assertEquals(GetCallingRelaysResponse.newBuilder() + .addRelays(GetCallingRelaysResponse.Relay.newBuilder() + .setUsername(username) + .setPassword(password) + .setCredentialTtlSeconds(credentialTtlSeconds) + .setHostnameUrls(GetCallingRelaysResponse.HostnameUrlList.newBuilder() + .addUrls(hostnameTurnUrl) + .build()) + .setIpUrls(GetCallingRelaysResponse.IpUrlList.newBuilder() + .addUrls(ipTurnUrl) + .addUrls(ipTurnsUrl) + .setHostname(hostname) + .build()) + .build()) + .build(), + authenticatedServiceStub().getCallingRelays(GetCallingRelaysRequest.getDefaultInstance())); + } + + @Test + void getCallingRelaysIoException() throws IOException { + when(cloudflareTurnCredentialsManager.retrieveFromCloudflare(any())) + .thenThrow(IOException.class); + + //noinspection ResultOfMethodCallIgnored,ThrowableNotThrown + GrpcTestUtils.assertStatusException(Status.UNAVAILABLE, + () -> authenticatedServiceStub().getCallingRelays(GetCallingRelaysRequest.getDefaultInstance())); + } + + @Test + void getCallingRelaysRateLimited() throws RateLimitExceededException { + final Duration retryAfter = Duration.ofSeconds(19); + + doThrow(new RateLimitExceededException(retryAfter)) + .when(rateLimiter).validate(any(UUID.class)); + + //noinspection ResultOfMethodCallIgnored + GrpcTestUtils.assertRateLimitExceeded(retryAfter, + () -> authenticatedServiceStub().getCallingRelays(GetCallingRelaysRequest.getDefaultInstance())); + } +}