Port CertificateController to gRPC

This commit is contained in:
Jon Chambers
2026-06-08 16:55:53 -04:00
committed by GitHub
parent 7471a21fee
commit c3a48fd08b
10 changed files with 625 additions and 282 deletions
@@ -153,11 +153,12 @@ import org.whispersystems.textsecuregcm.grpc.BackupsAnonymousGrpcService;
import org.whispersystems.textsecuregcm.grpc.BackupsGrpcService;
import org.whispersystems.textsecuregcm.grpc.CallQualitySurveyGrpcService;
import org.whispersystems.textsecuregcm.grpc.ChallengeGrpcService;
import org.whispersystems.textsecuregcm.grpc.CredentialsAnonymousGrpcService;
import org.whispersystems.textsecuregcm.grpc.CredentialsGrpcService;
import org.whispersystems.textsecuregcm.grpc.DevicesGrpcService;
import org.whispersystems.textsecuregcm.grpc.ErrorConformanceInterceptor;
import org.whispersystems.textsecuregcm.grpc.ErrorMappingInterceptor;
import org.whispersystems.textsecuregcm.grpc.ExternalServiceCredentialsAnonymousGrpcService;
import org.whispersystems.textsecuregcm.grpc.ExternalServiceCredentialsGrpcService;
import org.whispersystems.textsecuregcm.grpc.ExternalServiceDefinitions;
import org.whispersystems.textsecuregcm.grpc.GroupSendTokenUtil;
import org.whispersystems.textsecuregcm.grpc.GrpcAllowListInterceptor;
import org.whispersystems.textsecuregcm.grpc.KeysAnonymousGrpcService;
@@ -986,9 +987,15 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
final ProhibitAuthenticationInterceptor prohibitAuthenticationInterceptor = new ProhibitAuthenticationInterceptor();
final GroupSendTokenUtil groupSendTokenUtil = new GroupSendTokenUtil(zkSecretParams, Clock.systemUTC());
final CertificateGenerator certificateGenerator =
new CertificateGenerator(config.getDeliveryCertificate().certificate(),
config.getDeliveryCertificate().ecPrivateKey(),
config.getDeliveryCertificate().expiresDays(),
config.getDeliveryCertificate().embedSigner());
final List<ServerServiceDefinition> authenticatedServices = Stream.of(
new AccountsGrpcService(accountsManager, rateLimiters, usernameHashZkProofVerifier, registrationRecoveryPasswordsManager),
ExternalServiceCredentialsGrpcService.createForAllExternalServices(config, rateLimiters),
new CredentialsGrpcService(accountsManager, certificateGenerator, zkAuthOperations, callingGenericZkSecretParams, rateLimiters, Clock.systemUTC(), ExternalServiceDefinitions.createExternalServiceList(config, Clock.systemUTC())),
new KeysGrpcService(accountsManager, keysManager, rateLimiters),
new ProfileGrpcService(clock, accountsManager, profilesManager, dynamicConfigurationManager, config.getBadges(), profileCdnPolicyGenerator, profileCdnPolicySigner, profileBadgeConverter, rateLimiters),
new MessagesGrpcService(accountsManager, rateLimiters, messageSender, messageByteLimitCardinalityEstimator, spamChecker, Clock.systemUTC()),
@@ -1017,7 +1024,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new ProfileAnonymousGrpcService(accountsManager, profilesManager, profileBadgeConverter, zkSecretParams),
new MessagesAnonymousGrpcService(accountsManager, rateLimiters, messageSender, groupSendTokenUtil, messageByteLimitCardinalityEstimator, spamChecker, Clock.systemUTC()),
new BackupsAnonymousGrpcService(backupManager, backupMetrics, config.getAttachments().maxAttachmentUploadSizeInBytes(), config.getAttachments().maxMessageBackupUploadSizeInBytes()),
ExternalServiceCredentialsAnonymousGrpcService.create(accountsManager, config))
new CredentialsAnonymousGrpcService(accountsManager, ExternalServiceDefinitions.SVR.generatorFactory().apply(config, Clock.systemUTC())))
.map(bindableService -> ServerInterceptors.intercept(bindableService,
// Note: interceptors run in the reverse order they are added; the remote deprecation filter
// depends on the user-agent context so it has to come first here!
@@ -1140,9 +1147,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new CallRoutingControllerV2(rateLimiters, cloudflareTurnCredentialsManager),
new CallLinkController(rateLimiters, callingGenericZkSecretParams),
new CallQualitySurveyController(callQualitySurveyManager),
new CertificateController(accountsManager, new CertificateGenerator(config.getDeliveryCertificate().certificate(),
config.getDeliveryCertificate().ecPrivateKey(), config.getDeliveryCertificate().expiresDays(), config.getDeliveryCertificate().embedSigner()),
zkAuthOperations, callingGenericZkSecretParams, clock),
new CertificateController(accountsManager, certificateGenerator, zkAuthOperations, callingGenericZkSecretParams, clock),
new ChallengeController(accountsManager, rateLimitChallengeManager, challengeConstraintChecker),
new DeviceController(accountsManager, rateLimiters, persistentTimer),
new DeviceCheckController(clock, accountsManager, backupAuthManager, appleDeviceCheckManager, rateLimiters,
@@ -20,7 +20,6 @@ import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.security.InvalidKeyException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
@@ -76,8 +75,7 @@ public class CertificateController {
@Produces(MediaType.APPLICATION_JSON)
@Path("/delivery")
public DeliveryCertificate getDeliveryCertificate(@Auth AuthenticatedDevice auth,
@QueryParam("includeE164") @DefaultValue("true") boolean includeE164)
throws InvalidKeyException {
@QueryParam("includeE164") @DefaultValue("true") boolean includeE164) {
Metrics.counter(GENERATE_DELIVERY_CERTIFICATE_COUNTER_NAME, INCLUDE_E164_TAG_NAME, String.valueOf(includeE164))
.increment();
@@ -1,53 +1,35 @@
/*
* Copyright 2023 Signal Messenger, LLC
* Copyright 2026 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.SimpleExternalServiceCredentialsAnonymousGrpc;
import org.whispersystems.textsecuregcm.WhisperServerConfiguration;
import org.signal.chat.credentials.SimpleCredentialsAnonymousGrpc;
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 java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
public class ExternalServiceCredentialsAnonymousGrpcService extends
SimpleExternalServiceCredentialsAnonymousGrpc.ExternalServiceCredentialsAnonymousImplBase {
public class CredentialsAnonymousGrpcService extends SimpleCredentialsAnonymousGrpc.CredentialsAnonymousImplBase {
private final AccountsManager accountsManager;
private final ExternalServiceCredentialsGenerator svrCredentialsGenerator;
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,
public CredentialsAnonymousGrpcService(final AccountsManager accountsManager,
final ExternalServiceCredentialsGenerator svrCredentialsGenerator) {
this.accountsManager = requireNonNull(accountsManager);
this.svrCredentialsGenerator = requireNonNull(svrCredentialsGenerator);
this.svrCredentialsGenerator = svrCredentialsGenerator;
this.accountsManager = accountsManager;
}
@Override
@@ -57,14 +39,18 @@ public class ExternalServiceCredentialsAnonymousGrpcService extends
tokens,
svrCredentialsGenerator,
MAX_SVR_PASSWORD_AGE_SECONDS);
// the username associated with the provided number
final Optional<String> maybeUsername = accountsManager.getByE164(request.getNumber())
.map(Account::getUuid)
.map(svrCredentialsGenerator::generateForUuid)
.map(ExternalServiceCredentials::username);
final CheckSvrCredentialsResponse.Builder builder = CheckSvrCredentialsResponse.newBuilder();
for (ExternalServiceCredentialsSelector.CredentialInfo credentialInfo : credentials) {
final AuthCheckResult authCheckResult;
if (!credentialInfo.valid()) {
authCheckResult = AuthCheckResult.AUTH_CHECK_RESULT_INVALID;
} else {
@@ -74,8 +60,10 @@ public class ExternalServiceCredentialsAnonymousGrpcService extends
? AuthCheckResult.AUTH_CHECK_RESULT_MATCH
: AuthCheckResult.AUTH_CHECK_RESULT_NO_MATCH;
}
builder.putMatches(credentialInfo.token(), authCheckResult);
}
return builder.build();
}
}
@@ -0,0 +1,136 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc;
import com.google.protobuf.ByteString;
import java.time.Clock;
import java.time.Instant;
import java.util.Map;
import org.signal.chat.credentials.ExternalServiceType;
import org.signal.chat.credentials.GetDeliveryCertificateRequest;
import org.signal.chat.credentials.GetDeliveryCertificateResponse;
import org.signal.chat.credentials.GetExternalServiceCredentialsRequest;
import org.signal.chat.credentials.GetExternalServiceCredentialsResponse;
import org.signal.chat.credentials.GetGroupCredentialsRequest;
import org.signal.chat.credentials.GetGroupCredentialsResponse;
import org.signal.chat.credentials.SimpleCredentialsGrpc;
import org.signal.libsignal.protocol.ServiceId;
import org.signal.libsignal.zkgroup.GenericServerSecretParams;
import org.signal.libsignal.zkgroup.auth.ServerZkAuthOperations;
import org.signal.libsignal.zkgroup.calllinks.CallLinkAuthCredentialResponse;
import org.whispersystems.textsecuregcm.auth.CertificateGenerator;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.auth.RedemptionRange;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.identity.IdentityType;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.util.UUIDUtil;
public class CredentialsGrpcService extends SimpleCredentialsGrpc.CredentialsImplBase {
private final AccountsManager accountsManager;
private final CertificateGenerator certificateGenerator;
private final ServerZkAuthOperations serverZkAuthOperations;
private final GenericServerSecretParams serverSecretParams;
private final RateLimiters rateLimiters;
private final Clock clock;
private final Map<ExternalServiceType, ExternalServiceCredentialsGenerator> credentialsGeneratorByType;
public CredentialsGrpcService(final AccountsManager accountsManager,
final CertificateGenerator certificateGenerator,
final ServerZkAuthOperations serverZkAuthOperations,
final GenericServerSecretParams serverSecretParams,
final RateLimiters rateLimiters,
final Clock clock,
final Map<ExternalServiceType, ExternalServiceCredentialsGenerator> credentialsGeneratorByType) {
this.accountsManager = accountsManager;
this.certificateGenerator = certificateGenerator;
this.serverZkAuthOperations = serverZkAuthOperations;
this.serverSecretParams = serverSecretParams;
this.rateLimiters = rateLimiters;
this.clock = clock;
this.credentialsGeneratorByType = credentialsGeneratorByType;
}
@Override
public GetExternalServiceCredentialsResponse getExternalServiceCredentials(final GetExternalServiceCredentialsRequest request)
throws RateLimitExceededException {
final ExternalServiceCredentialsGenerator credentialsGenerator = this.credentialsGeneratorByType
.get(request.getExternalService());
if (credentialsGenerator == null) {
throw GrpcExceptions.fieldViolation("externalService", "Invalid external service type");
}
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
rateLimiters.forDescriptor(RateLimiters.For.EXTERNAL_SERVICE_CREDENTIALS).validate(authenticatedDevice.accountIdentifier());
final ExternalServiceCredentials externalServiceCredentials = credentialsGenerator
.generateForUuid(authenticatedDevice.accountIdentifier());
return GetExternalServiceCredentialsResponse.newBuilder()
.setUsername(externalServiceCredentials.username())
.setPassword(externalServiceCredentials.password())
.build();
}
@Override
public GetDeliveryCertificateResponse getDeliveryCertificate(final GetDeliveryCertificateRequest request) {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())
.orElseThrow(() -> GrpcExceptions.invalidCredentials("invalid credentials"));
return GetDeliveryCertificateResponse.newBuilder()
.setCertificateWithE164(ByteString.copyFrom(
certificateGenerator.createFor(account, authenticatedDevice.deviceId(), true)))
.setCertificateWithoutE164(ByteString.copyFrom(
certificateGenerator.createFor(account, authenticatedDevice.deviceId(), false)))
.build();
}
@Override
public GetGroupCredentialsResponse getGroupCredentials(final GetGroupCredentialsRequest request) {
final RedemptionRange redemptionRange;
try {
redemptionRange = RedemptionRange.inclusive(clock,
Instant.ofEpochSecond(request.getRedemptionStartSeconds()),
Instant.ofEpochSecond(request.getRedemptionEndSeconds()));
} catch (final IllegalArgumentException e) {
throw GrpcExceptions.invalidArguments(e.getMessage());
}
final Account account =
accountsManager.getByAccountIdentifier(AuthenticationUtil.requireAuthenticatedDevice().accountIdentifier())
.orElseThrow(() -> GrpcExceptions.invalidCredentials("invalid credentials"));
final ServiceId.Aci aci = new ServiceId.Aci(account.getIdentifier(IdentityType.ACI));
final ServiceId.Pni pni = new ServiceId.Pni(account.getIdentifier(IdentityType.PNI));
final GetGroupCredentialsResponse.Builder responseBuilder = GetGroupCredentialsResponse.newBuilder()
.setPni(UUIDUtil.toByteString(pni.getRawUUID()));
for (final Instant redemption : redemptionRange) {
responseBuilder.addGroupCredentials(GetGroupCredentialsResponse.CredentialAndRedemptionTime.newBuilder()
.setRedemptionTimeSeconds(redemption.getEpochSecond())
.setCredential(ByteString.copyFrom(
serverZkAuthOperations.issueAuthCredentialWithPniZkc(aci, pni, redemption).serialize()))
.build());
responseBuilder.addCallLinkAuthCredentials(GetGroupCredentialsResponse.CredentialAndRedemptionTime.newBuilder()
.setRedemptionTimeSeconds(redemption.getEpochSecond())
.setCredential(ByteString.copyFrom(
CallLinkAuthCredentialResponse.issueCredential(aci, redemption, serverSecretParams).serialize()))
.build());
}
return responseBuilder.build();
}
}
@@ -1,66 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc;
import static java.util.Objects.requireNonNull;
import com.google.common.annotations.VisibleForTesting;
import java.time.Clock;
import java.util.Map;
import org.signal.chat.credentials.ExternalServiceType;
import org.signal.chat.credentials.GetExternalServiceCredentialsRequest;
import org.signal.chat.credentials.GetExternalServiceCredentialsResponse;
import org.signal.chat.credentials.SimpleExternalServiceCredentialsGrpc;
import org.whispersystems.textsecuregcm.WhisperServerConfiguration;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
public class ExternalServiceCredentialsGrpcService extends SimpleExternalServiceCredentialsGrpc.ExternalServiceCredentialsImplBase {
private final Map<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 GetExternalServiceCredentialsResponse getExternalServiceCredentials(final GetExternalServiceCredentialsRequest request)
throws RateLimitExceededException {
final ExternalServiceCredentialsGenerator credentialsGenerator = this.credentialsGeneratorByType
.get(request.getExternalService());
if (credentialsGenerator == null) {
throw GrpcExceptions.fieldViolation("externalService", "Invalid external service type");
}
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
rateLimiters.forDescriptor(RateLimiters.For.EXTERNAL_SERVICE_CREDENTIALS).validate(authenticatedDevice.accountIdentifier());
final ExternalServiceCredentials externalServiceCredentials = credentialsGenerator
.generateForUuid(authenticatedDevice.accountIdentifier());
return GetExternalServiceCredentialsResponse.newBuilder()
.setUsername(externalServiceCredentials.username())
.setPassword(externalServiceCredentials.password())
.build();
}
}
@@ -20,7 +20,7 @@ import org.whispersystems.textsecuregcm.configuration.DirectoryV2ClientConfigura
import org.whispersystems.textsecuregcm.configuration.PaymentsServiceConfiguration;
import org.whispersystems.textsecuregcm.configuration.SecureValueRecoveryConfiguration;
enum ExternalServiceDefinitions {
public enum ExternalServiceDefinitions {
DIRECTORY(ExternalServiceType.EXTERNAL_SERVICE_TYPE_DIRECTORY, (chatConfig, clock) -> {
final DirectoryV2ClientConfiguration cfg = chatConfig.getDirectoryV2Configuration().getDirectoryV2ClientConfiguration();
return ExternalServiceCredentialsGenerator
@@ -30,7 +30,7 @@ enum ExternalServiceDefinitions {
.withClock(clock)
.build();
}),
PAYMENTS(ExternalServiceType.EXTERNAL_SERVICE_TYPE_PAYMENTS, (chatConfig, clock) -> {
PAYMENTS(ExternalServiceType.EXTERNAL_SERVICE_TYPE_PAYMENTS, (chatConfig, _) -> {
final PaymentsServiceConfiguration cfg = chatConfig.getPaymentsServiceConfiguration();
return ExternalServiceCredentialsGenerator
.builder(cfg.userAuthenticationTokenSharedSecret())
@@ -47,7 +47,7 @@ enum ExternalServiceDefinitions {
.withClock(clock)
.build();
}),
STORAGE(ExternalServiceType.EXTERNAL_SERVICE_TYPE_STORAGE, (chatConfig, clock) -> {
STORAGE(ExternalServiceType.EXTERNAL_SERVICE_TYPE_STORAGE, (chatConfig, _) -> {
final PaymentsServiceConfiguration cfg = chatConfig.getPaymentsServiceConfiguration();
return ExternalServiceCredentialsGenerator
.builder(cfg.userAuthenticationTokenSharedSecret())
@@ -11,20 +11,35 @@ import "org/signal/chat/require.proto";
package org.signal.chat.credentials;
// Provides methods for obtaining and verifying credentials for "external" services
// (i.e. services that are not a part of the chat server deployment).
// All methods of this service require authentication.
service ExternalServiceCredentials {
// Provides methods for obtaining and verifying credentials that allow an
// authenticated user to authenticate in another service or context without
// revealing their identity.
service Credentials {
// Generates and returns an external service credentials for the caller.
rpc GetExternalServiceCredentials(GetExternalServiceCredentialsRequest)
returns (GetExternalServiceCredentialsResponse) {}
// Generates a pair of delivery certificates that the holder can include to
// identify themselves to a message recipient (but not the server) in a
// sealed-sender message.
rpc GetDeliveryCertificate(GetDeliveryCertificateRequest)
returns (GetDeliveryCertificateResponse) {}
// Generates a set of zero-knowledge credentials for various group-related
// actions.
rpc GetGroupCredentials(GetGroupCredentialsRequest)
returns (GetGroupCredentialsResponse) {}
}
service ExternalServiceCredentialsAnonymous {
// Given a list of secure value recovery (SVR) service credentials and a phone number,
// checks, which of the provided credentials were generated by the user with the given phone number
// and have not yet expired.
// Provides methods for working with previously-generated credentials without
// revealing an association between the credentials and the caller's identity to
// the server.
service CredentialsAnonymous {
// Given a list of secure value recovery (SVR) service credentials and a phone
// number, checks and returns which of the provided credentials were generated
// by the user with the given phone number and have not yet expired.
rpc CheckSvrCredentials(CheckSvrCredentialsRequest)
returns (CheckSvrCredentialsResponse) {}
}
@@ -79,3 +94,51 @@ message CheckSvrCredentialsResponse {
map<string, AuthCheckResult> matches = 1;
}
message GetDeliveryCertificateRequest {
}
// A pair of message delivery certificates. The response unconditionally
// includes certificates with and without the caller's phone number so the
// server never learns anything about the caller's intent to share their phone
// number with their contacts.
message GetDeliveryCertificateResponse {
// A delivery receipt that includes the caller's phone number
bytes certificate_with_e164 = 1;
// A delivery receipt that does not include the caller's phone number
bytes certificate_without_e164 = 2;
}
message GetGroupCredentialsRequest {
// The earliest time for which to issue group credentials; must be aligned to
// a UTC day boundary and may be no more than one day in the past at the time
// of the call.
uint64 redemption_start_seconds = 1;
// The latest time for which to issue group credentials; must be aligned to a
// UTC day boundary and no more than seven days in the future at the time of
// the call.
uint64 redemption_end_seconds = 2;
}
message GetGroupCredentialsResponse {
// A zero-knowledge credential that may be redeemed at or up to one day after
// the given redemption time
message CredentialAndRedemptionTime {
bytes credential = 1;
uint64 redemption_time_seconds = 2;
}
// A collection of credentials allowing the holder to anonymously
// authenticate themselves for group-related actions
repeated CredentialAndRedemptionTime group_credentials = 1;
// A collection of credentials allowing the holder to read, update, and delete
// group call links
repeated CredentialAndRedemptionTime call_link_auth_credentials = 2;
// The phone number identifier for which the included credentials were
// generated
bytes pni = 3;
}
@@ -7,6 +7,8 @@ package org.whispersystems.textsecuregcm.grpc;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import io.grpc.Status;
@@ -25,7 +27,7 @@ import org.mockito.Mockito;
import org.signal.chat.credentials.AuthCheckResult;
import org.signal.chat.credentials.CheckSvrCredentialsRequest;
import org.signal.chat.credentials.CheckSvrCredentialsResponse;
import org.signal.chat.credentials.ExternalServiceCredentialsAnonymousGrpc;
import org.signal.chat.credentials.CredentialsAnonymousGrpc;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.storage.Account;
@@ -34,8 +36,8 @@ import org.whispersystems.textsecuregcm.util.MockUtils;
import org.whispersystems.textsecuregcm.util.MutableClock;
import org.whispersystems.textsecuregcm.util.TestRandomUtil;
class ExternalServiceCredentialsAnonymousGrpcServiceTest extends
SimpleBaseGrpcTest<ExternalServiceCredentialsAnonymousGrpcService, ExternalServiceCredentialsAnonymousGrpc.ExternalServiceCredentialsAnonymousBlockingStub> {
class CredentialsAnonymousGrpcServiceTest extends
SimpleBaseGrpcTest<CredentialsAnonymousGrpcService, CredentialsAnonymousGrpc.CredentialsAnonymousBlockingStub> {
private static final UUID USER_UUID = UUID.randomUUID();
@@ -46,29 +48,33 @@ class ExternalServiceCredentialsAnonymousGrpcServiceTest extends
private static final MutableClock CLOCK = MockUtils.mutableClock(0);
private static final ExternalServiceCredentialsGenerator SVR_CREDENTIALS_GENERATOR = Mockito.spy(ExternalServiceCredentialsGenerator
.builder(TestRandomUtil.nextBytes(32))
.withUserDerivationKey(TestRandomUtil.nextBytes(32))
.prependUsername(false)
.withDerivedUsernameTruncateLength(16)
.withClock(CLOCK)
.build());
private static final ExternalServiceCredentialsGenerator SVR_CREDENTIALS_GENERATOR =
Mockito.spy(ExternalServiceCredentialsGenerator
.builder(TestRandomUtil.nextBytes(32))
.withUserDerivationKey(TestRandomUtil.nextBytes(32))
.prependUsername(false)
.withDerivedUsernameTruncateLength(16)
.withClock(CLOCK)
.build());
@Mock
private AccountsManager accountsManager;
@Override
protected ExternalServiceCredentialsAnonymousGrpcService createServiceBeforeEachTest() {
return new ExternalServiceCredentialsAnonymousGrpcService(accountsManager, SVR_CREDENTIALS_GENERATOR);
protected CredentialsAnonymousGrpcService createServiceBeforeEachTest() {
return new CredentialsAnonymousGrpcService(accountsManager, SVR_CREDENTIALS_GENERATOR);
}
@BeforeEach
public void setup() {
Mockito.when(accountsManager.getByE164(USER_E164)).thenReturn(Optional.of(account(USER_UUID)));
final Account account = mock(Account.class);
when(account.getUuid()).thenReturn(USER_UUID);
when(accountsManager.getByE164(USER_E164)).thenReturn(Optional.of(account));
}
@Test
public void testOneMatch() throws Exception {
public void testOneMatch() {
final UUID user2 = UUID.randomUUID();
final UUID user3 = UUID.randomUUID();
assertExpectedCredentialCheckResponse(Map.of(
@@ -79,7 +85,7 @@ class ExternalServiceCredentialsAnonymousGrpcServiceTest extends
}
@Test
public void testNoMatch() throws Exception {
public void testNoMatch() {
final UUID user2 = UUID.randomUUID();
final UUID user3 = UUID.randomUUID();
assertExpectedCredentialCheckResponse(Map.of(
@@ -89,7 +95,7 @@ class ExternalServiceCredentialsAnonymousGrpcServiceTest extends
}
@Test
public void testSomeInvalid() throws Exception {
public void testSomeInvalid() {
final UUID user2 = UUID.randomUUID();
final UUID user3 = UUID.randomUUID();
final ExternalServiceCredentials user1Cred = credentials(USER_UUID, day(1));
@@ -105,7 +111,7 @@ class ExternalServiceCredentialsAnonymousGrpcServiceTest extends
}
@Test
public void testSomeExpired() throws Exception {
public void testSomeExpired() {
final UUID user2 = UUID.randomUUID();
final UUID user3 = UUID.randomUUID();
assertExpectedCredentialCheckResponse(Map.of(
@@ -117,7 +123,7 @@ class ExternalServiceCredentialsAnonymousGrpcServiceTest extends
}
@Test
public void testSomeHaveNewerVersions() throws Exception {
public void testSomeHaveNewerVersions() {
final UUID user2 = UUID.randomUUID();
final UUID user3 = UUID.randomUUID();
assertExpectedCredentialCheckResponse(Map.of(
@@ -134,7 +140,7 @@ class ExternalServiceCredentialsAnonymousGrpcServiceTest extends
public void testInvalidPasswordCount(int count) {
final CheckSvrCredentialsRequest request = CheckSvrCredentialsRequest.newBuilder()
.setNumber(USER_E164)
.addAllPasswords(IntStream.range(0, count).mapToObj(i -> token(UUID.randomUUID(), day(10))).toList())
.addAllPasswords(IntStream.range(0, count).mapToObj(_ -> token(UUID.randomUUID(), day(10))).toList())
.build();
final StatusRuntimeException status = assertThrows(StatusRuntimeException.class,
() -> unauthenticatedServiceStub().checkSvrCredentials(request));
@@ -143,7 +149,7 @@ class ExternalServiceCredentialsAnonymousGrpcServiceTest extends
private void assertExpectedCredentialCheckResponse(
final Map<String, AuthCheckResult> expected,
final long nowMillis) throws Exception {
final long nowMillis) {
CLOCK.setTimeMillis(nowMillis);
final CheckSvrCredentialsRequest request = CheckSvrCredentialsRequest.newBuilder()
.setNumber(USER_E164)
@@ -170,10 +176,4 @@ class ExternalServiceCredentialsAnonymousGrpcServiceTest extends
private static long day(final int n) {
return Duration.ofDays(n).toMillis();
}
private static Account account(final UUID uuid) {
final Account a = new Account();
a.setUuid(uuid);
return a;
}
}
@@ -0,0 +1,357 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertRateLimitExceeded;
import static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertStatusException;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import com.google.protobuf.ByteString;
import com.google.protobuf.InvalidProtocolBufferException;
import io.grpc.Status;
import java.io.UncheckedIOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.signal.chat.credentials.CredentialsGrpc;
import org.signal.chat.credentials.ExternalServiceType;
import org.signal.chat.credentials.GetDeliveryCertificateRequest;
import org.signal.chat.credentials.GetDeliveryCertificateResponse;
import org.signal.chat.credentials.GetExternalServiceCredentialsRequest;
import org.signal.chat.credentials.GetExternalServiceCredentialsResponse;
import org.signal.chat.credentials.GetGroupCredentialsRequest;
import org.signal.chat.credentials.GetGroupCredentialsResponse;
import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.protocol.ServiceId;
import org.signal.libsignal.protocol.ecc.ECKeyPair;
import org.signal.libsignal.protocol.ecc.ECPublicKey;
import org.signal.libsignal.zkgroup.GenericServerSecretParams;
import org.signal.libsignal.zkgroup.ServerSecretParams;
import org.signal.libsignal.zkgroup.auth.AuthCredentialWithPniResponse;
import org.signal.libsignal.zkgroup.auth.ClientZkAuthOperations;
import org.signal.libsignal.zkgroup.auth.ServerZkAuthOperations;
import org.signal.libsignal.zkgroup.calllinks.CallLinkAuthCredentialResponse;
import org.whispersystems.textsecuregcm.auth.CertificateGenerator;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.controllers.CertificateController;
import org.whispersystems.textsecuregcm.entities.MessageProtos;
import org.whispersystems.textsecuregcm.identity.IdentityType;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.util.MockUtils;
import org.whispersystems.textsecuregcm.util.TestClock;
import org.whispersystems.textsecuregcm.util.TestRandomUtil;
import org.whispersystems.textsecuregcm.util.UUIDUtil;
import reactor.core.publisher.Mono;
public class CredentialsGrpcServiceTest
extends SimpleBaseGrpcTest<CredentialsGrpcService, CredentialsGrpc.CredentialsBlockingStub> {
@Mock
private AccountsManager accountsManager;
@Mock
private RateLimiters rateLimiters;
@Mock
private Account authenticatedAccount;
private static final ExternalServiceCredentialsGenerator DIRECTORY_CREDENTIALS_GENERATOR = Mockito.spy(ExternalServiceCredentialsGenerator
.builder(TestRandomUtil.nextBytes(32))
.withUserDerivationKey(TestRandomUtil.nextBytes(32))
.prependUsername(false)
.truncateSignature(false)
.build());
private static final ExternalServiceCredentialsGenerator PAYMENTS_CREDENTIALS_GENERATOR = Mockito.spy(ExternalServiceCredentialsGenerator
.builder(TestRandomUtil.nextBytes(32))
.prependUsername(true)
.build());
private static final UUID AUTHENTICATED_PNI = UUID.randomUUID();
private static final String PHONE_NUMBER = PhoneNumberUtil.getInstance().format(
PhoneNumberUtil.getInstance().getExampleNumber("US"), PhoneNumberUtil.PhoneNumberFormat.E164);
private static ECKeyPair IDENTITY_KEY_PAIR;
private static ECKeyPair SERVER_KEY_PAIR;
private static MessageProtos.ServerCertificate SIGNED_SERVER_CERTIFICATE;
private static ServerSecretParams SERVER_SECRET_PARAMS;
private static GenericServerSecretParams GENERIC_SERVER_SECRET_PARAMS;
private static ServerZkAuthOperations SERVER_ZK_AUTH_OPERATIONS;
private static final Clock CLOCK = TestClock.pinned(Instant.now());
@BeforeAll
static void setUpBeforeAll() {
IDENTITY_KEY_PAIR = ECKeyPair.generate();
final ECKeyPair caKeyPair = ECKeyPair.generate();
SERVER_KEY_PAIR = ECKeyPair.generate();
final MessageProtos.ServerCertificate.Certificate serverCertificate =
MessageProtos.ServerCertificate.Certificate.newBuilder()
.setId(ThreadLocalRandom.current().nextInt())
.setKey(ByteString.copyFrom(SERVER_KEY_PAIR.getPublicKey().serialize()))
.build();
final byte[] signature = caKeyPair.getPrivateKey().calculateSignature(serverCertificate.toByteArray());
SIGNED_SERVER_CERTIFICATE = MessageProtos.ServerCertificate.newBuilder()
.setCertificate(ByteString.copyFrom(serverCertificate.toByteArray()))
.setSignature(ByteString.copyFrom(signature))
.build();
SERVER_SECRET_PARAMS = ServerSecretParams.generate();
GENERIC_SERVER_SECRET_PARAMS = GenericServerSecretParams.generate();
SERVER_ZK_AUTH_OPERATIONS = new ServerZkAuthOperations(SERVER_SECRET_PARAMS);
}
@BeforeEach
void setUp() {
when(authenticatedAccount.getUuid()).thenReturn(AUTHENTICATED_ACI);
when(authenticatedAccount.getNumber()).thenReturn(PHONE_NUMBER);
when(authenticatedAccount.getIdentifier(IdentityType.ACI)).thenReturn(AUTHENTICATED_ACI);
when(authenticatedAccount.getIdentifier(IdentityType.PNI)).thenReturn(AUTHENTICATED_PNI);
when(authenticatedAccount.getIdentityKey(IdentityType.ACI))
.thenReturn(new IdentityKey(IDENTITY_KEY_PAIR.getPublicKey()));
when(accountsManager.getByAccountIdentifier(AUTHENTICATED_ACI)).thenReturn(Optional.of(authenticatedAccount));
}
@Override
protected CredentialsGrpcService createServiceBeforeEachTest() {
final CertificateGenerator certificateGenerator;
try {
certificateGenerator = new CertificateGenerator(SIGNED_SERVER_CERTIFICATE.toByteArray(),
SERVER_KEY_PAIR.getPrivateKey(),
1,
false);
} catch (final InvalidProtocolBufferException e) {
throw new UncheckedIOException(e);
}
return new CredentialsGrpcService(accountsManager,
certificateGenerator,
SERVER_ZK_AUTH_OPERATIONS,
GENERIC_SERVER_SECRET_PARAMS,
rateLimiters,
CLOCK,
Map.of(
ExternalServiceType.EXTERNAL_SERVICE_TYPE_DIRECTORY, DIRECTORY_CREDENTIALS_GENERATOR,
ExternalServiceType.EXTERNAL_SERVICE_TYPE_PAYMENTS, PAYMENTS_CREDENTIALS_GENERATOR
));
}
static Stream<Arguments> testSuccess() {
return Stream.of(
Arguments.of(ExternalServiceType.EXTERNAL_SERVICE_TYPE_DIRECTORY, DIRECTORY_CREDENTIALS_GENERATOR),
Arguments.of(ExternalServiceType.EXTERNAL_SERVICE_TYPE_PAYMENTS, PAYMENTS_CREDENTIALS_GENERATOR)
);
}
@ParameterizedTest
@MethodSource
public void testSuccess(
final ExternalServiceType externalServiceType,
final ExternalServiceCredentialsGenerator credentialsGenerator) throws Exception {
final RateLimiter limiter = mock(RateLimiter.class);
doReturn(limiter).when(rateLimiters).forDescriptor(eq(RateLimiters.For.EXTERNAL_SERVICE_CREDENTIALS));
doReturn(Mono.fromFuture(CompletableFuture.completedFuture(null))).when(limiter).validateReactive(eq(AUTHENTICATED_ACI));
final GetExternalServiceCredentialsResponse artResponse = authenticatedServiceStub().getExternalServiceCredentials(
GetExternalServiceCredentialsRequest.newBuilder()
.setExternalService(externalServiceType)
.build());
final Optional<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));
Mockito.reset(DIRECTORY_CREDENTIALS_GENERATOR);
assertRateLimitExceeded(
retryAfter,
() -> authenticatedServiceStub().getExternalServiceCredentials(
GetExternalServiceCredentialsRequest.newBuilder()
.setExternalService(ExternalServiceType.EXTERNAL_SERVICE_TYPE_DIRECTORY)
.build()),
DIRECTORY_CREDENTIALS_GENERATOR
);
}
/**
* `ExternalServiceDefinitions` enum is supposed to have entries for all values in `ExternalServiceType`,
* except for the `EXTERNAL_SERVICE_TYPE_UNSPECIFIED` and `UNRECOGNIZED`.
* This test makes sure that is the case.
*/
@ParameterizedTest
@EnumSource(mode = EnumSource.Mode.EXCLUDE, names = { "UNRECOGNIZED", "EXTERNAL_SERVICE_TYPE_UNSPECIFIED" })
public void testHaveExternalServiceDefinitionForServiceTypes(final ExternalServiceType externalServiceType) throws Exception {
assertTrue(
Arrays.stream(ExternalServiceDefinitions.values()).anyMatch(v -> v.externalService() == externalServiceType),
"`ExternalServiceDefinitions` enum entry is missing for the `%s` value of `ExternalServiceType`".formatted(externalServiceType)
);
}
@Test
void getDeliveryCertificate() throws InvalidProtocolBufferException, InvalidKeyException {
final GetDeliveryCertificateResponse response =
authenticatedServiceStub().getDeliveryCertificate(GetDeliveryCertificateRequest.getDefaultInstance());
checkDeliveryCertificate(response.getCertificateWithE164().toByteArray(), true);
checkDeliveryCertificate(response.getCertificateWithoutE164().toByteArray(), false);
}
private static void checkDeliveryCertificate(final byte[] deliveryCertificate, final boolean expectE164)
throws InvalidProtocolBufferException, InvalidKeyException {
final MessageProtos.SenderCertificate senderCertificateHolder =
MessageProtos.SenderCertificate.parseFrom(deliveryCertificate);
final MessageProtos.SenderCertificate.Certificate senderCertificate =
MessageProtos.SenderCertificate.Certificate.parseFrom(senderCertificateHolder.getCertificate());
final MessageProtos.ServerCertificate.Certificate serverCertificate =
MessageProtos.ServerCertificate.Certificate.parseFrom(SIGNED_SERVER_CERTIFICATE.getCertificate());
assertEquals(serverCertificate.getId(), senderCertificate.getSignerId());
final ECPublicKey serverPublicKey = new ECPublicKey(serverCertificate.getKey().toByteArray());
assertTrue(serverPublicKey.verifySignature(senderCertificateHolder.getCertificate().toByteArray(),
senderCertificateHolder.getSignature().toByteArray()));
assertEquals(expectE164, senderCertificate.hasSenderE164());
if (expectE164) {
assertEquals(PHONE_NUMBER, senderCertificate.getSenderE164());
}
assertEquals(AUTHENTICATED_DEVICE_ID, senderCertificate.getSenderDevice());
assertTrue(senderCertificate.hasSenderUuid());
assertEquals(UUIDUtil.toByteString(AUTHENTICATED_ACI), senderCertificate.getSenderUuid());
assertArrayEquals(senderCertificate.getIdentityKey().toByteArray(),
new IdentityKey(IDENTITY_KEY_PAIR.getPublicKey()).serialize());
}
@ParameterizedTest
@ValueSource(ints = {0, 1, 2, 3, 4, 5, 6, 7})
void getGroupCredentials(final int redemptionEndOffsetDays) {
final Instant startOfDay = CLOCK.instant().truncatedTo(ChronoUnit.DAYS);
final GetGroupCredentialsResponse response =
authenticatedServiceStub().getGroupCredentials(GetGroupCredentialsRequest.newBuilder()
.setRedemptionStartSeconds(startOfDay.getEpochSecond())
.setRedemptionEndSeconds(startOfDay.plus(Duration.ofDays(redemptionEndOffsetDays)).getEpochSecond())
.build());
assertEquals(AUTHENTICATED_PNI, UUIDUtil.fromByteString(response.getPni()));
assertEquals(redemptionEndOffsetDays + 1, response.getGroupCredentialsCount());
assertEquals(redemptionEndOffsetDays + 1, response.getCallLinkAuthCredentialsCount());
final ClientZkAuthOperations clientZkAuthOperations =
new ClientZkAuthOperations(SERVER_SECRET_PARAMS.getPublicParams());
for (int i = 0; i < redemptionEndOffsetDays; i++) {
final Instant redemptionTime = startOfDay.plus(Duration.ofDays(i));
assertEquals(redemptionTime.getEpochSecond(), response.getGroupCredentials(i).getRedemptionTimeSeconds());
assertEquals(redemptionTime.getEpochSecond(), response.getCallLinkAuthCredentials(i).getRedemptionTimeSeconds());
final int index = i;
assertDoesNotThrow(() -> {
clientZkAuthOperations.receiveAuthCredentialWithPniAsServiceId(
new ServiceId.Aci(AUTHENTICATED_ACI),
new ServiceId.Pni(AUTHENTICATED_PNI),
redemptionTime.getEpochSecond(),
new AuthCredentialWithPniResponse(response.getGroupCredentials(index).getCredential().toByteArray()));
});
assertDoesNotThrow(() -> {
new CallLinkAuthCredentialResponse(response.getCallLinkAuthCredentials(index).getCredential().toByteArray())
.receive(new ServiceId.Aci(AUTHENTICATED_ACI), redemptionTime, GENERIC_SERVER_SECRET_PARAMS.getPublicParams());
});
}
}
@ParameterizedTest
@MethodSource
void getGroupCredentialsIllegalRedemptionTimes(final Instant redemptionStart, final Instant redemptionEnd) {
//noinspection ThrowableNotThrown
GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT, () -> {
//noinspection ResultOfMethodCallIgnored
authenticatedServiceStub().getGroupCredentials(GetGroupCredentialsRequest.newBuilder()
.setRedemptionStartSeconds(redemptionStart.getEpochSecond())
.setRedemptionEndSeconds(redemptionEnd.getEpochSecond())
.build());
});
}
private static Collection<Arguments> getGroupCredentialsIllegalRedemptionTimes() {
final Instant startOfDay = CLOCK.instant().truncatedTo(ChronoUnit.DAYS);
return List.of(
Arguments.argumentSet("Start is after end", startOfDay.plus(Duration.ofDays(1)), startOfDay),
Arguments.argumentSet("Start is in the past", startOfDay.minus(Duration.ofDays(2)), startOfDay),
Arguments.argumentSet("End is too far in the future", startOfDay,
startOfDay.plus(CertificateController.MAX_REDEMPTION_DURATION).plus(Duration.ofDays(1))),
Arguments.argumentSet("Start is not at a day boundary", startOfDay.plusSeconds(17),
startOfDay.plus(Duration.ofDays(1))),
Arguments.argumentSet("End is not at a day boundary", startOfDay, startOfDay.plusSeconds(17))
);
}
}
@@ -1,138 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertRateLimitExceeded;
import static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertStatusException;
import static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertStatusUnauthenticated;
import io.grpc.Status;
import java.time.Duration;
import java.util.Arrays;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.signal.chat.credentials.ExternalServiceCredentialsGrpc;
import org.signal.chat.credentials.ExternalServiceType;
import org.signal.chat.credentials.GetExternalServiceCredentialsRequest;
import org.signal.chat.credentials.GetExternalServiceCredentialsResponse;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.util.MockUtils;
import org.whispersystems.textsecuregcm.util.TestRandomUtil;
import reactor.core.publisher.Mono;
public class ExternalServiceCredentialsGrpcServiceTest
extends SimpleBaseGrpcTest<ExternalServiceCredentialsGrpcService, ExternalServiceCredentialsGrpc.ExternalServiceCredentialsBlockingStub> {
private static final ExternalServiceCredentialsGenerator DIRECTORY_CREDENTIALS_GENERATOR = Mockito.spy(ExternalServiceCredentialsGenerator
.builder(TestRandomUtil.nextBytes(32))
.withUserDerivationKey(TestRandomUtil.nextBytes(32))
.prependUsername(false)
.truncateSignature(false)
.build());
private static final ExternalServiceCredentialsGenerator PAYMENTS_CREDENTIALS_GENERATOR = Mockito.spy(ExternalServiceCredentialsGenerator
.builder(TestRandomUtil.nextBytes(32))
.prependUsername(true)
.build());
@Mock
private RateLimiters rateLimiters;
@Override
protected ExternalServiceCredentialsGrpcService createServiceBeforeEachTest() {
return new ExternalServiceCredentialsGrpcService(Map.of(
ExternalServiceType.EXTERNAL_SERVICE_TYPE_DIRECTORY, DIRECTORY_CREDENTIALS_GENERATOR,
ExternalServiceType.EXTERNAL_SERVICE_TYPE_PAYMENTS, PAYMENTS_CREDENTIALS_GENERATOR
), rateLimiters);
}
static Stream<Arguments> testSuccess() {
return Stream.of(
Arguments.of(ExternalServiceType.EXTERNAL_SERVICE_TYPE_DIRECTORY, DIRECTORY_CREDENTIALS_GENERATOR),
Arguments.of(ExternalServiceType.EXTERNAL_SERVICE_TYPE_PAYMENTS, PAYMENTS_CREDENTIALS_GENERATOR)
);
}
@ParameterizedTest
@MethodSource
public void testSuccess(
final ExternalServiceType externalServiceType,
final ExternalServiceCredentialsGenerator credentialsGenerator) throws Exception {
final RateLimiter limiter = mock(RateLimiter.class);
doReturn(limiter).when(rateLimiters).forDescriptor(eq(RateLimiters.For.EXTERNAL_SERVICE_CREDENTIALS));
doReturn(Mono.fromFuture(CompletableFuture.completedFuture(null))).when(limiter).validateReactive(eq(AUTHENTICATED_ACI));
final GetExternalServiceCredentialsResponse artResponse = authenticatedServiceStub().getExternalServiceCredentials(
GetExternalServiceCredentialsRequest.newBuilder()
.setExternalService(externalServiceType)
.build());
final Optional<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));
Mockito.reset(DIRECTORY_CREDENTIALS_GENERATOR);
assertRateLimitExceeded(
retryAfter,
() -> authenticatedServiceStub().getExternalServiceCredentials(
GetExternalServiceCredentialsRequest.newBuilder()
.setExternalService(ExternalServiceType.EXTERNAL_SERVICE_TYPE_DIRECTORY)
.build()),
DIRECTORY_CREDENTIALS_GENERATOR
);
}
/**
* `ExternalServiceDefinitions` enum is supposed to have entries for all values in `ExternalServiceType`,
* except for the `EXTERNAL_SERVICE_TYPE_UNSPECIFIED` and `UNRECOGNIZED`.
* This test makes sure that is the case.
*/
@ParameterizedTest
@EnumSource(mode = EnumSource.Mode.EXCLUDE, names = { "UNRECOGNIZED", "EXTERNAL_SERVICE_TYPE_UNSPECIFIED" })
public void testHaveExternalServiceDefinitionForServiceTypes(final ExternalServiceType externalServiceType) throws Exception {
assertTrue(
Arrays.stream(ExternalServiceDefinitions.values()).anyMatch(v -> v.externalService() == externalServiceType),
"`ExternalServiceDefinitions` enum entry is missing for the `%s` value of `ExternalServiceType`".formatted(externalServiceType)
);
}
}