Profile gRPC: Define `getUnversionedProfile` endpoint

This commit is contained in:
Katherine Yen 2023-08-30 14:24:43 -07:00 committed by GitHub
parent 5e221fa9a3
commit 5afc058f90
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 786 additions and 96 deletions

View File

@ -119,9 +119,11 @@ import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.filters.RemoteDeprecationFilter; import org.whispersystems.textsecuregcm.filters.RemoteDeprecationFilter;
import org.whispersystems.textsecuregcm.filters.RequestStatisticsFilter; import org.whispersystems.textsecuregcm.filters.RequestStatisticsFilter;
import org.whispersystems.textsecuregcm.filters.TimestampResponseFilter; import org.whispersystems.textsecuregcm.filters.TimestampResponseFilter;
import org.whispersystems.textsecuregcm.grpc.AcceptLanguageInterceptor;
import org.whispersystems.textsecuregcm.grpc.GrpcServerManagedWrapper; import org.whispersystems.textsecuregcm.grpc.GrpcServerManagedWrapper;
import org.whispersystems.textsecuregcm.grpc.KeysAnonymousGrpcService; import org.whispersystems.textsecuregcm.grpc.KeysAnonymousGrpcService;
import org.whispersystems.textsecuregcm.grpc.KeysGrpcService; import org.whispersystems.textsecuregcm.grpc.KeysGrpcService;
import org.whispersystems.textsecuregcm.grpc.ProfileAnonymousGrpcService;
import org.whispersystems.textsecuregcm.grpc.ProfileGrpcService; import org.whispersystems.textsecuregcm.grpc.ProfileGrpcService;
import org.whispersystems.textsecuregcm.grpc.UserAgentInterceptor; import org.whispersystems.textsecuregcm.grpc.UserAgentInterceptor;
import org.whispersystems.textsecuregcm.limits.CardinalityEstimator; 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(ServerInterceptors.intercept(new KeysGrpcService(accountsManager, keys, rateLimiters), basicCredentialAuthenticationInterceptor))
.addService(new KeysAnonymousGrpcService(accountsManager, keys)) .addService(new KeysAnonymousGrpcService(accountsManager, keys))
.addService(ServerInterceptors.intercept(new ProfileGrpcService(clock, accountsManager, profilesManager, dynamicConfigurationManager, .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); RemoteDeprecationFilter remoteDeprecationFilter = new RemoteDeprecationFilter(dynamicConfigurationManager);
environment.servlets() environment.servlets()
.addFilter("RemoteDeprecationFilter", remoteDeprecationFilter) .addFilter("RemoteDeprecationFilter", remoteDeprecationFilter)
.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, "/*"); .addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, "/*");
grpcServer.intercept(new AcceptLanguageInterceptor());
// Note: interceptors run in the reverse order they are added; the remote deprecation filter // 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! // 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- // http://grpc.github.io/grpc-java/javadoc/io/grpc/ServerBuilder.html#intercept-io.grpc.ServerInterceptor-

View File

@ -9,23 +9,21 @@ import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec; import javax.crypto.spec.SecretKeySpec;
import java.security.InvalidKeyException; import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.Optional;
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
public class UnidentifiedAccessChecksum { public class UnidentifiedAccessChecksum {
public static String generateFor(Optional<byte[]> unidentifiedAccessKey) { public static byte[] generateFor(byte[] unidentifiedAccessKey) {
try { 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 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) { } catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new AssertionError(e); throw new AssertionError(e);
} }
} }
} }

View File

