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
15 changed files with 786 additions and 96 deletions

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 io.grpc.Status;
import org.signal.chat.profile.GetUnversionedProfileRequest;
import org.signal.chat.profile.GetUnversionedProfileResponse;
import org.signal.chat.profile.SetProfileRequest.AvatarChange;
import org.signal.chat.profile.ProfileAvatarUploadAttributes;
import org.signal.chat.profile.ReactorProfileGrpc;
import org.signal.chat.profile.SetProfileRequest;
import org.signal.chat.profile.SetProfileResponse;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;
import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;
import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.s3.PolicySigner;
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
import org.whispersystems.textsecuregcm.storage.Account;
@@ -19,29 +25,34 @@ import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
import org.whispersystems.textsecuregcm.storage.VersionedProfile;
import org.whispersystems.textsecuregcm.util.Pair;
import org.whispersystems.textsecuregcm.util.ProfileHelper;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import software.amazon.awssdk.services.s3.S3AsyncClient;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import java.time.Clock;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
public class ProfileGrpcService extends ReactorProfileGrpc.ProfileImplBase {
private final Clock clock;
private final ProfilesManager profilesManager;
private final Clock clock;
private final AccountsManager accountsManager;
private final ProfilesManager profilesManager;
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
private final Map<String, BadgeConfiguration> badgeConfigurationMap;
private final PolicySigner policySigner;
private final PostPolicyGenerator policyGenerator;
private final S3AsyncClient asyncS3client;
private final PostPolicyGenerator policyGenerator;
private final PolicySigner policySigner;
private final ProfileBadgeConverter profileBadgeConverter;
private final RateLimiters rateLimiters;
private final String bucket;
private record AvatarData(Optional<String> currentAvatar,
@@ -49,29 +60,38 @@ public class ProfileGrpcService extends ReactorProfileGrpc.ProfileImplBase {
Optional<ProfileAvatarUploadAttributes> uploadAttributes) {}
public ProfileGrpcService(
Clock clock,
AccountsManager accountsManager,
ProfilesManager profilesManager,
DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
BadgesConfiguration badgesConfiguration,
S3AsyncClient asyncS3client,
PostPolicyGenerator policyGenerator,
PolicySigner policySigner,
String bucket) {
final Clock clock,
final AccountsManager accountsManager,
final ProfilesManager profilesManager,
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
final BadgesConfiguration badgesConfiguration,
final S3AsyncClient asyncS3client,
final PostPolicyGenerator policyGenerator,
final PolicySigner policySigner,
final ProfileBadgeConverter profileBadgeConverter,
final RateLimiters rateLimiters,
final String bucket) {
this.clock = clock;
this.accountsManager = accountsManager;
this.profilesManager = profilesManager;
this.dynamicConfigurationManager = dynamicConfigurationManager;
this.badgeConfigurationMap = badgesConfiguration.getBadges().stream().collect(Collectors.toMap(
BadgeConfiguration::getId, Function.identity()));
this.bucket = bucket;
this.asyncS3client = asyncS3client;
this.policyGenerator = policyGenerator;
this.policySigner = policySigner;
this.profileBadgeConverter = profileBadgeConverter;
this.rateLimiters = rateLimiters;
this.bucket = bucket;
}
@Override
public Mono<SetProfileResponse> setProfile(SetProfileRequest request) {
protected Throwable onErrorMap(final Throwable throwable) {
return RateLimitUtil.mapRateLimitExceededException(throwable);
}
@Override
public Mono<SetProfileResponse> setProfile(final SetProfileRequest request) {
validateRequest(request);
return Mono.fromSupplier(AuthenticationUtil::requireAuthenticatedDevice)
.flatMap(authenticatedDevice -> Mono.zip(
@@ -99,7 +119,7 @@ public class ProfileGrpcService extends ReactorProfileGrpc.ProfileImplBase {
case AVATAR_CHANGE_UPDATE -> {
final String updateAvatarObjectName = ProfileHelper.generateAvatarObjectName();
yield new AvatarData(currentAvatar, Optional.of(updateAvatarObjectName),
Optional.of(ProfileHelper.generateAvatarUploadFormGrpc(policyGenerator, policySigner, updateAvatarObjectName)));
Optional.of(generateAvatarUploadForm(updateAvatarObjectName)));
}
};
@@ -137,7 +157,27 @@ public class ProfileGrpcService extends ReactorProfileGrpc.ProfileImplBase {
);
}
private void validateRequest(SetProfileRequest request) {
@Override
public Mono<GetUnversionedProfileResponse> getUnversionedProfile(final GetUnversionedProfileRequest request) {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
final ServiceIdentifier targetIdentifier =
ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getServiceIdentifier());
return validateRateLimitAndGetAccount(authenticatedDevice.accountIdentifier(), targetIdentifier)
.map(targetAccount -> ProfileGrpcHelper.buildUnversionedProfileResponse(targetIdentifier,
authenticatedDevice.accountIdentifier(),
targetAccount,
profileBadgeConverter));
}
private Mono<Account> validateRateLimitAndGetAccount(final UUID requesterUuid,
final ServiceIdentifier targetIdentifier) {
return rateLimiters.getProfileLimiter().validateReactive(requesterUuid)
.then(Mono.fromFuture(() -> accountsManager.getByServiceIdentifierAsync(targetIdentifier))
.flatMap(Mono::justOrEmpty))
.switchIfEmpty(Mono.error(Status.NOT_FOUND.asException()));
}
private void validateRequest(final SetProfileRequest request) {
if (request.getVersion().isEmpty()) {
throw Status.INVALID_ARGUMENT.withDescription("Missing version").asRuntimeException();
}
@@ -163,4 +203,20 @@ public class ProfileGrpcService extends ReactorProfileGrpc.ProfileImplBase {
throw Status.INVALID_ARGUMENT.withDescription(errorMessage).asRuntimeException();
}
private ProfileAvatarUploadAttributes generateAvatarUploadForm(final String objectName) {
final ZonedDateTime now = ZonedDateTime.now(clock);
final Pair<String, String> policy = policyGenerator.createFor(now, objectName, ProfileHelper.MAX_PROFILE_AVATAR_SIZE_BYTES);
final String signature = policySigner.getSignature(now, policy.second());
return ProfileAvatarUploadAttributes.newBuilder()
.setPath(objectName)
.setCredential(policy.first())
.setAcl("private")
.setAlgorithm("AWS4-HMAC-SHA256")
.setDate(now.format(PostPolicyGenerator.AWS_DATE_TIME))
.setPolicy(policy.second())
.setSignature(ByteString.copyFrom(signature.getBytes()))
.build();
}
}

View File

@@ -1,104 +0,0 @@
package org.whispersystems.textsecuregcm.grpc;
import com.google.protobuf.ByteString;
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;
public class ProfileHelper {
public static List<AccountBadge> mergeBadgeIdsWithExistingAccountBadges(
final Clock clock,
final Map<String, BadgeConfiguration> badgeConfigurationMap,
final List<String> badgeIds,
final List<AccountBadge> accountBadges) {
LinkedHashMap<String, AccountBadge> existingBadges = new LinkedHashMap<>(accountBadges.size());
for (final AccountBadge accountBadge : accountBadges) {
existingBadges.putIfAbsent(accountBadge.getId(), accountBadge);
}
LinkedHashMap<String, AccountBadge> result = new LinkedHashMap<>(accountBadges.size());
for (final String badgeId : badgeIds) {
// duplicate in the list, ignore it
if (result.containsKey(badgeId)) {
continue;
}
// This is for testing badges and allows them to be added to an account at any time with an expiration of 1 day
// in the future.
BadgeConfiguration badgeConfiguration = badgeConfigurationMap.get(badgeId);
if (badgeConfiguration != null && badgeConfiguration.isTestBadge()) {
result.put(badgeId, new AccountBadge(badgeId, clock.instant().plus(Duration.ofDays(1)), true));
continue;
}
// reordering or making visible existing badges
if (existingBadges.containsKey(badgeId)) {
AccountBadge accountBadge = existingBadges.get(badgeId).withVisibility(true);
result.put(badgeId, accountBadge);
}
}
// take any remaining account badges and make them invisible
for (final Map.Entry<String, AccountBadge> entry : existingBadges.entrySet()) {
if (!result.containsKey(entry.getKey())) {
AccountBadge accountBadge = entry.getValue().withVisibility(false);
result.put(accountBadge.getId(), accountBadge);
}
}
return new ArrayList<>(result.values());
}
public static String generateAvatarObjectName() {
byte[] object = new byte[16];
new SecureRandom().nextBytes(object);
return "profiles/" + Base64.getUrlEncoder().encodeToString(object);
}
public static org.signal.chat.profile.ProfileAvatarUploadAttributes generateAvatarUploadFormGrpc(
final PostPolicyGenerator policyGenerator,
final PolicySigner policySigner,
final String objectName) {
final ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
final Pair<String, String> policy = policyGenerator.createFor(now, objectName, 10 * 1024 * 1024);
final String signature = policySigner.getSignature(now, policy.second());
return org.signal.chat.profile.ProfileAvatarUploadAttributes.newBuilder()
.setPath(objectName)
.setCredential(policy.first())
.setAcl("private")
.setAlgorithm("AWS4-HMAC-SHA256")
.setDate(now.format(PostPolicyGenerator.AWS_DATE_TIME))
.setPolicy(policy.second())
.setSignature(ByteString.copyFrom(signature.getBytes()))
.build();
}
public static org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes generateAvatarUploadForm(
final PostPolicyGenerator policyGenerator,
final PolicySigner policySigner,
final String objectName) {
ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
Pair<String, String> policy = policyGenerator.createFor(now, objectName, 10 * 1024 * 1024);
String signature = policySigner.getSignature(now, policy.second());
return new org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes(objectName, policy.first(), "private", "AWS4-HMAC-SHA256",
now.format(PostPolicyGenerator.AWS_DATE_TIME), policy.second(), signature);
}
}