Add an authentication-required gRPC service for working with accounts

This commit is contained in:
Jon Chambers 2023-10-25 14:47:20 -04:00 committed by GitHub
parent 3d92e5b8a9
commit 54bc3bce96
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 1358 additions and 2 deletions

View File

@ -119,6 +119,8 @@ import org.whispersystems.textsecuregcm.filters.RemoteDeprecationFilter;
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 class WhisperServerService extends Application<WhisperServerConfiguration
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))

View File

@ -73,6 +73,7 @@ import org.whispersystems.textsecuregcm.util.Util;
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;

View File

@ -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
) {}

View File

@ -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;
}

View File

@ -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<GetAccountIdentityResponse> 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<DeleteAccountResponse> 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<SetRegistrationLockResponse> 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<ClearRegistrationLockResponse> 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<ReserveUsernameHashResponse> 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<byte[]> 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<ConfirmUsernameHashResponse> 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<DeleteUsernameHashResponse> 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<SetUsernameLinkResponse> 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<DeleteUsernameLinkResponse> 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<ConfigureUnidentifiedAccessResponse> 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<SetDiscoverableByPhoneNumberResponse> 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<SetRegistrationRecoveryPasswordResponse> 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());
}
}

View File

@ -167,6 +167,10 @@ public class RateLimiters extends BaseRateLimiters<RateLimiters.For> {
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);
}

View File

@ -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.

View File

@ -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;
}

View File

@ -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<AccountsGrpcService, AccountsGrpc.AccountsBlockingStub> {
@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<Account> 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<String> hashCaptor = ArgumentCaptor.forClass(String.class);
final ArgumentCaptor<String> 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<byte[]> 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<Arguments> 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<ConfirmUsernameHashRequest> 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<UUID> 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<SetUsernameLinkRequest> 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<Arguments> 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<ConfigureUnidentifiedAccessRequest> 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()));
}
}

View File

@ -148,4 +148,8 @@ public abstract class SimpleBaseGrpcTest<SERVICE extends BindableService, STUB e
protected MockRemoteAddressInterceptor getMockRemoteAddressInterceptor() {
return mockRemoteAddressInterceptor;
}
protected MockAuthenticationInterceptor getMockAuthenticationInterceptor() {
return mockAuthenticationInterceptor;
}
}