From 54bc3bce96ede5f4e662f0c5aa69d2962f92d764 Mon Sep 17 00:00:00 2001 From: Jon Chambers <63609320+jon-signal@users.noreply.github.com> Date: Wed, 25 Oct 2023 14:47:20 -0400 Subject: [PATCH] Add an authentication-required gRPC service for working with accounts --- .../textsecuregcm/WhisperServerService.java | 4 + .../controllers/AccountController.java | 1 + .../entities/ConfirmUsernameHashRequest.java | 2 +- .../entities/EncryptedUsername.java | 4 +- .../grpc/AccountsGrpcService.java | 337 ++++++++ .../textsecuregcm/limits/RateLimiters.java | 4 + .../main/proto/org/signal/chat/account.proto | 261 +++++++ .../main/proto/org/signal/chat/common.proto | 12 + .../grpc/AccountsGrpcServiceTest.java | 731 ++++++++++++++++++ .../grpc/SimpleBaseGrpcTest.java | 4 + 10 files changed, 1358 insertions(+), 2 deletions(-) create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/AccountsGrpcService.java create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/grpc/AccountsGrpcServiceTest.java diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 90cc45cf0..1e7640af8 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -119,6 +119,8 @@ import org.whispersystems.textsecuregcm.filters.RequestStatisticsFilter; import org.whispersystems.textsecuregcm.filters.TimestampResponseFilter; import org.whispersystems.textsecuregcm.grpc.AcceptLanguageInterceptor; +import org.whispersystems.textsecuregcm.grpc.AccountsAnonymousGrpcService; +import org.whispersystems.textsecuregcm.grpc.AccountsGrpcService; import org.whispersystems.textsecuregcm.grpc.ErrorMappingInterceptor; import org.whispersystems.textsecuregcm.grpc.ExternalServiceCredentialsAnonymousGrpcService; import org.whispersystems.textsecuregcm.grpc.ExternalServiceCredentialsGrpcService; @@ -650,6 +652,8 @@ public void run(WhisperServerConfiguration config, Environment environment) thro new BasicCredentialAuthenticationInterceptor(new BaseAccountAuthenticator(accountsManager)); final ServerBuilder grpcServer = ServerBuilder.forPort(config.getGrpcPort()) + .addService(ServerInterceptors.intercept(new AccountsGrpcService(accountsManager, rateLimiters, usernameHashZkProofVerifier, registrationRecoveryPasswordsManager), basicCredentialAuthenticationInterceptor)) + .addService(new AccountsAnonymousGrpcService(accountsManager, rateLimiters)) .addService(ExternalServiceCredentialsGrpcService.createForAllExternalServices(config, rateLimiters)) .addService(ExternalServiceCredentialsAnonymousGrpcService.create(accountsManager, config)) .addService(ServerInterceptors.intercept(new KeysGrpcService(accountsManager, keys, rateLimiters), basicCredentialAuthenticationInterceptor)) 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 53516a2e1..82c72f637 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java @@ -73,6 +73,7 @@ public class AccountController { public static final int MAXIMUM_USERNAME_HASHES_LIST_LENGTH = 20; public static final int USERNAME_HASH_LENGTH = 32; + public static final int MAXIMUM_USERNAME_CIPHERTEXT_LENGTH = 128; private final AccountsManager accounts; private final RateLimiters rateLimiters; diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ConfirmUsernameHashRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ConfirmUsernameHashRequest.java index a38a6efbc..96a442c63 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ConfirmUsernameHashRequest.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ConfirmUsernameHashRequest.java @@ -31,6 +31,6 @@ public record ConfirmUsernameHashRequest( @Nullable @JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class) @JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class) - @Size(min = 1, max = 128) + @Size(min = 1, max = AccountController.MAXIMUM_USERNAME_CIPHERTEXT_LENGTH) byte[] encryptedUsername ) {} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/EncryptedUsername.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/EncryptedUsername.java index 93f257964..6e24c2dd4 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/EncryptedUsername.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/EncryptedUsername.java @@ -16,7 +16,9 @@ public record EncryptedUsername( @JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class) @JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class) @NotNull - @Size(min = 1, max = 128) + @Size(min = 1, max = EncryptedUsername.MAX_SIZE) @Schema(type = "string", description = "the URL-safe base64 encoding of the encrypted username") byte[] usernameLinkEncryptedValue) { + + public static final int MAX_SIZE = 128; } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/AccountsGrpcService.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/AccountsGrpcService.java new file mode 100644 index 000000000..d4221418f --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/AccountsGrpcService.java @@ -0,0 +1,337 @@ +/* + * 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 java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.UUID; +import org.signal.chat.account.ClearRegistrationLockRequest; +import org.signal.chat.account.ClearRegistrationLockResponse; +import org.signal.chat.account.ConfigureUnidentifiedAccessRequest; +import org.signal.chat.account.ConfigureUnidentifiedAccessResponse; +import org.signal.chat.account.ConfirmUsernameHashRequest; +import org.signal.chat.account.ConfirmUsernameHashResponse; +import org.signal.chat.account.DeleteAccountRequest; +import org.signal.chat.account.DeleteAccountResponse; +import org.signal.chat.account.DeleteUsernameHashRequest; +import org.signal.chat.account.DeleteUsernameHashResponse; +import org.signal.chat.account.DeleteUsernameLinkRequest; +import org.signal.chat.account.DeleteUsernameLinkResponse; +import org.signal.chat.account.GetAccountIdentityRequest; +import org.signal.chat.account.GetAccountIdentityResponse; +import org.signal.chat.account.ReactorAccountsGrpc; +import org.signal.chat.account.ReserveUsernameHashError; +import org.signal.chat.account.ReserveUsernameHashErrorType; +import org.signal.chat.account.ReserveUsernameHashRequest; +import org.signal.chat.account.ReserveUsernameHashResponse; +import org.signal.chat.account.SetDiscoverableByPhoneNumberRequest; +import org.signal.chat.account.SetDiscoverableByPhoneNumberResponse; +import org.signal.chat.account.SetRegistrationLockRequest; +import org.signal.chat.account.SetRegistrationLockResponse; +import org.signal.chat.account.SetRegistrationRecoveryPasswordRequest; +import org.signal.chat.account.SetRegistrationRecoveryPasswordResponse; +import org.signal.chat.account.SetUsernameLinkRequest; +import org.signal.chat.account.SetUsernameLinkResponse; +import org.signal.chat.common.AccountIdentifiers; +import org.signal.libsignal.usernames.BaseUsernameException; +import org.whispersystems.textsecuregcm.auth.SaltedTokenHash; +import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil; +import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice; +import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil; +import org.whispersystems.textsecuregcm.controllers.AccountController; +import org.whispersystems.textsecuregcm.entities.EncryptedUsername; +import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier; +import org.whispersystems.textsecuregcm.identity.PniServiceIdentifier; +import org.whispersystems.textsecuregcm.limits.RateLimiters; +import org.whispersystems.textsecuregcm.storage.AccountsManager; +import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager; +import org.whispersystems.textsecuregcm.storage.UsernameHashNotAvailableException; +import org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException; +import org.whispersystems.textsecuregcm.util.UUIDUtil; +import org.whispersystems.textsecuregcm.util.UsernameHashZkProofVerifier; +import reactor.core.publisher.Mono; + +public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase { + + private final AccountsManager accountsManager; + private final RateLimiters rateLimiters; + private final UsernameHashZkProofVerifier usernameHashZkProofVerifier; + private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager; + + public AccountsGrpcService(final AccountsManager accountsManager, + final RateLimiters rateLimiters, + final UsernameHashZkProofVerifier usernameHashZkProofVerifier, + final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager) { + + this.accountsManager = accountsManager; + this.rateLimiters = rateLimiters; + this.usernameHashZkProofVerifier = usernameHashZkProofVerifier; + this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager; + } + + @Override + public Mono getAccountIdentity(final GetAccountIdentityRequest request) { + final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice(); + + return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())) + .map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException)) + .map(account -> { + final AccountIdentifiers.Builder accountIdentifiersBuilder = AccountIdentifiers.newBuilder() + .addServiceIdentifiers(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(account.getUuid()))) + .addServiceIdentifiers(ServiceIdentifierUtil.toGrpcServiceIdentifier(new PniServiceIdentifier(account.getPhoneNumberIdentifier()))) + .setE164(account.getNumber()); + + account.getUsernameHash().ifPresent(usernameHash -> + accountIdentifiersBuilder.setUsernameHash(ByteString.copyFrom(usernameHash))); + + return GetAccountIdentityResponse.newBuilder() + .setAccountIdentifiers(accountIdentifiersBuilder.build()) + .build(); + }); + } + + @Override + public Mono deleteAccount(final DeleteAccountRequest request) { + final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedPrimaryDevice(); + + return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())) + .map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException)) + .flatMap(account -> Mono.fromFuture(() -> accountsManager.delete(account, AccountsManager.DeletionReason.USER_REQUEST))) + .thenReturn(DeleteAccountResponse.newBuilder().build()); + } + + @Override + public Mono setRegistrationLock(final SetRegistrationLockRequest request) { + final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedPrimaryDevice(); + + if (request.getRegistrationLock().isEmpty()) { + throw Status.INVALID_ARGUMENT.withDescription("Registration lock secret must not be empty").asRuntimeException(); + } + + return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())) + .map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException)) + .flatMap(account -> { + // In the previous REST-based API, clients would send hex strings directly. For backward compatibility, we + // convert the registration lock secret to a lowercase hex string before turning it into a salted hash. + final SaltedTokenHash credentials = + SaltedTokenHash.generateFor(HexFormat.of().withLowerCase().formatHex(request.getRegistrationLock().toByteArray())); + + return Mono.fromFuture(() -> accountsManager.updateAsync(account, + a -> a.setRegistrationLock(credentials.hash(), credentials.salt()))); + }) + .map(ignored -> SetRegistrationLockResponse.newBuilder().build()); + } + + @Override + public Mono clearRegistrationLock(final ClearRegistrationLockRequest request) { + final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedPrimaryDevice(); + + return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())) + .map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException)) + .flatMap(account -> Mono.fromFuture(() -> accountsManager.updateAsync(account, + a -> a.setRegistrationLock(null, null)))) + .map(ignored -> ClearRegistrationLockResponse.newBuilder().build()); + } + + @Override + public Mono reserveUsernameHash(final ReserveUsernameHashRequest request) { + final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice(); + + if (request.getUsernameHashesCount() == 0) { + throw Status.INVALID_ARGUMENT + .withDescription("List of username hashes must not be empty") + .asRuntimeException(); + } + + if (request.getUsernameHashesCount() > AccountController.MAXIMUM_USERNAME_HASHES_LIST_LENGTH) { + throw Status.INVALID_ARGUMENT + .withDescription(String.format("List of username hashes may have at most %d elements, but actually had %d", + AccountController.MAXIMUM_USERNAME_HASHES_LIST_LENGTH, request.getUsernameHashesCount())) + .asRuntimeException(); + } + + final List usernameHashes = new ArrayList<>(request.getUsernameHashesCount()); + + for (final ByteString usernameHash : request.getUsernameHashesList()) { + if (usernameHash.size() != AccountController.USERNAME_HASH_LENGTH) { + throw Status.INVALID_ARGUMENT + .withDescription(String.format("Username hash length must be %d bytes, but was actually %d", + AccountController.USERNAME_HASH_LENGTH, usernameHash.size())) + .asRuntimeException(); + } + + usernameHashes.add(usernameHash.toByteArray()); + } + + return rateLimiters.getUsernameReserveLimiter().validateReactive(authenticatedDevice.accountIdentifier()) + .then(Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))) + .map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException)) + .flatMap(account -> Mono.fromFuture(() -> accountsManager.reserveUsernameHash(account, usernameHashes))) + .map(reservation -> ReserveUsernameHashResponse.newBuilder() + .setUsernameHash(ByteString.copyFrom(reservation.reservedUsernameHash())) + .build()) + .onErrorReturn(UsernameHashNotAvailableException.class, ReserveUsernameHashResponse.newBuilder() + .setError(ReserveUsernameHashError.newBuilder() + .setErrorType(ReserveUsernameHashErrorType.RESERVE_USERNAME_HASH_ERROR_TYPE_NO_HASHES_AVAILABLE) + .build()) + .build()); + } + + @Override + public Mono confirmUsernameHash(final ConfirmUsernameHashRequest request) { + final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice(); + + if (request.getUsernameHash().isEmpty()) { + throw Status.INVALID_ARGUMENT + .withDescription("Username hash must not be empty") + .asRuntimeException(); + } + + if (request.getUsernameHash().size() != AccountController.USERNAME_HASH_LENGTH) { + throw Status.INVALID_ARGUMENT + .withDescription(String.format("Username hash length must be %d bytes, but was actually %d", + AccountController.USERNAME_HASH_LENGTH, request.getUsernameHash().size())) + .asRuntimeException(); + } + + if (request.getZkProof().isEmpty()) { + throw Status.INVALID_ARGUMENT + .withDescription("Zero-knowledge proof must not be empty") + .asRuntimeException(); + } + + if (request.getUsernameCiphertext().isEmpty()) { + throw Status.INVALID_ARGUMENT + .withDescription("Username ciphertext must not be empty") + .asRuntimeException(); + } + + if (request.getUsernameCiphertext().size() > AccountController.MAXIMUM_USERNAME_CIPHERTEXT_LENGTH) { + throw Status.INVALID_ARGUMENT + .withDescription(String.format("Username hash length must at most %d bytes, but was actually %d", + AccountController.MAXIMUM_USERNAME_CIPHERTEXT_LENGTH, request.getUsernameCiphertext().size())) + .asRuntimeException(); + } + + try { + usernameHashZkProofVerifier.verifyProof(request.getZkProof().toByteArray(), request.getUsernameHash().toByteArray()); + } catch (final BaseUsernameException e) { + throw Status.INVALID_ARGUMENT.withDescription("Could not verify proof").asRuntimeException(); + } + + return rateLimiters.getUsernameSetLimiter().validateReactive(authenticatedDevice.accountIdentifier()) + .then(Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))) + .map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException)) + .flatMap(account -> Mono.fromFuture(() -> accountsManager.confirmReservedUsernameHash(account, request.getUsernameHash().toByteArray(), request.getUsernameCiphertext().toByteArray()))) + .map(updatedAccount -> ConfirmUsernameHashResponse.newBuilder() + .setUsernameHash(ByteString.copyFrom(updatedAccount.getUsernameHash().orElseThrow())) + .setUsernameLinkHandle(UUIDUtil.toByteString(updatedAccount.getUsernameLinkHandle())) + .build()) + .onErrorMap(UsernameReservationNotFoundException.class, throwable -> Status.FAILED_PRECONDITION.asRuntimeException()) + .onErrorMap(UsernameHashNotAvailableException.class, throwable -> Status.NOT_FOUND.asRuntimeException()); + } + + @Override + public Mono deleteUsernameHash(final DeleteUsernameHashRequest request) { + final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice(); + + return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())) + .map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException)) + .flatMap(account -> Mono.fromFuture(() -> accountsManager.clearUsernameHash(account))) + .thenReturn(DeleteUsernameHashResponse.newBuilder().build()); + } + + @Override + public Mono setUsernameLink(final SetUsernameLinkRequest request) { + final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice(); + + if (request.getUsernameCiphertext().isEmpty() || request.getUsernameCiphertext().size() > EncryptedUsername.MAX_SIZE) { + throw Status.INVALID_ARGUMENT + .withDescription(String.format("Username ciphertext must not be empty and must be shorter than %d bytes", EncryptedUsername.MAX_SIZE)) + .asRuntimeException(); + } + + return rateLimiters.getUsernameLinkOperationLimiter().validateReactive(authenticatedDevice.accountIdentifier()) + .then(Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))) + .map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException)) + .flatMap(account -> { + if (account.getUsernameHash().isEmpty()) { + return Mono.error(Status.FAILED_PRECONDITION + .withDescription("Account does not have a username hash") + .asRuntimeException()); + } + + final UUID linkHandle = UUID.randomUUID(); + + return Mono.fromFuture(() -> accountsManager.updateAsync(account, a -> a.setUsernameLinkDetails(linkHandle, request.getUsernameCiphertext().toByteArray()))) + .thenReturn(linkHandle); + }) + .map(linkHandle -> SetUsernameLinkResponse.newBuilder() + .setUsernameLinkHandle(UUIDUtil.toByteString(linkHandle)) + .build()); + } + + @Override + public Mono deleteUsernameLink(final DeleteUsernameLinkRequest request) { + final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice(); + + return rateLimiters.getUsernameLinkOperationLimiter().validateReactive(authenticatedDevice.accountIdentifier()) + .then(Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))) + .map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException)) + .flatMap(account -> Mono.fromFuture(() -> accountsManager.updateAsync(account, a -> a.setUsernameLinkDetails(null, null)))) + .thenReturn(DeleteUsernameLinkResponse.newBuilder().build()); + } + + @Override + public Mono configureUnidentifiedAccess(final ConfigureUnidentifiedAccessRequest request) { + final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice(); + + if (!request.getAllowUnrestrictedUnidentifiedAccess() && request.getUnidentifiedAccessKey().size() != UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH) { + throw Status.INVALID_ARGUMENT + .withDescription(String.format("Unidentified access key must be %d bytes, but was actually %d", + UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH, request.getUnidentifiedAccessKey().size())) + .asRuntimeException(); + } + + return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())) + .map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException)) + .flatMap(account -> Mono.fromFuture(() -> accountsManager.updateAsync(account, a -> { + a.setUnrestrictedUnidentifiedAccess(request.getAllowUnrestrictedUnidentifiedAccess()); + a.setUnidentifiedAccessKey(request.getAllowUnrestrictedUnidentifiedAccess() ? null : request.getUnidentifiedAccessKey().toByteArray()); + }))) + .thenReturn(ConfigureUnidentifiedAccessResponse.newBuilder().build()); + } + + @Override + public Mono setDiscoverableByPhoneNumber(final SetDiscoverableByPhoneNumberRequest request) { + final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice(); + + return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())) + .map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException)) + .flatMap(account -> Mono.fromFuture(() -> accountsManager.updateAsync(account, + a -> a.setDiscoverableByPhoneNumber(request.getDiscoverableByPhoneNumber())))) + .thenReturn(SetDiscoverableByPhoneNumberResponse.newBuilder().build()); + } + + @Override + public Mono setRegistrationRecoveryPassword(final SetRegistrationRecoveryPasswordRequest request) { + final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice(); + + if (request.getRegistrationRecoveryPassword().isEmpty()) { + throw Status.INVALID_ARGUMENT + .withDescription("Registration recovery password must not be empty") + .asRuntimeException(); + } + + return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())) + .map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException)) + .flatMap(account -> Mono.fromFuture(() -> registrationRecoveryPasswordsManager.storeForCurrentNumber(account.getNumber(), request.getRegistrationRecoveryPassword().toByteArray()))) + .thenReturn(SetRegistrationRecoveryPasswordResponse.newBuilder().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 37cee6c9e..a93b5fa75 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java @@ -167,6 +167,10 @@ public RateLimiter getUsernameLinkLookupLimiter() { return forDescriptor(For.USERNAME_LINK_LOOKUP_PER_IP); } + public RateLimiter getUsernameLinkOperationLimiter() { + return forDescriptor(For.USERNAME_LINK_OPERATION); + } + 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 index d71e93878..32f7df9fc 100644 --- a/service/src/main/proto/org/signal/chat/account.proto +++ b/service/src/main/proto/org/signal/chat/account.proto @@ -6,6 +6,101 @@ package org.signal.chat.account; import "org/signal/chat/common.proto"; +/** + * Provides methods for working with Signal accounts. + */ +service Accounts { + /** + * Returns basic identifiers for the authenticated account. + */ + rpc GetAccountIdentity(GetAccountIdentityRequest) returns (GetAccountIdentityResponse) {} + + /** + * Deletes the authenticated account, purging all associated data in the + * process. + */ + rpc DeleteAccount(DeleteAccountRequest) returns (DeleteAccountResponse) {} + + /** + * Sets the registration lock secret for the authenticated account. To remove + * a registration lock, please use `ClearRegistrationLock`. + */ + rpc SetRegistrationLock(SetRegistrationLockRequest) returns (SetRegistrationLockResponse) {} + + /** + * Removes any registration lock credentials from the authenticated account. + */ + rpc ClearRegistrationLock(ClearRegistrationLockRequest) returns (ClearRegistrationLockResponse) {} + + /** + * Attempts to reserve one of multiple given username hashes. Reserved + * usernames may be claimed later via `ConfirmUsernameHash`. This RPC may + * fail with a `RESOURCE_EXHAUSTED` status if a rate limit for modifying + * usernames has been exceeded, in which case a `retry-after` header + * containing an ISO 8601 duration string will be present in the response + * trailers. + */ + rpc ReserveUsernameHash(ReserveUsernameHashRequest) returns (ReserveUsernameHashResponse) {} + + /** + * Sets the username hash/encrypted username to a previously-reserved value + * (see `ReserveUsernameHash`). This RPC may fail with a status of + * `FAILED_PRECONDITION` if no reserved username hash was foudn for the given + * account or `NOT_FOUND` if the reservation has lapsed and been claimed by + * another caller. It may also fail with a `RESOURCE_EXHAUSTED` if a rate + * limit for modifying usernames has been exceeded, in which case a + * `retry-after` header containing an ISO 8601 duration string will be present + * in the response trailers. + */ + rpc ConfirmUsernameHash(ConfirmUsernameHashRequest) returns (ConfirmUsernameHashResponse) {} + + /** + * Clears the current username hash, ciphertext, and link for the + * authenticated user. + */ + rpc DeleteUsernameHash(DeleteUsernameHashRequest) returns (DeleteUsernameHashResponse) {} + + /** + * Generates a new link handle for the given username ciphertext, displacing + * any previously-existing link handle. + * + * This RPC may fail with a status of `FAILED_PRECONDITION` if the + * authenticated account does not have a username. It may also fail with + * `RESOURCE_EXHAUSTED` if a rate limit for modifying username links has been + * exceeded, in which case a `retry-after` header containing an ISO 8601 + * duration string will be present in the response trailers. + */ + rpc SetUsernameLink(SetUsernameLinkRequest) returns (SetUsernameLinkResponse) {} + + /** + * Clears any username link associated with the authenticated account. This + * RPC may fail with `RESOURCE_EXHAUSTED` if a rate limit for modifying + * username links has been exceeded, in which case a `retry-after` header + * containing an ISO 8601 duration string will be present in the response + * trailers. + */ + rpc DeleteUsernameLink(DeleteUsernameLinkRequest) returns (DeleteUsernameLinkResponse) {} + + /** + * Configures "unidentified access" keys and preferences for the authenticated + * account. Other users permitted to interact with this account anonymously + * may take actions like fetching pre-keys and profiles for this account or + * sending sealed-sender messages without providing identifying credentials. + */ + rpc ConfigureUnidentifiedAccess(ConfigureUnidentifiedAccessRequest) returns (ConfigureUnidentifiedAccessResponse) {} + + /** + * Sets whether the authenticated account may be discovered by phone number + * via the Contact Discovery Service (CDS). + */ + rpc SetDiscoverableByPhoneNumber(SetDiscoverableByPhoneNumberRequest) returns (SetDiscoverableByPhoneNumberResponse) {} + + /** + * Sets the registration recovery password for the authenticated account. + */ + rpc SetRegistrationRecoveryPassword(SetRegistrationRecoveryPasswordRequest) returns (SetRegistrationRecoveryPasswordResponse) {} +} + /** * Provides methods for looking up Signal accounts. Callers must not provide * identifying credentials when calling methods in this service. @@ -31,6 +126,172 @@ service AccountsAnonymous { rpc LookupUsernameLink(LookupUsernameLinkRequest) returns (LookupUsernameLinkResponse) {} } +message GetAccountIdentityRequest { +} + +message GetAccountIdentityResponse { + /** + * A set of account identifiers for the authenticated account. + */ + common.AccountIdentifiers account_identifiers = 1; +} + +message DeleteAccountRequest { +} + +message DeleteAccountResponse { +} + +message SetRegistrationLockRequest { + /** + * The new registration lock secret for the authenticated account. + */ + bytes registration_lock = 1; +} + +message SetRegistrationLockResponse { +} + +message ClearRegistrationLockRequest { +} + +message ClearRegistrationLockResponse { +} + +message ReserveUsernameHashRequest { + /** + * A prioritized list of username hashes to attempt to reserve. + */ + repeated bytes username_hashes = 1; +} + +message ReserveUsernameHashResponse { + oneof response { + /** + * The first username hash that was available (and actually reserved). + */ + bytes username_hash = 1; + + /** + * An error indicating why a username hash could not be reserved. + */ + ReserveUsernameHashError error = 2; + } +} + +message ReserveUsernameHashError { + ReserveUsernameHashErrorType error_type = 1; +} + +enum ReserveUsernameHashErrorType { + RESERVE_USERNAME_HASH_ERROR_TYPE_UNSPECIFIED = 0; + + /** + * Indicates that, of all of the candidate hashes provided, none were + * available. Callers may generate a new set of hashes and and retry. + */ + RESERVE_USERNAME_HASH_ERROR_TYPE_NO_HASHES_AVAILABLE = 1; +} + +message ConfirmUsernameHashRequest { + /** + * The username hash to claim for the authenticated account. + */ + bytes username_hash = 1; + + /** + * A zero-knowledge proof that the given username hash was generated by the + * Signal username algorithm. + */ + bytes zk_proof = 2; + + /** + * The ciphertext of the chosen username for use in public-facing contexts + * (e.g. links and QR codes). + */ + bytes username_ciphertext = 3; +} + +message ConfirmUsernameHashResponse { + /** + * The newly-confirmed username hash. + */ + bytes username_hash = 1; + + /** + * The server-generated username link handle for the newly-confirmed username. + */ + bytes username_link_handle = 2; +} + +message DeleteUsernameHashRequest { +} + +message DeleteUsernameHashResponse { +} + +message SetUsernameLinkRequest { + /** + * The username ciphertext for which to generate a new link handle. + */ + bytes username_ciphertext = 1; +} + +message SetUsernameLinkResponse { + /** + * A new link handle for the given username ciphertext. + */ + bytes username_link_handle = 1; +} + +message DeleteUsernameLinkRequest { +} + +message DeleteUsernameLinkResponse { +} + +message ConfigureUnidentifiedAccessRequest { + /** + * The key that other users must provide to interact with this account + * anonymously (i.e. to retrieve keys or profiles or to send messages) unless + * unrestricted unidentified access is permitted. Must be present if + * unrestricted unidentified access is not allowed. + */ + bytes unidentified_access_key = 1; + + /** + * If `true`, any user may interact with this account anonymously without + * providing an unidentified access key. Otherwise, users must provide the + * given unidentified access key to interact with this account anonymously. + */ + bool allow_unrestricted_unidentified_access = 2; +} + +message ConfigureUnidentifiedAccessResponse { +} + +message SetDiscoverableByPhoneNumberRequest { + /** + * If true, the authenticated account may be discovered by phone number via + * the Contact Discovery Service (CDS). Otherwise, other users must discover + * this account by other means (i.e. by username). + */ + bool discoverable_by_phone_number = 1; +} + +message SetDiscoverableByPhoneNumberResponse { +} + +message SetRegistrationRecoveryPasswordRequest { + /** + * The new registration recovery password for the authenticated account. + */ + bytes registration_recovery_password = 1; +} + +message SetRegistrationRecoveryPasswordResponse { +} + message CheckAccountExistenceRequest { /** * The service identifier of an account that may or may not exist. diff --git a/service/src/main/proto/org/signal/chat/common.proto b/service/src/main/proto/org/signal/chat/common.proto index 1266ae5bc..f0ce90de5 100644 --- a/service/src/main/proto/org/signal/chat/common.proto +++ b/service/src/main/proto/org/signal/chat/common.proto @@ -28,8 +28,20 @@ message ServiceIdentifier { } message AccountIdentifiers { + /** + * A list of service identifiers for the identified account. + */ repeated ServiceIdentifier service_identifiers = 1; + + /** + * The phone number associated with the identified account. + */ string e164 = 2; + + /** + * The username hash (if any) associated with the identified account. May be + * empty if no username is associated with the identified account. + */ bytes username_hash = 3; } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/AccountsGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/AccountsGrpcServiceTest.java new file mode 100644 index 000000000..e44780ec5 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/AccountsGrpcServiceTest.java @@ -0,0 +1,731 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.grpc; + +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.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.protobuf.ByteString; +import io.grpc.Status; +import java.time.Duration; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Consumer; +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.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.signal.chat.account.AccountsGrpc; +import org.signal.chat.account.ClearRegistrationLockRequest; +import org.signal.chat.account.ClearRegistrationLockResponse; +import org.signal.chat.account.ConfigureUnidentifiedAccessRequest; +import org.signal.chat.account.ConfirmUsernameHashRequest; +import org.signal.chat.account.ConfirmUsernameHashResponse; +import org.signal.chat.account.DeleteAccountRequest; +import org.signal.chat.account.DeleteAccountResponse; +import org.signal.chat.account.DeleteUsernameHashRequest; +import org.signal.chat.account.DeleteUsernameLinkRequest; +import org.signal.chat.account.GetAccountIdentityRequest; +import org.signal.chat.account.GetAccountIdentityResponse; +import org.signal.chat.account.ReserveUsernameHashError; +import org.signal.chat.account.ReserveUsernameHashErrorType; +import org.signal.chat.account.ReserveUsernameHashRequest; +import org.signal.chat.account.ReserveUsernameHashResponse; +import org.signal.chat.account.SetDiscoverableByPhoneNumberRequest; +import org.signal.chat.account.SetRegistrationLockRequest; +import org.signal.chat.account.SetRegistrationLockResponse; +import org.signal.chat.account.SetRegistrationRecoveryPasswordRequest; +import org.signal.chat.account.SetUsernameLinkRequest; +import org.signal.chat.account.SetUsernameLinkResponse; +import org.signal.chat.common.AccountIdentifiers; +import org.signal.libsignal.usernames.BaseUsernameException; +import org.whispersystems.textsecuregcm.auth.SaltedTokenHash; +import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil; +import org.whispersystems.textsecuregcm.controllers.AccountController; +import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; +import org.whispersystems.textsecuregcm.entities.EncryptedUsername; +import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier; +import org.whispersystems.textsecuregcm.identity.PniServiceIdentifier; +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.storage.Device; +import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager; +import org.whispersystems.textsecuregcm.storage.UsernameHashNotAvailableException; +import org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException; +import org.whispersystems.textsecuregcm.util.UUIDUtil; +import org.whispersystems.textsecuregcm.util.UsernameHashZkProofVerifier; +import reactor.core.publisher.Mono; + +class AccountsGrpcServiceTest extends SimpleBaseGrpcTest { + + @Mock + private AccountsManager accountsManager; + + @Mock + private RateLimiter rateLimiter; + + @Mock + private UsernameHashZkProofVerifier usernameHashZkProofVerifier; + + @Mock + private RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager; + + @Override + protected AccountsGrpcService createServiceBeforeEachTest() { + when(accountsManager.updateAsync(any(), any())) + .thenAnswer(invocation -> { + final Account account = invocation.getArgument(0); + final Consumer updater = invocation.getArgument(1); + + updater.accept(account); + + return CompletableFuture.completedFuture(account); + }); + + final RateLimiters rateLimiters = mock(RateLimiters.class); + when(rateLimiters.getUsernameReserveLimiter()).thenReturn(rateLimiter); + when(rateLimiters.getUsernameSetLimiter()).thenReturn(rateLimiter); + when(rateLimiters.getUsernameLinkOperationLimiter()).thenReturn(rateLimiter); + + when(rateLimiter.validateReactive(any(UUID.class))).thenReturn(Mono.empty()); + when(rateLimiter.validateReactive(anyString())).thenReturn(Mono.empty()); + + when(registrationRecoveryPasswordsManager.storeForCurrentNumber(anyString(), any())) + .thenReturn(CompletableFuture.completedFuture(null)); + + return new AccountsGrpcService(accountsManager, + rateLimiters, + usernameHashZkProofVerifier, + registrationRecoveryPasswordsManager); + } + + @Test + void getAccountIdentity() { + final UUID phoneNumberIdentifier = UUID.randomUUID(); + final String e164 = PhoneNumberUtil.getInstance().format( + PhoneNumberUtil.getInstance().getExampleNumber("US"), PhoneNumberUtil.PhoneNumberFormat.E164); + + final byte[] usernameHash = new byte[32]; + ThreadLocalRandom.current().nextBytes(usernameHash); + + final Account account = mock(Account.class); + when(account.getUuid()).thenReturn(AUTHENTICATED_ACI); + when(account.getPhoneNumberIdentifier()).thenReturn(phoneNumberIdentifier); + when(account.getNumber()).thenReturn(e164); + when(account.getUsernameHash()).thenReturn(Optional.of(usernameHash)); + + when(accountsManager.getByAccountIdentifierAsync(AUTHENTICATED_ACI)) + .thenReturn(CompletableFuture.completedFuture(Optional.of(account))); + + final GetAccountIdentityResponse expectedResponse = GetAccountIdentityResponse.newBuilder() + .setAccountIdentifiers(AccountIdentifiers.newBuilder() + .addServiceIdentifiers(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(AUTHENTICATED_ACI))) + .addServiceIdentifiers(ServiceIdentifierUtil.toGrpcServiceIdentifier(new PniServiceIdentifier(phoneNumberIdentifier))) + .setE164(e164) + .setUsernameHash(ByteString.copyFrom(usernameHash)) + .build()) + .build(); + + assertEquals(expectedResponse, authenticatedServiceStub().getAccountIdentity(GetAccountIdentityRequest.newBuilder().build())); + } + + @Test + void deleteAccount() { + final Account account = mock(Account.class); + + when(accountsManager.getByAccountIdentifierAsync(AUTHENTICATED_ACI)) + .thenReturn(CompletableFuture.completedFuture(Optional.of(account))); + + when(accountsManager.delete(any(), any())) + .thenReturn(CompletableFuture.completedFuture(null)); + + final DeleteAccountResponse ignored = + authenticatedServiceStub().deleteAccount(DeleteAccountRequest.newBuilder().build()); + + verify(accountsManager).delete(account, AccountsManager.DeletionReason.USER_REQUEST); + } + + @Test + void deleteAccountLinkedDevice() { + getMockAuthenticationInterceptor().setAuthenticatedDevice(AUTHENTICATED_ACI, Device.PRIMARY_ID + 1); + + //noinspection ResultOfMethodCallIgnored + GrpcTestUtils.assertStatusException(Status.PERMISSION_DENIED, + () -> authenticatedServiceStub().deleteAccount(DeleteAccountRequest.newBuilder().build())); + + verify(accountsManager, never()).delete(any(), any()); + } + + @Test + void setRegistrationLock() { + final Account account = mock(Account.class); + + when(accountsManager.getByAccountIdentifierAsync(AUTHENTICATED_ACI)) + .thenReturn(CompletableFuture.completedFuture(Optional.of(account))); + + final byte[] registrationLockSecret = new byte[32]; + ThreadLocalRandom.current().nextBytes(registrationLockSecret); + + final SetRegistrationLockResponse ignored = + authenticatedServiceStub().setRegistrationLock(SetRegistrationLockRequest.newBuilder() + .setRegistrationLock(ByteString.copyFrom(registrationLockSecret)) + .build()); + + final ArgumentCaptor hashCaptor = ArgumentCaptor.forClass(String.class); + final ArgumentCaptor saltCaptor = ArgumentCaptor.forClass(String.class); + + verify(account).setRegistrationLock(hashCaptor.capture(), saltCaptor.capture()); + + final SaltedTokenHash registrationLock = new SaltedTokenHash(hashCaptor.getValue(), saltCaptor.getValue()); + assertTrue(registrationLock.verify(HexFormat.of().formatHex(registrationLockSecret))); + } + + @Test + void setRegistrationLockEmptySecret() { + //noinspection ResultOfMethodCallIgnored + GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT, + () -> authenticatedServiceStub().setRegistrationLock(SetRegistrationLockRequest.newBuilder() + .build())); + + verify(accountsManager, never()).updateAsync(any(), any()); + } + + @Test + void setRegistrationLockLinkedDevice() { + getMockAuthenticationInterceptor().setAuthenticatedDevice(AUTHENTICATED_ACI, Device.PRIMARY_ID + 1); + + //noinspection ResultOfMethodCallIgnored + GrpcTestUtils.assertStatusException(Status.PERMISSION_DENIED, + () -> authenticatedServiceStub().setRegistrationLock(SetRegistrationLockRequest.newBuilder() + .build())); + + verify(accountsManager, never()).updateAsync(any(), any()); + } + + @Test + void clearRegistrationLock() { + final Account account = mock(Account.class); + + when(accountsManager.getByAccountIdentifierAsync(AUTHENTICATED_ACI)) + .thenReturn(CompletableFuture.completedFuture(Optional.of(account))); + + final ClearRegistrationLockResponse ignored = + authenticatedServiceStub().clearRegistrationLock(ClearRegistrationLockRequest.newBuilder().build()); + + verify(account).setRegistrationLock(null, null); + } + + @Test + void clearRegistrationLockLinkedDevice() { + getMockAuthenticationInterceptor().setAuthenticatedDevice(AUTHENTICATED_ACI, Device.PRIMARY_ID + 1); + + //noinspection ResultOfMethodCallIgnored + GrpcTestUtils.assertStatusException(Status.PERMISSION_DENIED, + () -> authenticatedServiceStub().clearRegistrationLock(ClearRegistrationLockRequest.newBuilder().build())); + + verify(accountsManager, never()).updateAsync(any(), any()); + } + + @Test + void reserveUsernameHash() { + final Account account = mock(Account.class); + + when(accountsManager.getByAccountIdentifierAsync(AUTHENTICATED_ACI)) + .thenReturn(CompletableFuture.completedFuture(Optional.of(account))); + + final byte[] usernameHash = new byte[AccountController.USERNAME_HASH_LENGTH]; + ThreadLocalRandom.current().nextBytes(usernameHash); + + when(accountsManager.reserveUsernameHash(any(), any())) + .thenAnswer(invocation -> { + final List usernameHashes = invocation.getArgument(1); + + return CompletableFuture.completedFuture( + new AccountsManager.UsernameReservation(invocation.getArgument(0), usernameHashes.get(0))); + }); + + final ReserveUsernameHashResponse expectedResponse = ReserveUsernameHashResponse.newBuilder() + .setUsernameHash(ByteString.copyFrom(usernameHash)) + .build(); + + assertEquals(expectedResponse, + authenticatedServiceStub().reserveUsernameHash(ReserveUsernameHashRequest.newBuilder() + .addUsernameHashes(ByteString.copyFrom(usernameHash)) + .build())); + } + + @Test + void reserveUsernameHashNotAvailable() { + final Account account = mock(Account.class); + + when(accountsManager.getByAccountIdentifierAsync(AUTHENTICATED_ACI)) + .thenReturn(CompletableFuture.completedFuture(Optional.of(account))); + + final byte[] usernameHash = new byte[AccountController.USERNAME_HASH_LENGTH]; + ThreadLocalRandom.current().nextBytes(usernameHash); + + when(accountsManager.reserveUsernameHash(any(), any())) + .thenReturn(CompletableFuture.failedFuture(new UsernameHashNotAvailableException())); + + final ReserveUsernameHashResponse expectedResponse = ReserveUsernameHashResponse.newBuilder() + .setError(ReserveUsernameHashError.newBuilder() + .setErrorType(ReserveUsernameHashErrorType.RESERVE_USERNAME_HASH_ERROR_TYPE_NO_HASHES_AVAILABLE) + .build()) + .build(); + + assertEquals(expectedResponse, + authenticatedServiceStub().reserveUsernameHash(ReserveUsernameHashRequest.newBuilder() + .addUsernameHashes(ByteString.copyFrom(usernameHash)) + .build())); + } + + @Test + void reserveUsernameHashNoHashes() { + //noinspection ResultOfMethodCallIgnored + GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT, + () -> authenticatedServiceStub().reserveUsernameHash(ReserveUsernameHashRequest.newBuilder().build())); + } + + @Test + void reserveUsernameHashTooManyHashes() { + final ReserveUsernameHashRequest.Builder requestBuilder = ReserveUsernameHashRequest.newBuilder(); + + for (int i = 0; i < AccountController.MAXIMUM_USERNAME_HASHES_LIST_LENGTH + 1; i++) { + final byte[] usernameHash = new byte[AccountController.USERNAME_HASH_LENGTH]; + ThreadLocalRandom.current().nextBytes(usernameHash); + + requestBuilder.addUsernameHashes(ByteString.copyFrom(usernameHash)); + } + + //noinspection ResultOfMethodCallIgnored + GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT, + () -> authenticatedServiceStub().reserveUsernameHash(requestBuilder.build())); + } + + @Test + void reserveUsernameHashBadHashLength() { + final Account account = mock(Account.class); + + when(accountsManager.getByAccountIdentifierAsync(AUTHENTICATED_ACI)) + .thenReturn(CompletableFuture.completedFuture(Optional.of(account))); + + final byte[] usernameHash = new byte[AccountController.USERNAME_HASH_LENGTH + 1]; + ThreadLocalRandom.current().nextBytes(usernameHash); + + //noinspection ResultOfMethodCallIgnored + GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT, + () -> authenticatedServiceStub().reserveUsernameHash(ReserveUsernameHashRequest.newBuilder() + .addUsernameHashes(ByteString.copyFrom(usernameHash)) + .build())); + } + + @Test + void reserveUsernameHashRateLimited() { + final byte[] usernameHash = new byte[AccountController.USERNAME_HASH_LENGTH]; + ThreadLocalRandom.current().nextBytes(usernameHash); + + final Duration retryAfter = Duration.ofMinutes(3); + + when(rateLimiter.validateReactive(any(UUID.class))) + .thenReturn(Mono.error(new RateLimitExceededException(retryAfter, false))); + + //noinspection ResultOfMethodCallIgnored + GrpcTestUtils.assertRateLimitExceeded(retryAfter, + () -> authenticatedServiceStub().reserveUsernameHash(ReserveUsernameHashRequest.newBuilder() + .addUsernameHashes(ByteString.copyFrom(usernameHash)) + .build()), + accountsManager); + } + + @Test + void confirmUsernameHash() { + final byte[] usernameHash = new byte[AccountController.USERNAME_HASH_LENGTH]; + ThreadLocalRandom.current().nextBytes(usernameHash); + + final byte[] usernameCiphertext = new byte[32]; + ThreadLocalRandom.current().nextBytes(usernameCiphertext); + + final byte[] zkProof = new byte[32]; + ThreadLocalRandom.current().nextBytes(zkProof); + + final UUID linkHandle = UUID.randomUUID(); + + final Account account = mock(Account.class); + + when(accountsManager.getByAccountIdentifierAsync(AUTHENTICATED_ACI)) + .thenReturn(CompletableFuture.completedFuture(Optional.of(account))); + + when(accountsManager.confirmReservedUsernameHash(account, usernameHash, usernameCiphertext)) + .thenAnswer(invocation -> { + final Account updatedAccount = mock(Account.class); + + when(updatedAccount.getUsernameHash()).thenReturn(Optional.of(usernameHash)); + when(updatedAccount.getUsernameLinkHandle()).thenReturn(linkHandle); + + return CompletableFuture.completedFuture(updatedAccount); + }); + + final ConfirmUsernameHashResponse expectedResponse = ConfirmUsernameHashResponse.newBuilder() + .setUsernameHash(ByteString.copyFrom(usernameHash)) + .setUsernameLinkHandle(UUIDUtil.toByteString(linkHandle)) + .build(); + + assertEquals(expectedResponse, + authenticatedServiceStub().confirmUsernameHash(ConfirmUsernameHashRequest.newBuilder() + .setUsernameHash(ByteString.copyFrom(usernameHash)) + .setUsernameCiphertext(ByteString.copyFrom(usernameCiphertext)) + .setZkProof(ByteString.copyFrom(zkProof)) + .build())); + } + + @ParameterizedTest + @MethodSource + void confirmUsernameHashConfirmationException(final Exception confirmationException, final Status expectedStatus) { + final byte[] usernameHash = new byte[AccountController.USERNAME_HASH_LENGTH]; + ThreadLocalRandom.current().nextBytes(usernameHash); + + final byte[] usernameCiphertext = new byte[32]; + ThreadLocalRandom.current().nextBytes(usernameCiphertext); + + final byte[] zkProof = new byte[32]; + ThreadLocalRandom.current().nextBytes(zkProof); + + final Account account = mock(Account.class); + + when(accountsManager.getByAccountIdentifierAsync(AUTHENTICATED_ACI)) + .thenReturn(CompletableFuture.completedFuture(Optional.of(account))); + + when(accountsManager.confirmReservedUsernameHash(any(), any(), any())) + .thenReturn(CompletableFuture.failedFuture(confirmationException)); + + //noinspection ResultOfMethodCallIgnored + GrpcTestUtils.assertStatusException(expectedStatus, + () -> authenticatedServiceStub().confirmUsernameHash(ConfirmUsernameHashRequest.newBuilder() + .setUsernameHash(ByteString.copyFrom(usernameHash)) + .setUsernameCiphertext(ByteString.copyFrom(usernameCiphertext)) + .setZkProof(ByteString.copyFrom(zkProof)) + .build())); + } + + private static Stream confirmUsernameHashConfirmationException() { + return Stream.of( + Arguments.of(new UsernameHashNotAvailableException(), Status.NOT_FOUND), + Arguments.of(new UsernameReservationNotFoundException(), Status.FAILED_PRECONDITION) + ); + } + + @Test + void confirmUsernameHashInvalidProof() throws BaseUsernameException { + final byte[] usernameHash = new byte[AccountController.USERNAME_HASH_LENGTH]; + ThreadLocalRandom.current().nextBytes(usernameHash); + + final byte[] usernameCiphertext = new byte[32]; + ThreadLocalRandom.current().nextBytes(usernameCiphertext); + + final byte[] zkProof = new byte[32]; + ThreadLocalRandom.current().nextBytes(zkProof); + + final Account account = mock(Account.class); + + when(accountsManager.getByAccountIdentifierAsync(AUTHENTICATED_ACI)) + .thenReturn(CompletableFuture.completedFuture(Optional.of(account))); + + doThrow(BaseUsernameException.class).when(usernameHashZkProofVerifier).verifyProof(any(), any()); + + //noinspection ResultOfMethodCallIgnored + GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT, + () -> authenticatedServiceStub().confirmUsernameHash(ConfirmUsernameHashRequest.newBuilder() + .setUsernameHash(ByteString.copyFrom(usernameHash)) + .setUsernameCiphertext(ByteString.copyFrom(usernameCiphertext)) + .setZkProof(ByteString.copyFrom(zkProof)) + .build())); + } + + @ParameterizedTest + @MethodSource + void confirmUsernameHashInvalidArgument(final ConfirmUsernameHashRequest request) { + //noinspection ResultOfMethodCallIgnored + GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT, + () -> authenticatedServiceStub().confirmUsernameHash(request)); + } + + private static List confirmUsernameHashInvalidArgument() { + final ConfirmUsernameHashRequest prototypeRequest = ConfirmUsernameHashRequest.newBuilder() + .setUsernameHash(ByteString.copyFrom(new byte[AccountController.USERNAME_HASH_LENGTH])) + .setUsernameCiphertext(ByteString.copyFrom(new byte[AccountController.MAXIMUM_USERNAME_CIPHERTEXT_LENGTH])) + .setZkProof(ByteString.copyFrom(new byte[32])) + .build(); + + return List.of( + // No username hash + ConfirmUsernameHashRequest.newBuilder(prototypeRequest) + .clearUsernameHash() + .build(), + + // Incorrect username hash length + ConfirmUsernameHashRequest.newBuilder(prototypeRequest) + .setUsernameHash(ByteString.copyFrom(new byte[AccountController.USERNAME_HASH_LENGTH + 1])) + .build(), + + // No username ciphertext + ConfirmUsernameHashRequest.newBuilder(prototypeRequest) + .clearUsernameCiphertext() + .build(), + + // Excessive username ciphertext length + ConfirmUsernameHashRequest.newBuilder(prototypeRequest) + .setUsernameCiphertext(ByteString.copyFrom(new byte[AccountController.MAXIMUM_USERNAME_CIPHERTEXT_LENGTH + 1])) + .build(), + + // No ZK proof + ConfirmUsernameHashRequest.newBuilder(prototypeRequest) + .clearZkProof() + .build()); + } + + @Test + void deleteUsernameHash() { + final Account account = mock(Account.class); + + when(accountsManager.getByAccountIdentifierAsync(AUTHENTICATED_ACI)) + .thenReturn(CompletableFuture.completedFuture(Optional.of(account))); + + when(accountsManager.clearUsernameHash(account)).thenReturn(CompletableFuture.completedFuture(account)); + + assertDoesNotThrow(() -> + authenticatedServiceStub().deleteUsernameHash(DeleteUsernameHashRequest.newBuilder().build())); + + verify(accountsManager).clearUsernameHash(account); + } + + @Test + void setUsernameLink() { + final Account account = mock(Account.class); + when(account.getUsernameHash()).thenReturn(Optional.of(new byte[AccountController.USERNAME_HASH_LENGTH])); + + when(accountsManager.getByAccountIdentifierAsync(AUTHENTICATED_ACI)) + .thenReturn(CompletableFuture.completedFuture(Optional.of(account))); + + final byte[] usernameCiphertext = new byte[EncryptedUsername.MAX_SIZE]; + ThreadLocalRandom.current().nextBytes(usernameCiphertext); + + final SetUsernameLinkResponse response = + authenticatedServiceStub().setUsernameLink(SetUsernameLinkRequest.newBuilder() + .setUsernameCiphertext(ByteString.copyFrom(usernameCiphertext)) + .build()); + + final ArgumentCaptor linkHandleCaptor = ArgumentCaptor.forClass(UUID.class); + + verify(account).setUsernameLinkDetails(linkHandleCaptor.capture(), eq(usernameCiphertext)); + + final SetUsernameLinkResponse expectedResponse = SetUsernameLinkResponse.newBuilder() + .setUsernameLinkHandle(UUIDUtil.toByteString(linkHandleCaptor.getValue())) + .build(); + + assertEquals(expectedResponse, response); + } + + @Test + void setUsernameLinkMissingUsernameHash() { + final Account account = mock(Account.class); + when(account.getUsernameHash()).thenReturn(Optional.empty()); + + when(accountsManager.getByAccountIdentifierAsync(AUTHENTICATED_ACI)) + .thenReturn(CompletableFuture.completedFuture(Optional.of(account))); + + final byte[] usernameCiphertext = new byte[EncryptedUsername.MAX_SIZE]; + ThreadLocalRandom.current().nextBytes(usernameCiphertext); + + //noinspection ResultOfMethodCallIgnored + GrpcTestUtils.assertStatusException(Status.FAILED_PRECONDITION, + () -> authenticatedServiceStub().setUsernameLink(SetUsernameLinkRequest.newBuilder() + .setUsernameCiphertext(ByteString.copyFrom(usernameCiphertext)) + .build())); + } + + @ParameterizedTest + @MethodSource + void setUsernameLinkIllegalCiphertext(final SetUsernameLinkRequest request) { + //noinspection ResultOfMethodCallIgnored + GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT, + () -> authenticatedServiceStub().setUsernameLink(request)); + } + + private static List setUsernameLinkIllegalCiphertext() { + return List.of( + // No username ciphertext + SetUsernameLinkRequest.newBuilder().build(), + + // Excessive username ciphertext + SetUsernameLinkRequest.newBuilder() + .setUsernameCiphertext(ByteString.copyFrom(new byte[EncryptedUsername.MAX_SIZE + 1])) + .build() + ); + } + + @Test + void setUsernameLinkRateLimited() { + final Duration retryAfter = Duration.ofSeconds(97); + + when(rateLimiter.validateReactive(any(UUID.class))) + .thenReturn(Mono.error(new RateLimitExceededException(retryAfter, false))); + + final byte[] usernameCiphertext = new byte[EncryptedUsername.MAX_SIZE]; + ThreadLocalRandom.current().nextBytes(usernameCiphertext); + + //noinspection ResultOfMethodCallIgnored + GrpcTestUtils.assertRateLimitExceeded(retryAfter, + () -> authenticatedServiceStub().setUsernameLink(SetUsernameLinkRequest.newBuilder() + .setUsernameCiphertext(ByteString.copyFrom(usernameCiphertext)) + .build()), + accountsManager); + } + + @Test + void deleteUsernameLink() { + final Account account = mock(Account.class); + + when(accountsManager.getByAccountIdentifierAsync(AUTHENTICATED_ACI)) + .thenReturn(CompletableFuture.completedFuture(Optional.of(account))); + + assertDoesNotThrow( + () -> authenticatedServiceStub().deleteUsernameLink(DeleteUsernameLinkRequest.newBuilder().build())); + + verify(account).setUsernameLinkDetails(null, null); + } + + @Test + void deleteUsernameLinkRateLimited() { + final Duration retryAfter = Duration.ofSeconds(11); + + when(rateLimiter.validateReactive(any(UUID.class))) + .thenReturn(Mono.error(new RateLimitExceededException(retryAfter, false))); + + //noinspection ResultOfMethodCallIgnored + GrpcTestUtils.assertRateLimitExceeded(retryAfter, + () -> authenticatedServiceStub().deleteUsernameLink(DeleteUsernameLinkRequest.newBuilder().build()), + accountsManager); + } + + @ParameterizedTest + @MethodSource + void configureUnidentifiedAccess(final boolean unrestrictedUnidentifiedAccess, + final byte[] unidentifiedAccessKey, + final byte[] expectedUnidentifiedAccessKey) { + + final Account account = mock(Account.class); + + when(accountsManager.getByAccountIdentifierAsync(AUTHENTICATED_ACI)) + .thenReturn(CompletableFuture.completedFuture(Optional.of(account))); + + assertDoesNotThrow(() -> authenticatedServiceStub().configureUnidentifiedAccess(ConfigureUnidentifiedAccessRequest.newBuilder() + .setAllowUnrestrictedUnidentifiedAccess(unrestrictedUnidentifiedAccess) + .setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey)) + .build())); + + verify(account).setUnrestrictedUnidentifiedAccess(unrestrictedUnidentifiedAccess); + verify(account).setUnidentifiedAccessKey(expectedUnidentifiedAccessKey); + } + + private static Stream configureUnidentifiedAccess() { + final byte[] unidentifiedAccessKey = new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]; + ThreadLocalRandom.current().nextBytes(unidentifiedAccessKey); + + return Stream.of( + Arguments.of(true, new byte[0], null), + Arguments.of(true, unidentifiedAccessKey, null), + Arguments.of(false, unidentifiedAccessKey, unidentifiedAccessKey) + ); + } + + @ParameterizedTest + @MethodSource + void configureUnidentifiedAccessIllegalArguments(final ConfigureUnidentifiedAccessRequest request) { + //noinspection ResultOfMethodCallIgnored + GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT, + () -> authenticatedServiceStub().configureUnidentifiedAccess(request)); + } + + private static List configureUnidentifiedAccessIllegalArguments() { + return List.of( + // No key and no unrestricted unidentified access + ConfigureUnidentifiedAccessRequest.newBuilder().build(), + + // Key with incorrect length + ConfigureUnidentifiedAccessRequest.newBuilder() + .setAllowUnrestrictedUnidentifiedAccess(false) + .setUnidentifiedAccessKey(ByteString.copyFrom(new byte[15])) + .build() + ); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void setDiscoverableByPhoneNumber(final boolean discoverableByPhoneNumber) { + final Account account = mock(Account.class); + + when(accountsManager.getByAccountIdentifierAsync(AUTHENTICATED_ACI)) + .thenReturn(CompletableFuture.completedFuture(Optional.of(account))); + + assertDoesNotThrow(() -> + authenticatedServiceStub().setDiscoverableByPhoneNumber(SetDiscoverableByPhoneNumberRequest.newBuilder() + .setDiscoverableByPhoneNumber(discoverableByPhoneNumber) + .build())); + + verify(account).setDiscoverableByPhoneNumber(discoverableByPhoneNumber); + } + + @Test + void setRegistrationRecoveryPassword() { + final String phoneNumber = + PhoneNumberUtil.getInstance().format(PhoneNumberUtil.getInstance().getExampleNumber("US"), + PhoneNumberUtil.PhoneNumberFormat.E164); + + final Account account = mock(Account.class); + when(account.getNumber()).thenReturn(phoneNumber); + + when(accountsManager.getByAccountIdentifierAsync(AUTHENTICATED_ACI)) + .thenReturn(CompletableFuture.completedFuture(Optional.of(account))); + + final byte[] registrationRecoveryPassword = new byte[32]; + ThreadLocalRandom.current().nextBytes(registrationRecoveryPassword); + + assertDoesNotThrow(() -> + authenticatedServiceStub().setRegistrationRecoveryPassword(SetRegistrationRecoveryPasswordRequest.newBuilder() + .setRegistrationRecoveryPassword(ByteString.copyFrom(registrationRecoveryPassword)) + .build())); + + verify(registrationRecoveryPasswordsManager).storeForCurrentNumber(phoneNumber, registrationRecoveryPassword); + } + + @Test + void setRegistrationRecoveryPasswordMissingPassword() { + //noinspection ResultOfMethodCallIgnored + GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT, + () -> authenticatedServiceStub().setRegistrationRecoveryPassword( + SetRegistrationRecoveryPasswordRequest.newBuilder().build())); + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/SimpleBaseGrpcTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/SimpleBaseGrpcTest.java index 2c9baedc5..e4709b386 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/SimpleBaseGrpcTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/SimpleBaseGrpcTest.java @@ -148,4 +148,8 @@ protected STUB unauthenticatedServiceStub() { protected MockRemoteAddressInterceptor getMockRemoteAddressInterceptor() { return mockRemoteAddressInterceptor; } + + protected MockAuthenticationInterceptor getMockAuthenticationInterceptor() { + return mockAuthenticationInterceptor; + } }