Use enriched gRPC status errors

This commit is contained in:
Ravi Khadiwala 2025-12-22 11:56:35 -06:00 committed by ravi-signal
parent 77eaec0150
commit a1b1d051f5
29 changed files with 989 additions and 318 deletions

View File

@ -84,7 +84,7 @@
<reactor-bom.version>2024.0.10</reactor-bom.version> <!-- 3.7.11, see https://github.com/reactor/reactor#bom-versioning-scheme -->
<resilience4j.version>2.3.0</resilience4j.version>
<semver4j.version>3.1.0</semver4j.version>
<simple-grpc.version>0.1.0</simple-grpc.version>
<simple-grpc.version>0.2.0</simple-grpc.version>
<slf4j.version>2.0.17</slf4j.version>
<stripe.version>30.2.0</stripe.version>
<swagger.version>2.2.36</swagger.version>

View File

@ -6,8 +6,8 @@
package org.whispersystems.textsecuregcm.auth.grpc;
import io.grpc.Context;
import io.grpc.Status;
import javax.annotation.Nullable;
import org.whispersystems.textsecuregcm.grpc.GrpcExceptions;
import org.whispersystems.textsecuregcm.storage.Device;
/**
@ -18,13 +18,11 @@ public class AuthenticationUtil {
static final Context.Key<AuthenticatedDevice> CONTEXT_AUTHENTICATED_DEVICE = Context.key("authenticated-device");
/**
* Returns the account/device authenticated in the current gRPC context or throws an "unauthenticated" exception if
* no authenticated account/device is available.
* Returns the account/device authenticated in the current gRPC context. Should only be called from a service run with
* the {@link RequireAuthenticationInterceptor}.
*
* @return the account/device identifier authenticated in the current gRPC context
*
* @throws io.grpc.StatusRuntimeException with a status of {@code UNAUTHENTICATED} if no authenticated account/device
* could be retrieved from the current gRPC context
* @throws IllegalStateException if no authenticated account/device could be retrieved from the current gRPC context
*/
public static AuthenticatedDevice requireAuthenticatedDevice() {
@Nullable final AuthenticatedDevice authenticatedDevice = CONTEXT_AUTHENTICATED_DEVICE.get();
@ -33,27 +31,25 @@ public class AuthenticationUtil {
return authenticatedDevice;
}
throw Status.UNAUTHENTICATED.asRuntimeException();
throw new IllegalStateException(
"Configuration issue: service expects an authenticated device, but none was found. Request should have failed from an interceptor");
}
/**
* Returns the account/device authenticated in the current gRPC context or throws an "unauthenticated" exception if
* no authenticated account/device is available or "permission denied" if the authenticated device is not the primary
* device for the account.
* Returns the account/device authenticated in the current gRPC context or "invalid argument" if the authenticated
* device is not the primary device for the account.
*
* @return the account/device identifier authenticated in the current gRPC context
*
* @throws io.grpc.StatusRuntimeException with a status of {@code UNAUTHENTICATED} if no authenticated account/device
* could be retrieved from the current gRPC context or a status of {@code PERMISSION_DENIED} if the authenticated
* device is not the primary device for the authenticated account
* @throws io.grpc.StatusRuntimeException with a status of {@code INVALID_ARGUMENT} if the authenticated device is not
* the primary device for the authenticated account
* @throws IllegalStateException if no authenticated account/device could be retrieved from the current gRPC
* context
*/
public static AuthenticatedDevice requireAuthenticatedPrimaryDevice() {
final AuthenticatedDevice authenticatedDevice = requireAuthenticatedDevice();
if (authenticatedDevice.deviceId() != Device.PRIMARY_ID) {
throw Status.PERMISSION_DENIED.asRuntimeException();
throw GrpcExceptions.badAuthentication("RPC requires a primary device");
}
return authenticatedDevice;
}
}

View File

@ -9,6 +9,8 @@ import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
import io.grpc.Status;
import org.whispersystems.textsecuregcm.grpc.GrpcExceptions;
import org.whispersystems.textsecuregcm.grpc.ServerInterceptorUtil;
/**
* A "prohibit authentication" interceptor ensures that requests to endpoints that should be invoked anonymously do not
@ -22,8 +24,8 @@ public class ProhibitAuthenticationInterceptor implements ServerInterceptor {
final Metadata headers, final ServerCallHandler<ReqT, RespT> next) {
final String authHeaderString = headers.get(Metadata.Key.of(RequireAuthenticationInterceptor.AUTHORIZATION_HEADER, Metadata.ASCII_STRING_MARSHALLER));
if (authHeaderString != null) {
call.close(Status.UNAUTHENTICATED.withDescription("authorization header forbidden"), new Metadata());
return new ServerCall.Listener<>() {};
return ServerInterceptorUtil.closeWithStatusException(call,
GrpcExceptions.badAuthentication("The service forbids requests with an authentication header"));
}
return next.startCall(call, headers);
}

View File

@ -14,6 +14,7 @@ import io.grpc.ServerInterceptor;
import io.grpc.Status;
import java.util.Optional;
import org.whispersystems.textsecuregcm.auth.AccountAuthenticator;
import org.whispersystems.textsecuregcm.grpc.GrpcExceptions;
import org.whispersystems.textsecuregcm.grpc.ServerInterceptorUtil;
import org.whispersystems.textsecuregcm.util.HeaderUtils;
@ -40,21 +41,21 @@ public class RequireAuthenticationInterceptor implements ServerInterceptor {
Metadata.Key.of(AUTHORIZATION_HEADER, Metadata.ASCII_STRING_MARSHALLER));
if (authHeaderString == null) {
return ServerInterceptorUtil.closeWithStatus(call,
Status.UNAUTHENTICATED.withDescription("missing authorization header"));
return ServerInterceptorUtil.closeWithStatusException(call,
GrpcExceptions.invalidCredentials("missing authorization header"));
}
final Optional<BasicCredentials> basicCredentials = HeaderUtils.basicCredentialsFromAuthHeader(authHeaderString);
if (basicCredentials.isEmpty()) {
return ServerInterceptorUtil.closeWithStatus(call,
Status.UNAUTHENTICATED.withDescription("malformed authorization header"));
return ServerInterceptorUtil.closeWithStatusException(call,
GrpcExceptions.invalidCredentials("malformed authorization header"));
}
final Optional<org.whispersystems.textsecuregcm.auth.AuthenticatedDevice> authenticated =
authenticator.authenticate(basicCredentials.get());
if (authenticated.isEmpty()) {
return ServerInterceptorUtil.closeWithStatus(call,
Status.UNAUTHENTICATED.withDescription("invalid credentials"));
return ServerInterceptorUtil.closeWithStatusException(call,
GrpcExceptions.invalidCredentials("invalid credentials"));
}
final AuthenticatedDevice authenticatedDevice = new AuthenticatedDevice(

View File

@ -4,28 +4,15 @@
*/
package org.whispersystems.textsecuregcm.controllers;
import io.grpc.Metadata;
import io.grpc.Status;
import java.time.Duration;
import java.util.Optional;
import javax.annotation.Nullable;
import io.grpc.StatusRuntimeException;
import org.whispersystems.textsecuregcm.grpc.ConvertibleToGrpcStatus;
import org.whispersystems.textsecuregcm.grpc.GrpcExceptions;
public class RateLimitExceededException extends Exception implements ConvertibleToGrpcStatus {
public static final Metadata.Key<Duration> RETRY_AFTER_DURATION_KEY =
Metadata.Key.of("retry-after", new Metadata.AsciiMarshaller<>() {
@Override
public String toAsciiString(final Duration value) {
return value.toString();
}
@Override
public Duration parseAsciiString(final String serialized) {
return Duration.parse(serialized);
}
});
@Nullable
private final Duration retryDuration;
@ -44,17 +31,7 @@ public class RateLimitExceededException extends Exception implements Convertible
}
@Override
public Status grpcStatus() {
return Status.RESOURCE_EXHAUSTED;
}
@Override
public Optional<Metadata> grpcMetadata() {
return getRetryDuration()
.map(duration -> {
final Metadata metadata = new Metadata();
metadata.put(RETRY_AFTER_DURATION_KEY, duration);
return metadata;
});
public StatusRuntimeException toStatusRuntimeException() {
return GrpcExceptions.rateLimitExceeded(retryDuration);
}
}

View File

@ -27,8 +27,9 @@ import java.util.Set;
import javax.annotation.Nullable;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicRemoteDeprecationConfiguration;
import org.whispersystems.textsecuregcm.grpc.GrpcExceptions;
import org.whispersystems.textsecuregcm.grpc.RequestAttributesUtil;
import org.whispersystems.textsecuregcm.grpc.StatusConstants;
import org.whispersystems.textsecuregcm.grpc.ServerInterceptorUtil;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
@ -91,8 +92,7 @@ public class RemoteDeprecationFilter implements Filter, ServerInterceptor {
}).orElse(null);
if (shouldBlock(userAgent)) {
call.close(StatusConstants.UPGRADE_NEEDED_STATUS, new Metadata());
return new ServerCall.Listener<>() {};
return ServerInterceptorUtil.closeWithStatusException(call, GrpcExceptions.upgradeRequired());
} else {
return next.startCall(call, headers);
}

View File

@ -6,7 +6,6 @@
package org.whispersystems.textsecuregcm.grpc;
import com.google.protobuf.ByteString;
import io.grpc.Status;
import org.signal.chat.account.CheckAccountExistenceRequest;
import org.signal.chat.account.CheckAccountExistenceResponse;
import org.signal.chat.account.LookupUsernameHashRequest;
@ -14,6 +13,7 @@ import org.signal.chat.account.LookupUsernameHashResponse;
import org.signal.chat.account.LookupUsernameLinkRequest;
import org.signal.chat.account.LookupUsernameLinkResponse;
import org.signal.chat.account.ReactorAccountsAnonymousGrpc;
import org.signal.chat.errors.NotFound;
import org.whispersystems.textsecuregcm.controllers.AccountController;
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
@ -51,18 +51,18 @@ public class AccountsAnonymousGrpcService extends ReactorAccountsAnonymousGrpc.A
@Override
public Mono<LookupUsernameHashResponse> lookupUsernameHash(final LookupUsernameHashRequest request) {
if (request.getUsernameHash().size() != AccountController.USERNAME_HASH_LENGTH) {
throw Status.INVALID_ARGUMENT
.withDescription(String.format("Illegal username hash length; expected %d bytes, but got %d bytes",
AccountController.USERNAME_HASH_LENGTH, request.getUsernameHash().size()))
.asRuntimeException();
throw GrpcExceptions.fieldViolation("username_hash",
String.format("Illegal username hash length; expected %d bytes, but got %d bytes",
AccountController.USERNAME_HASH_LENGTH, request.getUsernameHash().size()));
}
return RateLimitUtil.rateLimitByRemoteAddress(rateLimiters.getUsernameLookupLimiter())
.then(Mono.fromFuture(() -> accountsManager.getByUsernameHash(request.getUsernameHash().toByteArray())))
.map(maybeAccount -> maybeAccount.orElseThrow(Status.NOT_FOUND::asRuntimeException))
.map(account -> LookupUsernameHashResponse.newBuilder()
.setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(account.getUuid())))
.build());
.map(maybeAccount -> maybeAccount
.map(account -> LookupUsernameHashResponse.newBuilder()
.setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(account.getUuid())))
.build())
.orElseGet(() -> LookupUsernameHashResponse.newBuilder().setNotFound(NotFound.getDefaultInstance()).build()));
}
@Override
@ -72,19 +72,16 @@ public class AccountsAnonymousGrpcService extends ReactorAccountsAnonymousGrpc.A
try {
linkHandle = UUIDUtil.fromByteString(request.getUsernameLinkHandle());
} catch (final IllegalArgumentException e) {
throw Status.INVALID_ARGUMENT
.withDescription("Could not interpret link handle as UUID")
.withCause(e)
.asRuntimeException();
throw GrpcExceptions.fieldViolation("username_link_handle", "Could not interpret link handle as UUID");
}
return RateLimitUtil.rateLimitByRemoteAddress(rateLimiters.getUsernameLinkLookupLimiter())
.then(Mono.fromFuture(() -> accountsManager.getByUsernameLinkHandle(linkHandle)))
.map(maybeAccount -> maybeAccount
.flatMap(Account::getEncryptedUsername)
.orElseThrow(Status.NOT_FOUND::asRuntimeException))
.map(usernameCiphertext -> LookupUsernameLinkResponse.newBuilder()
.setUsernameCiphertext(ByteString.copyFrom(usernameCiphertext))
.build());
.map(usernameCiphertext -> LookupUsernameLinkResponse.newBuilder()
.setUsernameCiphertext(ByteString.copyFrom(usernameCiphertext))
.build())
.orElseGet(() -> LookupUsernameLinkResponse.newBuilder().setNotFound(NotFound.getDefaultInstance()).build()));
}
}

View File

@ -6,7 +6,6 @@
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;
@ -26,8 +25,6 @@ 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;
@ -38,7 +35,9 @@ 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.account.UsernameNotAvailable;
import org.signal.chat.common.AccountIdentifiers;
import org.signal.chat.errors.FailedPrecondition;
import org.signal.libsignal.usernames.BaseUsernameException;
import org.whispersystems.textsecuregcm.auth.SaltedTokenHash;
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;
@ -50,6 +49,7 @@ import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
import org.whispersystems.textsecuregcm.identity.IdentityType;
import org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;
import org.whispersystems.textsecuregcm.storage.UsernameHashNotAvailableException;
@ -78,10 +78,7 @@ public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
@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))
return getAccount()
.map(account -> {
final AccountIdentifiers.Builder accountIdentifiersBuilder = AccountIdentifiers.newBuilder()
.addServiceIdentifiers(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(account.getUuid())))
@ -99,10 +96,7 @@ public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
@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))
return getAccount(AuthenticationUtil.requireAuthenticatedPrimaryDevice())
.flatMap(account -> Mono.fromFuture(() -> accountsManager.delete(account, AccountsManager.DeletionReason.USER_REQUEST)))
.thenReturn(DeleteAccountResponse.newBuilder().build());
}
@ -112,11 +106,10 @@ public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedPrimaryDevice();
if (request.getRegistrationLock().isEmpty()) {
throw Status.INVALID_ARGUMENT.withDescription("Registration lock secret must not be empty").asRuntimeException();
throw GrpcExceptions.fieldViolation("registration_lock", "Registration lock secret must not be empty");
}
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
return getAccount(authenticatedDevice)
.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.
@ -131,10 +124,7 @@ public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
@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))
return getAccount(AuthenticationUtil.requireAuthenticatedPrimaryDevice())
.flatMap(account -> Mono.fromFuture(() -> accountsManager.updateAsync(account,
a -> a.setRegistrationLock(null, null))))
.map(ignored -> ClearRegistrationLockResponse.newBuilder().build());
@ -145,42 +135,34 @@ public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
if (request.getUsernameHashesCount() == 0) {
throw Status.INVALID_ARGUMENT
.withDescription("List of username hashes must not be empty")
.asRuntimeException();
throw GrpcExceptions.fieldViolation("username_hashes", "List of username hashes must not be empty");
}
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();
throw GrpcExceptions.fieldViolation("username_hashes",
String.format("List of username hashes may have at most %d elements, but actually had %d",
AccountController.MAXIMUM_USERNAME_HASHES_LIST_LENGTH, request.getUsernameHashesCount()));
}
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();
throw GrpcExceptions.fieldViolation("username_hashes",
String.format("Username hash length must be %d bytes, but was actually %d",
AccountController.USERNAME_HASH_LENGTH, usernameHash.size()));
}
usernameHashes.add(usernameHash.toByteArray());
}
return rateLimiters.getUsernameReserveLimiter().validateReactive(authenticatedDevice.accountIdentifier())
.then(Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())))
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
.then(getAccount())
.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())
.setUsernameNotAvailable(UsernameNotAvailable.getDefaultInstance())
.build());
}
@ -189,61 +171,57 @@ public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
if (request.getUsernameHash().isEmpty()) {
throw Status.INVALID_ARGUMENT
.withDescription("Username hash must not be empty")
.asRuntimeException();
throw GrpcExceptions.fieldViolation("username_hash", "Username hash must not be empty");
}
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();
throw GrpcExceptions.fieldViolation("username_hash",
String.format("Username hash length must be %d bytes, but was actually %d",
AccountController.USERNAME_HASH_LENGTH, request.getUsernameHash().size()));
}
if (request.getZkProof().isEmpty()) {
throw Status.INVALID_ARGUMENT
.withDescription("Zero-knowledge proof must not be empty")
.asRuntimeException();
throw GrpcExceptions.fieldViolation("zk_proof", "Zero-knowledge proof must not be empty");
}
if (request.getUsernameCiphertext().isEmpty()) {
throw Status.INVALID_ARGUMENT
.withDescription("Username ciphertext must not be empty")
.asRuntimeException();
throw GrpcExceptions.fieldViolation("username_ciphertext", "Username ciphertext must not be empty");
}
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();
throw GrpcExceptions.fieldViolation("username_ciphertext",
String.format("Username ciphertext length must at most %d bytes, but was actually %d",
AccountController.MAXIMUM_USERNAME_CIPHERTEXT_LENGTH, request.getUsernameCiphertext().size()));
}
try {
usernameHashZkProofVerifier.verifyProof(request.getZkProof().toByteArray(), request.getUsernameHash().toByteArray());
} catch (final BaseUsernameException e) {
throw Status.INVALID_ARGUMENT.withDescription("Could not verify proof").asRuntimeException();
throw GrpcExceptions.constraintViolation("Could not verify proof");
}
return rateLimiters.getUsernameSetLimiter().validateReactive(authenticatedDevice.accountIdentifier())
.then(Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())))
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
.then(getAccount())
.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()))
.setConfirmedUsernameHash(ConfirmUsernameHashResponse.ConfirmedUsernameHash.newBuilder()
.setUsernameHash(ByteString.copyFrom(updatedAccount.getUsernameHash().orElseThrow()))
.setUsernameLinkHandle(UUIDUtil.toByteString(updatedAccount.getUsernameLinkHandle()))
.build())
.build())
.onErrorMap(UsernameReservationNotFoundException.class, throwable -> Status.FAILED_PRECONDITION.asRuntimeException())
.onErrorMap(UsernameHashNotAvailableException.class, throwable -> Status.NOT_FOUND.asRuntimeException());
.onErrorResume(UsernameReservationNotFoundException.class, _ -> Mono.just(ConfirmUsernameHashResponse
.newBuilder()
.setReservationNotFound(FailedPrecondition.getDefaultInstance())
.build()))
.onErrorResume(UsernameHashNotAvailableException.class, _ -> Mono.just(ConfirmUsernameHashResponse
.newBuilder()
.setUsernameNotAvailable(UsernameNotAvailable.getDefaultInstance())
.build()));
}
@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))
return getAccount()
.flatMap(account -> Mono.fromFuture(() -> accountsManager.clearUsernameHash(account)))
.thenReturn(DeleteUsernameHashResponse.newBuilder().build());
}
@ -253,19 +231,16 @@ public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
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();
throw GrpcExceptions.fieldViolation("username_ciphertext",
String.format("Username ciphertext must not be empty and must be shorter than %d bytes", EncryptedUsername.MAX_SIZE));
}
return rateLimiters.getUsernameLinkOperationLimiter().validateReactive(authenticatedDevice.accountIdentifier())
.then(Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier())))
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
.then(getAccount())
.flatMap(account -> {
final SetUsernameLinkResponse.Builder responseBuilder = SetUsernameLinkResponse.newBuilder();
if (account.getUsernameHash().isEmpty()) {
return Mono.error(Status.FAILED_PRECONDITION
.withDescription("Account does not have a username hash")
.asRuntimeException());
return Mono.just(responseBuilder.setNoUsernameSet(FailedPrecondition.getDefaultInstance()).build());
}
final UUID linkHandle;
@ -276,37 +251,28 @@ public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
}
return Mono.fromFuture(() -> accountsManager.updateAsync(account, a -> a.setUsernameLinkDetails(linkHandle, request.getUsernameCiphertext().toByteArray())))
.thenReturn(linkHandle);
})
.map(linkHandle -> SetUsernameLinkResponse.newBuilder()
.setUsernameLinkHandle(UUIDUtil.toByteString(linkHandle))
.build());
.thenReturn(responseBuilder.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))
.then(getAccount())
.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();
throw GrpcExceptions.fieldViolation("unidentified_access_key",
String.format("Unidentified access key must be %d bytes, but was actually %d",
UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH, request.getUnidentifiedAccessKey().size()));
}
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
return getAccount()
.flatMap(account -> Mono.fromFuture(() -> accountsManager.updateAsync(account, a -> {
a.setUnrestrictedUnidentifiedAccess(request.getAllowUnrestrictedUnidentifiedAccess());
a.setUnidentifiedAccessKey(request.getAllowUnrestrictedUnidentifiedAccess() ? null : request.getUnidentifiedAccessKey().toByteArray());
@ -316,10 +282,7 @@ public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
@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))
return getAccount()
.flatMap(account -> Mono.fromFuture(() -> accountsManager.updateAsync(account,
a -> a.setDiscoverableByPhoneNumber(request.getDiscoverableByPhoneNumber()))))
.thenReturn(SetDiscoverableByPhoneNumberResponse.newBuilder().build());
@ -327,17 +290,22 @@ public class AccountsGrpcService extends ReactorAccountsGrpc.AccountsImplBase {
@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();
throw GrpcExceptions.fieldViolation("registration_recovery_password", "Registration recovery password must not be empty");
}
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException))
return getAccount()
.flatMap(account -> Mono.fromFuture(() -> registrationRecoveryPasswordsManager.store(account.getIdentifier(IdentityType.PNI), request.getRegistrationRecoveryPassword().toByteArray())))
.thenReturn(SetRegistrationRecoveryPasswordResponse.newBuilder().build());
}
private Mono<Account> getAccount() {
return getAccount(AuthenticationUtil.requireAuthenticatedDevice());
}
private Mono<Account> getAccount(AuthenticatedDevice authenticatedDevice) {
return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
.map(maybeAccount -> maybeAccount
.orElseThrow(() -> GrpcExceptions.invalidCredentials("invalid credentials")));
}
}

