Profile gRPC: Define `getUnversionedProfile` endpoint
This commit is contained in:
		
							parent
							
								
									5e221fa9a3
								
							
						
					
					
						commit
						5afc058f90
					
				| 
						 | 
				
			
			@ -119,9 +119,11 @@ import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
 | 
			
		|||
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.GrpcServerManagedWrapper;
 | 
			
		||||
import org.whispersystems.textsecuregcm.grpc.KeysAnonymousGrpcService;
 | 
			
		||||
import org.whispersystems.textsecuregcm.grpc.KeysGrpcService;
 | 
			
		||||
import org.whispersystems.textsecuregcm.grpc.ProfileAnonymousGrpcService;
 | 
			
		||||
import org.whispersystems.textsecuregcm.grpc.ProfileGrpcService;
 | 
			
		||||
import org.whispersystems.textsecuregcm.grpc.UserAgentInterceptor;
 | 
			
		||||
import org.whispersystems.textsecuregcm.limits.CardinalityEstimator;
 | 
			
		||||
| 
						 | 
				
			
			@ -646,13 +648,16 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
 | 
			
		|||
        .addService(ServerInterceptors.intercept(new KeysGrpcService(accountsManager, keys, rateLimiters), basicCredentialAuthenticationInterceptor))
 | 
			
		||||
        .addService(new KeysAnonymousGrpcService(accountsManager, keys))
 | 
			
		||||
        .addService(ServerInterceptors.intercept(new ProfileGrpcService(clock, accountsManager, profilesManager, dynamicConfigurationManager,
 | 
			
		||||
                config.getBadges(), asyncCdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner, config.getCdnConfiguration().bucket()), basicCredentialAuthenticationInterceptor));
 | 
			
		||||
                config.getBadges(), asyncCdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner, profileBadgeConverter, rateLimiters, config.getCdnConfiguration().bucket()), basicCredentialAuthenticationInterceptor))
 | 
			
		||||
        .addService(new ProfileAnonymousGrpcService(accountsManager, profileBadgeConverter));
 | 
			
		||||
 | 
			
		||||
    RemoteDeprecationFilter remoteDeprecationFilter = new RemoteDeprecationFilter(dynamicConfigurationManager);
 | 
			
		||||
    environment.servlets()
 | 
			
		||||
        .addFilter("RemoteDeprecationFilter", remoteDeprecationFilter)
 | 
			
		||||
        .addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, "/*");
 | 
			
		||||
 | 
			
		||||
    grpcServer.intercept(new AcceptLanguageInterceptor());
 | 
			
		||||
 | 
			
		||||
    // Note: interceptors run in the reverse order they are added; the remote deprecation filter
 | 
			
		||||
    // depends on the user-agent context so it has to come first here!
 | 
			
		||||
    // http://grpc.github.io/grpc-java/javadoc/io/grpc/ServerBuilder.html#intercept-io.grpc.ServerInterceptor-
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,23 +9,21 @@ import javax.crypto.Mac;
 | 
			
		|||
import javax.crypto.spec.SecretKeySpec;
 | 
			
		||||
import java.security.InvalidKeyException;
 | 
			
		||||
import java.security.NoSuchAlgorithmException;
 | 
			
		||||
import java.util.Base64;
 | 
			
		||||
import java.util.Optional;
 | 
			
		||||
 | 
			
		||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
 | 
			
		||||
public class UnidentifiedAccessChecksum {
 | 
			
		||||
 | 
			
