From 5afc058f90553881e171a761ec6b1bada9287486 Mon Sep 17 00:00:00 2001 From: Katherine Yen Date: Wed, 30 Aug 2023 14:24:43 -0700 Subject: [PATCH] Profile gRPC: Define `getUnversionedProfile` endpoint --- .../textsecuregcm/WhisperServerService.java | 7 +- .../auth/UnidentifiedAccessChecksum.java | 14 +- .../controllers/ProfileController.java | 31 ++-- .../entities/BaseProfileResponse.java | 9 +- .../grpc/AcceptLanguageInterceptor.java | 68 +++++++ .../grpc/AcceptLanguageUtil.java | 12 ++ .../grpc/ProfileAnonymousGrpcService.java | 49 +++++ .../textsecuregcm/grpc/ProfileGrpcHelper.java | 89 +++++++++ .../grpc/ProfileGrpcService.java | 92 ++++++++-- .../{grpc => util}/ProfileHelper.java | 51 ++---- .../auth/UnidentifiedAccessChecksumTest.java | 39 ++++ .../controllers/ProfileControllerTest.java | 13 +- .../grpc/AcceptLanguageInterceptorTest.java | 79 ++++++++ .../grpc/ProfileAnonymousGrpcServiceTest.java | 169 ++++++++++++++++++ .../grpc/ProfileGrpcServiceTest.java | 160 ++++++++++++++++- 15 files changed, 786 insertions(+), 96 deletions(-) create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/AcceptLanguageInterceptor.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/AcceptLanguageUtil.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcService.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcHelper.java rename service/src/main/java/org/whispersystems/textsecuregcm/{grpc => util}/ProfileHelper.java (54%) create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/auth/UnidentifiedAccessChecksumTest.java create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/grpc/AcceptLanguageInterceptorTest.java create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcServiceTest.java diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index f7c276504..54e49c91d 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -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 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); } } - } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java index 481e2969b..2af40eab6 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java @@ -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 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 getAcceptableLanguagesForRequest(ContainerRequestContext containerRequestContext) { + private List 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 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 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 diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/BaseProfileResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/BaseProfileResponse.java index a2b5f3a20..c3414f628 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/BaseProfileResponse.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/BaseProfileResponse.java @@ -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 badges, @@ -61,7 +64,7 @@ public class BaseProfileResponse { return identityKey; } - public String getUnidentifiedAccess() { + public byte[] getUnidentifiedAccess() { return unidentifiedAccess; } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/AcceptLanguageInterceptor.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/AcceptLanguageInterceptor.java new file mode 100644 index 000000000..a80c2eb32 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/AcceptLanguageInterceptor.java @@ -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 ACCEPTABLE_LANGUAGES_GRPC_HEADER = + Metadata.Key.of("accept-language", Metadata.ASCII_STRING_MARSHALLER); + + @Override + public ServerCall.Listener interceptCall(final ServerCall call, + final Metadata headers, + final ServerCallHandler next) { + + final List 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 parseLocales(@Nullable final String acceptableLanguagesHeader) { + if (acceptableLanguagesHeader == null) { + return Collections.emptyList(); + } + try { + final List 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(); + } + } +} + diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/AcceptLanguageUtil.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/AcceptLanguageUtil.java new file mode 100644 index 000000000..d5ea68da4 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/AcceptLanguageUtil.java @@ -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> ACCEPTABLE_LANGUAGES_CONTEXT_KEY = Context.key("accept-language"); + public static List localeFromGrpcContext() { + return ACCEPTABLE_LANGUAGES_CONTEXT_KEY.get(); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcService.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcService.java new file mode 100644 index 000000000..0eb3704db --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcService.java @@ -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 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 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())); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcHelper.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcHelper.java new file mode 100644 index 000000000..f029b4ba0 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcHelper.java @@ -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 buildBadges(final List badges) { + final ArrayList 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 buildBadgeSvgs(final List badgeSvgs) { + ArrayList 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(); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcService.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcService.java index ddc264354..23bb02a58 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcService.java @@ -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 dynamicConfigurationManager; private final Map 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 currentAvatar, @@ -49,29 +60,38 @@ public class ProfileGrpcService extends ReactorProfileGrpc.ProfileImplBase { Optional uploadAttributes) {} public ProfileGrpcService( - Clock clock, - AccountsManager accountsManager, - ProfilesManager profilesManager, - DynamicConfigurationManager dynamicConfigurationManager, - BadgesConfiguration badgesConfiguration, - S3AsyncClient asyncS3client, - PostPolicyGenerator policyGenerator, - PolicySigner policySigner, - String bucket) { + final Clock clock, + final AccountsManager accountsManager, + final ProfilesManager profilesManager, + final DynamicConfigurationManager 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 setProfile(SetProfileRequest request) { + protected Throwable onErrorMap(final Throwable throwable) { + return RateLimitUtil.mapRateLimitExceededException(throwable); + } + + @Override + public Mono 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 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 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 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(); + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileHelper.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ProfileHelper.java similarity index 54% rename from service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileHelper.java rename to service/src/main/java/org/whispersystems/textsecuregcm/util/ProfileHelper.java index fd4948317..724a41a4a 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileHelper.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/ProfileHelper.java @@ -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 mergeBadgeIdsWithExistingAccountBadges( final Clock clock, final Map 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 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 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); } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/auth/UnidentifiedAccessChecksumTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/auth/UnidentifiedAccessChecksumTest.java new file mode 100644 index 000000000..4098310cb --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/auth/UnidentifiedAccessChecksumTest.java @@ -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 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)); + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ProfileControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ProfileControllerTest.java index 75e60ca01..81b21f0b0 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ProfileControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ProfileControllerTest.java @@ -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 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); diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/AcceptLanguageInterceptorTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/AcceptLanguageInterceptorTest.java new file mode 100644 index 000000000..c56ee22f7 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/AcceptLanguageInterceptorTest.java @@ -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 expectedLocales) throws IOException, InterruptedException { + final AtomicReference> observedLocales = new AtomicReference<>(null); + final EchoServiceImpl serviceImpl = new EchoServiceImpl() { + @Override + public void echo(EchoRequest req, StreamObserver 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 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()) + ); + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcServiceTest.java new file mode 100644 index 000000000..1e27ebf7b --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcServiceTest.java @@ -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 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 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) + ); + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcServiceTest.java index 9bc43cb8d..34fbbf9bb 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcServiceTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcServiceTest.java @@ -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 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 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 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); + } }