View File

@ -7,14 +7,12 @@ package org.whispersystems.textsecuregcm.grpc;
import io.grpc.Metadata;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import java.util.Optional;
/**
* Interface to be implemented by our custom exceptions that are consistently mapped to a gRPC status.
*/
public interface ConvertibleToGrpcStatus {
Status grpcStatus();
Optional<Metadata> grpcMetadata();
StatusRuntimeException toStatusRuntimeException();
}

View File

@ -11,6 +11,11 @@ import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.UncheckedIOException;
/**
* This interceptor observes responses from the service and if the response status is {@link Status#UNKNOWN}
@ -21,6 +26,8 @@ import io.grpc.Status;
*/
public class ErrorMappingInterceptor implements ServerInterceptor {
private static final Logger log = LoggerFactory.getLogger(ErrorMappingInterceptor.class);
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
final ServerCall<ReqT, RespT> call,
@ -34,15 +41,33 @@ public class ErrorMappingInterceptor implements ServerInterceptor {
// I.e. if at this point we see anything but the `UNKNOWN`,
// that means that some logic in the service made this decision already
// and automatic conversion may conflict with it.
if (status.getCode().equals(Status.Code.UNKNOWN)
&& status.getCause() instanceof ConvertibleToGrpcStatus convertibleToGrpcStatus) {
super.close(
convertibleToGrpcStatus.grpcStatus(),
convertibleToGrpcStatus.grpcMetadata().orElseGet(Metadata::new)
);
} else {
if (!status.getCode().equals(Status.Code.UNKNOWN)) {
super.close(status, trailers);
return;
}
final StatusRuntimeException statusException = switch (status.getCause()) {
case ConvertibleToGrpcStatus e -> e.toStatusRuntimeException();
case UncheckedIOException e -> {
log.warn("RPC {} encountered UncheckedIOException", call.getMethodDescriptor().getFullMethodName(), e.getCause());
yield GrpcExceptions.unavailable(e.getCause().getMessage());
}
case IOException e -> {
log.warn("RPC {} encountered IOException", call.getMethodDescriptor().getFullMethodName(), e);
yield GrpcExceptions.unavailable(e.getMessage());
}
case null -> {
log.error("RPC {} finished with status UNKNOWN: {}",
call.getMethodDescriptor().getFullMethodName(), status.getDescription());
yield GrpcExceptions.unavailable(status.getDescription());
}
default -> {
log.error("RPC {} finished with status UNKNOWN",
call.getMethodDescriptor().getFullMethodName(), status.getCause());
yield GrpcExceptions.unavailable(status.getCause().getMessage());
}
};
super.close(statusException.getStatus(), statusException.getTrailers());
}
}, headers);
}

View File

@ -0,0 +1,164 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc;
import com.google.protobuf.Any;
import com.google.rpc.BadRequest;
import com.google.rpc.ErrorInfo;
import com.google.rpc.RetryInfo;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import io.grpc.protobuf.StatusProto;
import java.time.Duration;
import javax.annotation.Nullable;
public class GrpcExceptions {
public static final String DOMAIN = "grpc.chat.signal.org";
private static final Any ERROR_INFO_CONSTRAINT_VIOLATED = Any.pack(ErrorInfo.newBuilder()
.setDomain(DOMAIN)
.setReason("CONSTRAINT_VIOLATED")
.build());
private static final Any ERROR_INFO_RESOURCE_EXHAUSTED = Any.pack(ErrorInfo.newBuilder()
.setDomain(DOMAIN)
.setReason("RESOURCE_EXHAUSTED")
.build());
private static final Any ERROR_INFO_INVALID_CREDENTIALS = Any.pack(ErrorInfo.newBuilder()
.setDomain(DOMAIN)
.setReason("INVALID_CREDENTIALS")
.build());
private static final Any ERROR_INFO_BAD_AUTHENTICATION = Any.pack(ErrorInfo.newBuilder()
.setDomain(DOMAIN)
.setReason("BAD_AUTHENTICATION")
.build());
private static final com.google.rpc.Status UPGRADE_REQUIRED = com.google.rpc.Status.newBuilder()
.setCode(Status.Code.INVALID_ARGUMENT.value())
.setMessage("Upgrade required")
.addDetails(Any.pack(ErrorInfo.newBuilder()
.setDomain(DOMAIN)
.setReason("UPGRADE_REQUIRED")
.build()))
.build();
private GrpcExceptions() {
}
/// The client version provided in the User-Agent is no longer supported. The client must upgrade to use the service.
///
/// @return A [StatusRuntimeException] encoding the error
public static StatusRuntimeException upgradeRequired() {
return StatusProto.toStatusRuntimeException(UPGRADE_REQUIRED);
}
/// The RPC argument violated a constraint that was annotated or documented in the service definition. It is always
/// possible to check this constraint without communicating with the chat server. This always represents a client bug
/// or out of date client. Additional information about the violating field will be included in the metadata.
///
/// @param fieldName The name of the field that violated a service constraint
/// @param message Additional context about the constraint violation
/// @return A [StatusRuntimeException] encoding the error
public static StatusRuntimeException fieldViolation(final String fieldName, @Nullable final String message) {
return StatusProto.toStatusRuntimeException(com.google.rpc.Status.newBuilder()
.setCode(Status.Code.INVALID_ARGUMENT.value())
.setMessage(messageOrDefault(message, Status.Code.INVALID_ARGUMENT))
.addDetails(ERROR_INFO_CONSTRAINT_VIOLATED)
.addDetails(Any.pack(BadRequest.newBuilder()
.addFieldViolations(BadRequest.FieldViolation.newBuilder()
.setField(fieldName)
.setDescription(messageOrDefault(message, Status.Code.INVALID_ARGUMENT)))
.build()))
.build());
}
/// The RPC argument violated a constraint that was annotated or documented in the service definition. It is always
/// possible to check this constraint without communicating with the chat server. This always represents a client bug
/// or out of date client.
///
/// @param message Additional context about the constraint violation
/// @return A [StatusRuntimeException] encoding the error
public static StatusRuntimeException constraintViolation(@Nullable final String message) {
return StatusProto.toStatusRuntimeException(com.google.rpc.Status.newBuilder()
.setCode(Status.Code.INVALID_ARGUMENT.value())
.setMessage(messageOrDefault(message, Status.Code.INVALID_ARGUMENT))
.addDetails(ERROR_INFO_CONSTRAINT_VIOLATED)
.build());
}
/// The request has incorrectly set authentication credentials for the RPC. This represents a client bug where the
/// authorization header is not correct for the RPC. For example,
///
/// - The RPC was for an anonymous service, but included an Authentication header in the RPC metadata
/// - The RPC should only be made by the primary device, but the request had linked device credentials
///
/// @param message indicating why the credentials were set incorrectly
/// @return A [StatusRuntimeException] encoding the error
public static StatusRuntimeException badAuthentication(@Nullable final String message) {
return StatusProto.toStatusRuntimeException(com.google.rpc.Status.newBuilder()
.setCode(Status.Code.INVALID_ARGUMENT.value())
.setMessage(messageOrDefault(message, Status.Code.INVALID_ARGUMENT))
.addDetails(ERROR_INFO_BAD_AUTHENTICATION)
.build());
}
/// The account credentials provided in the authorization header are no longer valid.
///
/// @param message indicating why the credentials were invalid
/// @return A [StatusRuntimeException] encoding the error
public static StatusRuntimeException invalidCredentials(@Nullable final String message) {
return StatusProto.toStatusRuntimeException(com.google.rpc.Status.newBuilder()
.setCode(Status.Code.UNAUTHENTICATED.value())
.setMessage(messageOrDefault(message, Status.Code.UNAUTHENTICATED))
.addDetails(ERROR_INFO_INVALID_CREDENTIALS)
.build());
}
/// A server-side resource was exhausted. The details field may include a RetryInfo message that includes the amount
/// of time in seconds the client should wait before retrying the request.
///
/// If a RetryInfo is present, the client must wait the indicated time before retrying the request. If absent, the
/// client should retry with an exponential backoff.
///
/// @param retryDuration If present, the duration the client should wait before retrying the request
/// @return A [StatusRuntimeException] encoding the error
public static StatusRuntimeException rateLimitExceeded(@Nullable final Duration retryDuration) {
final com.google.rpc.Status.Builder builder = com.google.rpc.Status.newBuilder()
.setCode(Status.Code.RESOURCE_EXHAUSTED.value())
.addDetails(ERROR_INFO_RESOURCE_EXHAUSTED);
if (retryDuration != null) {
builder.addDetails(Any.pack(RetryInfo.newBuilder()
.setRetryDelay(com.google.protobuf.Duration.newBuilder()
.setSeconds(retryDuration.getSeconds())
.setNanos(retryDuration.getNano()))
.build()));
}
return StatusProto.toStatusRuntimeException(builder.build());
}
/// There was an internal error processing the RPC. The client should retry the request with exponential backoff.
///
/// @return A [StatusRuntimeException] encoding the error
public static StatusRuntimeException unavailable(@Nullable final String message) {
return StatusProto.toStatusRuntimeException(com.google.rpc.Status.newBuilder()
.setCode(Status.Code.UNAVAILABLE.value())
.setMessage(messageOrDefault(message, Status.Code.UNAVAILABLE))
.addDetails(Any.pack(ErrorInfo.newBuilder()
.setDomain(DOMAIN)
.setReason("UNAVAILABLE")
.build()))
.build());
}
private static String messageOrDefault(@Nullable final String message, Status.Code code) {
return message == null ? code.toString() : message;
}
}

