gRPC API for external services credentials service

This commit is contained in:
Sergey Skrobotov 2023-09-14 14:38:58 -07:00
parent d0fdae3df7
commit 0b3af7d824
13 changed files with 698 additions and 15 deletions

View File

@ -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<WhisperServerConfiguration
new BasicCredentialAuthenticationInterceptor(new BaseAccountAuthenticator(accountsManager));
final ServerBuilder<?> 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))

View File

@ -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);
}
}

View File

@ -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;
}
)));

View File

@ -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<CheckSvrCredentialsResponse> checkSvrCredentials(final CheckSvrCredentialsRequest request) {
final List<String> tokens = request.getPasswordsList();
final List<ExternalServiceCredentialsSelector.CredentialInfo> credentials = ExternalServiceCredentialsSelector.check(
tokens,
svrCredentialsGenerator,
MAX_SVR_PASSWORD_AGE_SECONDS);
// the username associated with the provided number
final Optional<String> 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);
}
}

View File

@ -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<ExternalServiceType, ExternalServiceCredentialsGenerator> credentialsGeneratorByType;
private final RateLimiters rateLimiters;
public static ExternalServiceCredentialsGrpcService createForAllExternalServices(
final WhisperServerConfiguration chatConfiguration,
final RateLimiters rateLimiters) {
return new ExternalServiceCredentialsGrpcService(
ExternalServiceDefinitions.createExternalServiceList(chatConfiguration, Clock.systemUTC()),
rateLimiters
);
}
@VisibleForTesting
ExternalServiceCredentialsGrpcService(
final Map<ExternalServiceType, ExternalServiceCredentialsGenerator> credentialsGeneratorByType,
final RateLimiters rateLimiters) {
this.credentialsGeneratorByType = requireNonNull(credentialsGeneratorByType);
this.rateLimiters = requireNonNull(rateLimiters);
}
@Override
public Mono<GetExternalServiceCredentialsResponse> 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();
}));
}
}

View File

@ -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<WhisperServerConfiguration, Clock, ExternalServiceCredentialsGenerator> generatorFactory;
ExternalServiceDefinitions(
final ExternalServiceType externalService,
final BiFunction<WhisperServerConfiguration, Clock, ExternalServiceCredentialsGenerator> factory) {
this.externalService = requireNonNull(externalService);
this.generatorFactory = requireNonNull(factory);
}
public static Map<ExternalServiceType, ExternalServiceCredentialsGenerator> 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<WhisperServerConfiguration, Clock, ExternalServiceCredentialsGenerator> generatorFactory() {
return generatorFactory;
}
ExternalServiceType externalService() {
return externalService;
}
}

View File

@ -33,7 +33,6 @@ public class RateLimiters extends BaseRateLimiters<RateLimiters.For> {
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<RateLimiters.For> {
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<RateLimiters.For> {
return forDescriptor(For.STICKER_PACK);
}
public RateLimiter getArtPackLimiter() {
return forDescriptor(For.ART_PACK);
}
public RateLimiter getUsernameLookupLimiter() {
return forDescriptor(For.USERNAME_LOOKUP);
}

View File

@ -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<string, AuthCheckResult> matches = 1;
}

View File

@ -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()

View File

@ -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();
}

View File

@ -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<ExternalServiceCredentialsAnonymousGrpcService, ExternalServiceCredentialsAnonymousGrpc.ExternalServiceCredentialsAnonymousBlockingStub> {
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<String, AuthCheckResult> 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<String, AuthCheckResult> 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;
}
}

View File

@ -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<ExternalServiceCredentialsGrpcService, ExternalServiceCredentialsGrpc.ExternalServiceCredentialsBlockingStub> {
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<Arguments> 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<Long> 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)
);
}
}

View File

@ -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,