mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-21 13:08:05 +01:00
Profile gRPC: Define getUnversionedProfile endpoint
This commit is contained in:
@@ -119,9 +119,11 @@ import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
||||
import org.whispersystems.textsecuregcm.filters.RemoteDeprecationFilter;
|
||||
import org.whispersystems.textsecuregcm.filters.RequestStatisticsFilter;
|
||||
import org.whispersystems.textsecuregcm.filters.TimestampResponseFilter;
|
||||
import org.whispersystems.textsecuregcm.grpc.AcceptLanguageInterceptor;
|
||||
import org.whispersystems.textsecuregcm.grpc.GrpcServerManagedWrapper;
|
||||
import org.whispersystems.textsecuregcm.grpc.KeysAnonymousGrpcService;
|
||||
import org.whispersystems.textsecuregcm.grpc.KeysGrpcService;
|
||||
import org.whispersystems.textsecuregcm.grpc.ProfileAnonymousGrpcService;
|
||||
import org.whispersystems.textsecuregcm.grpc.ProfileGrpcService;
|
||||
import org.whispersystems.textsecuregcm.grpc.UserAgentInterceptor;
|
||||
import org.whispersystems.textsecuregcm.limits.CardinalityEstimator;
|
||||
@@ -646,13 +648,16 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
.addService(ServerInterceptors.intercept(new KeysGrpcService(accountsManager, keys, rateLimiters), basicCredentialAuthenticationInterceptor))
|
||||
.addService(new KeysAnonymousGrpcService(accountsManager, keys))
|
||||
.addService(ServerInterceptors.intercept(new ProfileGrpcService(clock, accountsManager, profilesManager, dynamicConfigurationManager,
|
||||
config.getBadges(), asyncCdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner, config.getCdnConfiguration().bucket()), basicCredentialAuthenticationInterceptor));
|
||||
config.getBadges(), asyncCdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner, profileBadgeConverter, rateLimiters, config.getCdnConfiguration().bucket()), basicCredentialAuthenticationInterceptor))
|
||||
.addService(new ProfileAnonymousGrpcService(accountsManager, profileBadgeConverter));
|
||||
|
||||
RemoteDeprecationFilter remoteDeprecationFilter = new RemoteDeprecationFilter(dynamicConfigurationManager);
|
||||
environment.servlets()
|
||||
.addFilter("RemoteDeprecationFilter", remoteDeprecationFilter)
|
||||
.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, "/*");
|
||||
|
||||
grpcServer.intercept(new AcceptLanguageInterceptor());
|
||||
|
||||
// Note: interceptors run in the reverse order they are added; the remote deprecation filter
|
||||
// depends on the user-agent context so it has to come first here!
|
||||
// http://grpc.github.io/grpc-java/javadoc/io/grpc/ServerBuilder.html#intercept-io.grpc.ServerInterceptor-
|
||||
|
||||
@@ -9,23 +9,21 @@ import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Base64;
|
||||
import java.util.Optional;
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
public class UnidentifiedAccessChecksum {
|
||||
|
||||
public static String generateFor(Optional<byte[]> unidentifiedAccessKey) {
|
||||
public static byte[] generateFor(byte[] unidentifiedAccessKey) {
|
||||
try {
|
||||
if (!unidentifiedAccessKey.isPresent()|| unidentifiedAccessKey.get().length != 16) return null;
|
||||
if (unidentifiedAccessKey.length != 16) {
|
||||
throw new IllegalArgumentException("Invalid UAK length: " + unidentifiedAccessKey.length);
|
||||
}
|
||||
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(unidentifiedAccessKey.get(), "HmacSHA256"));
|
||||
mac.init(new SecretKeySpec(unidentifiedAccessKey, "HmacSHA256"));
|
||||
|
||||
return Base64.getEncoder().encodeToString(mac.doFinal(new byte[32]));
|
||||
return mac.doFinal(new byte[32]);
|
||||
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
@@ -83,9 +85,10 @@ import org.whispersystems.textsecuregcm.entities.BatchIdentityCheckResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.CreateProfileRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.CredentialProfileResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.ExpiringProfileKeyCredentialProfileResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes;
|
||||
import org.whispersystems.textsecuregcm.entities.UserCapabilities;
|
||||
import org.whispersystems.textsecuregcm.entities.VersionedProfileResponse;
|
||||
import org.whispersystems.textsecuregcm.grpc.ProfileHelper;
|
||||
import org.whispersystems.textsecuregcm.util.ProfileHelper;
|
||||
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
|
||||
import org.whispersystems.textsecuregcm.identity.IdentityType;
|
||||
import org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;
|
||||
@@ -100,6 +103,7 @@ import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
|
||||
import org.whispersystems.textsecuregcm.storage.VersionedProfile;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
|
||||
@@ -108,9 +112,7 @@ import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
|
||||
@Path("/v1/profile")
|
||||
@Tag(name = "Profile")
|
||||
public class ProfileController {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(ProfileController.class);
|
||||
|
||||
private final Clock clock;
|
||||
private final RateLimiters rateLimiters;
|
||||
private final ProfilesManager profilesManager;
|
||||
@@ -223,7 +225,7 @@ public class ProfileController {
|
||||
});
|
||||
|
||||
if (request.getAvatarChange() == CreateProfileRequest.AvatarChange.UPDATE) {
|
||||
return Response.ok(ProfileHelper.generateAvatarUploadForm(policyGenerator, policySigner, avatar)).build();
|
||||
return Response.ok(generateAvatarUploadForm(avatar)).build();
|
||||
} else {
|
||||
return Response.ok().build();
|
||||
}
|
||||
@@ -245,7 +247,7 @@ public class ProfileController {
|
||||
|
||||
return buildVersionedProfileResponse(targetAccount,
|
||||
version,
|
||||
isSelfProfileRequest(maybeRequester, accountIdentifier),
|
||||
maybeRequester.map(requester -> ProfileHelper.isSelfProfileRequest(requester.getUuid(), accountIdentifier)).orElse(false),
|
||||
containerRequestContext);
|
||||
}
|
||||
|
||||
@@ -268,7 +270,7 @@ public class ProfileController {
|
||||
|
||||
final Optional<Account> maybeRequester = auth.map(AuthenticatedAccount::getAccount);
|
||||
final Account targetAccount = verifyPermissionToReceiveAccountIdentityProfile(maybeRequester, accessKey, accountIdentifier);
|
||||
final boolean isSelf = isSelfProfileRequest(maybeRequester, accountIdentifier);
|
||||
final boolean isSelf = maybeRequester.map(requester -> ProfileHelper.isSelfProfileRequest(requester.getUuid(), accountIdentifier)).orElse(false);
|
||||
|
||||
return buildExpiringProfileKeyCredentialProfileResponse(targetAccount,
|
||||
version,
|
||||
@@ -302,7 +304,7 @@ public class ProfileController {
|
||||
verifyPermissionToReceiveAccountIdentityProfile(maybeRequester, accessKey, aciServiceIdentifier);
|
||||
|
||||
yield buildBaseProfileResponseForAccountIdentity(targetAccount,
|
||||
isSelfProfileRequest(maybeRequester, aciServiceIdentifier),
|
||||
maybeRequester.map(requester -> ProfileHelper.isSelfProfileRequest(requester.getUuid(), aciServiceIdentifier)).orElse(false),
|
||||
containerRequestContext);
|
||||
}
|
||||
case PNI -> {
|
||||
@@ -432,7 +434,7 @@ public class ProfileController {
|
||||
final ContainerRequestContext containerRequestContext) {
|
||||
|
||||
return new BaseProfileResponse(account.getIdentityKey(IdentityType.ACI),
|
||||
UnidentifiedAccessChecksum.generateFor(account.getUnidentifiedAccessKey()),
|
||||
account.getUnidentifiedAccessKey().map(UnidentifiedAccessChecksum::generateFor).orElse(null),
|
||||
account.isUnrestrictedUnidentifiedAccess(),
|
||||
UserCapabilities.createForAccount(account),
|
||||
profileBadgeConverter.convert(
|
||||
@@ -468,7 +470,7 @@ public class ProfileController {
|
||||
}
|
||||
}
|
||||
|
||||
private List<Locale> getAcceptableLanguagesForRequest(ContainerRequestContext containerRequestContext) {
|
||||
private List<Locale> getAcceptableLanguagesForRequest(final ContainerRequestContext containerRequestContext) {
|
||||
try {
|
||||
return containerRequestContext.getAcceptableLanguages();
|
||||
} catch (final ProcessingException e) {
|
||||
@@ -517,8 +519,15 @@ public class ProfileController {
|
||||
return maybeTargetAccount.get();
|
||||
}
|
||||
|
||||
private boolean isSelfProfileRequest(final Optional<Account> maybeRequester, final AciServiceIdentifier targetIdentifier) {
|
||||
return maybeRequester.map(requester -> requester.getUuid().equals(targetIdentifier.uuid())).orElse(false);
|
||||
private ProfileAvatarUploadAttributes generateAvatarUploadForm(
|
||||
final String objectName) {
|
||||
ZonedDateTime now = ZonedDateTime.now(clock);
|
||||
Pair<String, String> policy = policyGenerator.createFor(now, objectName, ProfileHelper.MAX_PROFILE_AVATAR_SIZE_BYTES);
|
||||
String signature = policySigner.getSignature(now, policy.second());
|
||||
|
||||
return new ProfileAvatarUploadAttributes(objectName, policy.first(),
|
||||
"private", "AWS4-HMAC-SHA256",
|
||||
now.format(PostPolicyGenerator.AWS_DATE_TIME), policy.second(), signature);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import org.signal.libsignal.protocol.IdentityKey;
|
||||
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
|
||||
import org.whispersystems.textsecuregcm.util.ByteArrayBase64WithPaddingAdapter;
|
||||
import org.whispersystems.textsecuregcm.util.ServiceIdentifierAdapter;
|
||||
import org.whispersystems.textsecuregcm.util.IdentityKeyAdapter;
|
||||
|
||||
@@ -23,7 +24,9 @@ public class BaseProfileResponse {
|
||||
private IdentityKey identityKey;
|
||||
|
||||
@JsonProperty
|
||||
private String unidentifiedAccess;
|
||||
@JsonSerialize(using = ByteArrayBase64WithPaddingAdapter.Serializing.class)
|
||||
@JsonDeserialize(using = ByteArrayBase64WithPaddingAdapter.Deserializing.class)
|
||||
private byte[] unidentifiedAccess;
|
||||
|
||||
@JsonProperty
|
||||
private boolean unrestrictedUnidentifiedAccess;
|
||||
@@ -43,7 +46,7 @@ public class BaseProfileResponse {
|
||||
}
|
||||
|
||||
public BaseProfileResponse(final IdentityKey identityKey,
|
||||
final String unidentifiedAccess,
|
||||
final byte[] unidentifiedAccess,
|
||||
final boolean unrestrictedUnidentifiedAccess,
|
||||
final UserCapabilities capabilities,
|
||||
final List<Badge> badges,
|
||||
@@ -61,7 +64,7 @@ public class BaseProfileResponse {
|
||||
return identityKey;
|
||||
}
|
||||
|
||||
public String getUnidentifiedAccess() {
|
||||
public byte[] getUnidentifiedAccess() {
|
||||
return unidentifiedAccess;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.grpc;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import io.grpc.Context;
|
||||
import io.grpc.Contexts;
|
||||
import io.grpc.Metadata;
|
||||
import io.grpc.ServerCall;
|
||||
import io.grpc.ServerCallHandler;
|
||||
import io.grpc.ServerInterceptor;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UserAgent;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
|
||||
|
||||
public class AcceptLanguageInterceptor implements ServerInterceptor {
|
||||
private static final Logger logger = LoggerFactory.getLogger(AcceptLanguageInterceptor.class);
|
||||
private static final String INVALID_ACCEPT_LANGUAGE_COUNTER_NAME = name(AcceptLanguageInterceptor.class, "invalidAcceptLanguage");
|
||||
|
||||
@VisibleForTesting
|
||||
public static final Metadata.Key<String> ACCEPTABLE_LANGUAGES_GRPC_HEADER =
|
||||
Metadata.Key.of("accept-language", Metadata.ASCII_STRING_MARSHALLER);
|
||||
|
||||
@Override
|
||||
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(final ServerCall<ReqT, RespT> call,
|
||||
final Metadata headers,
|
||||
final ServerCallHandler<ReqT, RespT> next) {
|
||||
|
||||
final List<Locale> locales = parseLocales(headers.get(ACCEPTABLE_LANGUAGES_GRPC_HEADER));
|
||||
|
||||
return Contexts.interceptCall(
|
||||
Context.current().withValue(AcceptLanguageUtil.ACCEPTABLE_LANGUAGES_CONTEXT_KEY, locales),
|
||||
call,
|
||||
headers,
|
||||
next);
|
||||
}
|
||||
|
||||
static List<Locale> parseLocales(@Nullable final String acceptableLanguagesHeader) {
|
||||
if (acceptableLanguagesHeader == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
try {
|
||||
final List<Locale.LanguageRange> languageRanges = Locale.LanguageRange.parse(acceptableLanguagesHeader);
|
||||
return Locale.filter(languageRanges, Arrays.asList(Locale.getAvailableLocales()));
|
||||
} catch (final IllegalArgumentException e) {
|
||||
final UserAgent userAgent = UserAgentUtil.userAgentFromGrpcContext();
|
||||
Metrics.counter(INVALID_ACCEPT_LANGUAGE_COUNTER_NAME, "platform", userAgent.getPlatform().name().toLowerCase()).increment();
|
||||
logger.debug("Could not get acceptable languages; Accept-Language: {}; User-Agent: {}",
|
||||
acceptableLanguagesHeader,
|
||||
userAgent,
|
||||
e);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.whispersystems.textsecuregcm.grpc;
|
||||
|
||||
import io.grpc.Context;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
public class AcceptLanguageUtil {
|
||||
static final Context.Key<List<Locale>> ACCEPTABLE_LANGUAGES_CONTEXT_KEY = Context.key("accept-language");
|
||||
public static List<Locale> localeFromGrpcContext() {
|
||||
return ACCEPTABLE_LANGUAGES_CONTEXT_KEY.get();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.whispersystems.textsecuregcm.grpc;
|
||||
|
||||
import io.grpc.Status;
|
||||
import org.signal.chat.profile.GetUnversionedProfileAnonymousRequest;
|
||||
import org.signal.chat.profile.GetUnversionedProfileResponse;
|
||||
import org.signal.chat.profile.ReactorProfileAnonymousGrpc;
|
||||
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;
|
||||
import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;
|
||||
import org.whispersystems.textsecuregcm.identity.IdentityType;
|
||||
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public class ProfileAnonymousGrpcService extends ReactorProfileAnonymousGrpc.ProfileAnonymousImplBase {
|
||||
private final AccountsManager accountsManager;
|
||||
private final ProfileBadgeConverter profileBadgeConverter;
|
||||
|
||||
public ProfileAnonymousGrpcService(
|
||||
final AccountsManager accountsManager,
|
||||
final ProfileBadgeConverter profileBadgeConverter) {
|
||||
this.accountsManager = accountsManager;
|
||||
this.profileBadgeConverter = profileBadgeConverter;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GetUnversionedProfileResponse> getUnversionedProfile(final GetUnversionedProfileAnonymousRequest request) {
|
||||
final ServiceIdentifier targetIdentifier =
|
||||
ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getRequest().getServiceIdentifier());
|
||||
|
||||
// Callers must be authenticated to request unversioned profiles by PNI
|
||||
if (targetIdentifier.identityType() == IdentityType.PNI) {
|
||||
throw Status.UNAUTHENTICATED.asRuntimeException();
|
||||
}
|
||||
|
||||
return getTargetAccountAndValidateUnidentifiedAccess(targetIdentifier, request.getUnidentifiedAccessKey().toByteArray())
|
||||
.map(targetAccount -> ProfileGrpcHelper.buildUnversionedProfileResponse(targetIdentifier,
|
||||
null,
|
||||
targetAccount,
|
||||
profileBadgeConverter));
|
||||
}
|
||||
|
||||
private Mono<Account> getTargetAccountAndValidateUnidentifiedAccess(final ServiceIdentifier targetIdentifier, final byte[] unidentifiedAccessKey) {
|
||||
return Mono.fromFuture(() -> accountsManager.getByServiceIdentifierAsync(targetIdentifier))
|
||||
.flatMap(Mono::justOrEmpty)
|
||||
.filter(targetAccount -> UnidentifiedAccessUtil.checkUnidentifiedAccess(targetAccount, unidentifiedAccessKey))
|
||||
.switchIfEmpty(Mono.error(Status.UNAUTHENTICATED.asException()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package org.whispersystems.textsecuregcm.grpc;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.protobuf.ByteString;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.signal.chat.profile.Badge;
|
||||
import org.signal.chat.profile.BadgeSvg;
|
||||
import org.signal.chat.profile.GetUnversionedProfileResponse;
|
||||
import org.signal.chat.profile.UserCapabilities;
|
||||
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessChecksum;
|
||||
import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;
|
||||
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
|
||||
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.util.ProfileHelper;
|
||||
|
||||
public class ProfileGrpcHelper {
|
||||
@VisibleForTesting
|
||||
static List<Badge> buildBadges(final List<org.whispersystems.textsecuregcm.entities.Badge> badges) {
|
||||
final ArrayList<Badge> grpcBadges = new ArrayList<>();
|
||||
for (final org.whispersystems.textsecuregcm.entities.Badge badge : badges) {
|
||||
grpcBadges.add(Badge.newBuilder()
|
||||
.setId(badge.getId())
|
||||
.setCategory(badge.getCategory())
|
||||
.setName(badge.getName())
|
||||
.setDescription(badge.getDescription())
|
||||
.addAllSprites6(badge.getSprites6())
|
||||
.setSvg(badge.getSvg())
|
||||
.addAllSvgs(buildBadgeSvgs(badge.getSvgs()))
|
||||
.build());
|
||||
}
|
||||
return grpcBadges;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static UserCapabilities buildUserCapabilities(final org.whispersystems.textsecuregcm.entities.UserCapabilities capabilities) {
|
||||
return UserCapabilities.newBuilder()
|
||||
.setGv1Migration(capabilities.gv1Migration())
|
||||
.setSenderKey(capabilities.senderKey())
|
||||
.setAnnouncementGroup(capabilities.announcementGroup())
|
||||
.setChangeNumber(capabilities.changeNumber())
|
||||
.setStories(capabilities.stories())
|
||||
.setGiftBadges(capabilities.giftBadges())
|
||||
.setPaymentActivation(capabilities.paymentActivation())
|
||||
.setPni(capabilities.pni())
|
||||
.build();
|
||||
}
|
||||
|
||||
private static List<BadgeSvg> buildBadgeSvgs(final List<org.whispersystems.textsecuregcm.entities.BadgeSvg> badgeSvgs) {
|
||||
ArrayList<BadgeSvg> grpcBadgeSvgs = new ArrayList<>();
|
||||
for (final org.whispersystems.textsecuregcm.entities.BadgeSvg badgeSvg : badgeSvgs) {
|
||||
grpcBadgeSvgs.add(BadgeSvg.newBuilder()
|
||||
.setDark(badgeSvg.getDark())
|
||||
.setLight(badgeSvg.getLight())
|
||||
.build());
|
||||
}
|
||||
return grpcBadgeSvgs;
|
||||
}
|
||||
|
||||
static GetUnversionedProfileResponse buildUnversionedProfileResponse(
|
||||
final ServiceIdentifier targetIdentifier,
|
||||
final UUID requesterUuid,
|
||||
final Account targetAccount,
|
||||
final ProfileBadgeConverter profileBadgeConverter) {
|
||||
final GetUnversionedProfileResponse.Builder responseBuilder = GetUnversionedProfileResponse.newBuilder()
|
||||
.setIdentityKey(ByteString.copyFrom(targetAccount.getIdentityKey(targetIdentifier.identityType()).serialize()))
|
||||
.setCapabilities(buildUserCapabilities(org.whispersystems.textsecuregcm.entities.UserCapabilities.createForAccount(targetAccount)));
|
||||
|
||||
switch (targetIdentifier.identityType()) {
|
||||
case ACI -> {
|
||||
responseBuilder.setUnrestrictedUnidentifiedAccess(targetAccount.isUnrestrictedUnidentifiedAccess())
|
||||
.addAllBadges(buildBadges(profileBadgeConverter.convert(
|
||||
AcceptLanguageUtil.localeFromGrpcContext(),
|
||||
targetAccount.getBadges(),
|
||||
ProfileHelper.isSelfProfileRequest(requesterUuid, (AciServiceIdentifier) targetIdentifier))));
|
||||
|
||||
targetAccount.getUnidentifiedAccessKey()
|
||||
.map(UnidentifiedAccessChecksum::generateFor)
|
||||
.map(ByteString::copyFrom)
|
||||
.ifPresent(responseBuilder::setUnidentifiedAccess);
|
||||
}
|
||||
case PNI -> responseBuilder.setUnrestrictedUnidentifiedAccess(false);
|
||||
}
|
||||
|
||||
return responseBuilder.build();
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,21 @@ package org.whispersystems.textsecuregcm.grpc;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
import io.grpc.Status;
|
||||
import org.signal.chat.profile.GetUnversionedProfileRequest;
|
||||
import org.signal.chat.profile.GetUnversionedProfileResponse;
|
||||
import org.signal.chat.profile.SetProfileRequest.AvatarChange;
|
||||
import org.signal.chat.profile.ProfileAvatarUploadAttributes;
|
||||
import org.signal.chat.profile.ReactorProfileGrpc;
|
||||
import org.signal.chat.profile.SetProfileRequest;
|
||||
import org.signal.chat.profile.SetProfileResponse;
|
||||
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;
|
||||
import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;
|
||||
import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.s3.PolicySigner;
|
||||
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
@@ -19,29 +25,34 @@ import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
|
||||
import org.whispersystems.textsecuregcm.storage.VersionedProfile;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
import org.whispersystems.textsecuregcm.util.ProfileHelper;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import software.amazon.awssdk.services.s3.S3AsyncClient;
|
||||
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
|
||||
import java.time.Clock;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class ProfileGrpcService extends ReactorProfileGrpc.ProfileImplBase {
|
||||
private final Clock clock;
|
||||
|
||||
private final ProfilesManager profilesManager;
|
||||
private final Clock clock;
|
||||
private final AccountsManager accountsManager;
|
||||
private final ProfilesManager profilesManager;
|
||||
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
|
||||
private final Map<String, BadgeConfiguration> badgeConfigurationMap;
|
||||
private final PolicySigner policySigner;
|
||||
private final PostPolicyGenerator policyGenerator;
|
||||
|
||||
private final S3AsyncClient asyncS3client;
|
||||
private final PostPolicyGenerator policyGenerator;
|
||||
private final PolicySigner policySigner;
|
||||
private final ProfileBadgeConverter profileBadgeConverter;
|
||||
private final RateLimiters rateLimiters;
|
||||
private final String bucket;
|
||||
|
||||
private record AvatarData(Optional<String> currentAvatar,
|
||||
@@ -49,29 +60,38 @@ public class ProfileGrpcService extends ReactorProfileGrpc.ProfileImplBase {
|
||||
Optional<ProfileAvatarUploadAttributes> uploadAttributes) {}
|
||||
|
||||
public ProfileGrpcService(
|
||||
Clock clock,
|
||||
AccountsManager accountsManager,
|
||||
ProfilesManager profilesManager,
|
||||
DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
|
||||
BadgesConfiguration badgesConfiguration,
|
||||
S3AsyncClient asyncS3client,
|
||||
PostPolicyGenerator policyGenerator,
|
||||
PolicySigner policySigner,
|
||||
String bucket) {
|
||||
final Clock clock,
|
||||
final AccountsManager accountsManager,
|
||||
final ProfilesManager profilesManager,
|
||||
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
|
||||
final BadgesConfiguration badgesConfiguration,
|
||||
final S3AsyncClient asyncS3client,
|
||||
final PostPolicyGenerator policyGenerator,
|
||||
final PolicySigner policySigner,
|
||||
final ProfileBadgeConverter profileBadgeConverter,
|
||||
final RateLimiters rateLimiters,
|
||||
final String bucket) {
|
||||
this.clock = clock;
|
||||
this.accountsManager = accountsManager;
|
||||
this.profilesManager = profilesManager;
|
||||
this.dynamicConfigurationManager = dynamicConfigurationManager;
|
||||
this.badgeConfigurationMap = badgesConfiguration.getBadges().stream().collect(Collectors.toMap(
|
||||
BadgeConfiguration::getId, Function.identity()));
|
||||
this.bucket = bucket;
|
||||
this.asyncS3client = asyncS3client;
|
||||
this.policyGenerator = policyGenerator;
|
||||
this.policySigner = policySigner;
|
||||
this.profileBadgeConverter = profileBadgeConverter;
|
||||
this.rateLimiters = rateLimiters;
|
||||
this.bucket = bucket;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<SetProfileResponse> setProfile(SetProfileRequest request) {
|
||||
protected Throwable onErrorMap(final Throwable throwable) {
|
||||
return RateLimitUtil.mapRateLimitExceededException(throwable);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<SetProfileResponse> setProfile(final SetProfileRequest request) {
|
||||
validateRequest(request);
|
||||
return Mono.fromSupplier(AuthenticationUtil::requireAuthenticatedDevice)
|
||||
.flatMap(authenticatedDevice -> Mono.zip(
|
||||
@@ -99,7 +119,7 @@ public class ProfileGrpcService extends ReactorProfileGrpc.ProfileImplBase {
|
||||
case AVATAR_CHANGE_UPDATE -> {
|
||||
final String updateAvatarObjectName = ProfileHelper.generateAvatarObjectName();
|
||||
yield new AvatarData(currentAvatar, Optional.of(updateAvatarObjectName),
|
||||
Optional.of(ProfileHelper.generateAvatarUploadFormGrpc(policyGenerator, policySigner, updateAvatarObjectName)));
|
||||
Optional.of(generateAvatarUploadForm(updateAvatarObjectName)));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -137,7 +157,27 @@ public class ProfileGrpcService extends ReactorProfileGrpc.ProfileImplBase {
|
||||
);
|
||||
}
|
||||
|
||||
private void validateRequest(SetProfileRequest request) {
|
||||
@Override
|
||||
public Mono<GetUnversionedProfileResponse> getUnversionedProfile(final GetUnversionedProfileRequest request) {
|
||||
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
|
||||
final ServiceIdentifier targetIdentifier =
|
||||
ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getServiceIdentifier());
|
||||
return validateRateLimitAndGetAccount(authenticatedDevice.accountIdentifier(), targetIdentifier)
|
||||
.map(targetAccount -> ProfileGrpcHelper.buildUnversionedProfileResponse(targetIdentifier,
|
||||
authenticatedDevice.accountIdentifier(),
|
||||
targetAccount,
|
||||
profileBadgeConverter));
|
||||
}
|
||||
|
||||
private Mono<Account> validateRateLimitAndGetAccount(final UUID requesterUuid,
|
||||
final ServiceIdentifier targetIdentifier) {
|
||||
return rateLimiters.getProfileLimiter().validateReactive(requesterUuid)
|
||||
.then(Mono.fromFuture(() -> accountsManager.getByServiceIdentifierAsync(targetIdentifier))
|
||||
.flatMap(Mono::justOrEmpty))
|
||||
.switchIfEmpty(Mono.error(Status.NOT_FOUND.asException()));
|
||||
}
|
||||
|
||||
private void validateRequest(final SetProfileRequest request) {
|
||||
if (request.getVersion().isEmpty()) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription("Missing version").asRuntimeException();
|
||||
}
|
||||
@@ -163,4 +203,20 @@ public class ProfileGrpcService extends ReactorProfileGrpc.ProfileImplBase {
|
||||
|
||||
throw Status.INVALID_ARGUMENT.withDescription(errorMessage).asRuntimeException();
|
||||
}
|
||||
|
||||
private ProfileAvatarUploadAttributes generateAvatarUploadForm(final String objectName) {
|
||||
final ZonedDateTime now = ZonedDateTime.now(clock);
|
||||
final Pair<String, String> policy = policyGenerator.createFor(now, objectName, ProfileHelper.MAX_PROFILE_AVATAR_SIZE_BYTES);
|
||||
final String signature = policySigner.getSignature(now, policy.second());
|
||||
|
||||
return ProfileAvatarUploadAttributes.newBuilder()
|
||||
.setPath(objectName)
|
||||
.setCredential(policy.first())
|
||||
.setAcl("private")
|
||||
.setAlgorithm("AWS4-HMAC-SHA256")
|
||||
.setDate(now.format(PostPolicyGenerator.AWS_DATE_TIME))
|
||||
.setPolicy(policy.second())
|
||||
.setSignature(ByteString.copyFrom(signature.getBytes()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
package org.whispersystems.textsecuregcm.grpc;
|
||||
package org.whispersystems.textsecuregcm.util;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;
|
||||
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountBadge;
|
||||
import javax.annotation.Nullable;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.signal.chat.profile.ProfileAvatarUploadAttributes;
|
||||
import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;
|
||||
import org.whispersystems.textsecuregcm.s3.PolicySigner;
|
||||
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountBadge;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
import java.util.UUID;
|
||||
|
||||
public class ProfileHelper {
|
||||
public static int MAX_PROFILE_AVATAR_SIZE_BYTES = 10 * 1024 * 1024;
|
||||
public static List<AccountBadge> mergeBadgeIdsWithExistingAccountBadges(
|
||||
final Clock clock,
|
||||
final Map<String, BadgeConfiguration> badgeConfigurationMap,
|
||||
@@ -64,41 +61,13 @@ public class ProfileHelper {
|
||||
}
|
||||
|
||||
public static String generateAvatarObjectName() {
|
||||
byte[] object = new byte[16];
|
||||
final byte[] object = new byte[16];
|
||||
new SecureRandom().nextBytes(object);
|
||||
|
||||
return "profiles/" + Base64.getUrlEncoder().encodeToString(object);
|
||||
}
|
||||
|
||||
public static org.signal.chat.profile.ProfileAvatarUploadAttributes generateAvatarUploadFormGrpc(
|
||||
final PostPolicyGenerator policyGenerator,
|
||||
final PolicySigner policySigner,
|
||||
final String objectName) {
|
||||
final ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
|
||||
final Pair<String, String> policy = policyGenerator.createFor(now, objectName, 10 * 1024 * 1024);
|
||||
final String signature = policySigner.getSignature(now, policy.second());
|
||||
|
||||
return org.signal.chat.profile.ProfileAvatarUploadAttributes.newBuilder()
|
||||
.setPath(objectName)
|
||||
.setCredential(policy.first())
|
||||
.setAcl("private")
|
||||
.setAlgorithm("AWS4-HMAC-SHA256")
|
||||
.setDate(now.format(PostPolicyGenerator.AWS_DATE_TIME))
|
||||
.setPolicy(policy.second())
|
||||
.setSignature(ByteString.copyFrom(signature.getBytes()))
|
||||
.build();
|
||||
}
|
||||
|
||||
public static org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes generateAvatarUploadForm(
|
||||
final PostPolicyGenerator policyGenerator,
|
||||
final PolicySigner policySigner,
|
||||
final String objectName) {
|
||||
ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
|
||||
Pair<String, String> policy = policyGenerator.createFor(now, objectName, 10 * 1024 * 1024);
|
||||
String signature = policySigner.getSignature(now, policy.second());
|
||||
|
||||
return new org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes(objectName, policy.first(), "private", "AWS4-HMAC-SHA256",
|
||||
now.format(PostPolicyGenerator.AWS_DATE_TIME), policy.second(), signature);
|
||||
|
||||
public static boolean isSelfProfileRequest(@Nullable final UUID requesterUuid, final AciServiceIdentifier targetIdentifier) {
|
||||
return targetIdentifier.uuid().equals(requesterUuid);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user