View File

@ -6,26 +6,50 @@
package org.whispersystems.textsecuregcm.grpc;
import com.google.common.annotations.VisibleForTesting;
import io.grpc.*;
import com.google.protobuf.Descriptors;
import com.google.protobuf.InvalidProtocolBufferException;
import com.google.protobuf.Message;
import com.google.rpc.ErrorInfo;
import io.grpc.ForwardingServerCall;
import io.grpc.ForwardingServerCallListener;
import io.grpc.Metadata;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
import io.grpc.Status;
import io.grpc.protobuf.StatusProto;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.Tags;
import io.micrometer.core.instrument.Timer;
import java.io.UncheckedIOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;
import javax.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.storage.ClientReleaseManager;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
public class MetricServerInterceptor implements ServerInterceptor {
private static final Logger log = LoggerFactory.getLogger(MetricServerInterceptor.class);
private static final String TAG_SERVICE_NAME = "grpcService";
private static final String TAG_METHOD_NAME = "method";
private static final String TAG_METHOD_TYPE = "methodType";
private static final String TAG_STATUS_CODE = "statusCode";
private static final String TAG_REASON = "reason";
@VisibleForTesting
static final String DEFAULT_SUCCESS_REASON = "success";
@VisibleForTesting
static final String DEFAULT_ERROR_REASON = "n/a";
@VisibleForTesting
static final String REQUEST_MESSAGE_COUNTER_NAME = MetricsUtil.name(MetricServerInterceptor.class, "requestMessage");
@ -77,6 +101,7 @@ public class MetricServerInterceptor implements ServerInterceptor {
private final Counter responseMessageCounter;
private final Tags tags;
private @Nullable String reason = null;
MetricServerCall(final ServerCall<ReqT, RespT> delegate, final Tags tags) {
super(delegate);
@ -86,15 +111,59 @@ public class MetricServerInterceptor implements ServerInterceptor {
@Override
public void close(final Status status, final Metadata responseHeaders) {
meterRegistry.counter(RPC_COUNTER_NAME, tags.and(TAG_STATUS_CODE, status.getCode().name())).increment();
if (!status.isOk()) {
reason = errorInfo(StatusProto.fromStatusAndTrailers(status, responseHeaders))
.map(ErrorInfo::getReason)
.orElse(DEFAULT_ERROR_REASON);
}
Tags responseTags = tags.and(Tag.of(TAG_STATUS_CODE, status.getCode().name()));
if (reason != null) {
responseTags = responseTags.and(TAG_REASON, reason);
}
meterRegistry.counter(RPC_COUNTER_NAME, responseTags).increment();
super.close(status, responseHeaders);
}
@Override
public void sendMessage(final RespT responseMessage) {
this.responseMessageCounter.increment();
// Extract the annotated reason (if any) from the message
final String messageReason = MetricServerCall.reason(responseMessage);
// If there are multiple messages sent on this RPC (server-side streaming), just use the most recent reason
this.reason = messageReason == null ? DEFAULT_SUCCESS_REASON : messageReason;
super.sendMessage(responseMessage);
}
@Nullable
private static String reason(final Object obj) {
if (!(obj instanceof Message msg)) {
return null;
}
// iterate through all fields on the message
for (Map.Entry<Descriptors.FieldDescriptor, Object> field : msg.getAllFields().entrySet()) {
// iterate through all options on the field
for (Map.Entry<Descriptors.FieldDescriptor, Object> option : field.getKey().getOptions().getAllFields().entrySet()) {
if (option.getKey().getFullName().equals("org.signal.chat.tag.reason")) {
if (!(option.getValue() instanceof String s)) {
log.error("Invalid value for option tag.reason {}", option.getValue());
continue;
}
// return the first tag we see
return s;
}
}
// No reason on this field. Recursively check subfields of this field for a reason
final String subReason = reason(field.getValue());
if (subReason != null) {
return subReason;
}
}
// No field or subfield contained an annotated reason
return null;
}
}
/**
@ -131,4 +200,17 @@ public class MetricServerInterceptor implements ServerInterceptor {
super.onCancel();
}
}
private static Optional<ErrorInfo> errorInfo(final com.google.rpc.Status statusProto) {
return statusProto.getDetailsList().stream()
.filter(any -> any.is(ErrorInfo.class))
.map(errorInfo -> {
try {
return errorInfo.unpack(ErrorInfo.class);
} catch (final InvalidProtocolBufferException e) {
throw new UncheckedIOException(e);
}
})
.findFirst();
}
}

View File

@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.grpc;
import io.grpc.Metadata;
import io.grpc.ServerCall;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
public class ServerInterceptorUtil {
@ -36,4 +37,23 @@ public class ServerInterceptorUtil {
//noinspection unchecked
return NO_OP_LISTENER;
}
/**
* Closes the given server call with the status and metadata from the provided exception, returning a no-op listener.
*
* @param call the server call to close
* @param exception the {@link StatusRuntimeException} with which to close the call
*
* @return a no-op server call listener
*
* @param <ReqT> the type of request object handled by the server call
* @param <RespT> the type of response object returned by the server call
*/
public static <ReqT, RespT> ServerCall.Listener<ReqT> closeWithStatusException(final ServerCall<ReqT, RespT> call, final StatusRuntimeException exception) {
call.close(exception.getStatus(), exception.getTrailers());
//noinspection unchecked
return NO_OP_LISTENER;
}
}

View File

@ -1,12 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc;
import io.grpc.Status;
public abstract class StatusConstants {
public static final Status UPGRADE_NEEDED_STATUS = Status.INVALID_ARGUMENT.withDescription("signal-upgrade-required");
}

View File

@ -5,6 +5,7 @@ option java_multiple_files = true;
package org.signal.chat.account;
import "org/signal/chat/common.proto";
import "org/signal/chat/errors.proto";
/**
* Provides methods for working with Signal accounts.
@ -34,23 +35,13 @@ service Accounts {
/**
* 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.
* usernames may be claimed later via `ConfirmUsernameHash`.
*/
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 found 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.
* (see `ReserveUsernameHash`).
*/
rpc ConfirmUsernameHash(ConfirmUsernameHashRequest) returns (ConfirmUsernameHashResponse) {}
@ -64,21 +55,11 @@ service Accounts {
* Associates the given username ciphertext with the account, replacing any
* previously stored ciphertext. A new link handle will optionally be created,
* and the link handle to use will be returned in any event.
*
* 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.
* Clears any username link associated with the authenticated account.
*/
rpc DeleteUsernameLink(DeleteUsernameLinkRequest) returns (DeleteUsernameLinkResponse) {}
@ -114,8 +95,7 @@ service AccountsAnonymous {
/**
* Finds the service identifier of the account associated with the given
* username hash. This method will return a `NOT_FOUND` status if no account
* was found for the given username hash.
* username hash.
*/
rpc LookupUsernameHash(LookupUsernameHashRequest) returns (LookupUsernameHashResponse) {}
@ -166,6 +146,8 @@ message ReserveUsernameHashRequest {
repeated bytes username_hashes = 1;
}
message UsernameNotAvailable {}
message ReserveUsernameHashResponse {
oneof response {
/**
@ -174,26 +156,13 @@ message ReserveUsernameHashResponse {
bytes username_hash = 1;
/**
* An error indicating why a username hash could not be reserved.
* Indicates that, of all of the candidate hashes provided, none were
* available. Callers may generate a new set of hashes and and retry.
*/
ReserveUsernameHashError error = 2;
UsernameNotAvailable username_not_available = 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.
@ -214,15 +183,35 @@ message ConfirmUsernameHashRequest {
}
message ConfirmUsernameHashResponse {
/**
* The newly-confirmed username hash.
*/
bytes username_hash = 1;
message ConfirmedUsernameHash {
/**
* 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;
/**
* The server-generated username link handle for the newly-confirmed username.
*/
bytes username_link_handle = 2;
}
oneof response {
/**
* The details of the successfully confirmed username.
*/
ConfirmedUsernameHash confirmed_username_hash = 1;
/**
* The provided hash was not reserved for the account.
*/
errors.FailedPrecondition reservation_not_found = 2;
/**
* The reservation has lapsed and the requested username has been claimed by
* another caller.
*/
UsernameNotAvailable username_not_available = 3;
}
}
message DeleteUsernameHashRequest {
@ -245,11 +234,20 @@ message SetUsernameLinkRequest {
bool keep_link_handle = 2;
}
message SetUsernameLinkResponse {
/**
* A new link handle for the given username ciphertext.
*/
bytes username_link_handle = 1;
oneof response {
/**
* A new link handle for the given username ciphertext.
*/
bytes username_link_handle = 1;
/**
* The authenticated account did not have a username set.
*/
errors.FailedPrecondition no_username_set = 2;
}
}
message DeleteUsernameLinkRequest {
@ -323,10 +321,17 @@ message LookupUsernameHashRequest {
}
message LookupUsernameHashResponse {
/**
* The service identifier associated with a given username hash.
*/
common.ServiceIdentifier service_identifier = 1;
oneof response {
/**
* The service identifier associated with the provided username hash.
*/
common.ServiceIdentifier service_identifier = 1;
/**
* No account was found for the provided username hash.
*/
errors.NotFound not_found = 2;
}
}
message LookupUsernameLinkRequest {
@ -338,8 +343,16 @@ message LookupUsernameLinkRequest {
}
message LookupUsernameLinkResponse {
/**
* The ciphertext of the username identified by the given link handle.
*/
bytes username_ciphertext = 1;
oneof response {
/**
* The ciphertext of the username identified by the provided link handle.
*/
bytes username_ciphertext = 1;
/**
* No username was found for the provided link handle.
*/
errors.NotFound not_found = 2;
}
}

View File

@ -115,3 +115,4 @@ message ZkCredential {
*/
bytes credential = 2;
}

View File

@ -0,0 +1,28 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
syntax = "proto3";
option java_multiple_files = true;
package org.signal.chat.errors;
/**
* Response message that indicates a particular resource was not found.
*/
message NotFound {}
/**
* Response message that indicates that some precondition of the request was not
* met. For example, if there was a request to update foo, but foo had not been
* set, this would be an appropriate error.
*/
message FailedPrecondition {
/**
* An optional description indicating what precondition failed.
*/
string description = 1;
}

View File

@ -0,0 +1,40 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
syntax = "proto3";
option java_multiple_files = true;
package org.signal.chat.tag;
import "google/protobuf/descriptor.proto";
extend google.protobuf.FieldOptions {
/**
* Indicate that a message which includes this field (directly or indirectly)
* was generated for a particular reason.
*
* ```
* import "org/signal/chat/tag.proto"
*
* message LookupThingResponse {
* oneof response {
* string thing = 1;
* Error not_found = 2 [(tag.reason) = "not_found"];
* Error forbidden = 3 [(tag.reason) = "forbidden"];
* }
* }
* ```
*
* Metrics middleware may then inspect `LookupThingResponse` and tag responses
* with the provided reason. This is useful when multiple outcomes are
* potentially represented with a status = "OK" RPC response.
*
* Valid messages should only have a single reason set. If a message has
* multiple fields present that have a reason option set, no guarantees are
* made about the reason that is selected.
*/
optional string reason = 71000;
}

View File

@ -53,7 +53,7 @@ class ProhibitAuthenticationInterceptorTest {
final StatusRuntimeException e = assertThrows(StatusRuntimeException.class,
() -> client.echo(EchoRequest.getDefaultInstance()));
assertEquals(Status.Code.UNAUTHENTICATED, e.getStatus().getCode());
assertEquals(Status.Code.INVALID_ARGUMENT, e.getStatus().getCode());
}
@Test

View File

@ -5,6 +5,7 @@
package org.whispersystems.textsecuregcm.filters;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
@ -17,12 +18,14 @@ import static org.mockito.Mockito.when;
import com.google.common.net.HttpHeaders;
import com.google.common.net.InetAddresses;
import com.google.protobuf.ByteString;
import com.google.rpc.ErrorInfo;
import com.vdurmont.semver4j.Semver;
import io.grpc.ManagedChannel;
import io.grpc.Server;
import io.grpc.StatusRuntimeException;
import io.grpc.inprocess.InProcessChannelBuilder;
import io.grpc.inprocess.InProcessServerBuilder;
import io.grpc.protobuf.StatusProto;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
@ -40,9 +43,9 @@ import org.signal.chat.rpc.EchoServiceGrpc;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicRemoteDeprecationConfiguration;
import org.whispersystems.textsecuregcm.grpc.EchoServiceImpl;
import org.whispersystems.textsecuregcm.grpc.GrpcExceptions;
import org.whispersystems.textsecuregcm.grpc.MockRequestAttributesInterceptor;
import org.whispersystems.textsecuregcm.grpc.RequestAttributes;
import org.whispersystems.textsecuregcm.grpc.StatusConstants;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
@ -153,7 +156,14 @@ class RemoteDeprecationFilterTest {
final StatusRuntimeException e = assertThrows(
StatusRuntimeException.class,
() -> client.echo(req));
assertEquals(StatusConstants.UPGRADE_NEEDED_STATUS.toString(), e.getStatus().toString());
final com.google.rpc.Status status = StatusProto.fromThrowable(e);
final ErrorInfo errorInfo = assertDoesNotThrow(() -> status.getDetailsList().stream()
.filter(any -> any.is(ErrorInfo.class)).findFirst()
.orElseThrow(() -> new AssertionError("No error info found"))
.unpack(ErrorInfo.class));
assertEquals(GrpcExceptions.DOMAIN, errorInfo.getDomain());
assertEquals(io.grpc.Status.Code.INVALID_ARGUMENT.value(), status.getCode());
assertEquals("UPGRADE_REQUIRED", errorInfo.getReason());
} else {
assertEquals("cluck cluck, i'm a parrot", client.echo(req).getPayload().toStringUtf8());
}

View File

@ -29,8 +29,11 @@ import org.mockito.Mock;
import org.signal.chat.account.AccountsAnonymousGrpc;
import org.signal.chat.account.CheckAccountExistenceRequest;
import org.signal.chat.account.LookupUsernameHashRequest;
import org.signal.chat.account.LookupUsernameHashResponse;
import org.signal.chat.account.LookupUsernameLinkRequest;
import org.signal.chat.account.LookupUsernameLinkResponse;
import org.signal.chat.common.IdentityType;
import org.signal.chat.errors.NotFound;
import org.signal.chat.common.ServiceIdentifier;
import org.whispersystems.textsecuregcm.controllers.AccountController;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
@ -151,8 +154,8 @@ class AccountsAnonymousGrpcServiceTest extends
.getServiceIdentifier());
//noinspection ResultOfMethodCallIgnored
GrpcTestUtils.assertStatusException(Status.NOT_FOUND,
() -> unauthenticatedServiceStub().lookupUsernameHash(LookupUsernameHashRequest.newBuilder()
assertEquals(LookupUsernameHashResponse.newBuilder().setNotFound(NotFound.getDefaultInstance()).build(),
unauthenticatedServiceStub().lookupUsernameHash(LookupUsernameHashRequest.newBuilder()
.setUsernameHash(ByteString.copyFrom(new byte[AccountController.USERNAME_HASH_LENGTH]))
.build()));
}
@ -217,15 +220,16 @@ class AccountsAnonymousGrpcServiceTest extends
when(account.getEncryptedUsername()).thenReturn(Optional.empty());
//noinspection ResultOfMethodCallIgnored
GrpcTestUtils.assertStatusException(Status.NOT_FOUND,
() -> unauthenticatedServiceStub().lookupUsernameLink(LookupUsernameLinkRequest.newBuilder()
final LookupUsernameLinkResponse notFoundResponse = LookupUsernameLinkResponse.newBuilder()
.setNotFound(NotFound.getDefaultInstance())
.build();
assertEquals(notFoundResponse,
unauthenticatedServiceStub().lookupUsernameLink(LookupUsernameLinkRequest.newBuilder()
.setUsernameLinkHandle(UUIDUtil.toByteString(linkHandle))
.build()));
//noinspection ResultOfMethodCallIgnored
GrpcTestUtils.assertStatusException(Status.NOT_FOUND,
() -> unauthenticatedServiceStub().lookupUsernameLink(LookupUsernameLinkRequest.newBuilder()
assertEquals(notFoundResponse,
unauthenticatedServiceStub().lookupUsernameLink(LookupUsernameLinkRequest.newBuilder()
.setUsernameLinkHandle(UUIDUtil.toByteString(UUID.randomUUID()))
.build()));
}

View File

@ -47,8 +47,6 @@ 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;
@ -57,7 +55,9 @@ 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.account.UsernameNotAvailable;
import org.signal.chat.common.AccountIdentifiers;
import org.signal.chat.errors.FailedPrecondition;
import org.signal.libsignal.usernames.BaseUsernameException;
import org.whispersystems.textsecuregcm.auth.SaltedTokenHash;
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;
@ -173,7 +173,7 @@ class AccountsGrpcServiceTest extends SimpleBaseGrpcTest<AccountsGrpcService, Ac
getMockAuthenticationInterceptor().setAuthenticatedDevice(AUTHENTICATED_ACI, (byte) (Device.PRIMARY_ID + 1));
//noinspection ResultOfMethodCallIgnored
GrpcTestUtils.assertStatusException(Status.PERMISSION_DENIED,
GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT, "BAD_AUTHENTICATION",
() -> authenticatedServiceStub().deleteAccount(DeleteAccountRequest.newBuilder().build()));
verify(accountsManager, never()).delete(any(), any());
@ -217,7 +217,7 @@ class AccountsGrpcServiceTest extends SimpleBaseGrpcTest<AccountsGrpcService, Ac
getMockAuthenticationInterceptor().setAuthenticatedDevice(AUTHENTICATED_ACI, (byte) (Device.PRIMARY_ID + 1));
//noinspection ResultOfMethodCallIgnored
GrpcTestUtils.assertStatusException(Status.PERMISSION_DENIED,
GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT, "BAD_AUTHENTICATION",
() -> authenticatedServiceStub().setRegistrationLock(SetRegistrationLockRequest.newBuilder()
.build()));
@ -242,7 +242,7 @@ class AccountsGrpcServiceTest extends SimpleBaseGrpcTest<AccountsGrpcService, Ac
getMockAuthenticationInterceptor().setAuthenticatedDevice(AUTHENTICATED_ACI, (byte) (Device.PRIMARY_ID + 1));
//noinspection ResultOfMethodCallIgnored
GrpcTestUtils.assertStatusException(Status.PERMISSION_DENIED,
GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT,
() -> authenticatedServiceStub().clearRegistrationLock(ClearRegistrationLockRequest.newBuilder().build()));
verify(accountsManager, never()).updateAsync(any(), any());
@ -288,9 +288,7 @@ class AccountsGrpcServiceTest extends SimpleBaseGrpcTest<AccountsGrpcService, Ac
.thenReturn(CompletableFuture.failedFuture(new UsernameHashNotAvailableException()));
final ReserveUsernameHashResponse expectedResponse = ReserveUsernameHashResponse.newBuilder()
.setError(ReserveUsernameHashError.newBuilder()
.setErrorType(ReserveUsernameHashErrorType.RESERVE_USERNAME_HASH_ERROR_TYPE_NO_HASHES_AVAILABLE)
.build())
.setUsernameNotAvailable(UsernameNotAvailable.getDefaultInstance())
.build();
assertEquals(expectedResponse,
@ -379,8 +377,10 @@ class AccountsGrpcServiceTest extends SimpleBaseGrpcTest<AccountsGrpcService, Ac
});
final ConfirmUsernameHashResponse expectedResponse = ConfirmUsernameHashResponse.newBuilder()
.setUsernameHash(ByteString.copyFrom(usernameHash))
.setUsernameLinkHandle(UUIDUtil.toByteString(linkHandle))
.setConfirmedUsernameHash(ConfirmUsernameHashResponse.ConfirmedUsernameHash.newBuilder()
.setUsernameHash(ByteString.copyFrom(usernameHash))
.setUsernameLinkHandle(UUIDUtil.toByteString(linkHandle))
.build())
.build();
assertEquals(expectedResponse,
@ -393,7 +393,7 @@ class AccountsGrpcServiceTest extends SimpleBaseGrpcTest<AccountsGrpcService, Ac
@ParameterizedTest
@MethodSource
void confirmUsernameHashConfirmationException(final Exception confirmationException, final Status expectedStatus) {
void confirmUsernameHashConfirmationException(final Exception confirmationException, final ConfirmUsernameHashResponse expectedResponse) {
final byte[] usernameHash = TestRandomUtil.nextBytes(AccountController.USERNAME_HASH_LENGTH);
final byte[] usernameCiphertext = TestRandomUtil.nextBytes(32);
@ -408,19 +408,26 @@ class AccountsGrpcServiceTest extends SimpleBaseGrpcTest<AccountsGrpcService, Ac
when(accountsManager.confirmReservedUsernameHash(any(), any(), any()))
.thenReturn(CompletableFuture.failedFuture(confirmationException));
//noinspection ResultOfMethodCallIgnored
GrpcTestUtils.assertStatusException(expectedStatus,
() -> authenticatedServiceStub().confirmUsernameHash(ConfirmUsernameHashRequest.newBuilder()
final ConfirmUsernameHashResponse actualResponse = authenticatedServiceStub()
.confirmUsernameHash(ConfirmUsernameHashRequest.newBuilder()
.setUsernameHash(ByteString.copyFrom(usernameHash))
.setUsernameCiphertext(ByteString.copyFrom(usernameCiphertext))
.setZkProof(ByteString.copyFrom(zkProof))
.build()));
.build());
assertEquals(expectedResponse, actualResponse);
}
private static Stream<Arguments> confirmUsernameHashConfirmationException() {
return Stream.of(
Arguments.of(new UsernameHashNotAvailableException(), Status.NOT_FOUND),
Arguments.of(new UsernameReservationNotFoundException(), Status.FAILED_PRECONDITION)
Arguments.of( new UsernameHashNotAvailableException(),
ConfirmUsernameHashResponse.newBuilder()
.setUsernameNotAvailable(UsernameNotAvailable.getDefaultInstance())
.build()),
Arguments.of(new UsernameReservationNotFoundException(),
ConfirmUsernameHashResponse.newBuilder()
.setReservationNotFound(FailedPrecondition.getDefaultInstance())
.build())
);
}
@ -546,9 +553,11 @@ class AccountsGrpcServiceTest extends SimpleBaseGrpcTest<AccountsGrpcService, Ac
final byte[] usernameCiphertext = TestRandomUtil.nextBytes(EncryptedUsername.MAX_SIZE);
//noinspection ResultOfMethodCallIgnored
GrpcTestUtils.assertStatusException(Status.FAILED_PRECONDITION,
() -> authenticatedServiceStub().setUsernameLink(SetUsernameLinkRequest.newBuilder()
assertEquals(
SetUsernameLinkResponse.newBuilder()
.setNoUsernameSet(FailedPrecondition.getDefaultInstance())
.build(),
authenticatedServiceStub().setUsernameLink(SetUsernameLinkRequest.newBuilder()
.setUsernameCiphertext(ByteString.copyFrom(usernameCiphertext))
.build()));
}

View File

@ -17,6 +17,7 @@ import com.google.protobuf.ByteString;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import java.time.Clock;
import java.time.Duration;
import java.util.Arrays;
import java.util.Base64;
import java.util.Iterator;
@ -295,12 +296,10 @@ class BackupsAnonymousGrpcServiceTest extends
assertThat(uploadForm.getSignedUploadLocation()).isEqualTo("example.org");
// rate limit
Duration duration = Duration.ofSeconds(10);
when(backupManager.createTemporaryAttachmentUploadDescriptor(any()))
.thenReturn(CompletableFuture.failedFuture(new RateLimitExceededException(null)));
assertThatExceptionOfType(StatusRuntimeException.class)
.isThrownBy(() -> unauthenticatedServiceStub().getUploadForm(request))
.extracting(StatusRuntimeException::getStatus)
.isEqualTo(Status.RESOURCE_EXHAUSTED);
.thenReturn(CompletableFuture.failedFuture(new RateLimitExceededException(duration)));
GrpcTestUtils.assertRateLimitExceeded(duration, () -> unauthenticatedServiceStub().getUploadForm(request));
}
static Stream<Arguments> messagesUploadForm() {

View File

@ -0,0 +1,157 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc;
import com.google.protobuf.Any;
import com.google.rpc.ErrorInfo;
import io.grpc.ManagedChannel;
import io.grpc.Server;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import io.grpc.inprocess.InProcessChannelBuilder;
import io.grpc.inprocess.InProcessServerBuilder;
import io.grpc.protobuf.StatusProto;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.signal.chat.rpc.EchoRequest;
import org.signal.chat.rpc.EchoResponse;
import org.signal.chat.rpc.EchoServiceGrpc;
import org.signal.chat.rpc.ReactorEchoServiceGrpc;
import org.signal.chat.rpc.SimpleEchoServiceGrpc;
import reactor.core.publisher.Mono;
class ErrorMappingInterceptorTest {
private Server server;
private ManagedChannel channel;
@BeforeEach
void setUp() throws Exception {
channel = InProcessChannelBuilder.forName("ErrorMappingInterceptorTest")
.directExecutor()
.build();
}
@AfterEach
void tearDown() throws Exception {
server.shutdownNow();
channel.shutdownNow();
server.awaitTermination(1, TimeUnit.SECONDS);
channel.awaitTermination(1, TimeUnit.SECONDS);
}
@Test
public void includeDetailsSimpleGrpc() throws Exception {
final StatusRuntimeException e = StatusProto.toStatusRuntimeException(com.google.rpc.Status.newBuilder()
.setCode(Status.Code.INVALID_ARGUMENT.value())
.addDetails(Any.pack(ErrorInfo.newBuilder()
.setDomain("test")
.setReason("TEST")
.build()))
.build());
server = InProcessServerBuilder.forName("ErrorMappingInterceptorTest")
.directExecutor()
.addService(new SimpleEchoServiceErrorImpl(e))
.intercept(new ErrorMappingInterceptor())
.build()
.start();
final EchoServiceGrpc.EchoServiceBlockingStub client = EchoServiceGrpc.newBlockingStub(channel);
GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT, "TEST", () ->
client.echo(EchoRequest.getDefaultInstance()));
}
@Test
public void includeDetailsReactiveGrpc() throws Exception {
final StatusRuntimeException e = StatusProto.toStatusRuntimeException(com.google.rpc.Status.newBuilder()
.setCode(Status.Code.INVALID_ARGUMENT.value())
.addDetails(Any.pack(ErrorInfo.newBuilder()
.setDomain("test")
.setReason("TEST")
.build()))
.build());
server = InProcessServerBuilder.forName("ErrorMappingInterceptorTest")
.directExecutor()
.addService(new ReactorEchoServiceErrorImpl(e))
.intercept(new ErrorMappingInterceptor())
.build()
.start();
final EchoServiceGrpc.EchoServiceBlockingStub client = EchoServiceGrpc.newBlockingStub(channel);
GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT, "TEST", () ->
client.echo(EchoRequest.getDefaultInstance()));
}
@Test
public void mapIOExceptionsReactive() throws Exception {
server = InProcessServerBuilder.forName("ErrorMappingInterceptorTest")
.directExecutor()
.addService(new ReactorEchoServiceErrorImpl(new IOException("test")))
.intercept(new ErrorMappingInterceptor())
.build()
.start();
final EchoServiceGrpc.EchoServiceBlockingStub client = EchoServiceGrpc.newBlockingStub(channel);
GrpcTestUtils.assertStatusException(Status.UNAVAILABLE, "UNAVAILABLE", () ->
client.echo(EchoRequest.getDefaultInstance()));
}
@Test
public void mapIOExceptionsSimple() throws Exception {
server = InProcessServerBuilder.forName("ErrorMappingInterceptorTest")
.directExecutor()
.addService(new SimpleEchoServiceErrorImpl(new UncheckedIOException(new IOException("test"))))
.intercept(new ErrorMappingInterceptor())
.build()
.start();
final EchoServiceGrpc.EchoServiceBlockingStub client = EchoServiceGrpc.newBlockingStub(channel);
GrpcTestUtils.assertStatusException(Status.UNAVAILABLE, "UNAVAILABLE", () ->
client.echo(EchoRequest.getDefaultInstance()));
}
static class ReactorEchoServiceErrorImpl extends ReactorEchoServiceGrpc.EchoServiceImplBase {
private final Exception exception;
ReactorEchoServiceErrorImpl(final Exception exception) {
this.exception = exception;
}
@Override
public Mono<EchoResponse> echo(final EchoRequest echoRequest) {
return Mono.error(exception);
}
@Override
public Throwable onErrorMap(Throwable throwable) {
return new IllegalArgumentException(throwable);
}
}
static class SimpleEchoServiceErrorImpl extends SimpleEchoServiceGrpc.EchoServiceImplBase {
private final RuntimeException exception;
SimpleEchoServiceErrorImpl(final RuntimeException exception) {
this.exception = exception;
}
@Override
public EchoResponse echo(final EchoRequest echoRequest) {
throw exception;
}
}
}

View File

@ -122,14 +122,6 @@ public class ExternalServiceCredentialsGrpcServiceTest
);
}
@Test
public void testUnauthenticatedCall() throws Exception {
assertStatusUnauthenticated(() -> unauthenticatedServiceStub().getExternalServiceCredentials(
GetExternalServiceCredentialsRequest.newBuilder()
.setExternalService(ExternalServiceType.EXTERNAL_SERVICE_TYPE_DIRECTORY)
.build()));
}
/**
* `ExternalServiceDefinitions` enum is supposed to have entries for all values in `ExternalServiceType`,
* except for the `EXTERNAL_SERVICE_TYPE_UNSPECIFIED` and `UNRECOGNIZED`.

View File

@ -5,16 +5,24 @@
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.assertNotNull;
import static org.mockito.Mockito.verifyNoInteractions;
import com.google.protobuf.Any;
import com.google.protobuf.Message;
import com.google.rpc.ErrorInfo;
import com.google.rpc.RetryInfo;
import io.grpc.BindableService;
import io.grpc.ServerInterceptors;
import io.grpc.Status;
import io.grpc.StatusException;
import io.grpc.StatusRuntimeException;
import java.time.Duration;
import java.util.List;
import java.util.UUID;
import io.grpc.protobuf.StatusProto;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.function.Executable;
import org.whispersystems.textsecuregcm.auth.grpc.MockAuthenticationInterceptor;
@ -51,6 +59,12 @@ public final class GrpcTestUtils {
assertEquals(expected.getCode(), exception.getStatus().getCode());
}
public static void assertStatusException(final Status expected, final String expectedReason, final Executable serviceCall) {
final StatusRuntimeException exception = Assertions.assertThrows(StatusRuntimeException.class, serviceCall);
assertEquals(expected.getCode(), exception.getStatus().getCode());
assertEquals(expectedReason, extractErrorInfo(exception).getReason());
}
public static void assertStatusInvalidArgument(final Executable serviceCall) {
assertStatusException(Status.INVALID_ARGUMENT, serviceCall);
}
@ -68,11 +82,31 @@ public final class GrpcTestUtils {
final Executable serviceCall,
final Object... mocksToCheckForNoInteraction) {
final StatusRuntimeException exception = Assertions.assertThrows(StatusRuntimeException.class, serviceCall);
assertEquals(Status.RESOURCE_EXHAUSTED, exception.getStatus());
assertEquals(Status.RESOURCE_EXHAUSTED.getCode(), exception.getStatus().getCode());
assertNotNull(exception.getTrailers());
assertEquals(expectedRetryAfter, exception.getTrailers().get(RateLimitExceededException.RETRY_AFTER_DURATION_KEY));
final ErrorInfo errorInfo = extractErrorInfo(exception);
final RetryInfo retryInfo = extractDetail(RetryInfo.class, exception);
final Duration actual = Duration.ofSeconds(retryInfo.getRetryDelay().getSeconds(), retryInfo.getRetryDelay().getNanos());
assertEquals(errorInfo.getDomain(), GrpcExceptions.DOMAIN);
assertEquals(errorInfo.getReason(), "RESOURCE_EXHAUSTED");
assertEquals(expectedRetryAfter, actual);
for (final Object mock: mocksToCheckForNoInteraction) {
verifyNoInteractions(mock);
}
}
public static ErrorInfo extractErrorInfo(final StatusRuntimeException exception) {
return extractDetail(ErrorInfo.class, exception);
}
public static <T extends Message> T extractDetail(final Class<T> detailCls, final StatusRuntimeException exception) {
final com.google.rpc.Status status = StatusProto.fromThrowable(exception);
return assertDoesNotThrow(() -> status.getDetailsList().stream()
.filter(any -> any.is(detailCls)).findFirst()
.orElseThrow(() -> new AssertionError("No error info found"))
.unpack(detailCls));
}
}

View File

@ -10,12 +10,17 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.google.common.net.InetAddresses;
import com.google.protobuf.Any;
import com.google.protobuf.ByteString;
import com.google.protobuf.Empty;
import com.google.rpc.ErrorInfo;
import io.grpc.ManagedChannel;
import io.grpc.Server;
import io.grpc.Status;
import io.grpc.StatusException;
import io.grpc.inprocess.InProcessChannelBuilder;
import io.grpc.inprocess.InProcessServerBuilder;
import io.grpc.protobuf.StatusProto;
import io.grpc.stub.BlockingClientCall;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Meter;
@ -24,34 +29,48 @@ import io.micrometer.core.instrument.Tags;
import io.micrometer.core.instrument.Timer;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import java.util.List;
import java.util.concurrent.Flow;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Supplier;
import java.util.stream.Stream;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.junitpioneer.jupiter.cartesian.CartesianTest;
import org.signal.chat.rpc.EchoRequest;
import org.signal.chat.rpc.EchoResponse;
import org.signal.chat.rpc.EchoServiceGrpc;
import org.signal.chat.rpc.SimpleTagTestServiceGrpc;
import org.signal.chat.rpc.TagResponse;
import org.signal.chat.rpc.TagTestServiceGrpc;
import org.whispersystems.textsecuregcm.storage.ClientReleaseManager;
import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
import org.whispersystems.textsecuregcm.util.ua.UserAgent;
import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
import reactor.adapter.JdkFlowAdapter;
import reactor.core.publisher.Flux;
import reactor.core.scheduler.Schedulers;
public class MetricServerInterceptorTest {
private static String USER_AGENT = "Signal-Android/4.53.7 (Android 8.1; libsignal)";
private static final String USER_AGENT = "Signal-Android/4.53.7 (Android 8.1; libsignal)";
private Server server;
private ManagedChannel channel;
private SimpleMeterRegistry simpleMeterRegistry;
private ClientReleaseManager clientReleaseManager;
private Supplier<TagResponse> tagResponseSupplier;
@BeforeEach
void setUp() throws Exception {
simpleMeterRegistry = new SimpleMeterRegistry();
clientReleaseManager = mock(ClientReleaseManager.class);
tagResponseSupplier = mock(Supplier.class);
final MockRequestAttributesInterceptor mockRequestAttributesInterceptor = new MockRequestAttributesInterceptor();
mockRequestAttributesInterceptor.setRequestAttributes(
new RequestAttributes(InetAddresses.forString("127.0.0.1"), USER_AGENT, null));
@ -59,9 +78,9 @@ public class MetricServerInterceptorTest {
server = InProcessServerBuilder.forName("MetricServerInterceptorTest")
.directExecutor()
.addService(new EchoServiceImpl())
.addService(new TagTestServiceImpl(tagResponseSupplier))
.intercept(new MetricServerInterceptor(simpleMeterRegistry, clientReleaseManager))
.intercept(mockRequestAttributesInterceptor)
.intercept(mockRequestAttributesInterceptor)
.build()
.start();
@ -111,7 +130,7 @@ public class MetricServerInterceptorTest {
}
@Test
void streaming() throws StatusException, InterruptedException, TimeoutException {
void streaming() throws StatusException, InterruptedException {
final EchoServiceGrpc.EchoServiceBlockingV2Stub client = EchoServiceGrpc.newBlockingV2Stub(channel);
final BlockingClientCall<EchoRequest, EchoResponse> echoStream = client.echoStream();
echoStream.write(EchoRequest.newBuilder().setPayload(ByteString.copyFromUtf8("1")).build());
@ -152,6 +171,99 @@ public class MetricServerInterceptorTest {
assertThat(expectedClientVersion).isEqualTo(actualClientVersion);
}
static Stream<Arguments> testUnaryOkResponseReason() {
return Stream.of(
Arguments.argumentSet("Default reason", TagResponse.newBuilder().build(), "success"),
Arguments.argumentSet("No reason", TagResponse.newBuilder().setNoReason(true).build(), "success"),
Arguments.argumentSet("Explicitly set reason", TagResponse.newBuilder().setReason1(true).build(), "reason_1"),
Arguments.argumentSet("Nested reason", TagResponse.newBuilder().setNestedReason(TagResponse.NestedReason.newBuilder().setReason(true)).build(), "nested_reason"));
}
@ParameterizedTest
@MethodSource
void testUnaryOkResponseReason(TagResponse response, String expectedReason) throws InterruptedException {
final TagTestServiceGrpc.TagTestServiceBlockingStub tagTestServiceBlockingStub =
TagTestServiceGrpc.newBlockingStub(channel);
when(tagResponseSupplier.get()).thenReturn(response);
tagTestServiceBlockingStub.tagEndpoint(Empty.getDefaultInstance());
final Counter rpcCount = find(Counter.class, MetricServerInterceptor.RPC_COUNTER_NAME);
assertThat(rpcCount.count()).isCloseTo(1.0, offset(0.01));
assertThat(rpcCount.getId().getTag("statusCode")).isEqualTo("OK");
assertThat(rpcCount.getId().getTag("reason")).isEqualTo(expectedReason);
}
@Test
public void testConflictingReasons() {
final TagTestServiceGrpc.TagTestServiceBlockingStub tagTestServiceBlockingStub =
TagTestServiceGrpc.newBlockingStub(channel);
when(tagResponseSupplier.get())
.thenReturn(TagResponse.newBuilder().setReason1(true).setConflictingReason(true).build());
tagTestServiceBlockingStub.tagEndpoint(Empty.getDefaultInstance());
// We make no promises if proto fields that have reason tags are present on a message, but this tests for the sane
// behavior that at least one of these tags makes it into the metric.
assertThat(find(Counter.class, MetricServerInterceptor.RPC_COUNTER_NAME).getId().getTag("reason"))
.isIn("duplicate_reason", "reason_1");
}
@CartesianTest
public void testStatusErrorResponseReason(
@CartesianTest.Enum(mode = CartesianTest.Enum.Mode.EXCLUDE, names = {"OK"}) Status.Code statusCode,
@CartesianTest.Values(strings = {"test", "", "null"}) String reasonParam) {
final String reason, expectedReasonTag;
if (reasonParam.equals("null")) {
reason = null;
expectedReasonTag = MetricServerInterceptor.DEFAULT_ERROR_REASON;
} else {
reason = reasonParam;
expectedReasonTag = reasonParam;
}
final TagTestServiceGrpc.TagTestServiceBlockingStub tagTestServiceBlockingStub =
TagTestServiceGrpc.newBlockingStub(channel);
final com.google.rpc.Status.Builder builder = com.google.rpc.Status.newBuilder()
.setCode(statusCode.value())
.setMessage("test");
if (reason != null) {
builder.addDetails(Any.pack(ErrorInfo.newBuilder()
.setDomain("domain")
.setReason(reason)
.build()));
}
when(tagResponseSupplier.get()).thenThrow(StatusProto.toStatusRuntimeException(builder.build()));
GrpcTestUtils.assertStatusException(statusCode.toStatus(),
() -> tagTestServiceBlockingStub.tagEndpoint(Empty.getDefaultInstance()));
final Counter rpcCount = find(Counter.class, MetricServerInterceptor.RPC_COUNTER_NAME);
assertThat(rpcCount.count()).isCloseTo(1.0, offset(0.01));
assertThat(rpcCount.getId().getTag("statusCode")).isEqualTo(statusCode.name());
assertThat(rpcCount.getId().getTag("reason")).isEqualTo(expectedReasonTag);
}
@Test
public void testStreamingResponseReason() {
final TagTestServiceGrpc.TagTestServiceBlockingStub tagTestServiceBlockingStub =
TagTestServiceGrpc.newBlockingStub(channel);
when(tagResponseSupplier.get())
.thenReturn(TagResponse.newBuilder().setReason1(true).build())
.thenReturn(TagResponse.newBuilder().setNoReason(true).build())
.thenReturn(null);
tagTestServiceBlockingStub.streamingTagEndpoint(Empty.getDefaultInstance()).forEachRemaining(_ -> {});
final Counter messageCounter = find(Counter.class, MetricServerInterceptor.RESPONSE_COUNTER_NAME);
assertThat(messageCounter.count()).isCloseTo(2.0, offset(0.01));
final Counter rpcCount = find(Counter.class, MetricServerInterceptor.RPC_COUNTER_NAME);
assertThat(rpcCount.count()).isCloseTo(1.0, offset(0.01));
assertThat(rpcCount.getId().getTag("statusCode")).isEqualTo("OK");
assertThat(rpcCount.getId().getTag("reason")).isEqualTo(MetricServerInterceptor.DEFAULT_SUCCESS_REASON);
}
private <T extends Meter> T find(Class<T> cls, final String name) {
final Meter meter = simpleMeterRegistry.getMeters().stream()
.filter(m -> m.getId().getName().equals(name))
@ -162,4 +274,32 @@ public class MetricServerInterceptorTest {
}
throw new IllegalArgumentException("Meter " + name + " should be an instance of " + cls);
}
class TagTestServiceImpl extends SimpleTagTestServiceGrpc.TagTestServiceImplBase {
private Supplier<TagResponse> tagResponseSupplier;
TagTestServiceImpl(Supplier<TagResponse> tagResponseSupplier) {
this.tagResponseSupplier = tagResponseSupplier;
}
@Override
public TagResponse tagEndpoint(final Empty request) {
return tagResponseSupplier.get();
}
@Override
public Flow.Publisher<TagResponse> streamingTagEndpoint(com.google.protobuf.Empty request) {
return JdkFlowAdapter.publisherToFlowPublisher(Flux.<TagResponse>create(sink -> {
while (!sink.isCancelled()) {
TagResponse item = tagResponseSupplier.get();
if (item == null) {
sink.complete();
break;
}
sink.next(item);
}
})
.subscribeOn(Schedulers.boundedElastic()));
}
}
}

View File

@ -63,10 +63,4 @@ class PaymentsGrpcServiceTest extends SimpleBaseGrpcTest<PaymentsGrpcService, Pa
assertStatusException(Status.UNAVAILABLE, () -> authenticatedServiceStub().getCurrencyConversions(
GetCurrencyConversionsRequest.newBuilder().build()));
}
@Test
public void testUnauthenticated() throws Exception {
assertStatusException(Status.UNAUTHENTICATED, () -> unauthenticatedServiceStub().getCurrencyConversions(
GetCurrencyConversionsRequest.newBuilder().build()));
}
}

View File

@ -0,0 +1,32 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
syntax = "proto3";
option java_multiple_files = true;
package org.signal.chat.rpc;
import "org/signal/chat/tag.proto";
import "google/protobuf/empty.proto";
service TagTestService {
rpc TagEndpoint(google.protobuf.Empty) returns (TagResponse) {}
rpc StreamingTagEndpoint(google.protobuf.Empty) returns (stream TagResponse) {}
}
message TagResponse {
oneof response {
bool no_reason = 1;
bool reason_1 = 2 [(tag.reason) = "reason_1"];
}
bool conflicting_reason = 4 [(tag.reason) = "duplicate_reason"];
message NestedReason {
bool reason = 1 [(tag.reason) = "nested_reason"];
}
NestedReason nested_reason = 5;
}