From c3a48fd08bc6fba73db56451a32f2ccc25ded66e Mon Sep 17 00:00:00 2001 From: Jon Chambers <63609320+jon-signal@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:55:53 -0400 Subject: [PATCH] Port `CertificateController` to gRPC --- .../textsecuregcm/WhisperServerService.java | 19 +- .../controllers/CertificateController.java | 4 +- ...a => CredentialsAnonymousGrpcService.java} | 50 +-- .../grpc/CredentialsGrpcService.java | 136 +++++++ ...ExternalServiceCredentialsGrpcService.java | 66 ---- .../grpc/ExternalServiceDefinitions.java | 6 +- .../proto/org/signal/chat/credentials.proto | 79 +++- ... CredentialsAnonymousGrpcServiceTest.java} | 52 +-- .../grpc/CredentialsGrpcServiceTest.java | 357 ++++++++++++++++++ ...rnalServiceCredentialsGrpcServiceTest.java | 138 ------- 10 files changed, 625 insertions(+), 282 deletions(-) rename service/src/main/java/org/whispersystems/textsecuregcm/grpc/{ExternalServiceCredentialsAnonymousGrpcService.java => CredentialsAnonymousGrpcService.java} (69%) create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/CredentialsGrpcService.java delete mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceCredentialsGrpcService.java rename service/src/test/java/org/whispersystems/textsecuregcm/grpc/{ExternalServiceCredentialsAnonymousGrpcServiceTest.java => CredentialsAnonymousGrpcServiceTest.java} (81%) create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/grpc/CredentialsGrpcServiceTest.java delete mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceCredentialsGrpcServiceTest.java diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 925cbd6f7..6ec136df5 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -153,11 +153,12 @@ import org.whispersystems.textsecuregcm.grpc.BackupsAnonymousGrpcService; import org.whispersystems.textsecuregcm.grpc.BackupsGrpcService; import org.whispersystems.textsecuregcm.grpc.CallQualitySurveyGrpcService; 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.ErrorConformanceInterceptor; import org.whispersystems.textsecuregcm.grpc.ErrorMappingInterceptor; -import org.whispersystems.textsecuregcm.grpc.ExternalServiceCredentialsAnonymousGrpcService; -import org.whispersystems.textsecuregcm.grpc.ExternalServiceCredentialsGrpcService; +import org.whispersystems.textsecuregcm.grpc.ExternalServiceDefinitions; import org.whispersystems.textsecuregcm.grpc.GroupSendTokenUtil; import org.whispersystems.textsecuregcm.grpc.GrpcAllowListInterceptor; import org.whispersystems.textsecuregcm.grpc.KeysAnonymousGrpcService; @@ -986,9 +987,15 @@ public class WhisperServerService extends Application authenticatedServices = Stream.of( new AccountsGrpcService(accountsManager, rateLimiters, usernameHashZkProofVerifier, registrationRecoveryPasswordsManager), - ExternalServiceCredentialsGrpcService.createForAllExternalServices(config, rateLimiters), + new CredentialsGrpcService(accountsManager, certificateGenerator, zkAuthOperations, callingGenericZkSecretParams, rateLimiters, Clock.systemUTC(), ExternalServiceDefinitions.createExternalServiceList(config, Clock.systemUTC())), new KeysGrpcService(accountsManager, keysManager, rateLimiters), new ProfileGrpcService(clock, accountsManager, profilesManager, dynamicConfigurationManager, config.getBadges(), profileCdnPolicyGenerator, profileCdnPolicySigner, profileBadgeConverter, rateLimiters), new MessagesGrpcService(accountsManager, rateLimiters, messageSender, messageByteLimitCardinalityEstimator, spamChecker, Clock.systemUTC()), @@ -1017,7 +1024,7 @@ public class WhisperServerService extends Application 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! @@ -1140,9 +1147,7 @@ public class WhisperServerService extends Application maybeUsername = accountsManager.getByE164(request.getNumber()) .map(Account::getUuid) .map(svrCredentialsGenerator::generateForUuid) .map(ExternalServiceCredentials::username); + final CheckSvrCredentialsResponse.Builder builder = CheckSvrCredentialsResponse.newBuilder(); + for (ExternalServiceCredentialsSelector.CredentialInfo credentialInfo : credentials) { final AuthCheckResult authCheckResult; + if (!credentialInfo.valid()) { authCheckResult = AuthCheckResult.AUTH_CHECK_RESULT_INVALID; } else { @@ -74,8 +60,10 @@ public class ExternalServiceCredentialsAnonymousGrpcService extends ? AuthCheckResult.AUTH_CHECK_RESULT_MATCH : AuthCheckResult.AUTH_CHECK_RESULT_NO_MATCH; } + builder.putMatches(credentialInfo.token(), authCheckResult); } + return builder.build(); } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/CredentialsGrpcService.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/CredentialsGrpcService.java new file mode 100644 index 000000000..afc0f2023 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/CredentialsGrpcService.java @@ -0,0 +1,136 @@ +/* + * 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.util.Map; +import org.signal.chat.credentials.ExternalServiceType; +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.auth.ServerZkAuthOperations; +import org.signal.libsignal.zkgroup.calllinks.CallLinkAuthCredentialResponse; +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 credentialsGeneratorByType; + + public CredentialsGrpcService(final AccountsManager accountsManager, + final CertificateGenerator certificateGenerator, + final ServerZkAuthOperations serverZkAuthOperations, + final GenericServerSecretParams serverSecretParams, + final RateLimiters rateLimiters, + final Clock clock, + final Map 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(); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceCredentialsGrpcService.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceCredentialsGrpcService.java deleted file mode 100644 index bf9da3d4c..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceCredentialsGrpcService.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.grpc; - -import static java.util.Objects.requireNonNull; - -import com.google.common.annotations.VisibleForTesting; -import java.time.Clock; -import java.util.Map; -import org.signal.chat.credentials.ExternalServiceType; -import org.signal.chat.credentials.GetExternalServiceCredentialsRequest; -import org.signal.chat.credentials.GetExternalServiceCredentialsResponse; -import org.signal.chat.credentials.SimpleExternalServiceCredentialsGrpc; -import org.whispersystems.textsecuregcm.WhisperServerConfiguration; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; -import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice; -import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil; -import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; -import org.whispersystems.textsecuregcm.limits.RateLimiters; - -public class ExternalServiceCredentialsGrpcService extends SimpleExternalServiceCredentialsGrpc.ExternalServiceCredentialsImplBase { - - private final Map 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 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(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceDefinitions.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceDefinitions.java index 6576a8fe3..7c66dfd3c 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceDefinitions.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceDefinitions.java @@ -20,7 +20,7 @@ import org.whispersystems.textsecuregcm.configuration.DirectoryV2ClientConfigura import org.whispersystems.textsecuregcm.configuration.PaymentsServiceConfiguration; import org.whispersystems.textsecuregcm.configuration.SecureValueRecoveryConfiguration; -enum ExternalServiceDefinitions { +public enum ExternalServiceDefinitions { DIRECTORY(ExternalServiceType.EXTERNAL_SERVICE_TYPE_DIRECTORY, (chatConfig, clock) -> { final DirectoryV2ClientConfiguration cfg = chatConfig.getDirectoryV2Configuration().getDirectoryV2ClientConfiguration(); return ExternalServiceCredentialsGenerator @@ -30,7 +30,7 @@ enum ExternalServiceDefinitions { .withClock(clock) .build(); }), - PAYMENTS(ExternalServiceType.EXTERNAL_SERVICE_TYPE_PAYMENTS, (chatConfig, clock) -> { + PAYMENTS(ExternalServiceType.EXTERNAL_SERVICE_TYPE_PAYMENTS, (chatConfig, _) -> { final PaymentsServiceConfiguration cfg = chatConfig.getPaymentsServiceConfiguration(); return ExternalServiceCredentialsGenerator .builder(cfg.userAuthenticationTokenSharedSecret()) @@ -47,7 +47,7 @@ enum ExternalServiceDefinitions { .withClock(clock) .build(); }), - STORAGE(ExternalServiceType.EXTERNAL_SERVICE_TYPE_STORAGE, (chatConfig, clock) -> { + STORAGE(ExternalServiceType.EXTERNAL_SERVICE_TYPE_STORAGE, (chatConfig, _) -> { final PaymentsServiceConfiguration cfg = chatConfig.getPaymentsServiceConfiguration(); return ExternalServiceCredentialsGenerator .builder(cfg.userAuthenticationTokenSharedSecret()) diff --git a/service/src/main/proto/org/signal/chat/credentials.proto b/service/src/main/proto/org/signal/chat/credentials.proto index 59ccb0cb0..6d8c21964 100644 --- a/service/src/main/proto/org/signal/chat/credentials.proto +++ b/service/src/main/proto/org/signal/chat/credentials.proto @@ -11,20 +11,35 @@ import "org/signal/chat/require.proto"; package org.signal.chat.credentials; -// Provides methods for obtaining and verifying credentials for "external" services -// (i.e. services that are not a part of the chat server deployment). -// All methods of this service require authentication. -service ExternalServiceCredentials { +// Provides methods for obtaining and verifying credentials that allow an +// authenticated user to authenticate in another service or context without +// revealing their identity. +service Credentials { // Generates and returns an external service credentials for the caller. rpc GetExternalServiceCredentials(GetExternalServiceCredentialsRequest) returns (GetExternalServiceCredentialsResponse) {} + + // Generates a pair of delivery certificates that the holder can include to + // identify themselves to a message recipient (but not the server) in a + // sealed-sender message. + rpc GetDeliveryCertificate(GetDeliveryCertificateRequest) + returns (GetDeliveryCertificateResponse) {} + + // Generates a set of zero-knowledge credentials for various group-related + // actions. + rpc GetGroupCredentials(GetGroupCredentialsRequest) + returns (GetGroupCredentialsResponse) {} } -service ExternalServiceCredentialsAnonymous { - // Given a list of secure value recovery (SVR) service credentials and a phone number, - // checks, which of the provided credentials were generated by the user with the given phone number - // and have not yet expired. +// Provides methods for working with previously-generated credentials without +// revealing an association between the credentials and the caller's identity to +// the server. +service CredentialsAnonymous { + + // Given a list of secure value recovery (SVR) service credentials and a phone + // number, checks and returns which of the provided credentials were generated + // by the user with the given phone number and have not yet expired. rpc CheckSvrCredentials(CheckSvrCredentialsRequest) returns (CheckSvrCredentialsResponse) {} } @@ -79,3 +94,51 @@ message CheckSvrCredentialsResponse { map matches = 1; } + +message GetDeliveryCertificateRequest { +} + +// A pair of message delivery certificates. The response unconditionally +// includes certificates with and without the caller's phone number so the +// server never learns anything about the caller's intent to share their phone +// number with their contacts. +message GetDeliveryCertificateResponse { + // A delivery receipt that includes the caller's phone number + bytes certificate_with_e164 = 1; + + // A delivery receipt that does not include the caller's phone number + bytes certificate_without_e164 = 2; +} + +message GetGroupCredentialsRequest { + // The earliest time for which to issue group credentials; must be aligned to + // a UTC day boundary and may be no more than one day in the past at the time + // of the call. + uint64 redemption_start_seconds = 1; + + // The latest time for which to issue group credentials; must be aligned to a + // UTC day boundary and no more than seven days in the future at the time of + // the call. + uint64 redemption_end_seconds = 2; +} + +message GetGroupCredentialsResponse { + // A zero-knowledge credential that may be redeemed at or up to one day after + // the given redemption time + message CredentialAndRedemptionTime { + bytes credential = 1; + uint64 redemption_time_seconds = 2; + } + + // A collection of credentials allowing the holder to anonymously + // authenticate themselves for group-related actions + repeated CredentialAndRedemptionTime group_credentials = 1; + + // A collection of credentials allowing the holder to read, update, and delete + // group call links + repeated CredentialAndRedemptionTime call_link_auth_credentials = 2; + + // The phone number identifier for which the included credentials were + // generated + bytes pni = 3; +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceCredentialsAnonymousGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/CredentialsAnonymousGrpcServiceTest.java similarity index 81% rename from service/src/test/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceCredentialsAnonymousGrpcServiceTest.java rename to service/src/test/java/org/whispersystems/textsecuregcm/grpc/CredentialsAnonymousGrpcServiceTest.java index a37c603dc..c17f462ee 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceCredentialsAnonymousGrpcServiceTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/CredentialsAnonymousGrpcServiceTest.java @@ -7,6 +7,8 @@ package org.whispersystems.textsecuregcm.grpc; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import com.google.i18n.phonenumbers.PhoneNumberUtil; import io.grpc.Status; @@ -25,7 +27,7 @@ import org.mockito.Mockito; import org.signal.chat.credentials.AuthCheckResult; import org.signal.chat.credentials.CheckSvrCredentialsRequest; import org.signal.chat.credentials.CheckSvrCredentialsResponse; -import org.signal.chat.credentials.ExternalServiceCredentialsAnonymousGrpc; +import org.signal.chat.credentials.CredentialsAnonymousGrpc; import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; import org.whispersystems.textsecuregcm.storage.Account; @@ -34,8 +36,8 @@ import org.whispersystems.textsecuregcm.util.MockUtils; import org.whispersystems.textsecuregcm.util.MutableClock; import org.whispersystems.textsecuregcm.util.TestRandomUtil; -class ExternalServiceCredentialsAnonymousGrpcServiceTest extends - SimpleBaseGrpcTest { +class CredentialsAnonymousGrpcServiceTest extends + SimpleBaseGrpcTest { private static final UUID USER_UUID = UUID.randomUUID(); @@ -46,29 +48,33 @@ class ExternalServiceCredentialsAnonymousGrpcServiceTest extends private static final MutableClock CLOCK = MockUtils.mutableClock(0); - private static final ExternalServiceCredentialsGenerator SVR_CREDENTIALS_GENERATOR = Mockito.spy(ExternalServiceCredentialsGenerator - .builder(TestRandomUtil.nextBytes(32)) - .withUserDerivationKey(TestRandomUtil.nextBytes(32)) - .prependUsername(false) - .withDerivedUsernameTruncateLength(16) - .withClock(CLOCK) - .build()); + private static final ExternalServiceCredentialsGenerator SVR_CREDENTIALS_GENERATOR = + Mockito.spy(ExternalServiceCredentialsGenerator + .builder(TestRandomUtil.nextBytes(32)) + .withUserDerivationKey(TestRandomUtil.nextBytes(32)) + .prependUsername(false) + .withDerivedUsernameTruncateLength(16) + .withClock(CLOCK) + .build()); @Mock private AccountsManager accountsManager; @Override - protected ExternalServiceCredentialsAnonymousGrpcService createServiceBeforeEachTest() { - return new ExternalServiceCredentialsAnonymousGrpcService(accountsManager, SVR_CREDENTIALS_GENERATOR); + protected CredentialsAnonymousGrpcService createServiceBeforeEachTest() { + return new CredentialsAnonymousGrpcService(accountsManager, SVR_CREDENTIALS_GENERATOR); } @BeforeEach public void setup() { - Mockito.when(accountsManager.getByE164(USER_E164)).thenReturn(Optional.of(account(USER_UUID))); + final Account account = mock(Account.class); + when(account.getUuid()).thenReturn(USER_UUID); + + when(accountsManager.getByE164(USER_E164)).thenReturn(Optional.of(account)); } @Test - public void testOneMatch() throws Exception { + public void testOneMatch() { final UUID user2 = UUID.randomUUID(); final UUID user3 = UUID.randomUUID(); assertExpectedCredentialCheckResponse(Map.of( @@ -79,7 +85,7 @@ class ExternalServiceCredentialsAnonymousGrpcServiceTest extends } @Test - public void testNoMatch() throws Exception { + public void testNoMatch() { final UUID user2 = UUID.randomUUID(); final UUID user3 = UUID.randomUUID(); assertExpectedCredentialCheckResponse(Map.of( @@ -89,7 +95,7 @@ class ExternalServiceCredentialsAnonymousGrpcServiceTest extends } @Test - public void testSomeInvalid() throws Exception { + public void testSomeInvalid() { final UUID user2 = UUID.randomUUID(); final UUID user3 = UUID.randomUUID(); final ExternalServiceCredentials user1Cred = credentials(USER_UUID, day(1)); @@ -105,7 +111,7 @@ class ExternalServiceCredentialsAnonymousGrpcServiceTest extends } @Test - public void testSomeExpired() throws Exception { + public void testSomeExpired() { final UUID user2 = UUID.randomUUID(); final UUID user3 = UUID.randomUUID(); assertExpectedCredentialCheckResponse(Map.of( @@ -117,7 +123,7 @@ class ExternalServiceCredentialsAnonymousGrpcServiceTest extends } @Test - public void testSomeHaveNewerVersions() throws Exception { + public void testSomeHaveNewerVersions() { final UUID user2 = UUID.randomUUID(); final UUID user3 = UUID.randomUUID(); assertExpectedCredentialCheckResponse(Map.of( @@ -134,7 +140,7 @@ class ExternalServiceCredentialsAnonymousGrpcServiceTest extends public void testInvalidPasswordCount(int count) { final CheckSvrCredentialsRequest request = CheckSvrCredentialsRequest.newBuilder() .setNumber(USER_E164) - .addAllPasswords(IntStream.range(0, count).mapToObj(i -> token(UUID.randomUUID(), day(10))).toList()) + .addAllPasswords(IntStream.range(0, count).mapToObj(_ -> token(UUID.randomUUID(), day(10))).toList()) .build(); final StatusRuntimeException status = assertThrows(StatusRuntimeException.class, () -> unauthenticatedServiceStub().checkSvrCredentials(request)); @@ -143,7 +149,7 @@ class ExternalServiceCredentialsAnonymousGrpcServiceTest extends private void assertExpectedCredentialCheckResponse( final Map expected, - final long nowMillis) throws Exception { + final long nowMillis) { CLOCK.setTimeMillis(nowMillis); final CheckSvrCredentialsRequest request = CheckSvrCredentialsRequest.newBuilder() .setNumber(USER_E164) @@ -170,10 +176,4 @@ class ExternalServiceCredentialsAnonymousGrpcServiceTest extends private static long day(final int n) { return Duration.ofDays(n).toMillis(); } - - private static Account account(final UUID uuid) { - final Account a = new Account(); - a.setUuid(uuid); - return a; - } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/CredentialsGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/CredentialsGrpcServiceTest.java new file mode 100644 index 000000000..d3d499f88 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/CredentialsGrpcServiceTest.java @@ -0,0 +1,357 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.grpc; + +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.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertRateLimitExceeded; +import static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertStatusException; + +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; +import io.grpc.Status; +import java.io.UncheckedIOException; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ThreadLocalRandom; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.signal.chat.credentials.CredentialsGrpc; +import org.signal.chat.credentials.ExternalServiceType; +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.libsignal.protocol.IdentityKey; +import org.signal.libsignal.protocol.InvalidKeyException; +import org.signal.libsignal.protocol.ServiceId; +import org.signal.libsignal.protocol.ecc.ECKeyPair; +import org.signal.libsignal.protocol.ecc.ECPublicKey; +import org.signal.libsignal.zkgroup.GenericServerSecretParams; +import org.signal.libsignal.zkgroup.ServerSecretParams; +import org.signal.libsignal.zkgroup.auth.AuthCredentialWithPniResponse; +import org.signal.libsignal.zkgroup.auth.ClientZkAuthOperations; +import org.signal.libsignal.zkgroup.auth.ServerZkAuthOperations; +import org.signal.libsignal.zkgroup.calllinks.CallLinkAuthCredentialResponse; +import org.whispersystems.textsecuregcm.auth.CertificateGenerator; +import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; +import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; +import org.whispersystems.textsecuregcm.controllers.CertificateController; +import org.whispersystems.textsecuregcm.entities.MessageProtos; +import org.whispersystems.textsecuregcm.identity.IdentityType; +import org.whispersystems.textsecuregcm.limits.RateLimiter; +import org.whispersystems.textsecuregcm.limits.RateLimiters; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.AccountsManager; +import org.whispersystems.textsecuregcm.util.MockUtils; +import org.whispersystems.textsecuregcm.util.TestClock; +import org.whispersystems.textsecuregcm.util.TestRandomUtil; +import org.whispersystems.textsecuregcm.util.UUIDUtil; +import reactor.core.publisher.Mono; + +public class CredentialsGrpcServiceTest + extends SimpleBaseGrpcTest { + + @Mock + private AccountsManager accountsManager; + + @Mock + private RateLimiters rateLimiters; + + @Mock + private Account authenticatedAccount; + + private static final ExternalServiceCredentialsGenerator DIRECTORY_CREDENTIALS_GENERATOR = Mockito.spy(ExternalServiceCredentialsGenerator + .builder(TestRandomUtil.nextBytes(32)) + .withUserDerivationKey(TestRandomUtil.nextBytes(32)) + .prependUsername(false) + .truncateSignature(false) + .build()); + + private static final ExternalServiceCredentialsGenerator PAYMENTS_CREDENTIALS_GENERATOR = Mockito.spy(ExternalServiceCredentialsGenerator + .builder(TestRandomUtil.nextBytes(32)) + .prependUsername(true) + .build()); + + private static final UUID AUTHENTICATED_PNI = UUID.randomUUID(); + private static final String PHONE_NUMBER = PhoneNumberUtil.getInstance().format( + PhoneNumberUtil.getInstance().getExampleNumber("US"), PhoneNumberUtil.PhoneNumberFormat.E164); + + private static ECKeyPair IDENTITY_KEY_PAIR; + + private static ECKeyPair SERVER_KEY_PAIR; + private static MessageProtos.ServerCertificate SIGNED_SERVER_CERTIFICATE; + + private static ServerSecretParams SERVER_SECRET_PARAMS; + private static GenericServerSecretParams GENERIC_SERVER_SECRET_PARAMS; + private static ServerZkAuthOperations SERVER_ZK_AUTH_OPERATIONS; + + private static final Clock CLOCK = TestClock.pinned(Instant.now()); + + @BeforeAll + static void setUpBeforeAll() { + IDENTITY_KEY_PAIR = ECKeyPair.generate(); + + final ECKeyPair caKeyPair = ECKeyPair.generate(); + SERVER_KEY_PAIR = ECKeyPair.generate(); + + final MessageProtos.ServerCertificate.Certificate serverCertificate = + MessageProtos.ServerCertificate.Certificate.newBuilder() + .setId(ThreadLocalRandom.current().nextInt()) + .setKey(ByteString.copyFrom(SERVER_KEY_PAIR.getPublicKey().serialize())) + .build(); + + final byte[] signature = caKeyPair.getPrivateKey().calculateSignature(serverCertificate.toByteArray()); + + SIGNED_SERVER_CERTIFICATE = MessageProtos.ServerCertificate.newBuilder() + .setCertificate(ByteString.copyFrom(serverCertificate.toByteArray())) + .setSignature(ByteString.copyFrom(signature)) + .build(); + + SERVER_SECRET_PARAMS = ServerSecretParams.generate(); + GENERIC_SERVER_SECRET_PARAMS = GenericServerSecretParams.generate(); + SERVER_ZK_AUTH_OPERATIONS = new ServerZkAuthOperations(SERVER_SECRET_PARAMS); + } + + @BeforeEach + void setUp() { + when(authenticatedAccount.getUuid()).thenReturn(AUTHENTICATED_ACI); + when(authenticatedAccount.getNumber()).thenReturn(PHONE_NUMBER); + when(authenticatedAccount.getIdentifier(IdentityType.ACI)).thenReturn(AUTHENTICATED_ACI); + when(authenticatedAccount.getIdentifier(IdentityType.PNI)).thenReturn(AUTHENTICATED_PNI); + when(authenticatedAccount.getIdentityKey(IdentityType.ACI)) + .thenReturn(new IdentityKey(IDENTITY_KEY_PAIR.getPublicKey())); + + when(accountsManager.getByAccountIdentifier(AUTHENTICATED_ACI)).thenReturn(Optional.of(authenticatedAccount)); + } + + @Override + protected CredentialsGrpcService createServiceBeforeEachTest() { + final CertificateGenerator certificateGenerator; + try { + certificateGenerator = new CertificateGenerator(SIGNED_SERVER_CERTIFICATE.toByteArray(), + SERVER_KEY_PAIR.getPrivateKey(), + 1, + false); + } catch (final InvalidProtocolBufferException e) { + throw new UncheckedIOException(e); + } + + return new CredentialsGrpcService(accountsManager, + certificateGenerator, + SERVER_ZK_AUTH_OPERATIONS, + GENERIC_SERVER_SECRET_PARAMS, + rateLimiters, + CLOCK, + Map.of( + ExternalServiceType.EXTERNAL_SERVICE_TYPE_DIRECTORY, DIRECTORY_CREDENTIALS_GENERATOR, + ExternalServiceType.EXTERNAL_SERVICE_TYPE_PAYMENTS, PAYMENTS_CREDENTIALS_GENERATOR + )); + } + + static Stream testSuccess() { + return Stream.of( + Arguments.of(ExternalServiceType.EXTERNAL_SERVICE_TYPE_DIRECTORY, DIRECTORY_CREDENTIALS_GENERATOR), + Arguments.of(ExternalServiceType.EXTERNAL_SERVICE_TYPE_PAYMENTS, PAYMENTS_CREDENTIALS_GENERATOR) + ); + } + + @ParameterizedTest + @MethodSource + public void testSuccess( + final ExternalServiceType externalServiceType, + final ExternalServiceCredentialsGenerator credentialsGenerator) throws Exception { + final RateLimiter limiter = mock(RateLimiter.class); + doReturn(limiter).when(rateLimiters).forDescriptor(eq(RateLimiters.For.EXTERNAL_SERVICE_CREDENTIALS)); + doReturn(Mono.fromFuture(CompletableFuture.completedFuture(null))).when(limiter).validateReactive(eq(AUTHENTICATED_ACI)); + final GetExternalServiceCredentialsResponse artResponse = authenticatedServiceStub().getExternalServiceCredentials( + GetExternalServiceCredentialsRequest.newBuilder() + .setExternalService(externalServiceType) + .build()); + final Optional artValidation = credentialsGenerator.validateAndGetTimestamp( + new ExternalServiceCredentials(artResponse.getUsername(), artResponse.getPassword())); + assertTrue(artValidation.isPresent()); + } + + @ParameterizedTest + @ValueSource(ints = { -1, 0, 1000 }) + public void testUnrecognizedService(final int externalServiceTypeValue) throws Exception { + assertStatusException(Status.INVALID_ARGUMENT, () -> authenticatedServiceStub().getExternalServiceCredentials( + GetExternalServiceCredentialsRequest.newBuilder() + .setExternalServiceValue(externalServiceTypeValue) + .build())); + } + + @Test + public void testInvalidRequest() throws Exception { + assertStatusException(Status.INVALID_ARGUMENT, () -> authenticatedServiceStub().getExternalServiceCredentials( + GetExternalServiceCredentialsRequest.newBuilder() + .build())); + } + + @Test + public void testRateLimitExceeded() throws Exception { + final Duration retryAfter = MockUtils.updateRateLimiterResponseToFail( + rateLimiters, RateLimiters.For.EXTERNAL_SERVICE_CREDENTIALS, AUTHENTICATED_ACI, Duration.ofSeconds(100)); + Mockito.reset(DIRECTORY_CREDENTIALS_GENERATOR); + assertRateLimitExceeded( + retryAfter, + () -> authenticatedServiceStub().getExternalServiceCredentials( + GetExternalServiceCredentialsRequest.newBuilder() + .setExternalService(ExternalServiceType.EXTERNAL_SERVICE_TYPE_DIRECTORY) + .build()), + DIRECTORY_CREDENTIALS_GENERATOR + ); + } + + /** + * `ExternalServiceDefinitions` enum is supposed to have entries for all values in `ExternalServiceType`, + * except for the `EXTERNAL_SERVICE_TYPE_UNSPECIFIED` and `UNRECOGNIZED`. + * This test makes sure that is the case. + */ + @ParameterizedTest + @EnumSource(mode = EnumSource.Mode.EXCLUDE, names = { "UNRECOGNIZED", "EXTERNAL_SERVICE_TYPE_UNSPECIFIED" }) + public void testHaveExternalServiceDefinitionForServiceTypes(final ExternalServiceType externalServiceType) throws Exception { + assertTrue( + Arrays.stream(ExternalServiceDefinitions.values()).anyMatch(v -> v.externalService() == externalServiceType), + "`ExternalServiceDefinitions` enum entry is missing for the `%s` value of `ExternalServiceType`".formatted(externalServiceType) + ); + } + + @Test + void getDeliveryCertificate() throws InvalidProtocolBufferException, InvalidKeyException { + final GetDeliveryCertificateResponse response = + authenticatedServiceStub().getDeliveryCertificate(GetDeliveryCertificateRequest.getDefaultInstance()); + + checkDeliveryCertificate(response.getCertificateWithE164().toByteArray(), true); + checkDeliveryCertificate(response.getCertificateWithoutE164().toByteArray(), false); + } + + private static void checkDeliveryCertificate(final byte[] deliveryCertificate, final boolean expectE164) + throws InvalidProtocolBufferException, InvalidKeyException { + final MessageProtos.SenderCertificate senderCertificateHolder = + MessageProtos.SenderCertificate.parseFrom(deliveryCertificate); + + final MessageProtos.SenderCertificate.Certificate senderCertificate = + MessageProtos.SenderCertificate.Certificate.parseFrom(senderCertificateHolder.getCertificate()); + + final MessageProtos.ServerCertificate.Certificate serverCertificate = + MessageProtos.ServerCertificate.Certificate.parseFrom(SIGNED_SERVER_CERTIFICATE.getCertificate()); + + assertEquals(serverCertificate.getId(), senderCertificate.getSignerId()); + + final ECPublicKey serverPublicKey = new ECPublicKey(serverCertificate.getKey().toByteArray()); + + assertTrue(serverPublicKey.verifySignature(senderCertificateHolder.getCertificate().toByteArray(), + senderCertificateHolder.getSignature().toByteArray())); + + assertEquals(expectE164, senderCertificate.hasSenderE164()); + + if (expectE164) { + assertEquals(PHONE_NUMBER, senderCertificate.getSenderE164()); + } + + assertEquals(AUTHENTICATED_DEVICE_ID, senderCertificate.getSenderDevice()); + assertTrue(senderCertificate.hasSenderUuid()); + assertEquals(UUIDUtil.toByteString(AUTHENTICATED_ACI), senderCertificate.getSenderUuid()); + assertArrayEquals(senderCertificate.getIdentityKey().toByteArray(), + new IdentityKey(IDENTITY_KEY_PAIR.getPublicKey()).serialize()); + } + + @ParameterizedTest + @ValueSource(ints = {0, 1, 2, 3, 4, 5, 6, 7}) + void getGroupCredentials(final int redemptionEndOffsetDays) { + final Instant startOfDay = CLOCK.instant().truncatedTo(ChronoUnit.DAYS); + + final GetGroupCredentialsResponse response = + authenticatedServiceStub().getGroupCredentials(GetGroupCredentialsRequest.newBuilder() + .setRedemptionStartSeconds(startOfDay.getEpochSecond()) + .setRedemptionEndSeconds(startOfDay.plus(Duration.ofDays(redemptionEndOffsetDays)).getEpochSecond()) + .build()); + + assertEquals(AUTHENTICATED_PNI, UUIDUtil.fromByteString(response.getPni())); + assertEquals(redemptionEndOffsetDays + 1, response.getGroupCredentialsCount()); + assertEquals(redemptionEndOffsetDays + 1, response.getCallLinkAuthCredentialsCount()); + + final ClientZkAuthOperations clientZkAuthOperations = + new ClientZkAuthOperations(SERVER_SECRET_PARAMS.getPublicParams()); + + for (int i = 0; i < redemptionEndOffsetDays; i++) { + final Instant redemptionTime = startOfDay.plus(Duration.ofDays(i)); + assertEquals(redemptionTime.getEpochSecond(), response.getGroupCredentials(i).getRedemptionTimeSeconds()); + assertEquals(redemptionTime.getEpochSecond(), response.getCallLinkAuthCredentials(i).getRedemptionTimeSeconds()); + + final int index = i; + + assertDoesNotThrow(() -> { + clientZkAuthOperations.receiveAuthCredentialWithPniAsServiceId( + new ServiceId.Aci(AUTHENTICATED_ACI), + new ServiceId.Pni(AUTHENTICATED_PNI), + redemptionTime.getEpochSecond(), + new AuthCredentialWithPniResponse(response.getGroupCredentials(index).getCredential().toByteArray())); + }); + + assertDoesNotThrow(() -> { + new CallLinkAuthCredentialResponse(response.getCallLinkAuthCredentials(index).getCredential().toByteArray()) + .receive(new ServiceId.Aci(AUTHENTICATED_ACI), redemptionTime, GENERIC_SERVER_SECRET_PARAMS.getPublicParams()); + }); + } + } + + @ParameterizedTest + @MethodSource + void getGroupCredentialsIllegalRedemptionTimes(final Instant redemptionStart, final Instant redemptionEnd) { + //noinspection ThrowableNotThrown + GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT, () -> { + //noinspection ResultOfMethodCallIgnored + authenticatedServiceStub().getGroupCredentials(GetGroupCredentialsRequest.newBuilder() + .setRedemptionStartSeconds(redemptionStart.getEpochSecond()) + .setRedemptionEndSeconds(redemptionEnd.getEpochSecond()) + .build()); + }); + } + + private static Collection getGroupCredentialsIllegalRedemptionTimes() { + final Instant startOfDay = CLOCK.instant().truncatedTo(ChronoUnit.DAYS); + + return List.of( + Arguments.argumentSet("Start is after end", startOfDay.plus(Duration.ofDays(1)), startOfDay), + Arguments.argumentSet("Start is in the past", startOfDay.minus(Duration.ofDays(2)), startOfDay), + Arguments.argumentSet("End is too far in the future", startOfDay, + startOfDay.plus(CertificateController.MAX_REDEMPTION_DURATION).plus(Duration.ofDays(1))), + Arguments.argumentSet("Start is not at a day boundary", startOfDay.plusSeconds(17), + startOfDay.plus(Duration.ofDays(1))), + Arguments.argumentSet("End is not at a day boundary", startOfDay, startOfDay.plusSeconds(17)) + ); + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceCredentialsGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceCredentialsGrpcServiceTest.java deleted file mode 100644 index 98739be03..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceCredentialsGrpcServiceTest.java +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.grpc; - -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.mock; -import static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertRateLimitExceeded; -import static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertStatusException; -import static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertStatusUnauthenticated; - -import io.grpc.Status; -import java.time.Duration; -import java.util.Arrays; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import java.util.stream.Stream; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.EnumSource; -import org.junit.jupiter.params.provider.MethodSource; -import org.junit.jupiter.params.provider.ValueSource; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.signal.chat.credentials.ExternalServiceCredentialsGrpc; -import org.signal.chat.credentials.ExternalServiceType; -import org.signal.chat.credentials.GetExternalServiceCredentialsRequest; -import org.signal.chat.credentials.GetExternalServiceCredentialsResponse; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; -import org.whispersystems.textsecuregcm.limits.RateLimiter; -import org.whispersystems.textsecuregcm.limits.RateLimiters; -import org.whispersystems.textsecuregcm.util.MockUtils; -import org.whispersystems.textsecuregcm.util.TestRandomUtil; -import reactor.core.publisher.Mono; - -public class ExternalServiceCredentialsGrpcServiceTest - extends SimpleBaseGrpcTest { - - private static final ExternalServiceCredentialsGenerator DIRECTORY_CREDENTIALS_GENERATOR = Mockito.spy(ExternalServiceCredentialsGenerator - .builder(TestRandomUtil.nextBytes(32)) - .withUserDerivationKey(TestRandomUtil.nextBytes(32)) - .prependUsername(false) - .truncateSignature(false) - .build()); - - private static final ExternalServiceCredentialsGenerator PAYMENTS_CREDENTIALS_GENERATOR = Mockito.spy(ExternalServiceCredentialsGenerator - .builder(TestRandomUtil.nextBytes(32)) - .prependUsername(true) - .build()); - - @Mock - private RateLimiters rateLimiters; - - - @Override - protected ExternalServiceCredentialsGrpcService createServiceBeforeEachTest() { - return new ExternalServiceCredentialsGrpcService(Map.of( - ExternalServiceType.EXTERNAL_SERVICE_TYPE_DIRECTORY, DIRECTORY_CREDENTIALS_GENERATOR, - ExternalServiceType.EXTERNAL_SERVICE_TYPE_PAYMENTS, PAYMENTS_CREDENTIALS_GENERATOR - ), rateLimiters); - } - - static Stream testSuccess() { - return Stream.of( - Arguments.of(ExternalServiceType.EXTERNAL_SERVICE_TYPE_DIRECTORY, DIRECTORY_CREDENTIALS_GENERATOR), - Arguments.of(ExternalServiceType.EXTERNAL_SERVICE_TYPE_PAYMENTS, PAYMENTS_CREDENTIALS_GENERATOR) - ); - } - - @ParameterizedTest - @MethodSource - public void testSuccess( - final ExternalServiceType externalServiceType, - final ExternalServiceCredentialsGenerator credentialsGenerator) throws Exception { - final RateLimiter limiter = mock(RateLimiter.class); - doReturn(limiter).when(rateLimiters).forDescriptor(eq(RateLimiters.For.EXTERNAL_SERVICE_CREDENTIALS)); - doReturn(Mono.fromFuture(CompletableFuture.completedFuture(null))).when(limiter).validateReactive(eq(AUTHENTICATED_ACI)); - final GetExternalServiceCredentialsResponse artResponse = authenticatedServiceStub().getExternalServiceCredentials( - GetExternalServiceCredentialsRequest.newBuilder() - .setExternalService(externalServiceType) - .build()); - final Optional artValidation = credentialsGenerator.validateAndGetTimestamp( - new ExternalServiceCredentials(artResponse.getUsername(), artResponse.getPassword())); - assertTrue(artValidation.isPresent()); - } - - @ParameterizedTest - @ValueSource(ints = { -1, 0, 1000 }) - public void testUnrecognizedService(final int externalServiceTypeValue) throws Exception { - assertStatusException(Status.INVALID_ARGUMENT, () -> authenticatedServiceStub().getExternalServiceCredentials( - GetExternalServiceCredentialsRequest.newBuilder() - .setExternalServiceValue(externalServiceTypeValue) - .build())); - } - - @Test - public void testInvalidRequest() throws Exception { - assertStatusException(Status.INVALID_ARGUMENT, () -> authenticatedServiceStub().getExternalServiceCredentials( - GetExternalServiceCredentialsRequest.newBuilder() - .build())); - } - - @Test - public void testRateLimitExceeded() throws Exception { - final Duration retryAfter = MockUtils.updateRateLimiterResponseToFail( - rateLimiters, RateLimiters.For.EXTERNAL_SERVICE_CREDENTIALS, AUTHENTICATED_ACI, Duration.ofSeconds(100)); - Mockito.reset(DIRECTORY_CREDENTIALS_GENERATOR); - assertRateLimitExceeded( - retryAfter, - () -> authenticatedServiceStub().getExternalServiceCredentials( - GetExternalServiceCredentialsRequest.newBuilder() - .setExternalService(ExternalServiceType.EXTERNAL_SERVICE_TYPE_DIRECTORY) - .build()), - DIRECTORY_CREDENTIALS_GENERATOR - ); - } - - /** - * `ExternalServiceDefinitions` enum is supposed to have entries for all values in `ExternalServiceType`, - * except for the `EXTERNAL_SERVICE_TYPE_UNSPECIFIED` and `UNRECOGNIZED`. - * This test makes sure that is the case. - */ - @ParameterizedTest - @EnumSource(mode = EnumSource.Mode.EXCLUDE, names = { "UNRECOGNIZED", "EXTERNAL_SERVICE_TYPE_UNSPECIFIED" }) - public void testHaveExternalServiceDefinitionForServiceTypes(final ExternalServiceType externalServiceType) throws Exception { - assertTrue( - Arrays.stream(ExternalServiceDefinitions.values()).anyMatch(v -> v.externalService() == externalServiceType), - "`ExternalServiceDefinitions` enum entry is missing for the `%s` value of `ExternalServiceType`".formatted(externalServiceType) - ); - } -}