@ -20,6 +20,8 @@ import java.security.NoSuchAlgorithmException;
import java.time.Clock; import java.time.Clock;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; 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.CreateProfileRequest;
import org.whispersystems.textsecuregcm.entities.CredentialProfileResponse; import org.whispersystems.textsecuregcm.entities.CredentialProfileResponse;
import org.whispersystems.textsecuregcm.entities.ExpiringProfileKeyCredentialProfileResponse; import org.whispersystems.textsecuregcm.entities.ExpiringProfileKeyCredentialProfileResponse;
import org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes;
import org.whispersystems.textsecuregcm.entities.UserCapabilities; import org.whispersystems.textsecuregcm.entities.UserCapabilities;
import org.whispersystems.textsecuregcm.entities.VersionedProfileResponse; 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.AciServiceIdentifier;
import org.whispersystems.textsecuregcm.identity.IdentityType; import org.whispersystems.textsecuregcm.identity.IdentityType;
import org.whispersystems.textsecuregcm.identity.PniServiceIdentifier; 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.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.storage.ProfilesManager; import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import org.whispersystems.textsecuregcm.storage.VersionedProfile; import org.whispersystems.textsecuregcm.storage.VersionedProfile;
import org.whispersystems.textsecuregcm.util.Pair;
import org.whispersystems.textsecuregcm.util.Util; import org.whispersystems.textsecuregcm.util.Util;
import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
@ -108,9 +112,7 @@ import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
@Path("/v1/profile") @Path("/v1/profile")
@Tag(name = "Profile") @Tag(name = "Profile")
public class ProfileController { public class ProfileController {
private final Logger logger = LoggerFactory.getLogger(ProfileController.class); private final Logger logger = LoggerFactory.getLogger(ProfileController.class);
private final Clock clock; private final Clock clock;
private final RateLimiters rateLimiters; private final RateLimiters rateLimiters;
private final ProfilesManager profilesManager; private final ProfilesManager profilesManager;
@ -223,7 +225,7 @@ public class ProfileController {
}); });
if (request.getAvatarChange() == CreateProfileRequest.AvatarChange.UPDATE) { if (request.getAvatarChange() == CreateProfileRequest.AvatarChange.UPDATE) {
return Response.ok(ProfileHelper.generateAvatarUploadForm(policyGenerator, policySigner, avatar)).build(); return Response.ok(generateAvatarUploadForm(avatar)).build();
} else { } else {
return Response.ok().build(); return Response.ok().build();
} }
@ -245,7 +247,7 @@ public class ProfileController {
return buildVersionedProfileResponse(targetAccount, return buildVersionedProfileResponse(targetAccount,
version, version,
isSelfProfileRequest(maybeRequester, accountIdentifier), maybeRequester.map(requester -> ProfileHelper.isSelfProfileRequest(requester.getUuid(), accountIdentifier)).orElse(false),
containerRequestContext); containerRequestContext);
} }
@ -268,7 +270,7 @@ public class ProfileController {
final Optional<Account> maybeRequester = auth.map(AuthenticatedAccount::getAccount); final Optional<Account> maybeRequester = auth.map(AuthenticatedAccount::getAccount);
final Account targetAccount = verifyPermissionToReceiveAccountIdentityProfile(maybeRequester, accessKey, accountIdentifier); 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, return buildExpiringProfileKeyCredentialProfileResponse(targetAccount,
version, version,
@ -302,7 +304,7 @@ public class ProfileController {
verifyPermissionToReceiveAccountIdentityProfile(maybeRequester, accessKey, aciServiceIdentifier); verifyPermissionToReceiveAccountIdentityProfile(maybeRequester, accessKey, aciServiceIdentifier);
yield buildBaseProfileResponseForAccountIdentity(targetAccount, yield buildBaseProfileResponseForAccountIdentity(targetAccount,
isSelfProfileRequest(maybeRequester, aciServiceIdentifier), maybeRequester.map(requester -> ProfileHelper.isSelfProfileRequest(requester.getUuid(), aciServiceIdentifier)).orElse(false),
containerRequestContext); containerRequestContext);
} }
case PNI -> { case PNI -> {
@ -432,7 +434,7 @@ public class ProfileController {
final ContainerRequestContext containerRequestContext) { final ContainerRequestContext containerRequestContext) {
return new BaseProfileResponse(account.getIdentityKey(IdentityType.ACI), return new BaseProfileResponse(account.getIdentityKey(IdentityType.ACI),
UnidentifiedAccessChecksum.generateFor(account.getUnidentifiedAccessKey()), account.getUnidentifiedAccessKey().map(UnidentifiedAccessChecksum::generateFor).orElse(null),
account.isUnrestrictedUnidentifiedAccess(), account.isUnrestrictedUnidentifiedAccess(),
UserCapabilities.createForAccount(account), UserCapabilities.createForAccount(account),
profileBadgeConverter.convert( profileBadgeConverter.convert(
@ -468,7 +470,7 @@ public class ProfileController {
} }
} }
private List<Locale> getAcceptableLanguagesForRequest(ContainerRequestContext containerRequestContext) { private List<Locale> getAcceptableLanguagesForRequest(final ContainerRequestContext containerRequestContext) {
try { try {
return containerRequestContext.getAcceptableLanguages(); return containerRequestContext.getAcceptableLanguages();
} catch (final ProcessingException e) { } catch (final ProcessingException e) {
@ -517,8 +519,15 @@ public class ProfileController {
return maybeTargetAccount.get(); return maybeTargetAccount.get();
} }
private boolean isSelfProfileRequest(final Optional<Account> maybeRequester, final AciServiceIdentifier targetIdentifier) { private ProfileAvatarUploadAttributes generateAvatarUploadForm(
return maybeRequester.map(requester -> requester.getUuid().equals(targetIdentifier.uuid())).orElse(false); 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 @Nullable

View File

@ -10,6 +10,7 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import org.signal.libsignal.protocol.IdentityKey; import org.signal.libsignal.protocol.IdentityKey;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier; import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
import org.whispersystems.textsecuregcm.util.ByteArrayBase64WithPaddingAdapter;
import org.whispersystems.textsecuregcm.util.ServiceIdentifierAdapter; import org.whispersystems.textsecuregcm.util.ServiceIdentifierAdapter;
import org.whispersystems.textsecuregcm.util.IdentityKeyAdapter; import org.whispersystems.textsecuregcm.util.IdentityKeyAdapter;
@ -23,7 +24,9 @@ public class BaseProfileResponse {
private IdentityKey identityKey; private IdentityKey identityKey;
@JsonProperty @JsonProperty
private String unidentifiedAccess; @JsonSerialize(using = ByteArrayBase64WithPaddingAdapter.Serializing.class)
@JsonDeserialize(using = ByteArrayBase64WithPaddingAdapter.Deserializing.class)
private byte[] unidentifiedAccess;
@JsonProperty @JsonProperty
private boolean unrestrictedUnidentifiedAccess; private boolean unrestrictedUnidentifiedAccess;
@ -43,7 +46,7 @@ public class BaseProfileResponse {
} }
public BaseProfileResponse(final IdentityKey identityKey, public BaseProfileResponse(final IdentityKey identityKey,
final String unidentifiedAccess, final byte[] unidentifiedAccess,
final boolean unrestrictedUnidentifiedAccess, final boolean unrestrictedUnidentifiedAccess,
final UserCapabilities capabilities, final UserCapabilities capabilities,
final List<Badge> badges, final List<Badge> badges,
@ -61,7 +64,7 @@ public class BaseProfileResponse {
return identityKey; return identityKey;
} }
public String getUnidentifiedAccess() { public byte[] getUnidentifiedAccess() {
return unidentifiedAccess; return unidentifiedAccess;
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -2,15 +2,21 @@ package org.whispersystems.textsecuregcm.grpc;
import com.google.protobuf.ByteString; import com.google.protobuf.ByteString;
import io.grpc.Status; 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.SetProfileRequest.AvatarChange;
import org.signal.chat.profile.ProfileAvatarUploadAttributes; import org.signal.chat.profile.ProfileAvatarUploadAttributes;
import org.signal.chat.profile.ReactorProfileGrpc; import org.signal.chat.profile.ReactorProfileGrpc;
import org.signal.chat.profile.SetProfileRequest; import org.signal.chat.profile.SetProfileRequest;
import org.signal.chat.profile.SetProfileResponse; import org.signal.chat.profile.SetProfileResponse;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil; import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;
import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;
import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration; import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration; import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; 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.PolicySigner;
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator; import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
import org.whispersystems.textsecuregcm.storage.Account; 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.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.storage.ProfilesManager; import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import org.whispersystems.textsecuregcm.storage.VersionedProfile; 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.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import software.amazon.awssdk.services.s3.S3AsyncClient; import software.amazon.awssdk.services.s3.S3AsyncClient;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import java.time.Clock; import java.time.Clock;
import java.time.ZonedDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.UUID;
import java.util.function.Function; import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
public class ProfileGrpcService extends ReactorProfileGrpc.ProfileImplBase { public class ProfileGrpcService extends ReactorProfileGrpc.ProfileImplBase {
private final Clock clock;
private final ProfilesManager profilesManager; private final Clock clock;
private final AccountsManager accountsManager; private final AccountsManager accountsManager;
private final ProfilesManager profilesManager;
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager; private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
private final Map<String, BadgeConfiguration> badgeConfigurationMap; private final Map<String, BadgeConfiguration> badgeConfigurationMap;
private final PolicySigner policySigner;
private final PostPolicyGenerator policyGenerator;
private final S3AsyncClient asyncS3client; 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 final String bucket;
private record AvatarData(Optional<String> currentAvatar, private record AvatarData(Optional<String> currentAvatar,
@ -49,29 +60,38 @@ public class ProfileGrpcService extends ReactorProfileGrpc.ProfileImplBase {
Optional<ProfileAvatarUploadAttributes> uploadAttributes) {} Optional<ProfileAvatarUploadAttributes> uploadAttributes) {}
public ProfileGrpcService( public ProfileGrpcService(
Clock clock, final Clock clock,
AccountsManager accountsManager, final AccountsManager accountsManager,
ProfilesManager profilesManager, final ProfilesManager profilesManager,
DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager, final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
BadgesConfiguration badgesConfiguration, final BadgesConfiguration badgesConfiguration,
S3AsyncClient asyncS3client, final S3AsyncClient asyncS3client,
PostPolicyGenerator policyGenerator, final PostPolicyGenerator policyGenerator,
PolicySigner policySigner, final PolicySigner policySigner,
String bucket) { final ProfileBadgeConverter profileBadgeConverter,
final RateLimiters rateLimiters,
final String bucket) {
this.clock = clock; this.clock = clock;
this.accountsManager = accountsManager; this.accountsManager = accountsManager;
this.profilesManager = profilesManager; this.profilesManager = profilesManager;
this.dynamicConfigurationManager = dynamicConfigurationManager; this.dynamicConfigurationManager = dynamicConfigurationManager;
this.badgeConfigurationMap = badgesConfiguration.getBadges().stream().collect(Collectors.toMap( this.badgeConfigurationMap = badgesConfiguration.getBadges().stream().collect(Collectors.toMap(
BadgeConfiguration::getId, Function.identity())); BadgeConfiguration::getId, Function.identity()));
this.bucket = bucket;
this.asyncS3client = asyncS3client; this.asyncS3client = asyncS3client;
this.policyGenerator = policyGenerator; this.policyGenerator = policyGenerator;
this.policySigner = policySigner; this.policySigner = policySigner;
this.profileBadgeConverter = profileBadgeConverter;
this.rateLimiters = rateLimiters;
this.bucket = bucket;
} }
@Override @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); validateRequest(request);
return Mono.fromSupplier(AuthenticationUtil::requireAuthenticatedDevice) return Mono.fromSupplier(AuthenticationUtil::requireAuthenticatedDevice)
.flatMap(authenticatedDevice -> Mono.zip( .flatMap(authenticatedDevice -> Mono.zip(
@ -99,7 +119,7 @@ public class ProfileGrpcService extends ReactorProfileGrpc.ProfileImplBase {
case AVATAR_CHANGE_UPDATE -> { case AVATAR_CHANGE_UPDATE -> {
final String updateAvatarObjectName = ProfileHelper.generateAvatarObjectName(); final String updateAvatarObjectName = ProfileHelper.generateAvatarObjectName();
yield new AvatarData(currentAvatar, Optional.of(updateAvatarObjectName), 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()) { if (request.getVersion().isEmpty()) {
throw Status.INVALID_ARGUMENT.withDescription("Missing version").asRuntimeException(); 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(); 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();
}
} }

View File

@ -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.security.SecureRandom;
import java.time.Clock; import java.time.Clock;
import java.time.Duration; import java.time.Duration;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Base64; import java.util.Base64;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import org.signal.chat.profile.ProfileAvatarUploadAttributes; import java.util.UUID;
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;
public class ProfileHelper { public class ProfileHelper {
public static int MAX_PROFILE_AVATAR_SIZE_BYTES = 10 * 1024 * 1024;
public static List<AccountBadge> mergeBadgeIdsWithExistingAccountBadges( public static List<AccountBadge> mergeBadgeIdsWithExistingAccountBadges(
final Clock clock, final Clock clock,
final Map<String, BadgeConfiguration> badgeConfigurationMap, final Map<String, BadgeConfiguration> badgeConfigurationMap,
@ -64,41 +61,13 @@ public class ProfileHelper {
} }
public static String generateAvatarObjectName() { public static String generateAvatarObjectName() {
byte[] object = new byte[16]; final byte[] object = new byte[16];
new SecureRandom().nextBytes(object); new SecureRandom().nextBytes(object);
return "profiles/" + Base64.getUrlEncoder().encodeToString(object); return "profiles/" + Base64.getUrlEncoder().encodeToString(object);
} }
public static org.signal.chat.profile.ProfileAvatarUploadAttributes generateAvatarUploadFormGrpc( public static boolean isSelfProfileRequest(@Nullable final UUID requesterUuid, final AciServiceIdentifier targetIdentifier) {
final PostPolicyGenerator policyGenerator, return targetIdentifier.uuid().equals(requesterUuid);
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);
} }
} }

View File

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

View File

@ -130,7 +130,7 @@ class ProfileControllerTest {
private static final PolicySigner policySigner = new PolicySigner("accessSecret", "us-west-1"); private static final PolicySigner policySigner = new PolicySigner("accessSecret", "us-west-1");
private static final ServerZkProfileOperations zkProfileOperations = mock(ServerZkProfileOperations.class); 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_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_PHONE_NUMBER_IDENTITY_KEY = new IdentityKey(Curve.generateKeyPair().getPublicKey());
private static final IdentityKey ACCOUNT_TWO_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.isEnabled()).thenReturn(true);
when(profileAccount.getCurrentProfileVersion()).thenReturn(Optional.empty()); when(profileAccount.getCurrentProfileVersion()).thenReturn(Optional.empty());
when(profileAccount.getUsernameHash()).thenReturn(Optional.of(USERNAME_HASH)); 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); Account capabilitiesAccount = mock(Account.class);
@ -279,7 +279,7 @@ class ProfileControllerTest {
final BaseProfileResponse profile = resources.getJerseyTest() final BaseProfileResponse profile = resources.getJerseyTest()
.target("/v1/profile/" + AuthHelper.VALID_UUID_TWO) .target("/v1/profile/" + AuthHelper.VALID_UUID_TWO)
.request() .request()
.header(OptionalAccess.UNIDENTIFIED, AuthHelper.getUnidentifiedAccessHeader("1337".getBytes())) .header(OptionalAccess.UNIDENTIFIED, AuthHelper.getUnidentifiedAccessHeader(UNIDENTIFIED_ACCESS_KEY))
.get(BaseProfileResponse.class); .get(BaseProfileResponse.class);
assertThat(profile.getIdentityKey()).isEqualTo(ACCOUNT_TWO_IDENTITY_KEY); assertThat(profile.getIdentityKey()).isEqualTo(ACCOUNT_TWO_IDENTITY_KEY);
@ -306,7 +306,7 @@ class ProfileControllerTest {
final Response response = resources.getJerseyTest() final Response response = resources.getJerseyTest()
.target("/v1/profile/" + UUID.randomUUID()) .target("/v1/profile/" + UUID.randomUUID())
.request() .request()
.header(OptionalAccess.UNIDENTIFIED, AuthHelper.getUnidentifiedAccessHeader("1337".getBytes())) .header(OptionalAccess.UNIDENTIFIED, AuthHelper.getUnidentifiedAccessHeader(UNIDENTIFIED_ACCESS_KEY))
.get(); .get();
assertThat(response.getStatus()).isEqualTo(401); assertThat(response.getStatus()).isEqualTo(401);
@ -351,7 +351,7 @@ class ProfileControllerTest {
final Response response = resources.getJerseyTest() final Response response = resources.getJerseyTest()
.target("/v1/profile/PNI:" + AuthHelper.VALID_PNI_TWO) .target("/v1/profile/PNI:" + AuthHelper.VALID_PNI_TWO)
.request() .request()
.header(OptionalAccess.UNIDENTIFIED, AuthHelper.getUnidentifiedAccessHeader("1337".getBytes())) .header(OptionalAccess.UNIDENTIFIED, AuthHelper.getUnidentifiedAccessHeader(UNIDENTIFIED_ACCESS_KEY))
.get(); .get();
assertThat(response.getStatus()).isEqualTo(401); assertThat(response.getStatus()).isEqualTo(401);
@ -1054,7 +1054,6 @@ class ProfileControllerTest {
void testGetProfileWithExpiringProfileKeyCredential(final MultivaluedMap<String, Object> authHeaders) void testGetProfileWithExpiringProfileKeyCredential(final MultivaluedMap<String, Object> authHeaders)
throws VerificationFailedException, InvalidInputException { throws VerificationFailedException, InvalidInputException {
final String version = "version"; final String version = "version";
final byte[] unidentifiedAccessKey = "test-uak".getBytes(StandardCharsets.UTF_8);
final ServerSecretParams serverSecretParams = ServerSecretParams.generate(); final ServerSecretParams serverSecretParams = ServerSecretParams.generate();
final ServerPublicParams serverPublicParams = serverSecretParams.getPublicParams(); final ServerPublicParams serverPublicParams = serverSecretParams.getPublicParams();
@ -1080,7 +1079,7 @@ class ProfileControllerTest {
when(account.getUuid()).thenReturn(AuthHelper.VALID_UUID); when(account.getUuid()).thenReturn(AuthHelper.VALID_UUID);
when(account.getCurrentProfileVersion()).thenReturn(Optional.of(version)); when(account.getCurrentProfileVersion()).thenReturn(Optional.of(version));
when(account.isEnabled()).thenReturn(true); 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) final Instant expiration = Instant.now().plus(ProfileController.EXPIRING_PROFILE_KEY_CREDENTIAL_EXPIRATION)
.truncatedTo(ChronoUnit.DAYS); .truncatedTo(ChronoUnit.DAYS);

View File

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

View File

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

View File

@ -3,30 +3,49 @@ package org.whispersystems.textsecuregcm.grpc;
import com.google.i18n.phonenumbers.PhoneNumberUtil; import com.google.i18n.phonenumbers.PhoneNumberUtil;
import com.google.i18n.phonenumbers.Phonenumber; import com.google.i18n.phonenumbers.Phonenumber;
import com.google.protobuf.ByteString; import com.google.protobuf.ByteString;
import io.grpc.Metadata;
import io.grpc.ServerInterceptors; import io.grpc.ServerInterceptors;
import io.grpc.Status; import io.grpc.Status;
import io.grpc.StatusRuntimeException; import io.grpc.StatusRuntimeException;
import io.grpc.stub.MetadataUtils;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.api.extension.RegisterExtension;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments; 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.MethodSource;
import org.junit.jupiter.params.provider.ValueSource; import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor; 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.SetProfileRequest.AvatarChange;
import org.signal.chat.profile.ProfileGrpc; import org.signal.chat.profile.ProfileGrpc;
import org.signal.chat.profile.SetProfileRequest; import org.signal.chat.profile.SetProfileRequest;
import org.signal.chat.profile.SetProfileResponse; import org.signal.chat.profile.SetProfileResponse;
import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.protocol.ServiceId; 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.InvalidInputException;
import org.signal.libsignal.zkgroup.profiles.ProfileKey; import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessChecksum;
import org.whispersystems.textsecuregcm.auth.grpc.MockAuthenticationInterceptor; import org.whispersystems.textsecuregcm.auth.grpc.MockAuthenticationInterceptor;
import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;
import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration; import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration; import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicPaymentsConfiguration; 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.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.PolicySigner;
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator; import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
import org.whispersystems.textsecuregcm.storage.Account; 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.ProfilesManager;
import org.whispersystems.textsecuregcm.storage.VersionedProfile; import org.whispersystems.textsecuregcm.storage.VersionedProfile;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper; 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.S3AsyncClient;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import java.security.SecureRandom;
import java.time.Clock; import java.time.Clock;
import java.time.Duration;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -50,9 +73,11 @@ import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals; 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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
@ -66,11 +91,14 @@ public class ProfileGrpcServiceTest {
private static final String S3_BUCKET = "profileBucket"; private static final String S3_BUCKET = "profileBucket";
private static final String VERSION = "someVersion"; private static final String VERSION = "someVersion";
private static final byte[] VALID_NAME = new byte[81]; private static final byte[] VALID_NAME = new byte[81];
private AccountsManager accountsManager;
private ProfilesManager profilesManager; private ProfilesManager profilesManager;
private DynamicPaymentsConfiguration dynamicPaymentsConfiguration; private DynamicPaymentsConfiguration dynamicPaymentsConfiguration;
private S3AsyncClient asyncS3client; private S3AsyncClient asyncS3client;
private VersionedProfile profile; private VersionedProfile profile;
private Account account; private Account account;
private RateLimiter rateLimiter;
private ProfileBadgeConverter profileBadgeConverter;
private ProfileGrpc.ProfileBlockingStub profileBlockingStub; private ProfileGrpc.ProfileBlockingStub profileBlockingStub;
@RegisterExtension @RegisterExtension
@ -78,13 +106,15 @@ public class ProfileGrpcServiceTest {
@BeforeEach @BeforeEach
void setup() { void setup() {
accountsManager = mock(AccountsManager.class);
profilesManager = mock(ProfilesManager.class); profilesManager = mock(ProfilesManager.class);
dynamicPaymentsConfiguration = mock(DynamicPaymentsConfiguration.class); dynamicPaymentsConfiguration = mock(DynamicPaymentsConfiguration.class);
asyncS3client = mock(S3AsyncClient.class); asyncS3client = mock(S3AsyncClient.class);
profile = mock(VersionedProfile.class); profile = mock(VersionedProfile.class);
account = mock(Account.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); @SuppressWarnings("unchecked") final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager = mock(DynamicConfigurationManager.class);
final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class); final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);
final PolicySigner policySigner = new PolicySigner("accessSecret", "us-west-1"); final PolicySigner policySigner = new PolicySigner("accessSecret", "us-west-1");
@ -104,11 +134,15 @@ public class ProfileGrpcServiceTest {
List.of("TEST1"), List.of("TEST1"),
Map.of(1L, "TEST1", 2L, "TEST2", 3L, "TEST3") Map.of(1L, "TEST1", 2L, "TEST2", 3L, "TEST3")
); );
final RateLimiters rateLimiters = mock(RateLimiters.class);
final String phoneNumber = PhoneNumberUtil.getInstance().format( final String phoneNumber = PhoneNumberUtil.getInstance().format(
PhoneNumberUtil.getInstance().getExampleNumber("US"), PhoneNumberUtil.getInstance().getExampleNumber("US"),
PhoneNumberUtil.PhoneNumberFormat.E164); 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( final ProfileGrpcService profileGrpcService = new ProfileGrpcService(
Clock.systemUTC(), Clock.systemUTC(),
@ -119,6 +153,8 @@ public class ProfileGrpcServiceTest {
asyncS3client, asyncS3client,
policyGenerator, policyGenerator,
policySigner, policySigner,
profileBadgeConverter,
rateLimiters,
S3_BUCKET S3_BUCKET
); );
@ -128,6 +164,9 @@ public class ProfileGrpcServiceTest {
GRPC_SERVER_EXTENSION.getServiceRegistry() GRPC_SERVER_EXTENSION.getServiceRegistry()
.addService(ServerInterceptors.intercept(profileGrpcService, mockAuthenticationInterceptor)); .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(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);
when(dynamicConfiguration.getPaymentsConfiguration()).thenReturn(dynamicPaymentsConfiguration); when(dynamicConfiguration.getPaymentsConfiguration()).thenReturn(dynamicPaymentsConfiguration);
@ -168,7 +207,6 @@ public class ProfileGrpcServiceTest {
.setCommitment(ByteString.copyFrom(commitment)) .setCommitment(ByteString.copyFrom(commitment))
.build(); .build();
//noinspection ResultOfMethodCallIgnored
profileBlockingStub.setProfile(request); profileBlockingStub.setProfile(request);
final ArgumentCaptor<VersionedProfile> profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class); final ArgumentCaptor<VersionedProfile> profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class);
@ -188,8 +226,8 @@ public class ProfileGrpcServiceTest {
@ParameterizedTest @ParameterizedTest
@MethodSource @MethodSource
void setProfileUpload(AvatarChange avatarChange, boolean hasPreviousProfile, void setProfileUpload(final AvatarChange avatarChange, final boolean hasPreviousProfile,
boolean expectHasS3UploadPath, boolean expectDeleteS3Object) throws InvalidInputException { final boolean expectHasS3UploadPath, final boolean expectDeleteS3Object) throws InvalidInputException {
final String currentAvatar = "profiles/currentAvatar"; final String currentAvatar = "profiles/currentAvatar";
final byte[] commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AUTHENTICATED_ACI)).serialize(); final byte[] commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AUTHENTICATED_ACI)).serialize();
@ -243,7 +281,7 @@ public class ProfileGrpcServiceTest {
@ParameterizedTest @ParameterizedTest
@MethodSource @MethodSource
void setProfileInvalidRequestData(SetProfileRequest request) { void setProfileInvalidRequestData(final SetProfileRequest request) {
final StatusRuntimeException exception = final StatusRuntimeException exception =
assertThrows(StatusRuntimeException.class, () -> profileBlockingStub.setProfile(request)); assertThrows(StatusRuntimeException.class, () -> profileBlockingStub.setProfile(request));
@ -294,7 +332,7 @@ public class ProfileGrpcServiceTest {
@ParameterizedTest @ParameterizedTest
@ValueSource(booleans = {true, false}) @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 Phonenumber.PhoneNumber disallowedPhoneNumber = PhoneNumberUtil.getInstance().getExampleNumber("CU");
final byte[] commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AUTHENTICATED_ACI)).serialize(); 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()); 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);
}
} }