Skip to content
Snippets Groups Projects
Commit 601e9eeb authored by Jon Chambers's avatar Jon Chambers Committed by Jon Chambers
Browse files

Implement an anonymous account service for looking up accounts

parent eaa868cf
No related branches found
No related tags found
No related merge requests found
...@@ -379,7 +379,7 @@ public class AccountController { ...@@ -379,7 +379,7 @@ public class AccountController {
) )
@ApiResponse(responseCode = "200", description = "Account found for the given username.", useReturnTypeSchema = true) @ApiResponse(responseCode = "200", description = "Account found for the given username.", useReturnTypeSchema = true)
@ApiResponse(responseCode = "400", description = "Request must not be authenticated.") @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<AccountIdentifierResponse> lookupUsernameHash( public CompletableFuture<AccountIdentifierResponse> lookupUsernameHash(
@Auth final Optional<AuthenticatedAccount> maybeAuthenticatedAccount, @Auth final Optional<AuthenticatedAccount> maybeAuthenticatedAccount,
@PathParam("usernameHash") final String usernameHash) { @PathParam("usernameHash") final String usernameHash) {
......
/*
* 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<CheckAccountExistenceResponse> 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<LookupUsernameHashResponse> 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<LookupUsernameLinkResponse> 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());
}
}
...@@ -163,6 +163,10 @@ public class RateLimiters extends BaseRateLimiters<RateLimiters.For> { ...@@ -163,6 +163,10 @@ public class RateLimiters extends BaseRateLimiters<RateLimiters.For> {
return forDescriptor(For.USERNAME_LOOKUP); return forDescriptor(For.USERNAME_LOOKUP);
} }
public RateLimiter getUsernameLinkLookupLimiter() {
return forDescriptor(For.USERNAME_LINK_LOOKUP_PER_IP);
}
public RateLimiter getUsernameSetLimiter() { public RateLimiter getUsernameSetLimiter() {
return forDescriptor(For.USERNAME_SET); return forDescriptor(For.USERNAME_SET);
} }
......
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;
}
...@@ -27,6 +27,12 @@ message ServiceIdentifier { ...@@ -27,6 +27,12 @@ message ServiceIdentifier {
bytes uuid = 2; bytes uuid = 2;
} }
message AccountIdentifiers {
repeated ServiceIdentifier service_identifiers = 1;
string e164 = 2;
bytes username_hash = 3;
}
message EcPreKey { message EcPreKey {
/** /**
* A locally-unique identifier for this key. * A locally-unique identifier for this key.
......
/*
* 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<AccountsAnonymousGrpcService, AccountsAnonymousGrpc.AccountsAnonymousBlockingStub> {
@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<Arguments> 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<Arguments> 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<Arguments> 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);
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment