From 0b3af7d824f698e194373aaeea48c5d2400f4a49 Mon Sep 17 00:00:00 2001 From: Sergey Skrobotov Date: Thu, 14 Sep 2023 14:38:58 -0700 Subject: [PATCH] gRPC API for external services credentials service --- .../textsecuregcm/WhisperServerService.java | 4 + .../controllers/ArtController.java | 2 +- .../controllers/SecureBackupController.java | 2 +- ...erviceCredentialsAnonymousGrpcService.java | 86 ++++++++++ ...ExternalServiceCredentialsGrpcService.java | 68 ++++++++ .../grpc/ExternalServiceDefinitions.java | 95 +++++++++++ .../textsecuregcm/limits/RateLimiters.java | 9 +- .../proto/org/signal/chat/credentials.proto | 117 +++++++++++++ .../controllers/ArtControllerTest.java | 9 +- ...SecureValueRecoveryControllerBaseTest.java | 2 +- ...ceCredentialsAnonymousGrpcServiceTest.java | 161 ++++++++++++++++++ ...rnalServiceCredentialsGrpcServiceTest.java | 146 ++++++++++++++++ .../textsecuregcm/grpc/GrpcTestUtils.java | 12 ++ 13 files changed, 698 insertions(+), 15 deletions(-) create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceCredentialsAnonymousGrpcService.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceCredentialsGrpcService.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceDefinitions.java create mode 100644 service/src/main/proto/org/signal/chat/credentials.proto create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceCredentialsAnonymousGrpcServiceTest.java create 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 5d2513626..df5721352 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -121,6 +121,8 @@ import org.whispersystems.textsecuregcm.filters.RequestStatisticsFilter; import org.whispersystems.textsecuregcm.filters.TimestampResponseFilter; import org.whispersystems.textsecuregcm.grpc.AcceptLanguageInterceptor; import org.whispersystems.textsecuregcm.grpc.ErrorMappingInterceptor; +import org.whispersystems.textsecuregcm.grpc.ExternalServiceCredentialsAnonymousGrpcService; +import org.whispersystems.textsecuregcm.grpc.ExternalServiceCredentialsGrpcService; import org.whispersystems.textsecuregcm.grpc.GrpcServerManagedWrapper; import org.whispersystems.textsecuregcm.grpc.KeysAnonymousGrpcService; import org.whispersystems.textsecuregcm.grpc.KeysGrpcService; @@ -647,6 +649,8 @@ public class WhisperServerService extends Application grpcServer = ServerBuilder.forPort(config.getGrpcPort()) + .addService(ExternalServiceCredentialsGrpcService.createForAllExternalServices(config, rateLimiters)) + .addService(ExternalServiceCredentialsAnonymousGrpcService.create(accountsManager, config)) .addService(ServerInterceptors.intercept(new KeysGrpcService(accountsManager, keys, rateLimiters), basicCredentialAuthenticationInterceptor)) .addService(new KeysAnonymousGrpcService(accountsManager, keys)) .addService(new PaymentsGrpcService(currencyManager)) diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ArtController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ArtController.java index a9393b5b3..7178fa76d 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ArtController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ArtController.java @@ -45,7 +45,7 @@ public class ArtController { public ExternalServiceCredentials getAuth(final @Auth AuthenticatedAccount auth) throws RateLimitExceededException { final UUID uuid = auth.getAccount().getUuid(); - rateLimiters.getArtPackLimiter().validate(uuid); + rateLimiters.forDescriptor(RateLimiters.For.EXTERNAL_SERVICE_CREDENTIALS).validate(uuid); return artServiceCredentialsGenerator.generateForUuid(uuid); } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureBackupController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureBackupController.java index a285308fc..7dc7c903e 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureBackupController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureBackupController.java @@ -120,7 +120,7 @@ public class SecureBackupController { } final String username = info.credentials().username(); // does this credential match the account id for the e164 provided in the request? - boolean match = UUIDUtil.fromStringSafe(username).filter(uuidMatches).isPresent(); + final boolean match = UUIDUtil.fromStringSafe(username).filter(uuidMatches).isPresent(); return match ? AuthCheckResponse.Result.MATCH : AuthCheckResponse.Result.NO_MATCH; } ))); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceCredentialsAnonymousGrpcService.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceCredentialsAnonymousGrpcService.java new file mode 100644 index 000000000..f76bf3d02 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceCredentialsAnonymousGrpcService.java @@ -0,0 +1,86 @@ +/* + * 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.ReactorExternalServiceCredentialsAnonymousGrpc; +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 reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class ExternalServiceCredentialsAnonymousGrpcService extends + ReactorExternalServiceCredentialsAnonymousGrpc.ExternalServiceCredentialsAnonymousImplBase { + + private static final long MAX_SVR_PASSWORD_AGE_SECONDS = TimeUnit.DAYS.toSeconds(30); + + private final ExternalServiceCredentialsGenerator svrCredentialsGenerator; + + private final AccountsManager accountsManager; + + + public static ExternalServiceCredentialsAnonymousGrpcService create( + final AccountsManager accountsManager, + final WhisperServerConfiguration chatConfiguration) { + return new ExternalServiceCredentialsAnonymousGrpcService( + accountsManager, + ExternalServiceDefinitions.SVR.generatorFactory().apply(chatConfiguration, Clock.systemUTC()) + ); + } + + @VisibleForTesting + ExternalServiceCredentialsAnonymousGrpcService( + final AccountsManager accountsManager, + final ExternalServiceCredentialsGenerator svrCredentialsGenerator) { + this.accountsManager = requireNonNull(accountsManager); + this.svrCredentialsGenerator = requireNonNull(svrCredentialsGenerator); + } + + @Override + public Mono checkSvrCredentials(final CheckSvrCredentialsRequest request) { + final List tokens = request.getPasswordsList(); + final List credentials = ExternalServiceCredentialsSelector.check( + tokens, + svrCredentialsGenerator, + MAX_SVR_PASSWORD_AGE_SECONDS); + + // the username associated with the provided number + final Optional matchingUsername = accountsManager + .getByE164(request.getNumber()) + .map(Account::getUuid) + .map(svrCredentialsGenerator::generateForUuid) + .map(ExternalServiceCredentials::username); + + return Flux.fromIterable(credentials) + .reduce(CheckSvrCredentialsResponse.newBuilder(), ((builder, credentialInfo) -> { + final AuthCheckResult authCheckResult; + if (!credentialInfo.valid()) { + authCheckResult = AuthCheckResult.AUTH_CHECK_RESULT_INVALID; + } else { + final String username = credentialInfo.credentials().username(); + // does this credential match the account id for the e164 provided in the request? + authCheckResult = matchingUsername.map(username::equals).orElse(false) + ? AuthCheckResult.AUTH_CHECK_RESULT_MATCH + : AuthCheckResult.AUTH_CHECK_RESULT_NO_MATCH; + } + return builder.putMatches(credentialInfo.token(), authCheckResult); + })) + .map(CheckSvrCredentialsResponse.Builder::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 new file mode 100644 index 000000000..df54391e9 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceCredentialsGrpcService.java @@ -0,0 +1,68 @@ +/* + * 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 io.grpc.Status; +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.ReactorExternalServiceCredentialsGrpc; +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.limits.RateLimiters; +import reactor.core.publisher.Mono; + +public class ExternalServiceCredentialsGrpcService extends ReactorExternalServiceCredentialsGrpc.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 Mono getExternalServiceCredentials(final GetExternalServiceCredentialsRequest request) { + final ExternalServiceCredentialsGenerator credentialsGenerator = this.credentialsGeneratorByType + .get(request.getExternalService()); + if (credentialsGenerator == null) { + return Mono.error(Status.INVALID_ARGUMENT.asException()); + } + final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice(); + return rateLimiters.forDescriptor(RateLimiters.For.EXTERNAL_SERVICE_CREDENTIALS).validateReactive(authenticatedDevice.accountIdentifier()) + .then(Mono.fromSupplier(() -> { + 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 new file mode 100644 index 000000000..dcf285a3a --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceDefinitions.java @@ -0,0 +1,95 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.grpc; + +import static java.util.Objects.requireNonNull; + +import java.time.Clock; +import java.util.Arrays; +import java.util.Map; +import java.util.function.BiFunction; +import java.util.stream.Collectors; +import org.apache.commons.lang3.tuple.Pair; +import org.signal.chat.credentials.ExternalServiceType; +import org.whispersystems.textsecuregcm.WhisperServerConfiguration; +import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; +import org.whispersystems.textsecuregcm.configuration.ArtServiceConfiguration; +import org.whispersystems.textsecuregcm.configuration.DirectoryV2ClientConfiguration; +import org.whispersystems.textsecuregcm.configuration.PaymentsServiceConfiguration; +import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration; + +enum ExternalServiceDefinitions { + ART(ExternalServiceType.EXTERNAL_SERVICE_TYPE_ART, (chatConfig, clock) -> { + final ArtServiceConfiguration cfg = chatConfig.getArtServiceConfiguration(); + return ExternalServiceCredentialsGenerator + .builder(cfg.userAuthenticationTokenSharedSecret()) + .withUserDerivationKey(cfg.userAuthenticationTokenUserIdSecret()) + .prependUsername(false) + .truncateSignature(false) + .build(); + }), + DIRECTORY(ExternalServiceType.EXTERNAL_SERVICE_TYPE_DIRECTORY, (chatConfig, clock) -> { + final DirectoryV2ClientConfiguration cfg = chatConfig.getDirectoryV2Configuration().getDirectoryV2ClientConfiguration(); + return ExternalServiceCredentialsGenerator + .builder(cfg.userAuthenticationTokenSharedSecret()) + .withUserDerivationKey(cfg.userIdTokenSharedSecret()) + .prependUsername(false) + .withClock(clock) + .build(); + }), + PAYMENTS(ExternalServiceType.EXTERNAL_SERVICE_TYPE_PAYMENTS, (chatConfig, clock) -> { + final PaymentsServiceConfiguration cfg = chatConfig.getPaymentsServiceConfiguration(); + return ExternalServiceCredentialsGenerator + .builder(cfg.userAuthenticationTokenSharedSecret()) + .prependUsername(true) + .build(); + }), + SVR(ExternalServiceType.EXTERNAL_SERVICE_TYPE_SVR, (chatConfig, clock) -> { + final SecureValueRecovery2Configuration cfg = chatConfig.getSvr2Configuration(); + return ExternalServiceCredentialsGenerator + .builder(cfg.userAuthenticationTokenSharedSecret()) + .withUserDerivationKey(cfg.userIdTokenSharedSecret().value()) + .prependUsername(false) + .withDerivedUsernameTruncateLength(16) + .withClock(clock) + .build(); + }), + STORAGE(ExternalServiceType.EXTERNAL_SERVICE_TYPE_STORAGE, (chatConfig, clock) -> { + final PaymentsServiceConfiguration cfg = chatConfig.getPaymentsServiceConfiguration(); + return ExternalServiceCredentialsGenerator + .builder(cfg.userAuthenticationTokenSharedSecret()) + .prependUsername(true) + .build(); + }), + ; + + private final ExternalServiceType externalService; + + private final BiFunction generatorFactory; + + ExternalServiceDefinitions( + final ExternalServiceType externalService, + final BiFunction factory) { + this.externalService = requireNonNull(externalService); + this.generatorFactory = requireNonNull(factory); + } + + public static Map createExternalServiceList( + final WhisperServerConfiguration chatConfiguration, + final Clock clock) { + return Arrays.stream(values()) + .map(esd -> Pair.of(esd.externalService, esd.generatorFactory().apply(chatConfiguration, clock))) + .collect(Collectors.toMap(Pair::getKey, Pair::getValue)); + } + + public BiFunction generatorFactory() { + return generatorFactory; + } + + ExternalServiceType externalService() { + return externalService; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java index 59a1146e5..cd3c142b1 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java @@ -33,7 +33,6 @@ public class RateLimiters extends BaseRateLimiters { TURN("turnAllocate", false, new RateLimiterConfig(60, Duration.ofSeconds(1))), PROFILE("profile", false, new RateLimiterConfig(4320, Duration.ofSeconds(20))), STICKER_PACK("stickerPack", false, new RateLimiterConfig(50, Duration.ofMinutes(72))), - ART_PACK("artPack", false, new RateLimiterConfig(50, Duration.ofMinutes(72))), USERNAME_LOOKUP("usernameLookup", false, new RateLimiterConfig(100, Duration.ofMinutes(15))), USERNAME_SET("usernameSet", false, new RateLimiterConfig(100, Duration.ofMinutes(15))), USERNAME_RESERVE("usernameReserve", false, new RateLimiterConfig(100, Duration.ofMinutes(15))), @@ -49,7 +48,9 @@ public class RateLimiters extends BaseRateLimiters { PUSH_CHALLENGE_ATTEMPT("pushChallengeAttempt", true, new RateLimiterConfig(10, Duration.ofMinutes(144))), PUSH_CHALLENGE_SUCCESS("pushChallengeSuccess", true, new RateLimiterConfig(2, Duration.ofHours(12))), CREATE_CALL_LINK("createCallLink", false, new RateLimiterConfig(100, Duration.ofMinutes(15))), - INBOUND_MESSAGE_BYTES("inboundMessageBytes", true, new RateLimiterConfig(128 * 1024 * 1024, Duration.ofNanos(500_000))); + INBOUND_MESSAGE_BYTES("inboundMessageBytes", true, new RateLimiterConfig(128 * 1024 * 1024, Duration.ofNanos(500_000))), + EXTERNAL_SERVICE_CREDENTIALS("externalServiceCredentials", true, new RateLimiterConfig(100, Duration.ofMinutes(15))), + ; private final String id; @@ -157,10 +158,6 @@ public class RateLimiters extends BaseRateLimiters { return forDescriptor(For.STICKER_PACK); } - public RateLimiter getArtPackLimiter() { - return forDescriptor(For.ART_PACK); - } - public RateLimiter getUsernameLookupLimiter() { return forDescriptor(For.USERNAME_LOOKUP); } diff --git a/service/src/main/proto/org/signal/chat/credentials.proto b/service/src/main/proto/org/signal/chat/credentials.proto new file mode 100644 index 000000000..5ce2bace8 --- /dev/null +++ b/service/src/main/proto/org/signal/chat/credentials.proto @@ -0,0 +1,117 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +syntax = "proto3"; + +option java_multiple_files = true; + +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 { + + /** + * Generates and returns an external service credentials for the caller. + * + * `UNAUTHENTICATED` status is returned if the call is made on unauthenticated channel. + * + * `INVALID_ARGUMENT` status is returned if service is not configured for the service type + * found in the request OR if `externalService` is not specified in the request. + * + * `RESOURCE_EXHAUSTED` status is returned if a rate limit for + * generating credentials has been exceeded, in which case a + * `retry-after` header containing an ISO 8601 duration string will be present + * in the response trailers. + */ + rpc GetExternalServiceCredentials(GetExternalServiceCredentialsRequest) + returns (GetExternalServiceCredentialsResponse) {} +} + +service ExternalServiceCredentialsAnonymous { + /** + * Given a list of secure value recovery (SVR) service credentials and a phone number, + * checks, which of the provided credetials were generated by the user with the given phone number + * and have not yet expired. + * + * `UNAUTHENTICATED` status is returned if the call is made on unauthenticated channel. + * + * `INVALID_ARGUMENT` status is returned if request contains more than 10 passwords to be checked. + */ + rpc CheckSvrCredentials(CheckSvrCredentialsRequest) + returns (CheckSvrCredentialsResponse) {} +} + +enum ExternalServiceType { + EXTERNAL_SERVICE_TYPE_UNSPECIFIED = 0; + EXTERNAL_SERVICE_TYPE_ART = 1; + EXTERNAL_SERVICE_TYPE_DIRECTORY = 2; + EXTERNAL_SERVICE_TYPE_PAYMENTS = 3; + EXTERNAL_SERVICE_TYPE_STORAGE = 4; + EXTERNAL_SERVICE_TYPE_SVR = 5; +} + +message GetExternalServiceCredentialsRequest { + /** + * A service to request credentials for. + */ + ExternalServiceType externalService = 1; +} + +message GetExternalServiceCredentialsResponse { + /** + * A username that can be presented to authenticate with the external service. + */ + string username = 1; + + /** + * A password that can be presented to authenticate with the external service. + */ + string password = 2; +} + +enum AuthCheckResult { + AUTH_CHECK_RESULT_UNSPECIFIED = 0; + /** + * The credentials could be used to make a call to SVR service by the user + * associated with the `CheckSvrCredentialsRequest.number` phone number. + */ + AUTH_CHECK_RESULT_MATCH = 1; + /** + * The credentials were generated by a different user. + */ + AUTH_CHECK_RESULT_NO_MATCH = 2; + /** + * This status indicates that the corresponding credentials token should no longer be used. + * This may be because it has expired or invalid, but it can also mean that there is a more + * recent token in the request which should be used instead. + */ + AUTH_CHECK_RESULT_INVALID = 3; +} + +message CheckSvrCredentialsRequest { + /** + * A phone number in the E164 format to check the passwords against. + * Only passwords generated for the user associated with the given number will be marked as `AUTH_CHECK_RESULT_MATCH`. + */ + string number = 1; + + /** + * A list of credentials from previously made calls to `ExternalServiceCredentials.GetExternalServiceCredentials()` + * for `EXTERNAL_SERVICE_TYPE_SVR`. This list may contain credentials generated by different users. + */ + repeated string passwords = 2; +} + +/** + * For each of the credentials tokens in the `CheckSvrCredentialsRequest` contains the result of the check. + */ +message CheckSvrCredentialsResponse { + + map matches = 1; +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ArtControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ArtControllerTest.java index c87a7fad5..f3cd583c2 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ArtControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ArtControllerTest.java @@ -7,7 +7,6 @@ package org.whispersystems.textsecuregcm.controllers; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; import static org.whispersystems.textsecuregcm.util.MockUtils.randomSecretBytes; import com.google.common.collect.ImmutableSet; @@ -23,9 +22,9 @@ import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccou import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; import org.whispersystems.textsecuregcm.configuration.ArtServiceConfiguration; -import org.whispersystems.textsecuregcm.limits.RateLimiter; import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.tests.util.AuthHelper; +import org.whispersystems.textsecuregcm.util.MockUtils; import org.whispersystems.textsecuregcm.util.SystemMapper; @ExtendWith(DropwizardExtensionsSupport.class) @@ -33,7 +32,6 @@ class ArtControllerTest { private static final ArtServiceConfiguration ART_SERVICE_CONFIGURATION = new ArtServiceConfiguration( randomSecretBytes(32), randomSecretBytes(32), Duration.ofDays(1)); private static final ExternalServiceCredentialsGenerator artCredentialsGenerator = ArtController.credentialsGenerator(ART_SERVICE_CONFIGURATION); - private static final RateLimiter rateLimiter = mock(RateLimiter.class); private static final RateLimiters rateLimiters = mock(RateLimiters.class); private static final ResourceExtension resources = ResourceExtension.builder() @@ -47,9 +45,8 @@ class ArtControllerTest { @Test void testGetAuthToken() { - when(rateLimiters.getArtPackLimiter()).thenReturn(rateLimiter); - - ExternalServiceCredentials token = + MockUtils.updateRateLimiterResponseToAllow(rateLimiters, RateLimiters.For.EXTERNAL_SERVICE_CREDENTIALS, AuthHelper.VALID_UUID); + final ExternalServiceCredentials token = resources.getJerseyTest() .target("/v1/art/auth") .request() diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecoveryControllerBaseTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecoveryControllerBaseTest.java index 0e8e9475b..36ee25170 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecoveryControllerBaseTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecoveryControllerBaseTest.java @@ -268,7 +268,7 @@ abstract class SecureValueRecoveryControllerBaseTest { return token(credentials(uuid, timeMillis)); } - private static String token(ExternalServiceCredentials credentials) { + private static String token(final ExternalServiceCredentials credentials) { return credentials.username() + ":" + credentials.password(); } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceCredentialsAnonymousGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceCredentialsAnonymousGrpcServiceTest.java new file mode 100644 index 000000000..c4b253aca --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceCredentialsAnonymousGrpcServiceTest.java @@ -0,0 +1,161 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.grpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import java.time.Duration; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.apache.commons.lang3.RandomUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +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.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; +import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.AccountsManager; +import org.whispersystems.textsecuregcm.util.MockUtils; +import org.whispersystems.textsecuregcm.util.MutableClock; + +class ExternalServiceCredentialsAnonymousGrpcServiceTest extends + SimpleBaseGrpcTest { + + private static final UUID USER_UUID = UUID.randomUUID(); + + private static final String USER_E164 = PhoneNumberUtil.getInstance().format( + PhoneNumberUtil.getInstance().getExampleNumber("US"), + PhoneNumberUtil.PhoneNumberFormat.E164 + ); + + private static final MutableClock CLOCK = MockUtils.mutableClock(0); + + private static final ExternalServiceCredentialsGenerator SVR_CREDENTIALS_GENERATOR = Mockito.spy(ExternalServiceCredentialsGenerator + .builder(RandomUtils.nextBytes(32)) + .withUserDerivationKey(RandomUtils.nextBytes(32)) + .prependUsername(false) + .withDerivedUsernameTruncateLength(16) + .withClock(CLOCK) + .build()); + + @Mock + private AccountsManager accountsManager; + + @Override + protected ExternalServiceCredentialsAnonymousGrpcService createServiceBeforeEachTest() { + return new ExternalServiceCredentialsAnonymousGrpcService(accountsManager, SVR_CREDENTIALS_GENERATOR); + } + + @BeforeEach + public void setup() { + Mockito.when(accountsManager.getByE164(USER_E164)).thenReturn(Optional.of(account(USER_UUID))); + } + + @Test + public void testOneMatch() throws Exception { + final UUID user2 = UUID.randomUUID(); + final UUID user3 = UUID.randomUUID(); + assertExpectedCredentialCheckResponse(Map.of( + token(USER_UUID, day(1)), AuthCheckResult.AUTH_CHECK_RESULT_MATCH, + token(user2, day(1)), AuthCheckResult.AUTH_CHECK_RESULT_NO_MATCH, + token(user3, day(1)), AuthCheckResult.AUTH_CHECK_RESULT_NO_MATCH + ), day(2)); + } + + @Test + public void testNoMatch() throws Exception { + final UUID user2 = UUID.randomUUID(); + final UUID user3 = UUID.randomUUID(); + assertExpectedCredentialCheckResponse(Map.of( + token(user2, day(1)), AuthCheckResult.AUTH_CHECK_RESULT_NO_MATCH, + token(user3, day(1)), AuthCheckResult.AUTH_CHECK_RESULT_NO_MATCH + ), day(2)); + } + + @Test + public void testSomeInvalid() throws Exception { + final UUID user2 = UUID.randomUUID(); + final UUID user3 = UUID.randomUUID(); + final ExternalServiceCredentials user1Cred = credentials(USER_UUID, day(1)); + final ExternalServiceCredentials user2Cred = credentials(user2, day(1)); + final ExternalServiceCredentials user3Cred = credentials(user3, day(1)); + + final String fakeToken = token(new ExternalServiceCredentials(user2Cred.username(), user3Cred.password())); + assertExpectedCredentialCheckResponse(Map.of( + token(user1Cred), AuthCheckResult.AUTH_CHECK_RESULT_MATCH, + token(user2Cred), AuthCheckResult.AUTH_CHECK_RESULT_NO_MATCH, + fakeToken, AuthCheckResult.AUTH_CHECK_RESULT_INVALID + ), day(2)); + } + + @Test + public void testSomeExpired() throws Exception { + final UUID user2 = UUID.randomUUID(); + final UUID user3 = UUID.randomUUID(); + assertExpectedCredentialCheckResponse(Map.of( + token(USER_UUID, day(100)), AuthCheckResult.AUTH_CHECK_RESULT_MATCH, + token(user2, day(100)), AuthCheckResult.AUTH_CHECK_RESULT_NO_MATCH, + token(user3, day(10)), AuthCheckResult.AUTH_CHECK_RESULT_INVALID, + token(user3, day(20)), AuthCheckResult.AUTH_CHECK_RESULT_INVALID + ), day(110)); + } + + @Test + public void testSomeHaveNewerVersions() throws Exception { + final UUID user2 = UUID.randomUUID(); + final UUID user3 = UUID.randomUUID(); + assertExpectedCredentialCheckResponse(Map.of( + token(USER_UUID, day(10)), AuthCheckResult.AUTH_CHECK_RESULT_INVALID, + token(USER_UUID, day(20)), AuthCheckResult.AUTH_CHECK_RESULT_MATCH, + token(user2, day(10)), AuthCheckResult.AUTH_CHECK_RESULT_NO_MATCH, + token(user3, day(20)), AuthCheckResult.AUTH_CHECK_RESULT_NO_MATCH, + token(user3, day(10)), AuthCheckResult.AUTH_CHECK_RESULT_INVALID + ), day(25)); + } + + private void assertExpectedCredentialCheckResponse( + final Map expected, + final long nowMillis) throws Exception { + CLOCK.setTimeMillis(nowMillis); + final CheckSvrCredentialsRequest request = CheckSvrCredentialsRequest.newBuilder() + .setNumber(USER_E164) + .addAllPasswords(expected.keySet()) + .build(); + final CheckSvrCredentialsResponse response = unauthenticatedServiceStub().checkSvrCredentials(request); + final Map matchesMap = response.getMatchesMap(); + assertEquals(expected, matchesMap); + } + + private static String token(final UUID uuid, final long timeMillis) { + return token(credentials(uuid, timeMillis)); + } + + private static String token(final ExternalServiceCredentials credentials) { + return credentials.username() + ":" + credentials.password(); + } + + private static ExternalServiceCredentials credentials(final UUID uuid, final long timeMillis) { + CLOCK.setTimeMillis(timeMillis); + return SVR_CREDENTIALS_GENERATOR.generateForUuid(uuid); + } + + 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/ExternalServiceCredentialsGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceCredentialsGrpcServiceTest.java new file mode 100644 index 000000000..1beaabb53 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceCredentialsGrpcServiceTest.java @@ -0,0 +1,146 @@ +/* + * 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.apache.commons.lang3.RandomUtils; +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 reactor.core.publisher.Mono; + +public class ExternalServiceCredentialsGrpcServiceTest + extends SimpleBaseGrpcTest { + + private static final ExternalServiceCredentialsGenerator ART_CREDENTIALS_GENERATOR = Mockito.spy(ExternalServiceCredentialsGenerator + .builder(RandomUtils.nextBytes(32)) + .withUserDerivationKey(RandomUtils.nextBytes(32)) + .prependUsername(false) + .truncateSignature(false) + .build()); + + private static final ExternalServiceCredentialsGenerator PAYMENTS_CREDENTIALS_GENERATOR = Mockito.spy(ExternalServiceCredentialsGenerator + .builder(RandomUtils.nextBytes(32)) + .prependUsername(true) + .build()); + + @Mock + private RateLimiters rateLimiters; + + + @Override + protected ExternalServiceCredentialsGrpcService createServiceBeforeEachTest() { + return new ExternalServiceCredentialsGrpcService(Map.of( + ExternalServiceType.EXTERNAL_SERVICE_TYPE_ART, ART_CREDENTIALS_GENERATOR, + ExternalServiceType.EXTERNAL_SERVICE_TYPE_PAYMENTS, PAYMENTS_CREDENTIALS_GENERATOR + ), rateLimiters); + } + + static Stream testSuccess() { + return Stream.of( + Arguments.of(ExternalServiceType.EXTERNAL_SERVICE_TYPE_ART, ART_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), false); + Mockito.reset(ART_CREDENTIALS_GENERATOR); + assertRateLimitExceeded( + retryAfter, + () -> authenticatedServiceStub().getExternalServiceCredentials( + GetExternalServiceCredentialsRequest.newBuilder() + .setExternalService(ExternalServiceType.EXTERNAL_SERVICE_TYPE_ART) + .build()), + ART_CREDENTIALS_GENERATOR + ); + } + + @Test + public void testUnauthenticatedCall() throws Exception { + assertStatusUnauthenticated(() -> unauthenticatedServiceStub().getExternalServiceCredentials( + GetExternalServiceCredentialsRequest.newBuilder() + .setExternalService(ExternalServiceType.EXTERNAL_SERVICE_TYPE_ART) + .build())); + } + + /** + * `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) + ); + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/GrpcTestUtils.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/GrpcTestUtils.java index 116aafc13..2432b88bb 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/GrpcTestUtils.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/GrpcTestUtils.java @@ -50,6 +50,18 @@ public final class GrpcTestUtils { assertEquals(expected.getCode(), exception.getStatus().getCode()); } + public static void assertStatusInvalidArgument(final Executable serviceCall) { + assertStatusException(Status.INVALID_ARGUMENT, serviceCall); + } + + public static void assertStatusUnauthenticated(final Executable serviceCall) { + assertStatusException(Status.UNAUTHENTICATED, serviceCall); + } + + public static void assertStatusPermissionDenied(final Executable serviceCall) { + assertStatusException(Status.PERMISSION_DENIED, serviceCall); + } + public static void assertRateLimitExceeded( final Duration expectedRetryAfter, final Executable serviceCall,