		||||
  public static String generateFor(Optional<byte[]> unidentifiedAccessKey) {
 | 
			
		||||
  public static byte[] generateFor(byte[] unidentifiedAccessKey) {
 | 
			
		||||
    try {
 | 
			
		||||
      if (!unidentifiedAccessKey.isPresent()|| unidentifiedAccessKey.get().length != 16) return null;
 | 
			
		||||
      if (unidentifiedAccessKey.length != 16) {
 | 
			
		||||
        throw new IllegalArgumentException("Invalid UAK length: " + unidentifiedAccessKey.length);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      Mac mac = Mac.getInstance("HmacSHA256");
 | 
			
		||||
      mac.init(new SecretKeySpec(unidentifiedAccessKey.get(), "HmacSHA256"));
 | 
			
		||||
      mac.init(new SecretKeySpec(unidentifiedAccessKey, "HmacSHA256"));
 | 
			
		||||
 | 
			
		||||
      return Base64.getEncoder().encodeToString(mac.doFinal(new byte[32]));
 | 
			
		||||
      return mac.doFinal(new byte[32]);
 | 
			
		||||
    } catch (NoSuchAlgorithmException | InvalidKeyException e) {
 | 
			
		||||
      throw new AssertionError(e);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -20,6 +20,8 @@ import java.security.NoSuchAlgorithmException;
 | 
			
		|||
import java.time.Clock;
 | 
			
		||||
import java.time.Duration;
 | 
			
		||||
import java.time.Instant;
 | 
			
		||||
import java.time.ZoneOffset;
 | 
			
		||||
import java.time.ZonedDateTime;
 | 
			
		||||
import java.time.temporal.ChronoUnit;
 | 
			
		||||
import java.util.ArrayList;
 | 
			
		||||
import java.util.Arrays;
 | 
			
		||||
| 
						 | 
				
			
			@ -83,9 +85,10 @@ import org.whispersystems.textsecuregcm.entities.BatchIdentityCheckResponse;
 | 
			
		|||
import org.whispersystems.textsecuregcm.entities.CreateProfileRequest;
 | 
			
		||||
import org.whispersystems.textsecuregcm.entities.CredentialProfileResponse;
 | 
			
		||||
import org.whispersystems.textsecuregcm.entities.ExpiringProfileKeyCredentialProfileResponse;
 | 
			
		||||
import org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes;
 | 
			
		||||
import org.whispersystems.textsecuregcm.entities.UserCapabilities;
 | 
			
		||||
import org.whispersystems.textsecuregcm.entities.VersionedProfileResponse;
 | 
			
		||||
import org.whispersystems.textsecuregcm.grpc.ProfileHelper;
 | 
			
		||||
import org.whispersystems.textsecuregcm.util.ProfileHelper;
 | 
			
		||||
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
 | 
			
		||||
import org.whispersystems.textsecuregcm.identity.IdentityType;
 | 
			
		||||
import org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;
 | 
			
		||||
| 
						 | 
				
			
			@ -100,6 +103,7 @@ import org.whispersystems.textsecuregcm.storage.AccountsManager;
 | 
			
		|||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
 | 
			
		||||
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
 | 
			
		||||
import org.whispersystems.textsecuregcm.storage.VersionedProfile;
 | 
			
		||||
import org.whispersystems.textsecuregcm.util.Pair;
 | 
			
		||||
import org.whispersystems.textsecuregcm.util.Util;
 | 
			
		||||
import software.amazon.awssdk.services.s3.S3Client;
 | 
			
		||||
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
 | 
			
		||||
| 
						 | 
				
			
			@ -108,9 +112,7 @@ import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
 | 
			
		|||
@Path("/v1/profile")
 | 
			
		||||
@Tag(name = "Profile")
 | 
			
		||||
public class ProfileController {
 | 
			
		||||
 | 
			
		||||
  private final Logger logger = LoggerFactory.getLogger(ProfileController.class);
 | 
			
		||||
 | 
			
		||||
  private final Clock clock;
 | 
			
		||||
  private final RateLimiters     rateLimiters;
 | 
			
		||||
  private final ProfilesManager  profilesManager;
 | 
			
		||||
| 
						 | 
				
			
			@ -223,7 +225,7 @@ public class ProfileController {
 | 
			
		|||
    });
 | 
			
		||||
 | 
			
		||||
    if (request.getAvatarChange() == CreateProfileRequest.AvatarChange.UPDATE) {
 | 
			
		||||
      return Response.ok(ProfileHelper.generateAvatarUploadForm(policyGenerator, policySigner, avatar)).build();
 | 
			
		||||
      return Response.ok(generateAvatarUploadForm(avatar)).build();
 | 
			
		||||
    } else {
 | 
			
		||||
      return Response.ok().build();
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -245,7 +247,7 @@ public class ProfileController {
 | 
			
		|||
 | 
			
		||||
    return buildVersionedProfileResponse(targetAccount,
 | 
			
		||||
        version,
 | 
			
		||||
        isSelfProfileRequest(maybeRequester, accountIdentifier),
 | 
			
		||||
        maybeRequester.map(requester -> ProfileHelper.isSelfProfileRequest(requester.getUuid(), accountIdentifier)).orElse(false),
 | 
			
		||||
        containerRequestContext);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -268,7 +270,7 @@ public class ProfileController {
 | 
			
		|||
 | 
			
		||||
    final Optional<Account> maybeRequester = auth.map(AuthenticatedAccount::getAccount);
 | 
			
		||||
    final Account targetAccount = verifyPermissionToReceiveAccountIdentityProfile(maybeRequester, accessKey, accountIdentifier);
 | 
			
		||||
    final boolean isSelf = isSelfProfileRequest(maybeRequester, accountIdentifier);
 | 
			
		||||
    final boolean isSelf = maybeRequester.map(requester -> ProfileHelper.isSelfProfileRequest(requester.getUuid(), accountIdentifier)).orElse(false);
 | 
			
		||||
 | 
			
		||||
    return buildExpiringProfileKeyCredentialProfileResponse(targetAccount,
 | 
			
		||||
        version,
 | 
			
		||||
| 
						 | 
				
			
			@ -302,7 +304,7 @@ public class ProfileController {
 | 
			
		|||
            verifyPermissionToReceiveAccountIdentityProfile(maybeRequester, accessKey, aciServiceIdentifier);
 | 
			
		||||
 | 
			
		||||
        yield buildBaseProfileResponseForAccountIdentity(targetAccount,
 | 
			
		||||
            isSelfProfileRequest(maybeRequester, aciServiceIdentifier),
 | 
			
		||||
            maybeRequester.map(requester -> ProfileHelper.isSelfProfileRequest(requester.getUuid(), aciServiceIdentifier)).orElse(false),
 | 
			
		||||
            containerRequestContext);
 | 
			
		||||
      }
 | 
			
		||||
      case PNI -> {
 | 
			
		||||
| 
						 | 
				
			
			@ -432,7 +434,7 @@ public class ProfileController {
 | 
			
		|||
      final ContainerRequestContext containerRequestContext) {
 | 
			
		||||
 | 
			
		||||
    return new BaseProfileResponse(account.getIdentityKey(IdentityType.ACI),
 | 
			
		||||
        UnidentifiedAccessChecksum.generateFor(account.getUnidentifiedAccessKey()),
 | 
			
		||||
        account.getUnidentifiedAccessKey().map(UnidentifiedAccessChecksum::generateFor).orElse(null),
 | 
			
		||||
        account.isUnrestrictedUnidentifiedAccess(),
 | 
			
		||||
        UserCapabilities.createForAccount(account),
 | 
			
		||||
        profileBadgeConverter.convert(
 | 
			
		||||
| 
						 | 
				
			
			@ -468,7 +470,7 @@ public class ProfileController {
 | 
			
		|||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private List<Locale> getAcceptableLanguagesForRequest(ContainerRequestContext containerRequestContext) {
 | 
			
		||||
  private List<Locale> getAcceptableLanguagesForRequest(final ContainerRequestContext containerRequestContext) {
 | 
			
		||||
    try {
 | 
			
		||||
      return containerRequestContext.getAcceptableLanguages();
 | 
			
		||||
    } catch (final ProcessingException e) {
 | 
			
		||||
| 
						 | 
				
			
			@ -517,8 +519,15 @@ public class ProfileController {
 | 
			
		|||
    return maybeTargetAccount.get();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private boolean isSelfProfileRequest(final Optional<Account> maybeRequester, final AciServiceIdentifier targetIdentifier) {
 | 
			
		||||
    return maybeRequester.map(requester -> requester.getUuid().equals(targetIdentifier.uuid())).orElse(false);
 | 
			
		||||
  private ProfileAvatarUploadAttributes generateAvatarUploadForm(
 | 
			
		||||
      final String objectName) {
 | 
			
		||||
    ZonedDateTime now = ZonedDateTime.now(clock);
 | 
			
		||||
    Pair<String, String> policy = policyGenerator.createFor(now, objectName, ProfileHelper.MAX_PROFILE_AVATAR_SIZE_BYTES);
 | 
			
		||||
    String signature = policySigner.getSignature(now, policy.second());
 | 
			
		||||
 | 
			
		||||
    return new ProfileAvatarUploadAttributes(objectName, policy.first(),
 | 
			
		||||
        "private", "AWS4-HMAC-SHA256",
 | 
			
		||||
        now.format(PostPolicyGenerator.AWS_DATE_TIME), policy.second(), signature);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Nullable
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -10,6 +10,7 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
 | 
			
		|||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
 | 
			
		||||
import org.signal.libsignal.protocol.IdentityKey;
 | 
			
		||||
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
 | 
			
		||||
import org.whispersystems.textsecuregcm.util.ByteArrayBase64WithPaddingAdapter;
 | 
			
		||||
import org.whispersystems.textsecuregcm.util.ServiceIdentifierAdapter;
 | 
			
		||||
import org.whispersystems.textsecuregcm.util.IdentityKeyAdapter;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -23,7 +24,9 @@ public class BaseProfileResponse {
 | 
			
		|||
  private IdentityKey identityKey;
 | 
			
		||||
 | 
			
		||||
  @JsonProperty
 | 
			
		||||
  private String unidentifiedAccess;
 | 
			
		||||
  @JsonSerialize(using = ByteArrayBase64WithPaddingAdapter.Serializing.class)
 | 
			
		||||
  @JsonDeserialize(using = ByteArrayBase64WithPaddingAdapter.Deserializing.class)
 | 
			
		||||
  private byte[] unidentifiedAccess;
 | 
			
		||||
 | 
			
		||||
  @JsonProperty
 | 
			
		||||
  private boolean unrestrictedUnidentifiedAccess;
 | 
			
		||||
| 
						 | 
				
			
			@ -43,7 +46,7 @@ public class BaseProfileResponse {
 | 
			
		|||
  }
 | 
			
		||||
 | 
			
		||||
  public BaseProfileResponse(final IdentityKey identityKey,
 | 
			
		||||
      final String unidentifiedAccess,
 | 
			
		||||
      final byte[] unidentifiedAccess,
 | 
			
		||||
      final boolean unrestrictedUnidentifiedAccess,
 | 
			
		||||
      final UserCapabilities capabilities,
 | 
			
		||||
      final List<Badge> badges,
 | 
			
		||||
| 
						 | 
				
			
			@ -61,7 +64,7 @@ public class BaseProfileResponse {
 | 
			
		|||
    return identityKey;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public String getUnidentifiedAccess() {
 | 
			
		||||
  public byte[] getUnidentifiedAccess() {
 | 
			
		||||
    return unidentifiedAccess;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,68 @@
 | 
			
		|||
/*
 | 
			
		||||
 * Copyright 2023 Signal Messenger, LLC
 | 
			
		||||
 * SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
package org.whispersystems.textsecuregcm.grpc;
 | 
			
		||||
 | 
			
		||||
import com.google.common.annotations.VisibleForTesting;
 | 
			
		||||
import io.grpc.Context;
 | 
			
		||||
import io.grpc.Contexts;
 | 
			
		||||
import io.grpc.Metadata;
 | 
			
		||||
import io.grpc.ServerCall;
 | 
			
		||||
import io.grpc.ServerCallHandler;
 | 
			
		||||
import io.grpc.ServerInterceptor;
 | 
			
		||||
import io.micrometer.core.instrument.Metrics;
 | 
			
		||||
import org.slf4j.Logger;
 | 
			
		||||
import org.slf4j.LoggerFactory;
 | 
			
		||||
import org.whispersystems.textsecuregcm.util.ua.UserAgent;
 | 
			
		||||
import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
 | 
			
		||||
import javax.annotation.Nullable;
 | 
			
		||||
import java.util.Arrays;
 | 
			
		||||
import java.util.Collections;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.Locale;
 | 
			
		||||
 | 
			
		||||
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
 | 
			
		||||
 | 
			
		||||
public class AcceptLanguageInterceptor implements ServerInterceptor {
 | 
			
		||||
  private static final Logger logger = LoggerFactory.getLogger(AcceptLanguageInterceptor.class);
 | 
			
		||||
  private static final String INVALID_ACCEPT_LANGUAGE_COUNTER_NAME = name(AcceptLanguageInterceptor.class, "invalidAcceptLanguage");
 | 
			
		||||
 | 
			
		||||
  @VisibleForTesting
 | 
			
		||||
  public static final Metadata.Key<String> ACCEPTABLE_LANGUAGES_GRPC_HEADER =
 | 
			
		||||
      Metadata.Key.of("accept-language", Metadata.ASCII_STRING_MARSHALLER);
 | 
			
		||||
 | 
			
		||||
  @Override
 | 
			
		||||
  public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(final ServerCall<ReqT, RespT> call,
 | 
			
		||||
      final Metadata headers,
 | 
			
		||||
      final ServerCallHandler<ReqT, RespT> next) {
 | 
			
		||||
 | 
			
		||||
    final List<Locale> locales = parseLocales(headers.get(ACCEPTABLE_LANGUAGES_GRPC_HEADER));
 | 
			
		||||
 | 
			
		||||
    return Contexts.interceptCall(
 | 
			
		||||
        Context.current().withValue(AcceptLanguageUtil.ACCEPTABLE_LANGUAGES_CONTEXT_KEY, locales),
 | 
			
		||||
        call,
 | 
			
		||||
        headers,
 | 
			
		||||
        next);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static List<Locale> parseLocales(@Nullable final String acceptableLanguagesHeader) {
 | 
			
		||||
    if (acceptableLanguagesHeader == null) {
 | 
			
		||||
      return Collections.emptyList();
 | 
			
		||||
    }
 | 
			
		||||
    try {
 | 
			
		||||
      final List<Locale.LanguageRange> languageRanges = Locale.LanguageRange.parse(acceptableLanguagesHeader);
 | 
			
		||||
      return Locale.filter(languageRanges, Arrays.asList(Locale.getAvailableLocales()));
 | 
			
		||||
    } catch (final IllegalArgumentException e) {
 | 
			
		||||
      final UserAgent userAgent = UserAgentUtil.userAgentFromGrpcContext();
 | 
			
		||||
      Metrics.counter(INVALID_ACCEPT_LANGUAGE_COUNTER_NAME, "platform", userAgent.getPlatform().name().toLowerCase()).increment();
 | 
			
		||||
      logger.debug("Could not get acceptable languages; Accept-Language: {}; User-Agent: {}",
 | 
			
		||||
          acceptableLanguagesHeader,
 | 
			
		||||
          userAgent,
 | 
			
		||||
          e);
 | 
			
		||||
      return Collections.emptyList();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,12 @@
 | 
			
		|||
package org.whispersystems.textsecuregcm.grpc;
 | 
			
		||||
 | 
			
		||||
import io.grpc.Context;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.Locale;
 | 
			
		||||
 | 
			
		||||
public class AcceptLanguageUtil {
 | 
			
		||||
  static final Context.Key<List<Locale>> ACCEPTABLE_LANGUAGES_CONTEXT_KEY = Context.key("accept-language");
 | 
			
		||||
  public static List<Locale> localeFromGrpcContext() {
 | 
			
		||||
    return ACCEPTABLE_LANGUAGES_CONTEXT_KEY.get();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,49 @@
 | 
			
		|||
package org.whispersystems.textsecuregcm.grpc;
 | 
			
		||||
 | 
			
		||||
import io.grpc.Status;
 | 
			
		||||
import org.signal.chat.profile.GetUnversionedProfileAnonymousRequest;
 | 
			
		||||
import org.signal.chat.profile.GetUnversionedProfileResponse;
 | 
			
		||||
import org.signal.chat.profile.ReactorProfileAnonymousGrpc;
 | 
			
		||||
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;
 | 
			
		||||
import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;
 | 
			
		||||
import org.whispersystems.textsecuregcm.identity.IdentityType;
 | 
			
		||||
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
 | 
			
		||||
import org.whispersystems.textsecuregcm.storage.Account;
 | 
			
		||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
 | 
			
		||||
import reactor.core.publisher.Mono;
 | 
			
		||||
 | 
			
		||||
public class ProfileAnonymousGrpcService extends ReactorProfileAnonymousGrpc.ProfileAnonymousImplBase {
 | 
			
		||||
  private final AccountsManager accountsManager;
 | 
			
		||||
  private final ProfileBadgeConverter profileBadgeConverter;
 | 
			
		||||
 | 
			
		||||
  public ProfileAnonymousGrpcService(
 | 
			
		||||
      final AccountsManager accountsManager,
 | 
			
		||||
      final ProfileBadgeConverter profileBadgeConverter) {
 | 
			
		||||
    this.accountsManager = accountsManager;
 | 
			
		||||
    this.profileBadgeConverter = profileBadgeConverter;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Override
 | 
			
		||||
  public Mono<GetUnversionedProfileResponse> getUnversionedProfile(final GetUnversionedProfileAnonymousRequest request) {
 | 
			
		||||
    final ServiceIdentifier targetIdentifier =
 | 
			
		||||
        ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getRequest().getServiceIdentifier());
 | 
			
		||||
 | 
			
		||||
    // Callers must be authenticated to request unversioned profiles by PNI
 | 
			
		||||
    if (targetIdentifier.identityType() == IdentityType.PNI) {
 | 
			
		||||
      throw Status.UNAUTHENTICATED.asRuntimeException();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return getTargetAccountAndValidateUnidentifiedAccess(targetIdentifier, request.getUnidentifiedAccessKey().toByteArray())
 | 
			
		||||
        .map(targetAccount -> ProfileGrpcHelper.buildUnversionedProfileResponse(targetIdentifier,
 | 
			
		||||
            null,
 | 
			
		||||
            targetAccount,
 | 
			
		||||
            profileBadgeConverter));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private Mono<Account> getTargetAccountAndValidateUnidentifiedAccess(final ServiceIdentifier targetIdentifier, final byte[] unidentifiedAccessKey) {
 | 
			
		||||
    return Mono.fromFuture(() -> accountsManager.getByServiceIdentifierAsync(targetIdentifier))
 | 
			
		||||
        .flatMap(Mono::justOrEmpty)
 | 
			
		||||
        .filter(targetAccount -> UnidentifiedAccessUtil.checkUnidentifiedAccess(targetAccount, unidentifiedAccessKey))
 | 
			
		||||
        .switchIfEmpty(Mono.error(Status.UNAUTHENTICATED.asException()));
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,89 @@
 | 
			
		|||
package org.whispersystems.textsecuregcm.grpc;
 | 
			
		||||
 | 
			
		||||
import com.google.common.annotations.VisibleForTesting;
 | 
			
		||||
import com.google.protobuf.ByteString;
 | 
			
		||||
import java.util.ArrayList;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.UUID;
 | 
			
		||||
import org.signal.chat.profile.Badge;
 | 
			
		||||
import org.signal.chat.profile.BadgeSvg;
 | 
			
		||||
import org.signal.chat.profile.GetUnversionedProfileResponse;
 | 
			
		||||
import org.signal.chat.profile.UserCapabilities;
 | 
			
		||||
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessChecksum;
 | 
			
		||||
import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;
 | 
			
		||||
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
 | 
			
		||||
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
 | 
			
		||||
import org.whispersystems.textsecuregcm.storage.Account;
 | 
			
		||||
import org.whispersystems.textsecuregcm.util.ProfileHelper;
 | 
			
		||||
 | 
			
		||||
public class ProfileGrpcHelper {
 | 
			
		||||
  @VisibleForTesting
 | 
			
		||||
  static List<Badge> buildBadges(final List<org.whispersystems.textsecuregcm.entities.Badge> badges) {
 | 
			
		||||
    final ArrayList<Badge> grpcBadges = new ArrayList<>();
 | 
			
		||||
    for (final org.whispersystems.textsecuregcm.entities.Badge badge : badges) {
 | 
			
		||||
      grpcBadges.add(Badge.newBuilder()
 | 
			
		||||
          .setId(badge.getId())
 | 
			
		||||
          .setCategory(badge.getCategory())
 | 
			
		||||
          .setName(badge.getName())
 | 
			
		||||
          .setDescription(badge.getDescription())
 | 
			
		||||
          .addAllSprites6(badge.getSprites6())
 | 
			
		||||
          .setSvg(badge.getSvg())
 | 
			
		||||
          .addAllSvgs(buildBadgeSvgs(badge.getSvgs()))
 | 
			
		||||
          .build());
 | 
			
		||||
    }
 | 
			
		||||
    return grpcBadges;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @VisibleForTesting
 | 
			
		||||
  static UserCapabilities buildUserCapabilities(final org.whispersystems.textsecuregcm.entities.UserCapabilities capabilities) {
 | 
			
		||||
    return UserCapabilities.newBuilder()
 | 
			
		||||
        .setGv1Migration(capabilities.gv1Migration())
 | 
			
		||||
        .setSenderKey(capabilities.senderKey())
 | 
			
		||||
        .setAnnouncementGroup(capabilities.announcementGroup())
 | 
			
		||||
        .setChangeNumber(capabilities.changeNumber())
 | 
			
		||||
        .setStories(capabilities.stories())
 | 
			
		||||
        .setGiftBadges(capabilities.giftBadges())
 | 
			
		||||
        .setPaymentActivation(capabilities.paymentActivation())
 | 
			
		||||
        .setPni(capabilities.pni())
 | 
			
		||||
        .build();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private static List<BadgeSvg> buildBadgeSvgs(final List<org.whispersystems.textsecuregcm.entities.BadgeSvg> badgeSvgs) {
 | 
			
		||||
    ArrayList<BadgeSvg> grpcBadgeSvgs = new ArrayList<>();
 | 
			
		||||
    for (final org.whispersystems.textsecuregcm.entities.BadgeSvg badgeSvg : badgeSvgs) {
 | 
			
		||||
      grpcBadgeSvgs.add(BadgeSvg.newBuilder()
 | 
			
		||||
          .setDark(badgeSvg.getDark())
 | 
			
		||||
          .setLight(badgeSvg.getLight())
 | 
			
		||||
          .build());
 | 
			
		||||
    }
 | 
			
		||||
    return grpcBadgeSvgs;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static GetUnversionedProfileResponse buildUnversionedProfileResponse(
 | 
			
		||||
      final ServiceIdentifier targetIdentifier,
 | 
			
		||||
      final UUID requesterUuid,
 | 
			
		||||
      final Account targetAccount,
 | 
			
		||||
      final ProfileBadgeConverter profileBadgeConverter) {
 | 
			
		||||
    final GetUnversionedProfileResponse.Builder responseBuilder = GetUnversionedProfileResponse.newBuilder()
 | 
			
		||||
        .setIdentityKey(ByteString.copyFrom(targetAccount.getIdentityKey(targetIdentifier.identityType()).serialize()))
 | 
			
		||||
        .setCapabilities(buildUserCapabilities(org.whispersystems.textsecuregcm.entities.UserCapabilities.createForAccount(targetAccount)));
 | 
			
		||||
 | 
			
		||||
    switch (targetIdentifier.identityType()) {
 | 
			
		||||
      case ACI -> {
 | 
			
		||||
        responseBuilder.setUnrestrictedUnidentifiedAccess(targetAccount.isUnrestrictedUnidentifiedAccess())
 | 
			
		||||
            .addAllBadges(buildBadges(profileBadgeConverter.convert(
 | 
			
		||||
                AcceptLanguageUtil.localeFromGrpcContext(),
 | 
			
		||||
                targetAccount.getBadges(),
 | 
			
		||||
                ProfileHelper.isSelfProfileRequest(requesterUuid, (AciServiceIdentifier) targetIdentifier))));
 | 
			
		||||
 | 
			
		||||
        targetAccount.getUnidentifiedAccessKey()
 | 
			
		||||
            .map(UnidentifiedAccessChecksum::generateFor)
 | 
			
		||||
            .map(ByteString::copyFrom)
 | 
			
		||||
            .ifPresent(responseBuilder::setUnidentifiedAccess);
 | 
			
		||||
      }
 | 
			
		||||
      case PNI -> responseBuilder.setUnrestrictedUnidentifiedAccess(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return responseBuilder.build();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -2,15 +2,21 @@ package org.whispersystems.textsecuregcm.grpc;
 | 
			
		|||
 | 
			
		||||
import com.google.protobuf.ByteString;
 | 
			
		||||
import io.grpc.Status;
 | 
			
		||||
import org.signal.chat.profile.GetUnversionedProfileRequest;
 | 
			
		||||
import org.signal.chat.profile.GetUnversionedProfileResponse;
 | 
			
		||||
import org.signal.chat.profile.SetProfileRequest.AvatarChange;
 | 
			
		||||
import org.signal.chat.profile.ProfileAvatarUploadAttributes;
 | 
			
		||||
import org.signal.chat.profile.ReactorProfileGrpc;
 | 
			
		||||
import org.signal.chat.profile.SetProfileRequest;
 | 
			
		||||
import org.signal.chat.profile.SetProfileResponse;
 | 
			
		||||
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
 | 
			
		||||
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;
 | 
			
		||||
import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;
 | 
			
		||||
import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;
 | 
			
		||||
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
 | 
			
		||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
 | 
			
		||||
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
 | 
			
		||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
 | 
			
		||||
import org.whispersystems.textsecuregcm.s3.PolicySigner;
 | 
			
		||||
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
 | 
			
		||||
import org.whispersystems.textsecuregcm.storage.Account;
 | 
			
		||||
| 
						 | 
				
			
			@ -19,29 +25,34 @@ import org.whispersystems.textsecuregcm.storage.AccountsManager;
 | 
			
		|||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
 | 
			
		||||
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
 | 
			
		||||
import org.whispersystems.textsecuregcm.storage.VersionedProfile;
 | 
			
		||||
import org.whispersystems.textsecuregcm.util.Pair;
 | 
			
		||||
import org.whispersystems.textsecuregcm.util.ProfileHelper;
 | 
			
		||||
import reactor.core.publisher.Flux;
 | 
			
		||||
import reactor.core.publisher.Mono;
 | 
			
		||||
import software.amazon.awssdk.services.s3.S3AsyncClient;
 | 
			
		||||
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
 | 
			
		||||
import java.time.Clock;
 | 
			
		||||
import java.time.ZonedDateTime;
 | 
			
		||||
import java.util.ArrayList;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.Map;
 | 
			
		||||
import java.util.Optional;
 | 
			
		||||
import java.util.UUID;
 | 
			
		||||
import java.util.function.Function;
 | 
			
		||||
import java.util.stream.Collectors;
 | 
			
		||||
 | 
			
		||||
public class ProfileGrpcService extends ReactorProfileGrpc.ProfileImplBase {
 | 
			
		||||
  private final Clock clock;
 | 
			
		||||
 | 
			
		||||
  private final ProfilesManager  profilesManager;
 | 
			
		||||
  private final Clock clock;
 | 
			
		||||
  private final AccountsManager accountsManager;
 | 
			
		||||
  private final ProfilesManager  profilesManager;
 | 
			
		||||
  private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
 | 
			
		||||
  private final Map<String, BadgeConfiguration> badgeConfigurationMap;
 | 
			
		||||
  private final PolicySigner policySigner;
 | 
			
		||||
  private final PostPolicyGenerator policyGenerator;
 | 
			
		||||
 | 
			
		||||
  private final S3AsyncClient asyncS3client;
 | 
			
		||||
  private final PostPolicyGenerator policyGenerator;
 | 
			
		||||
  private final PolicySigner policySigner;
 | 
			
		||||
  private final ProfileBadgeConverter profileBadgeConverter;
 | 
			
		||||
  private final RateLimiters rateLimiters;
 | 
			
		||||
  private final String bucket;
 | 
			
		||||
 | 
			
		||||
  private record AvatarData(Optional<String> currentAvatar,
 | 
			
		||||
| 
						 | 
				
			
			@ -49,29 +60,38 @@ public class ProfileGrpcService extends ReactorProfileGrpc.ProfileImplBase {
 | 
			
		|||
                            Optional<ProfileAvatarUploadAttributes> uploadAttributes) {}
 | 
			
		||||
 | 
			
		||||
  public ProfileGrpcService(
 | 
			
		||||
      Clock clock,
 | 
			
		||||
      AccountsManager accountsManager,
 | 
			
		||||
      ProfilesManager profilesManager,
 | 
			
		||||
      DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
 | 
			
		||||
      BadgesConfiguration badgesConfiguration,
 | 
			
		||||
      S3AsyncClient asyncS3client,
 | 
			
		||||
      PostPolicyGenerator policyGenerator,
 | 
			
		||||
      PolicySigner policySigner,
 | 
			
		||||
      String bucket) {
 | 
			
		||||
      final Clock clock,
 | 
			
		||||
      final AccountsManager accountsManager,
 | 
			
		||||
      final ProfilesManager profilesManager,
 | 
			
		||||
      final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
 | 
			
		||||
      final BadgesConfiguration badgesConfiguration,
 | 
			
		||||
      final S3AsyncClient asyncS3client,
 | 
			
		||||
      final PostPolicyGenerator policyGenerator,
 | 
			
		||||
      final PolicySigner policySigner,
 | 
			
		||||
      final ProfileBadgeConverter profileBadgeConverter,
 | 
			
		||||
      final RateLimiters rateLimiters,
 | 
			
		||||
      final String bucket) {
 | 
			
		||||
    this.clock = clock;
 | 
			
		||||
    this.accountsManager = accountsManager;
 | 
			
		||||
    this.profilesManager = profilesManager;
 | 
			
		||||
    this.dynamicConfigurationManager = dynamicConfigurationManager;
 | 
			
		||||
    this.badgeConfigurationMap = badgesConfiguration.getBadges().stream().collect(Collectors.toMap(
 | 
			
		||||
        BadgeConfiguration::getId, Function.identity()));
 | 
			
		||||
    this.bucket = bucket;
 | 
			
		||||
    this.asyncS3client  = asyncS3client;
 | 
			
		||||
    this.policyGenerator = policyGenerator;
 | 
			
		||||
    this.policySigner = policySigner;
 | 
			
		||||
    this.profileBadgeConverter = profileBadgeConverter;
 | 
			
		||||
    this.rateLimiters = rateLimiters;
 | 
			
		||||
    this.bucket = bucket;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Override
 | 
			
		||||
  public Mono<SetProfileResponse> setProfile(SetProfileRequest request) {
 | 
			
		||||
  protected Throwable onErrorMap(final Throwable throwable) {
 | 
			
		||||
    return RateLimitUtil.mapRateLimitExceededException(throwable);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Override
 | 
			
		||||
  public Mono<SetProfileResponse> setProfile(final SetProfileRequest request) {
 | 
			
		||||
    validateRequest(request);
 | 
			
		||||
    return Mono.fromSupplier(AuthenticationUtil::requireAuthenticatedDevice)
 | 
			
		||||
        .flatMap(authenticatedDevice -> Mono.zip(
 | 
			
		||||
| 
						 | 
				
			
			@ -99,7 +119,7 @@ public class ProfileGrpcService extends ReactorProfileGrpc.ProfileImplBase {
 | 
			
		|||
            case AVATAR_CHANGE_UPDATE -> {
 | 
			
		||||
              final String updateAvatarObjectName = ProfileHelper.generateAvatarObjectName();
 | 
			
		||||
              yield new AvatarData(currentAvatar, Optional.of(updateAvatarObjectName),
 | 
			
		||||
                  Optional.of(ProfileHelper.generateAvatarUploadFormGrpc(policyGenerator, policySigner, updateAvatarObjectName)));
 | 
			
		||||
                  Optional.of(generateAvatarUploadForm(updateAvatarObjectName)));
 | 
			
		||||
            }
 | 
			
		||||
          };
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -137,7 +157,27 @@ public class ProfileGrpcService extends ReactorProfileGrpc.ProfileImplBase {
 | 
			
		|||
        );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private void validateRequest(SetProfileRequest request) {
 | 
			
		||||
  @Override
 | 
			
		||||
  public Mono<GetUnversionedProfileResponse> getUnversionedProfile(final GetUnversionedProfileRequest request) {
 | 
			
		||||
    final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
 | 
			
		||||
    final ServiceIdentifier targetIdentifier =
 | 
			
		||||
        ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getServiceIdentifier());
 | 
			
		||||
    return validateRateLimitAndGetAccount(authenticatedDevice.accountIdentifier(), targetIdentifier)
 | 
			
		||||
        .map(targetAccount -> ProfileGrpcHelper.buildUnversionedProfileResponse(targetIdentifier,
 | 
			
		||||
            authenticatedDevice.accountIdentifier(),
 | 
			
		||||
            targetAccount,
 | 
			
		||||
            profileBadgeConverter));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private Mono<Account> validateRateLimitAndGetAccount(final UUID requesterUuid,
 | 
			
		||||
      final ServiceIdentifier targetIdentifier) {
 | 
			
		||||
    return rateLimiters.getProfileLimiter().validateReactive(requesterUuid)
 | 
			
		||||
        .then(Mono.fromFuture(() -> accountsManager.getByServiceIdentifierAsync(targetIdentifier))
 | 
			
		||||
            .flatMap(Mono::justOrEmpty))
 | 
			
		||||
        .switchIfEmpty(Mono.error(Status.NOT_FOUND.asException()));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private void validateRequest(final SetProfileRequest request) {
 | 
			
		||||
    if (request.getVersion().isEmpty()) {
 | 
			
		||||
      throw Status.INVALID_ARGUMENT.withDescription("Missing version").asRuntimeException();
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -163,4 +203,20 @@ public class ProfileGrpcService extends ReactorProfileGrpc.ProfileImplBase {
 | 
			
		|||
 | 
			
		||||
    throw Status.INVALID_ARGUMENT.withDescription(errorMessage).asRuntimeException();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private ProfileAvatarUploadAttributes generateAvatarUploadForm(final String objectName) {
 | 
			
		||||
    final ZonedDateTime now = ZonedDateTime.now(clock);
 | 
			
		||||
    final Pair<String, String> policy = policyGenerator.createFor(now, objectName, ProfileHelper.MAX_PROFILE_AVATAR_SIZE_BYTES);
 | 
			
		||||
    final String signature = policySigner.getSignature(now, policy.second());
 | 
			
		||||
 | 
			
		||||
    return ProfileAvatarUploadAttributes.newBuilder()
 | 
			
		||||
        .setPath(objectName)
 | 
			
		||||
        .setCredential(policy.first())
 | 
			
		||||
        .setAcl("private")
 | 
			
		||||
        .setAlgorithm("AWS4-HMAC-SHA256")
 | 
			
		||||
        .setDate(now.format(PostPolicyGenerator.AWS_DATE_TIME))
 | 
			
		||||
        .setPolicy(policy.second())
 | 
			
		||||
        .setSignature(ByteString.copyFrom(signature.getBytes()))
 | 
			
		||||
        .build();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,24 +1,21 @@
 | 
			
		|||
package org.whispersystems.textsecuregcm.grpc;
 | 
			
		||||
package org.whispersystems.textsecuregcm.util;
 | 
			
		||||
 | 
			
		||||
import com.google.protobuf.ByteString;
 | 
			
		||||
import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;
 | 
			
		||||
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
 | 
			
		||||
import org.whispersystems.textsecuregcm.storage.AccountBadge;
 | 
			
		||||
import javax.annotation.Nullable;
 | 
			
		||||
import java.security.SecureRandom;
 | 
			
		||||
import java.time.Clock;
 | 
			
		||||
import java.time.Duration;
 | 
			
		||||
import java.time.ZoneOffset;
 | 
			
		||||
import java.time.ZonedDateTime;
 | 
			
		||||
import java.util.ArrayList;
 | 
			
		||||
import java.util.Base64;
 | 
			
		||||
import java.util.LinkedHashMap;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.Map;
 | 
			
		||||
import org.signal.chat.profile.ProfileAvatarUploadAttributes;
 | 
			
		||||
import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;
 | 
			
		||||
import org.whispersystems.textsecuregcm.s3.PolicySigner;
 | 
			
		||||
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
 | 
			
		||||
import org.whispersystems.textsecuregcm.storage.AccountBadge;
 | 
			
		||||
import org.whispersystems.textsecuregcm.util.Pair;
 | 
			
		||||
import java.util.UUID;
 | 
			
		||||
 | 
			
		||||
public class ProfileHelper {
 | 
			
		||||
  public static int MAX_PROFILE_AVATAR_SIZE_BYTES = 10 * 1024 * 1024;
 | 
			
		||||
  public static List<AccountBadge> mergeBadgeIdsWithExistingAccountBadges(
 | 
			
		||||
      final Clock clock,
 | 
			
		||||
      final Map<String, BadgeConfiguration> badgeConfigurationMap,
 | 
			
		||||
| 
						 | 
				
			
			@ -64,41 +61,13 @@ public class ProfileHelper {
 | 
			
		|||
  }
 | 
			
		||||
 | 
			
		||||
  public static String generateAvatarObjectName() {
 | 
			
		||||
    byte[] object = new byte[16];
 | 
			
		||||
    final byte[] object = new byte[16];
 | 
			
		||||
    new SecureRandom().nextBytes(object);
 | 
			
		||||
 | 
			
		||||
    return "profiles/" + Base64.getUrlEncoder().encodeToString(object);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public static org.signal.chat.profile.ProfileAvatarUploadAttributes generateAvatarUploadFormGrpc(
 | 
			
		||||
      final PostPolicyGenerator policyGenerator,
 | 
			
		||||
      final PolicySigner policySigner,
 | 
			
		||||
      final String objectName) {
 | 
			
		||||
    final ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
 | 
			
		||||
    final Pair<String, String> policy = policyGenerator.createFor(now, objectName, 10 * 1024 * 1024);
 | 
			
		||||
    final String signature = policySigner.getSignature(now, policy.second());
 | 
			
		||||
 | 
			
		||||
    return org.signal.chat.profile.ProfileAvatarUploadAttributes.newBuilder()
 | 
			
		||||
        .setPath(objectName)
 | 
			
		||||
        .setCredential(policy.first())
 | 
			
		||||
        .setAcl("private")
 | 
			
		||||
        .setAlgorithm("AWS4-HMAC-SHA256")
 | 
			
		||||
        .setDate(now.format(PostPolicyGenerator.AWS_DATE_TIME))
 | 
			
		||||
        .setPolicy(policy.second())
 | 
			
		||||
        .setSignature(ByteString.copyFrom(signature.getBytes()))
 | 
			
		||||
        .build();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public static org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes generateAvatarUploadForm(
 | 
			
		||||
      final PostPolicyGenerator policyGenerator,
 | 
			
		||||
      final PolicySigner policySigner,
 | 
			
		||||
      final String objectName) {
 | 
			
		||||
    ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
 | 
			
		||||
    Pair<String, String> policy = policyGenerator.createFor(now, objectName, 10 * 1024 * 1024);
 | 
			
		||||
    String signature = policySigner.getSignature(now, policy.second());
 | 
			
		||||
 | 
			
		||||
    return new org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes(objectName, policy.first(), "private", "AWS4-HMAC-SHA256",
 | 
			
		||||
        now.format(PostPolicyGenerator.AWS_DATE_TIME), policy.second(), signature);
 | 
			
		||||
 | 
			
		||||
  public static boolean isSelfProfileRequest(@Nullable final UUID requesterUuid, final AciServiceIdentifier targetIdentifier) {
 | 
			
		||||
    return targetIdentifier.uuid().equals(requesterUuid);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,39 @@
 | 
			
		|||
package org.whispersystems.textsecuregcm.auth;
 | 
			
		||||
 | 
			
		||||
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 java.security.SecureRandom;
 | 
			
		||||
import java.util.Base64;
 | 
			
		||||
import java.util.stream.Stream;
 | 
			
		||||
 | 
			
		||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 | 
			
		||||
import static org.junit.jupiter.api.Assertions.assertThrows;
 | 
			
		||||
 | 
			
		||||
public class UnidentifiedAccessChecksumTest {
 | 
			
		||||
  @ParameterizedTest
 | 
			
		||||
  @MethodSource
 | 
			
		||||
  public void generateFor(final byte[] unidentifiedAccessKey, final byte[] expectedChecksum) {
 | 
			
		||||
    final byte[] checksum = UnidentifiedAccessChecksum.generateFor(unidentifiedAccessKey);
 | 
			
		||||
 | 
			
		||||
    assertArrayEquals(expectedChecksum, checksum);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private static Stream<Arguments> generateFor() {
 | 
			
		||||
    return Stream.of(
 | 
			
		||||
        Arguments.of(Base64.getDecoder().decode("hqqo9upWeC0HSHOSJcXl/Q=="),
 | 
			
		||||
            Base64.getDecoder().decode("2DNxpQCjTefuEhdvJayIbAVUcZSXotu8nqXwWr+q6hI=")),
 | 
			
		||||
        Arguments.of(Base64.getDecoder().decode("0bNEmhGzmxBsDYhEhk+bAw=="),
 | 
			
		||||
            Base64.getDecoder().decode("gJTodQfP8TUITZhvrWr0t1siDZXYxRQ/qdpNB8jC+yc="))
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Test
 | 
			
		||||
  public void generateForIllegalArgument() {
 | 
			
		||||
    final byte[] invalidLengthUnidentifiedAccessKey = new byte[15];
 | 
			
		||||
    new SecureRandom().nextBytes(invalidLengthUnidentifiedAccessKey);
 | 
			
		||||
 | 
			
		||||
    assertThrows(IllegalArgumentException.class, () -> UnidentifiedAccessChecksum.generateFor(invalidLengthUnidentifiedAccessKey));
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -130,7 +130,7 @@ class ProfileControllerTest {
 | 
			
		|||
  private static final PolicySigner policySigner = new PolicySigner("accessSecret", "us-west-1");
 | 
			
		||||
  private static final ServerZkProfileOperations zkProfileOperations = mock(ServerZkProfileOperations.class);
 | 
			
		||||
 | 
			
		||||
  private static final byte[] UNIDENTIFIED_ACCESS_KEY = "test-uak".getBytes(StandardCharsets.UTF_8);
 | 
			
		||||
  private static final byte[] UNIDENTIFIED_ACCESS_KEY = "sixteenbytes1234".getBytes(StandardCharsets.UTF_8);
 | 
			
		||||
  private static final IdentityKey ACCOUNT_IDENTITY_KEY = new IdentityKey(Curve.generateKeyPair().getPublicKey());
 | 
			
		||||
  private static final IdentityKey ACCOUNT_PHONE_NUMBER_IDENTITY_KEY = new IdentityKey(Curve.generateKeyPair().getPublicKey());
 | 
			
		||||
  private static final IdentityKey ACCOUNT_TWO_IDENTITY_KEY = new IdentityKey(Curve.generateKeyPair().getPublicKey());
 | 
			
		||||
| 
						 | 
				
			
			@ -200,7 +200,7 @@ class ProfileControllerTest {
 | 
			
		|||
    when(profileAccount.isEnabled()).thenReturn(true);
 | 
			
		||||
    when(profileAccount.getCurrentProfileVersion()).thenReturn(Optional.empty());
 | 
			
		||||
    when(profileAccount.getUsernameHash()).thenReturn(Optional.of(USERNAME_HASH));
 | 
			
		||||
    when(profileAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of("1337".getBytes()));
 | 
			
		||||
    when(profileAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of(UNIDENTIFIED_ACCESS_KEY));
 | 
			
		||||
 | 
			
		||||
    Account capabilitiesAccount = mock(Account.class);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -279,7 +279,7 @@ class ProfileControllerTest {
 | 
			
		|||
    final BaseProfileResponse profile = resources.getJerseyTest()
 | 
			
		||||
        .target("/v1/profile/" + AuthHelper.VALID_UUID_TWO)
 | 
			
		||||
        .request()
 | 
			
		||||
        .header(OptionalAccess.UNIDENTIFIED, AuthHelper.getUnidentifiedAccessHeader("1337".getBytes()))
 | 
			
		||||
        .header(OptionalAccess.UNIDENTIFIED, AuthHelper.getUnidentifiedAccessHeader(UNIDENTIFIED_ACCESS_KEY))
 | 
			
		||||
        .get(BaseProfileResponse.class);
 | 
			
		||||
 | 
			
		||||
    assertThat(profile.getIdentityKey()).isEqualTo(ACCOUNT_TWO_IDENTITY_KEY);
 | 
			
		||||
| 
						 | 
				
			
			@ -306,7 +306,7 @@ class ProfileControllerTest {
 | 
			
		|||
    final Response response = resources.getJerseyTest()
 | 
			
		||||
        .target("/v1/profile/" + UUID.randomUUID())
 | 
			
		||||
        .request()
 | 
			
		||||
        .header(OptionalAccess.UNIDENTIFIED, AuthHelper.getUnidentifiedAccessHeader("1337".getBytes()))
 | 
			
		||||
        .header(OptionalAccess.UNIDENTIFIED, AuthHelper.getUnidentifiedAccessHeader(UNIDENTIFIED_ACCESS_KEY))
 | 
			
		||||
        .get();
 | 
			
		||||
 | 
			
		||||
    assertThat(response.getStatus()).isEqualTo(401);
 | 
			
		||||
| 
						 | 
				
			
			@ -351,7 +351,7 @@ class ProfileControllerTest {
 | 
			
		|||
    final Response response = resources.getJerseyTest()
 | 
			
		||||
        .target("/v1/profile/PNI:" + AuthHelper.VALID_PNI_TWO)
 | 
			
		||||
        .request()
 | 
			
		||||
        .header(OptionalAccess.UNIDENTIFIED, AuthHelper.getUnidentifiedAccessHeader("1337".getBytes()))
 | 
			
		||||
        .header(OptionalAccess.UNIDENTIFIED, AuthHelper.getUnidentifiedAccessHeader(UNIDENTIFIED_ACCESS_KEY))
 | 
			
		||||
        .get();
 | 
			
		||||
 | 
			
		||||
    assertThat(response.getStatus()).isEqualTo(401);
 | 
			
		||||
| 
						 | 
				
			
			@ -1054,7 +1054,6 @@ class ProfileControllerTest {
 | 
			
		|||
  void testGetProfileWithExpiringProfileKeyCredential(final MultivaluedMap<String, Object> authHeaders)
 | 
			
		||||
      throws VerificationFailedException, InvalidInputException {
 | 
			
		||||
    final String version = "version";
 | 
			
		||||
    final byte[] unidentifiedAccessKey = "test-uak".getBytes(StandardCharsets.UTF_8);
 | 
			
		||||
 | 
			
		||||
    final ServerSecretParams serverSecretParams = ServerSecretParams.generate();
 | 
			
		||||
    final ServerPublicParams serverPublicParams = serverSecretParams.getPublicParams();
 | 
			
		||||
| 
						 | 
				
			
			@ -1080,7 +1079,7 @@ class ProfileControllerTest {
 | 
			
		|||
    when(account.getUuid()).thenReturn(AuthHelper.VALID_UUID);
 | 
			
		||||
    when(account.getCurrentProfileVersion()).thenReturn(Optional.of(version));
 | 
			
		||||
    when(account.isEnabled()).thenReturn(true);
 | 
			
		||||
    when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey));
 | 
			
		||||
    when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(UNIDENTIFIED_ACCESS_KEY));
 | 
			
		||||
 | 
			
		||||
    final Instant expiration = Instant.now().plus(ProfileController.EXPIRING_PROFILE_KEY_CREDENTIAL_EXPIRATION)
 | 
			
		||||
        .truncatedTo(ChronoUnit.DAYS);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,79 @@
 | 
			
		|||
package org.whispersystems.textsecuregcm.grpc;
 | 
			
		||||
 | 
			
		||||
import com.google.protobuf.ByteString;
 | 
			
		||||
import io.grpc.ManagedChannel;
 | 
			
		||||
import io.grpc.Metadata;
 | 
			
		||||
import io.grpc.Server;
 | 
			
		||||
import io.grpc.inprocess.InProcessChannelBuilder;
 | 
			
		||||
import io.grpc.inprocess.InProcessServerBuilder;
 | 
			
		||||
import io.grpc.stub.MetadataUtils;
 | 
			
		||||
import io.grpc.stub.StreamObserver;
 | 
			
		||||
import org.junit.jupiter.params.ParameterizedTest;
 | 
			
		||||
import org.junit.jupiter.params.provider.Arguments;
 | 
			
		||||
import org.junit.jupiter.params.provider.MethodSource;
 | 
			
		||||
import org.signal.chat.rpc.EchoRequest;
 | 
			
		||||
import org.signal.chat.rpc.EchoResponse;
 | 
			
		||||
import org.signal.chat.rpc.EchoServiceGrpc;
 | 
			
		||||
import java.io.IOException;
 | 
			
		||||
import java.util.Collections;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.Locale;
 | 
			
		||||
import java.util.concurrent.atomic.AtomicReference;
 | 
			
		||||
import java.util.stream.Stream;
 | 
			
		||||
 | 
			
		||||
import static org.junit.jupiter.api.Assertions.assertEquals;
 | 
			
		||||
 | 
			
		||||
public class AcceptLanguageInterceptorTest {
 | 
			
		||||
  @ParameterizedTest
 | 
			
		||||
  @MethodSource
 | 
			
		||||
  void parseLocale(final String header, final List<Locale> expectedLocales) throws IOException, InterruptedException {
 | 
			
		||||
    final AtomicReference<List<Locale>> observedLocales = new AtomicReference<>(null);
 | 
			
		||||
    final EchoServiceImpl serviceImpl = new EchoServiceImpl() {
 | 
			
		||||
      @Override
 | 
			
		||||
      public void echo(EchoRequest req, StreamObserver<EchoResponse> responseObserver) {
 | 
			
		||||
        observedLocales.set(AcceptLanguageUtil.localeFromGrpcContext());
 | 
			
		||||
        super.echo(req, responseObserver);
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    final Server testServer = InProcessServerBuilder.forName("AcceptLanguageTest")
 | 
			
		||||
        .directExecutor()
 | 
			
		||||
        .addService(serviceImpl)
 | 
			
		||||
        .intercept(new AcceptLanguageInterceptor())
 | 
			
		||||
        .intercept(new UserAgentInterceptor())
 | 
			
		||||
        .build()
 | 
			
		||||
        .start();
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final ManagedChannel channel = InProcessChannelBuilder.forName("AcceptLanguageTest")
 | 
			
		||||
          .directExecutor()
 | 
			
		||||
          .userAgent("Signal-Android/1.2.3")
 | 
			
		||||
          .build();
 | 
			
		||||
 | 
			
		||||
      final Metadata metadata = new Metadata();
 | 
			
		||||
      metadata.put(AcceptLanguageInterceptor.ACCEPTABLE_LANGUAGES_GRPC_HEADER, header);
 | 
			
		||||
 | 
			
		||||
      final EchoServiceGrpc.EchoServiceBlockingStub client = EchoServiceGrpc.newBlockingStub(channel)
 | 
			
		||||
          .withInterceptors(MetadataUtils.newAttachHeadersInterceptor(metadata));
 | 
			
		||||
 | 
			
		||||
      final EchoRequest request = EchoRequest.newBuilder().setPayload(ByteString.copyFromUtf8("test request")).build();
 | 
			
		||||
      client.echo(request);
 | 
			
		||||
      assertEquals(expectedLocales, observedLocales.get());
 | 
			
		||||
    } finally {
 | 
			
		||||
      testServer.shutdownNow();
 | 
			
		||||
      testServer.awaitTermination();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private static Stream<Arguments> parseLocale() {
 | 
			
		||||
    return Stream.of(
 | 
			
		||||
        // en-US-POSIX is a special locale that exists alongside en-US. It matches because of the definition of
 | 
			
		||||
        // basic filtering in RFC 4647 (https://datatracker.ietf.org/doc/html/rfc4647#section-3.3.1)
 | 
			
		||||
        Arguments.of("en-US,fr-CA", List.of(Locale.forLanguageTag("en-US-POSIX"), Locale.forLanguageTag("en-US"), Locale.forLanguageTag("fr-CA"))),
 | 
			
		||||
        Arguments.of("en-US; q=0.9, fr-CA", List.of(Locale.forLanguageTag("fr-CA"), Locale.forLanguageTag("en-US-POSIX"), Locale.forLanguageTag("en-US"))),
 | 
			
		||||
        Arguments.of("invalid-locale,fr-CA", List.of(Locale.forLanguageTag("fr-CA"))),
 | 
			
		||||
        Arguments.of("", Collections.emptyList()),
 | 
			
		||||
        Arguments.of("acompletely,unexpectedfor , mat", Collections.emptyList())
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,169 @@
 | 
			
		|||
package org.whispersystems.textsecuregcm.grpc;
 | 
			
		||||
 | 
			
		||||
import static org.junit.jupiter.api.Assertions.assertEquals;
 | 
			
		||||
import static org.junit.jupiter.api.Assertions.assertThrows;
 | 
			
		||||
import static org.mockito.ArgumentMatchers.any;
 | 
			
		||||
import static org.mockito.ArgumentMatchers.anyBoolean;
 | 
			
		||||
import static org.mockito.Mockito.mock;
 | 
			
		||||
import static org.mockito.Mockito.verify;
 | 
			
		||||
import static org.mockito.Mockito.when;
 | 
			
		||||
 | 
			
		||||
import com.google.protobuf.ByteString;
 | 
			
		||||
import io.grpc.Metadata;
 | 
			
		||||
import io.grpc.Status;
 | 
			
		||||
import java.security.SecureRandom;
 | 
			
		||||
import java.util.Collections;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.Optional;
 | 
			
		||||
import java.util.UUID;
 | 
			
		||||
import java.util.concurrent.CompletableFuture;
 | 
			
		||||
import java.util.stream.Stream;
 | 
			
		||||
import io.grpc.StatusRuntimeException;
 | 
			
		||||
import io.grpc.stub.MetadataUtils;
 | 
			
		||||
import org.junit.jupiter.api.BeforeEach;
 | 
			
		||||
import org.junit.jupiter.api.Test;
 | 
			
		||||
import org.junit.jupiter.api.extension.RegisterExtension;
 | 
			
		||||
import org.junit.jupiter.params.ParameterizedTest;
 | 
			
		||||
import org.junit.jupiter.params.provider.Arguments;
 | 
			
		||||
import org.junit.jupiter.params.provider.MethodSource;
 | 
			
		||||
import org.signal.chat.common.IdentityType;
 | 
			
		||||
import org.signal.chat.common.ServiceIdentifier;
 | 
			
		||||
import org.signal.chat.profile.GetUnversionedProfileAnonymousRequest;
 | 
			
		||||
import org.signal.chat.profile.GetUnversionedProfileRequest;
 | 
			
		||||
import org.signal.chat.profile.GetUnversionedProfileResponse;
 | 
			
		||||
import org.signal.chat.profile.ProfileAnonymousGrpc;
 | 
			
		||||
import org.signal.libsignal.protocol.IdentityKey;
 | 
			
		||||
import org.signal.libsignal.protocol.ecc.Curve;
 | 
			
		||||
import org.signal.libsignal.protocol.ecc.ECKeyPair;
 | 
			
		||||
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessChecksum;
 | 
			
		||||
import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;
 | 
			
		||||
import org.whispersystems.textsecuregcm.entities.Badge;
 | 
			
		||||
import org.whispersystems.textsecuregcm.entities.BadgeSvg;
 | 
			
		||||
import org.whispersystems.textsecuregcm.entities.UserCapabilities;
 | 
			
		||||
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
 | 
			
		||||
import org.whispersystems.textsecuregcm.storage.Account;
 | 
			
		||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
 | 
			
		||||
import org.whispersystems.textsecuregcm.util.UUIDUtil;
 | 
			
		||||
 | 
			
		||||
public class ProfileAnonymousGrpcServiceTest {
 | 
			
		||||
  private Account account;
 | 
			
		||||
  private AccountsManager accountsManager;
 | 
			
		||||
  private ProfileBadgeConverter profileBadgeConverter;
 | 
			
		||||
  private ProfileAnonymousGrpc.ProfileAnonymousBlockingStub profileAnonymousBlockingStub;
 | 
			
		||||
 | 
			
		||||
  @RegisterExtension
 | 
			
		||||
  static final GrpcServerExtension GRPC_SERVER_EXTENSION = new GrpcServerExtension();
 | 
			
		||||
 | 
			
		||||
  @BeforeEach
 | 
			
		||||
  void setup() {
 | 
			
		||||
    account = mock(Account.class);
 | 
			
		||||
    accountsManager = mock(AccountsManager.class);
 | 
			
		||||
    profileBadgeConverter = mock(ProfileBadgeConverter.class);
 | 
			
		||||
 | 
			
		||||
    final Metadata metadata = new Metadata();
 | 
			
		||||
    metadata.put(AcceptLanguageInterceptor.ACCEPTABLE_LANGUAGES_GRPC_HEADER, "en-us");
 | 
			
		||||
 | 
			
		||||
    profileAnonymousBlockingStub = ProfileAnonymousGrpc.newBlockingStub(GRPC_SERVER_EXTENSION.getChannel())
 | 
			
		||||
        .withInterceptors(MetadataUtils.newAttachHeadersInterceptor(metadata));
 | 
			
		||||
 | 
			
		||||
    final ProfileAnonymousGrpcService profileAnonymousGrpcService = new ProfileAnonymousGrpcService(
 | 
			
		||||
        accountsManager,
 | 
			
		||||
        profileBadgeConverter
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    GRPC_SERVER_EXTENSION.getServiceRegistry()
 | 
			
		||||
        .addService(profileAnonymousGrpcService);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Test
 | 
			
		||||
  void getUnversionedProfile() {
 | 
			
		||||
    final UUID targetUuid = UUID.randomUUID();
 | 
			
		||||
    final org.whispersystems.textsecuregcm.identity.ServiceIdentifier serviceIdentifier = new AciServiceIdentifier(targetUuid);
 | 
			
		||||
 | 
			
		||||
    final byte[] unidentifiedAccessKey = new byte[16];
 | 
			
		||||
    new SecureRandom().nextBytes(unidentifiedAccessKey);
 | 
			
		||||
    final ECKeyPair identityKeyPair = Curve.generateKeyPair();
 | 
			
		||||
    final IdentityKey identityKey = new IdentityKey(identityKeyPair.getPublicKey());
 | 
			
		||||
 | 
			
		||||
    final List<Badge> badges = List.of(new Badge(
 | 
			
		||||
        "TEST",
 | 
			
		||||
        "other",
 | 
			
		||||
        "Test Badge",
 | 
			
		||||
        "This badge is in unit tests.",
 | 
			
		||||
        List.of("l", "m", "h", "x", "xx", "xxx"),
 | 
			
		||||
        "SVG",
 | 
			
		||||
        List.of(
 | 
			
		||||
            new BadgeSvg("sl", "sd"),
 | 
			
		||||
            new BadgeSvg("ml", "md"),
 | 
			
		||||
            new BadgeSvg("ll", "ld")))
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    when(account.getBadges()).thenReturn(Collections.emptyList());
 | 
			
		||||
    when(profileBadgeConverter.convert(any(), any(), anyBoolean())).thenReturn(badges);
 | 
			
		||||
    when(account.isUnrestrictedUnidentifiedAccess()).thenReturn(false);
 | 
			
		||||
    when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey));
 | 
			
		||||
    when(account.getIdentityKey(org.whispersystems.textsecuregcm.identity.IdentityType.ACI)).thenReturn(identityKey);
 | 
			
		||||
    when(accountsManager.getByServiceIdentifierAsync(serviceIdentifier)).thenReturn(CompletableFuture.completedFuture(Optional.of(account)));
 | 
			
		||||
 | 
			
		||||
    final GetUnversionedProfileAnonymousRequest request = GetUnversionedProfileAnonymousRequest.newBuilder()
 | 
			
		||||
        .setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey))
 | 
			
		||||
        .setRequest(GetUnversionedProfileRequest.newBuilder()
 | 
			
		||||
            .setServiceIdentifier(ServiceIdentifier.newBuilder()
 | 
			
		||||
                .setIdentityType(IdentityType.IDENTITY_TYPE_ACI)
 | 
			
		||||
                .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(targetUuid)))
 | 
			
		||||
                .build())
 | 
			
		||||
            .build())
 | 
			
		||||
        .build();
 | 
			
		||||
 | 
			
		||||
    final GetUnversionedProfileResponse response = profileAnonymousBlockingStub.getUnversionedProfile(request);
 | 
			
		||||
 | 
			
		||||
    final byte[] unidentifiedAccessChecksum = UnidentifiedAccessChecksum.generateFor(unidentifiedAccessKey);
 | 
			
		||||
    final GetUnversionedProfileResponse expectedResponse = GetUnversionedProfileResponse.newBuilder()
 | 
			
		||||
        .setIdentityKey(ByteString.copyFrom(identityKey.serialize()))
 | 
			
		||||
        .setUnidentifiedAccess(ByteString.copyFrom(unidentifiedAccessChecksum))
 | 
			
		||||
        .setUnrestrictedUnidentifiedAccess(false)
 | 
			
		||||
        .setCapabilities(ProfileGrpcHelper.buildUserCapabilities(UserCapabilities.createForAccount(account)))
 | 
			
		||||
        .addAllBadges(ProfileGrpcHelper.buildBadges(badges))
 | 
			
		||||
        .build();
 | 
			
		||||
 | 
			
		||||
    verify(accountsManager).getByServiceIdentifierAsync(serviceIdentifier);
 | 
			
		||||
    assertEquals(expectedResponse, response);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @ParameterizedTest
 | 
			
		||||
  @MethodSource
 | 
			
		||||
  void getUnversionedProfileUnauthenticated(final IdentityType identityType, final boolean missingUnidentifiedAccessKey, final boolean accountNotFound) {
 | 
			
		||||
    final byte[] unidentifiedAccessKey = new byte[16];
 | 
			
		||||
    new SecureRandom().nextBytes(unidentifiedAccessKey);
 | 
			
		||||
 | 
			
		||||
    when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey));
 | 
			
		||||
    when(account.isUnrestrictedUnidentifiedAccess()).thenReturn(false);
 | 
			
		||||
    when(accountsManager.getByServiceIdentifierAsync(any())).thenReturn(
 | 
			
		||||
        CompletableFuture.completedFuture(accountNotFound ? Optional.empty() : Optional.of(account)));
 | 
			
		||||
 | 
			
		||||
    final GetUnversionedProfileAnonymousRequest.Builder requestBuilder = GetUnversionedProfileAnonymousRequest.newBuilder()
 | 
			
		||||
        .setRequest(GetUnversionedProfileRequest.newBuilder()
 | 
			
		||||
            .setServiceIdentifier(ServiceIdentifier.newBuilder()
 | 
			
		||||
                .setIdentityType(identityType)
 | 
			
		||||
                .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(UUID.randomUUID())))
 | 
			
		||||
                .build())
 | 
			
		||||
            .build());
 | 
			
		||||
 | 
			
		||||
    if (!missingUnidentifiedAccessKey) {
 | 
			
		||||
      requestBuilder.setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    final StatusRuntimeException statusRuntimeException = assertThrows(StatusRuntimeException.class,
 | 
			
		||||
        () -> profileAnonymousBlockingStub.getUnversionedProfile(requestBuilder.build()));
 | 
			
		||||
 | 
			
		||||
    assertEquals(Status.UNAUTHENTICATED.getCode(), statusRuntimeException.getStatus().getCode());
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private static Stream<Arguments> getUnversionedProfileUnauthenticated() {
 | 
			
		||||
    return Stream.of(
 | 
			
		||||
        Arguments.of(IdentityType.IDENTITY_TYPE_PNI, false, false),
 | 
			
		||||
        Arguments.of(IdentityType.IDENTITY_TYPE_ACI, true, false),
 | 
			
		||||
        Arguments.of(IdentityType.IDENTITY_TYPE_ACI, false, true)
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -3,30 +3,49 @@ package org.whispersystems.textsecuregcm.grpc;
 | 
			
		|||
import com.google.i18n.phonenumbers.PhoneNumberUtil;
 | 
			
		||||
import com.google.i18n.phonenumbers.Phonenumber;
 | 
			
		||||
import com.google.protobuf.ByteString;
 | 
			
		||||
import io.grpc.Metadata;
 | 
			
		||||
import io.grpc.ServerInterceptors;
 | 
			
		||||
import io.grpc.Status;
 | 
			
		||||
import io.grpc.StatusRuntimeException;
 | 
			
		||||
import io.grpc.stub.MetadataUtils;
 | 
			
		||||
import org.junit.jupiter.api.BeforeEach;
 | 
			
		||||
import org.junit.jupiter.api.extension.RegisterExtension;
 | 
			
		||||
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.EnumSource;
 | 
			
		||||
import org.junit.jupiter.params.provider.MethodSource;
 | 
			
		||||
import org.junit.jupiter.params.provider.ValueSource;
 | 
			
		||||
import org.mockito.ArgumentCaptor;
 | 
			
		||||
import org.signal.chat.common.IdentityType;
 | 
			
		||||
import org.signal.chat.common.ServiceIdentifier;
 | 
			
		||||
import org.signal.chat.profile.GetUnversionedProfileRequest;
 | 
			
		||||
import org.signal.chat.profile.GetUnversionedProfileResponse;
 | 
			
		||||
import org.signal.chat.profile.SetProfileRequest.AvatarChange;
 | 
			
		||||
import org.signal.chat.profile.ProfileGrpc;
 | 
			
		||||
import org.signal.chat.profile.SetProfileRequest;
 | 
			
		||||
import org.signal.chat.profile.SetProfileResponse;
 | 
			
		||||
import org.signal.libsignal.protocol.IdentityKey;
 | 
			
		||||
import org.signal.libsignal.protocol.ServiceId;
 | 
			
		||||
import org.signal.libsignal.protocol.ecc.Curve;
 | 
			
		||||
import org.signal.libsignal.protocol.ecc.ECKeyPair;
 | 
			
		||||
import org.signal.libsignal.zkgroup.InvalidInputException;
 | 
			
		||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
 | 
			
		||||
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessChecksum;
 | 
			
		||||
import org.whispersystems.textsecuregcm.auth.grpc.MockAuthenticationInterceptor;
 | 
			
		||||
import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;
 | 
			
		||||
import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;
 | 
			
		||||
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
 | 
			
		||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
 | 
			
		||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicPaymentsConfiguration;
 | 
			
		||||
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
 | 
			
		||||
import org.whispersystems.textsecuregcm.entities.Badge;
 | 
			
		||||
import org.whispersystems.textsecuregcm.entities.BadgeSvg;
 | 
			
		||||
import org.whispersystems.textsecuregcm.entities.UserCapabilities;
 | 
			
		||||
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.s3.PolicySigner;
 | 
			
		||||
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
 | 
			
		||||
import org.whispersystems.textsecuregcm.storage.Account;
 | 
			
		||||
| 
						 | 
				
			
			@ -36,9 +55,13 @@ import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
 | 
			
		|||
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
 | 
			
		||||
import org.whispersystems.textsecuregcm.storage.VersionedProfile;
 | 
			
		||||
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
 | 
			
		||||
import org.whispersystems.textsecuregcm.util.UUIDUtil;
 | 
			
		||||
import reactor.core.publisher.Mono;
 | 
			
		||||
import software.amazon.awssdk.services.s3.S3AsyncClient;
 | 
			
		||||
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
 | 
			
		||||
import java.security.SecureRandom;
 | 
			
		||||
import java.time.Clock;
 | 
			
		||||
import java.time.Duration;
 | 
			
		||||
import java.util.Collections;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.Map;
 | 
			
		||||
| 
						 | 
				
			
			@ -50,9 +73,11 @@ import java.util.stream.Stream;
 | 
			
		|||
import static org.assertj.core.api.Assertions.assertThat;
 | 
			
		||||
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.junit.jupiter.api.Assertions.assertThrows;
 | 
			
		||||
import static org.junit.jupiter.api.Assertions.assertTrue;
 | 
			
		||||
import static org.mockito.ArgumentMatchers.any;
 | 
			
		||||
import static org.mockito.ArgumentMatchers.anyBoolean;
 | 
			
		||||
import static org.mockito.ArgumentMatchers.anyString;
 | 
			
		||||
import static org.mockito.ArgumentMatchers.eq;
 | 
			
		||||
import static org.mockito.Mockito.mock;
 | 
			
		||||
| 
						 | 
				
			
			@ -66,11 +91,14 @@ public class ProfileGrpcServiceTest {
 | 
			
		|||
  private static final String S3_BUCKET = "profileBucket";
 | 
			
		||||
  private static final String VERSION = "someVersion";
 | 
			
		||||
  private static final byte[] VALID_NAME = new byte[81];
 | 
			
		||||
  private AccountsManager accountsManager;
 | 
			
		||||
  private ProfilesManager profilesManager;
 | 
			
		||||
  private DynamicPaymentsConfiguration dynamicPaymentsConfiguration;
 | 
			
		||||
  private S3AsyncClient asyncS3client;
 | 
			
		||||
  private VersionedProfile profile;
 | 
			
		||||
  private Account account;
 | 
			
		||||
  private RateLimiter rateLimiter;
 | 
			
		||||
  private ProfileBadgeConverter profileBadgeConverter;
 | 
			
		||||
  private ProfileGrpc.ProfileBlockingStub profileBlockingStub;
 | 
			
		||||
 | 
			
		||||
  @RegisterExtension
 | 
			
		||||
| 
						 | 
				
			
			@ -78,13 +106,15 @@ public class ProfileGrpcServiceTest {
 | 
			
		|||
 | 
			
		||||
  @BeforeEach
 | 
			
		||||
  void setup() {
 | 
			
		||||
    accountsManager = mock(AccountsManager.class);
 | 
			
		||||
    profilesManager = mock(ProfilesManager.class);
 | 
			
		||||
    dynamicPaymentsConfiguration = mock(DynamicPaymentsConfiguration.class);
 | 
			
		||||
    asyncS3client = mock(S3AsyncClient.class);
 | 
			
		||||
    profile = mock(VersionedProfile.class);
 | 
			
		||||
    account = mock(Account.class);
 | 
			
		||||
    rateLimiter = mock(RateLimiter.class);
 | 
			
		||||
    profileBadgeConverter = mock(ProfileBadgeConverter.class);
 | 
			
		||||
 | 
			
		||||
    final AccountsManager accountsManager = mock(AccountsManager.class);
 | 
			
		||||
    @SuppressWarnings("unchecked") final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager = mock(DynamicConfigurationManager.class);
 | 
			
		||||
    final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);
 | 
			
		||||
    final PolicySigner policySigner = new PolicySigner("accessSecret", "us-west-1");
 | 
			
		||||
| 
						 | 
				
			
			@ -104,11 +134,15 @@ public class ProfileGrpcServiceTest {
 | 
			
		|||
        List.of("TEST1"),
 | 
			
		||||
        Map.of(1L, "TEST1", 2L, "TEST2", 3L, "TEST3")
 | 
			
		||||
    );
 | 
			
		||||
    final RateLimiters rateLimiters = mock(RateLimiters.class);
 | 
			
		||||
    final String phoneNumber = PhoneNumberUtil.getInstance().format(
 | 
			
		||||
        PhoneNumberUtil.getInstance().getExampleNumber("US"),
 | 
			
		||||
        PhoneNumberUtil.PhoneNumberFormat.E164);
 | 
			
		||||
    final Metadata metadata = new Metadata();
 | 
			
		||||
    metadata.put(AcceptLanguageInterceptor.ACCEPTABLE_LANGUAGES_GRPC_HEADER, "en-us");
 | 
			
		||||
 | 
			
		||||
    profileBlockingStub = ProfileGrpc.newBlockingStub(GRPC_SERVER_EXTENSION.getChannel());
 | 
			
		||||
    profileBlockingStub = ProfileGrpc.newBlockingStub(GRPC_SERVER_EXTENSION.getChannel())
 | 
			
		||||
        .withInterceptors(MetadataUtils.newAttachHeadersInterceptor(metadata));
 | 
			
		||||
 | 
			
		||||
    final ProfileGrpcService profileGrpcService = new ProfileGrpcService(
 | 
			
		||||
        Clock.systemUTC(),
 | 
			
		||||
| 
						 | 
				
			
			@ -119,6 +153,8 @@ public class ProfileGrpcServiceTest {
 | 
			
		|||
        asyncS3client,
 | 
			
		||||
        policyGenerator,
 | 
			
		||||
        policySigner,
 | 
			
		||||
        profileBadgeConverter,
 | 
			
		||||
        rateLimiters,
 | 
			
		||||
        S3_BUCKET
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -128,6 +164,9 @@ public class ProfileGrpcServiceTest {
 | 
			
		|||
    GRPC_SERVER_EXTENSION.getServiceRegistry()
 | 
			
		||||
        .addService(ServerInterceptors.intercept(profileGrpcService, mockAuthenticationInterceptor));
 | 
			
		||||
 | 
			
		||||
    when(rateLimiters.getProfileLimiter()).thenReturn(rateLimiter);
 | 
			
		||||
    when(rateLimiter.validateReactive(any(UUID.class))).thenReturn(Mono.empty());
 | 
			
		||||
 | 
			
		||||
    when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);
 | 
			
		||||
    when(dynamicConfiguration.getPaymentsConfiguration()).thenReturn(dynamicPaymentsConfiguration);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -168,7 +207,6 @@ public class ProfileGrpcServiceTest {
 | 
			
		|||
        .setCommitment(ByteString.copyFrom(commitment))
 | 
			
		||||
        .build();
 | 
			
		||||
 | 
			
		||||
    //noinspection ResultOfMethodCallIgnored
 | 
			
		||||
    profileBlockingStub.setProfile(request);
 | 
			
		||||
 | 
			
		||||
    final ArgumentCaptor<VersionedProfile> profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class);
 | 
			
		||||
| 
						 | 
				
			
			@ -188,8 +226,8 @@ public class ProfileGrpcServiceTest {
 | 
			
		|||
 | 
			
		||||
  @ParameterizedTest
 | 
			
		||||
  @MethodSource
 | 
			
		||||
  void setProfileUpload(AvatarChange avatarChange, boolean hasPreviousProfile,
 | 
			
		||||
      boolean expectHasS3UploadPath, boolean expectDeleteS3Object) throws InvalidInputException {
 | 
			
		||||
  void setProfileUpload(final AvatarChange avatarChange, final boolean hasPreviousProfile,
 | 
			
		||||
      final boolean expectHasS3UploadPath, final boolean expectDeleteS3Object) throws InvalidInputException {
 | 
			
		||||
    final String currentAvatar = "profiles/currentAvatar";
 | 
			
		||||
    final byte[] commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AUTHENTICATED_ACI)).serialize();
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -243,7 +281,7 @@ public class ProfileGrpcServiceTest {
 | 
			
		|||
 | 
			
		||||
  @ParameterizedTest
 | 
			
		||||
  @MethodSource
 | 
			
		||||
  void setProfileInvalidRequestData(SetProfileRequest request) {
 | 
			
		||||
  void setProfileInvalidRequestData(final SetProfileRequest request) {
 | 
			
		||||
    final StatusRuntimeException exception =
 | 
			
		||||
        assertThrows(StatusRuntimeException.class, () -> profileBlockingStub.setProfile(request));
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -294,7 +332,7 @@ public class ProfileGrpcServiceTest {
 | 
			
		|||
 | 
			
		||||
  @ParameterizedTest
 | 
			
		||||
  @ValueSource(booleans = {true, false})
 | 
			
		||||
  void setPaymentAddressDisallowedCountry(boolean hasExistingPaymentAddress) throws InvalidInputException {
 | 
			
		||||
  void setPaymentAddressDisallowedCountry(final boolean hasExistingPaymentAddress) throws InvalidInputException {
 | 
			
		||||
    final Phonenumber.PhoneNumber disallowedPhoneNumber = PhoneNumberUtil.getInstance().getExampleNumber("CU");
 | 
			
		||||
    final byte[] commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AUTHENTICATED_ACI)).serialize();
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -326,4 +364,112 @@ public class ProfileGrpcServiceTest {
 | 
			
		|||
      assertEquals(Status.PERMISSION_DENIED.getCode(), exception.getStatus().getCode());
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @ParameterizedTest
 | 
			
		||||
  @EnumSource(value = org.signal.chat.common.IdentityType.class, names = {"IDENTITY_TYPE_ACI", "IDENTITY_TYPE_PNI"})
 | 
			
		||||
  void getUnversionedProfile(final IdentityType identityType) {
 | 
			
		||||
    final UUID targetUuid = UUID.randomUUID();
 | 
			
		||||
    final org.whispersystems.textsecuregcm.identity.ServiceIdentifier targetIdentifier =
 | 
			
		||||
        identityType == IdentityType.IDENTITY_TYPE_ACI ? new AciServiceIdentifier(targetUuid) : new PniServiceIdentifier(targetUuid);
 | 
			
		||||
 | 
			
		||||
    final GetUnversionedProfileRequest request = GetUnversionedProfileRequest.newBuilder()
 | 
			
		||||
        .setServiceIdentifier(ServiceIdentifier.newBuilder()
 | 
			
		||||
            .setIdentityType(identityType)
 | 
			
		||||
            .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(targetUuid)))
 | 
			
		||||
            .build())
 | 
			
		||||
        .build();
 | 
			
		||||
    final byte[] unidentifiedAccessKey = new byte[16];
 | 
			
		||||
    new SecureRandom().nextBytes(unidentifiedAccessKey);
 | 
			
		||||
    final ECKeyPair identityKeyPair = Curve.generateKeyPair();
 | 
			
		||||
    final IdentityKey identityKey = new IdentityKey(identityKeyPair.getPublicKey());
 | 
			
		||||
 | 
			
		||||
    final List<Badge> badges = List.of(new Badge(
 | 
			
		||||
        "TEST",
 | 
			
		||||
        "other",
 | 
			
		||||
        "Test Badge",
 | 
			
		||||
        "This badge is in unit tests.",
 | 
			
		||||
        List.of("l", "m", "h", "x", "xx", "xxx"),
 | 
			
		||||
        "SVG",
 | 
			
		||||
        List.of(
 | 
			
		||||
            new BadgeSvg("sl", "sd"),
 | 
			
		||||
            new BadgeSvg("ml", "md"),
 | 
			
		||||
            new BadgeSvg("ll", "ld")))
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    when(account.getIdentityKey(IdentityTypeUtil.fromGrpcIdentityType(identityType))).thenReturn(identityKey);
 | 
			
		||||
    when(account.isUnrestrictedUnidentifiedAccess()).thenReturn(true);
 | 
			
		||||
    when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey));
 | 
			
		||||
    when(account.getBadges()).thenReturn(Collections.emptyList());
 | 
			
		||||
    when(profileBadgeConverter.convert(any(), any(), anyBoolean())).thenReturn(badges);
 | 
			
		||||
    when(accountsManager.getByServiceIdentifierAsync(any())).thenReturn(CompletableFuture.completedFuture(Optional.of(account)));
 | 
			
		||||
 | 
			
		||||
    final GetUnversionedProfileResponse response = profileBlockingStub.getUnversionedProfile(request);
 | 
			
		||||
 | 
			
		||||
    final byte[] unidentifiedAccessChecksum = UnidentifiedAccessChecksum.generateFor(unidentifiedAccessKey);
 | 
			
		||||
    final GetUnversionedProfileResponse prototypeExpectedResponse = GetUnversionedProfileResponse.newBuilder()
 | 
			
		||||
        .setIdentityKey(ByteString.copyFrom(identityKey.serialize()))
 | 
			
		||||
        .setUnidentifiedAccess(ByteString.copyFrom(unidentifiedAccessChecksum))
 | 
			
		||||
        .setUnrestrictedUnidentifiedAccess(true)
 | 
			
		||||
        .setCapabilities(ProfileGrpcHelper.buildUserCapabilities(UserCapabilities.createForAccount(account)))
 | 
			
		||||
        .addAllBadges(ProfileGrpcHelper.buildBadges(badges))
 | 
			
		||||
        .build();
 | 
			
		||||
 | 
			
		||||
    final GetUnversionedProfileResponse expectedResponse;
 | 
			
		||||
    if (identityType == IdentityType.IDENTITY_TYPE_PNI) {
 | 
			
		||||
      expectedResponse = GetUnversionedProfileResponse.newBuilder(prototypeExpectedResponse)
 | 
			
		||||
          .clearUnidentifiedAccess()
 | 
			
		||||
          .clearBadges()
 | 
			
		||||
          .setUnrestrictedUnidentifiedAccess(false)
 | 
			
		||||
          .build();
 | 
			
		||||
    } else {
 | 
			
		||||
      expectedResponse = prototypeExpectedResponse;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    verify(rateLimiter).validateReactive(AUTHENTICATED_ACI);
 | 
			
		||||
    verify(accountsManager).getByServiceIdentifierAsync(targetIdentifier);
 | 
			
		||||
 | 
			
		||||
    assertEquals(expectedResponse, response);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Test
 | 
			
		||||
  void getUnversionedProfileTargetAccountNotFound() {
 | 
			
		||||
    when(accountsManager.getByServiceIdentifierAsync(any())).thenReturn(CompletableFuture.completedFuture(Optional.empty()));
 | 
			
		||||
 | 
			
		||||
    final GetUnversionedProfileRequest request = GetUnversionedProfileRequest.newBuilder()
 | 
			
		||||
        .setServiceIdentifier(ServiceIdentifier.newBuilder()
 | 
			
		||||
            .setIdentityType(IdentityType.IDENTITY_TYPE_ACI)
 | 
			
		||||
            .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(UUID.randomUUID())))
 | 
			
		||||
            .build())
 | 
			
		||||
        .build();
 | 
			
		||||
 | 
			
		||||
    final StatusRuntimeException statusRuntimeException = assertThrows(StatusRuntimeException.class,
 | 
			
		||||
        () -> profileBlockingStub.getUnversionedProfile(request));
 | 
			
		||||
 | 
			
		||||
    assertEquals(Status.NOT_FOUND.getCode(), statusRuntimeException.getStatus().getCode());
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @ParameterizedTest
 | 
			
		||||
  @EnumSource(value = org.signal.chat.common.IdentityType.class, names = {"IDENTITY_TYPE_ACI", "IDENTITY_TYPE_PNI"})
 | 
			
		||||
  void getUnversionedProfileRatelimited(final IdentityType identityType) {
 | 
			
		||||
    final Duration retryAfterDuration = Duration.ofMinutes(7);
 | 
			
		||||
    when(accountsManager.getByServiceIdentifierAsync(any())).thenReturn(CompletableFuture.completedFuture(Optional.of(account)));
 | 
			
		||||
    when(rateLimiter.validateReactive(any(UUID.class)))
 | 
			
		||||
        .thenReturn(Mono.error(new RateLimitExceededException(retryAfterDuration, false)));
 | 
			
		||||
 | 
			
		||||
    final GetUnversionedProfileRequest request = GetUnversionedProfileRequest.newBuilder()
 | 
			
		||||
        .setServiceIdentifier(ServiceIdentifier.newBuilder()
 | 
			
		||||
            .setIdentityType(identityType)
 | 
			
		||||
            .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(UUID.randomUUID())))
 | 
			
		||||
            .build())
 | 
			
		||||
        .build();
 | 
			
		||||
 | 
			
		||||
    final StatusRuntimeException exception =
 | 
			
		||||
        assertThrows(StatusRuntimeException.class, () -> profileBlockingStub.getUnversionedProfile(request));
 | 
			
		||||
 | 
			
		||||
    assertEquals(Status.Code.RESOURCE_EXHAUSTED, exception.getStatus().getCode());
 | 
			
		||||
    assertNotNull(exception.getTrailers());
 | 
			
		||||
    assertEquals(retryAfterDuration, exception.getTrailers().get(RateLimitUtil.RETRY_AFTER_DURATION_KEY));
 | 
			
		||||
 | 
			
		||||
    verifyNoInteractions(accountsManager);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue