gRPC API for external services credentials service
This commit is contained in:
parent
d0fdae3df7
commit
0b3af7d824
|
@ -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))
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
)));
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}));
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue