diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java index 17dc5c497..9384c2ffa 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java @@ -379,7 +379,7 @@ public class AccountController { ) @ApiResponse(responseCode = "200", description = "Account found for the given username.", useReturnTypeSchema = true) @ApiResponse(responseCode = "400", description = "Request must not be authenticated.") - @ApiResponse(responseCode = "404", description = "Account not fount for the given username.") + @ApiResponse(responseCode = "404", description = "Account not found for the given username.") public CompletableFuture lookupUsernameHash( @Auth final Optional maybeAuthenticatedAccount, @PathParam("usernameHash") final String usernameHash) { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/AccountsAnonymousGrpcService.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/AccountsAnonymousGrpcService.java new file mode 100644 index 000000000..8936cb89d --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/AccountsAnonymousGrpcService.java @@ -0,0 +1,90 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.grpc; + +import com.google.protobuf.ByteString; +import io.grpc.Status; +import org.signal.chat.account.CheckAccountExistenceRequest; +import org.signal.chat.account.CheckAccountExistenceResponse; +import org.signal.chat.account.LookupUsernameHashRequest; +import org.signal.chat.account.LookupUsernameHashResponse; +import org.signal.chat.account.LookupUsernameLinkRequest; +import org.signal.chat.account.LookupUsernameLinkResponse; +import org.signal.chat.account.ReactorAccountsAnonymousGrpc; +import org.whispersystems.textsecuregcm.controllers.AccountController; +import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier; +import org.whispersystems.textsecuregcm.identity.ServiceIdentifier; +import org.whispersystems.textsecuregcm.limits.RateLimiters; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.AccountsManager; +import org.whispersystems.textsecuregcm.util.UUIDUtil; +import reactor.core.publisher.Mono; +import java.util.Optional; +import java.util.UUID; + +public class AccountsAnonymousGrpcService extends ReactorAccountsAnonymousGrpc.AccountsAnonymousImplBase { + + private final AccountsManager accountsManager; + private final RateLimiters rateLimiters; + + public AccountsAnonymousGrpcService(final AccountsManager accountsManager, final RateLimiters rateLimiters) { + this.accountsManager = accountsManager; + this.rateLimiters = rateLimiters; + } + + @Override + public Mono checkAccountExistence(final CheckAccountExistenceRequest request) { + final ServiceIdentifier serviceIdentifier = + ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getServiceIdentifier()); + + return RateLimitUtil.rateLimitByRemoteAddress(rateLimiters.getCheckAccountExistenceLimiter()) + .then(Mono.fromFuture(() -> accountsManager.getByServiceIdentifierAsync(serviceIdentifier))) + .map(Optional::isPresent) + .map(accountExists -> CheckAccountExistenceResponse.newBuilder() + .setAccountExists(accountExists) + .build()); + } + + @Override + public Mono lookupUsernameHash(final LookupUsernameHashRequest request) { + if (request.getUsernameHash().size() != AccountController.USERNAME_HASH_LENGTH) { + throw Status.INVALID_ARGUMENT + .withDescription(String.format("Illegal username hash length; expected %d bytes, but got %d bytes", + AccountController.USERNAME_HASH_LENGTH, request.getUsernameHash().size())) + .asRuntimeException(); + } + + return RateLimitUtil.rateLimitByRemoteAddress(rateLimiters.getUsernameLookupLimiter()) + .then(Mono.fromFuture(() -> accountsManager.getByUsernameHash(request.getUsernameHash().toByteArray()))) + .map(maybeAccount -> maybeAccount.orElseThrow(Status.NOT_FOUND::asRuntimeException)) + .map(account -> LookupUsernameHashResponse.newBuilder() + .setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(account.getUuid()))) + .build()); + } + + @Override + public Mono lookupUsernameLink(final LookupUsernameLinkRequest request) { + final UUID linkHandle; + + try { + linkHandle = UUIDUtil.fromByteString(request.getUsernameLinkHandle()); + } catch (final IllegalArgumentException e) { + throw Status.INVALID_ARGUMENT + .withDescription("Could not interpret link handle as UUID") + .withCause(e) + .asRuntimeException(); + } + + return RateLimitUtil.rateLimitByRemoteAddress(rateLimiters.getUsernameLinkLookupLimiter()) + .then(Mono.fromFuture(() -> accountsManager.getByUsernameLinkHandle(linkHandle))) + .map(maybeAccount -> maybeAccount + .flatMap(Account::getEncryptedUsername) + .orElseThrow(Status.NOT_FOUND::asRuntimeException)) + .map(usernameCiphertext -> LookupUsernameLinkResponse.newBuilder() + .setUsernameCiphertext(ByteString.copyFrom(usernameCiphertext)) + .build()); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java index bb6f8f23a..37cee6c9e 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java @@ -163,6 +163,10 @@ public class RateLimiters extends BaseRateLimiters { return forDescriptor(For.USERNAME_LOOKUP); } + public RateLimiter getUsernameLinkLookupLimiter() { + return forDescriptor(For.USERNAME_LINK_LOOKUP_PER_IP); + } + public RateLimiter getUsernameSetLimiter() { return forDescriptor(For.USERNAME_SET); } diff --git a/service/src/main/proto/org/signal/chat/account.proto b/service/src/main/proto/org/signal/chat/account.proto new file mode 100644 index 000000000..d71e93878 --- /dev/null +++ b/service/src/main/proto/org/signal/chat/account.proto @@ -0,0 +1,76 @@ +syntax = "proto3"; + +option java_multiple_files = true; + +package org.signal.chat.account; + +import "org/signal/chat/common.proto"; + +/** + * Provides methods for looking up Signal accounts. Callers must not provide + * identifying credentials when calling methods in this service. + */ +service AccountsAnonymous { + /** + * Checks whether an account with the given service identifier exists. + */ + rpc CheckAccountExistence(CheckAccountExistenceRequest) returns (CheckAccountExistenceResponse) {} + + /** + * Finds the service identifier of the account associated with the given + * username hash. This method will return a `NOT_FOUND` status if no account + * was found for the given username hash. + */ + rpc LookupUsernameHash(LookupUsernameHashRequest) returns (LookupUsernameHashResponse) {} + + /** + * Finds the encrypted username identified by a given username link handle. + * This method will return a `NOT_FOUND` status if no username was found for + * the given link handle. + */ + rpc LookupUsernameLink(LookupUsernameLinkRequest) returns (LookupUsernameLinkResponse) {} +} + +message CheckAccountExistenceRequest { + /** + * The service identifier of an account that may or may not exist. + */ + common.ServiceIdentifier service_identifier = 1; +} + +message CheckAccountExistenceResponse { + /** + * True if an account exists with the given service identifier or false if no + * account was found. + */ + bool account_exists = 1; +} + +message LookupUsernameHashRequest { + /** + * A 32-byte username hash for which to find an account. + */ + bytes username_hash = 1; +} + +message LookupUsernameHashResponse { + /** + * The service identifier associated with a given username hash. + */ + common.ServiceIdentifier service_identifier = 1; +} + +message LookupUsernameLinkRequest { + /** + * The link handle for which to find an encrypted username. Link handles are + * 16-byte representations of UUIDs. + */ + bytes username_link_handle = 1; +} + +message LookupUsernameLinkResponse { + /** + * The ciphertext of the username identified by the given link handle. + */ + bytes username_ciphertext = 1; +} diff --git a/service/src/main/proto/org/signal/chat/common.proto b/service/src/main/proto/org/signal/chat/common.proto index 7d4af557d..1266ae5bc 100644 --- a/service/src/main/proto/org/signal/chat/common.proto +++ b/service/src/main/proto/org/signal/chat/common.proto @@ -27,6 +27,12 @@ message ServiceIdentifier { bytes uuid = 2; } +message AccountIdentifiers { + repeated ServiceIdentifier service_identifiers = 1; + string e164 = 2; + bytes username_hash = 3; +} + message EcPreKey { /** * A locally-unique identifier for this key. diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/AccountsAnonymousGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/AccountsAnonymousGrpcServiceTest.java new file mode 100644 index 000000000..fb4911b20 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/AccountsAnonymousGrpcServiceTest.java @@ -0,0 +1,268 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.protobuf.ByteString; +import io.grpc.Status; +import java.net.InetSocketAddress; +import java.security.SecureRandom; +import java.time.Duration; +import java.util.Optional; +import java.util.UUID; +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.MethodSource; +import org.mockito.Mock; +import org.signal.chat.account.AccountsAnonymousGrpc; +import org.signal.chat.account.CheckAccountExistenceRequest; +import org.signal.chat.account.LookupUsernameHashRequest; +import org.signal.chat.account.LookupUsernameLinkRequest; +import org.signal.chat.common.IdentityType; +import org.signal.chat.common.ServiceIdentifier; +import org.whispersystems.textsecuregcm.controllers.AccountController; +import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; +import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier; +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.UUIDUtil; +import reactor.core.publisher.Mono; + +class AccountsAnonymousGrpcServiceTest extends + SimpleBaseGrpcTest { + + @Mock + private AccountsManager accountsManager; + + @Mock + private RateLimiters rateLimiters; + + @Mock + private RateLimiter rateLimiter; + + @Override + protected AccountsAnonymousGrpcService createServiceBeforeEachTest() { + when(accountsManager.getByServiceIdentifierAsync(any())) + .thenReturn(CompletableFuture.completedFuture(Optional.empty())); + + when(accountsManager.getByUsernameHash(any())) + .thenReturn(CompletableFuture.completedFuture(Optional.empty())); + + when(accountsManager.getByUsernameLinkHandle(any())) + .thenReturn(CompletableFuture.completedFuture(Optional.empty())); + + when(rateLimiters.getCheckAccountExistenceLimiter()).thenReturn(rateLimiter); + when(rateLimiters.getUsernameLookupLimiter()).thenReturn(rateLimiter); + when(rateLimiters.getUsernameLinkLookupLimiter()).thenReturn(rateLimiter); + + when(rateLimiter.validateReactive(anyString())).thenReturn(Mono.empty()); + + getMockRemoteAddressInterceptor().setRemoteAddress(new InetSocketAddress("127.0.0.1", 12345)); + + return new AccountsAnonymousGrpcService(accountsManager, rateLimiters); + } + + @Test + void checkAccountExistence() { + final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID()); + + when(accountsManager.getByServiceIdentifierAsync(serviceIdentifier)) + .thenReturn(CompletableFuture.completedFuture(Optional.of(mock(Account.class)))); + + assertTrue(unauthenticatedServiceStub().checkAccountExistence(CheckAccountExistenceRequest.newBuilder() + .setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(serviceIdentifier)) + .build()).getAccountExists()); + + assertFalse(unauthenticatedServiceStub().checkAccountExistence(CheckAccountExistenceRequest.newBuilder() + .setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(UUID.randomUUID()))) + .build()).getAccountExists()); + } + + @ParameterizedTest + @MethodSource + void checkAccountExistenceIllegalRequest(final CheckAccountExistenceRequest request) { + //noinspection ResultOfMethodCallIgnored + GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT, + () -> unauthenticatedServiceStub().checkAccountExistence(request)); + } + + private static Stream checkAccountExistenceIllegalRequest() { + return Stream.of( + // No service identifier + Arguments.of(CheckAccountExistenceRequest.newBuilder().build()), + + // Bad service identifier + Arguments.of(CheckAccountExistenceRequest.newBuilder() + .setServiceIdentifier(ServiceIdentifier.newBuilder() + .setIdentityType(IdentityType.IDENTITY_TYPE_ACI) + .setUuid(ByteString.copyFrom(new byte[15])) + .build()) + .build()) + ); + } + + @Test + void checkAccountExistenceRateLimited() { + final Duration retryAfter = Duration.ofSeconds(11); + + when(rateLimiter.validateReactive(anyString())) + .thenReturn(Mono.error(new RateLimitExceededException(retryAfter, false))); + + //noinspection ResultOfMethodCallIgnored + GrpcTestUtils.assertRateLimitExceeded(retryAfter, + () -> unauthenticatedServiceStub().checkAccountExistence(CheckAccountExistenceRequest.newBuilder() + .setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(UUID.randomUUID()))) + .build()), + accountsManager); + } + + @Test + void lookupUsernameHash() { + final UUID accountIdentifier = UUID.randomUUID(); + + final byte[] usernameHash = new byte[AccountController.USERNAME_HASH_LENGTH]; + new SecureRandom().nextBytes(usernameHash); + + final Account account = mock(Account.class); + when(account.getUuid()).thenReturn(accountIdentifier); + + when(accountsManager.getByUsernameHash(usernameHash)) + .thenReturn(CompletableFuture.completedFuture(Optional.of(account))); + + assertEquals(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(accountIdentifier)), + unauthenticatedServiceStub().lookupUsernameHash(LookupUsernameHashRequest.newBuilder() + .setUsernameHash(ByteString.copyFrom(usernameHash)) + .build()) + .getServiceIdentifier()); + + //noinspection ResultOfMethodCallIgnored + GrpcTestUtils.assertStatusException(Status.NOT_FOUND, + () -> unauthenticatedServiceStub().lookupUsernameHash(LookupUsernameHashRequest.newBuilder() + .setUsernameHash(ByteString.copyFrom(new byte[AccountController.USERNAME_HASH_LENGTH])) + .build())); + } + + @ParameterizedTest + @MethodSource + void lookupUsernameHashIllegalHash(final LookupUsernameHashRequest request) { + //noinspection ResultOfMethodCallIgnored + GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT, + () -> unauthenticatedServiceStub().lookupUsernameHash(request)); + } + + private static Stream lookupUsernameHashIllegalHash() { + return Stream.of( + // No username hash + Arguments.of(LookupUsernameHashRequest.newBuilder().build()), + + // Hash too long + Arguments.of(LookupUsernameHashRequest.newBuilder() + .setUsernameHash(ByteString.copyFrom(new byte[AccountController.USERNAME_HASH_LENGTH + 1])) + .build()), + + // Hash too short + Arguments.of(LookupUsernameHashRequest.newBuilder() + .setUsernameHash(ByteString.copyFrom(new byte[AccountController.USERNAME_HASH_LENGTH - 1])) + .build()) + ); + } + + @Test + void lookupUsernameHashRateLimited() { + final Duration retryAfter = Duration.ofSeconds(13); + + when(rateLimiter.validateReactive(anyString())) + .thenReturn(Mono.error(new RateLimitExceededException(retryAfter, false))); + + //noinspection ResultOfMethodCallIgnored + GrpcTestUtils.assertRateLimitExceeded(retryAfter, + () -> unauthenticatedServiceStub().lookupUsernameHash(LookupUsernameHashRequest.newBuilder() + .setUsernameHash(ByteString.copyFrom(new byte[AccountController.USERNAME_HASH_LENGTH])) + .build()), + accountsManager); + } + + @Test + void lookupUsernameLink() { + final UUID linkHandle = UUID.randomUUID(); + + final byte[] usernameCiphertext = new byte[32]; + new SecureRandom().nextBytes(usernameCiphertext); + + final Account account = mock(Account.class); + when(account.getEncryptedUsername()).thenReturn(Optional.of(usernameCiphertext)); + + when(accountsManager.getByUsernameLinkHandle(linkHandle)) + .thenReturn(CompletableFuture.completedFuture(Optional.of(account))); + + assertEquals(ByteString.copyFrom(usernameCiphertext), + unauthenticatedServiceStub().lookupUsernameLink(LookupUsernameLinkRequest.newBuilder() + .setUsernameLinkHandle(UUIDUtil.toByteString(linkHandle)) + .build()) + .getUsernameCiphertext()); + + when(account.getEncryptedUsername()).thenReturn(Optional.empty()); + + //noinspection ResultOfMethodCallIgnored + GrpcTestUtils.assertStatusException(Status.NOT_FOUND, + () -> unauthenticatedServiceStub().lookupUsernameLink(LookupUsernameLinkRequest.newBuilder() + .setUsernameLinkHandle(UUIDUtil.toByteString(linkHandle)) + .build())); + + //noinspection ResultOfMethodCallIgnored + GrpcTestUtils.assertStatusException(Status.NOT_FOUND, + () -> unauthenticatedServiceStub().lookupUsernameLink(LookupUsernameLinkRequest.newBuilder() + .setUsernameLinkHandle(UUIDUtil.toByteString(UUID.randomUUID())) + .build())); + } + + @ParameterizedTest + @MethodSource + void lookupUsernameLinkIllegalHandle(final LookupUsernameLinkRequest request) { + //noinspection ResultOfMethodCallIgnored + GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT, + () -> unauthenticatedServiceStub().lookupUsernameLink(request)); + } + + private static Stream lookupUsernameLinkIllegalHandle() { + return Stream.of( + // No handle + Arguments.of(LookupUsernameLinkRequest.newBuilder().build()), + + // Bad handle length + Arguments.of(LookupUsernameLinkRequest.newBuilder() + .setUsernameLinkHandle(ByteString.copyFrom(new byte[15])) + .build()) + ); + } + + @Test + void lookupUsernameLinkRateLimited() { + final Duration retryAfter = Duration.ofSeconds(17); + + when(rateLimiter.validateReactive(anyString())) + .thenReturn(Mono.error(new RateLimitExceededException(retryAfter, false))); + + //noinspection ResultOfMethodCallIgnored + GrpcTestUtils.assertRateLimitExceeded(retryAfter, + () -> unauthenticatedServiceStub().lookupUsernameLink(LookupUsernameLinkRequest.newBuilder() + .setUsernameLinkHandle(UUIDUtil.toByteString(UUID.randomUUID())) + .build()), + accountsManager); + } +}