Define ProfileController protobufs and setProfile endpoint

This commit is contained in:
Katherine Yen 2023-08-08 09:58:10 -07:00
parent 95b90e7c5a
commit a953cb33b7
8 changed files with 1048 additions and 63 deletions

View File

@ -115,6 +115,7 @@ import org.whispersystems.textsecuregcm.controllers.VerificationController;
import org.whispersystems.textsecuregcm.currency.CoinMarketCapClient;
import org.whispersystems.textsecuregcm.currency.CurrencyConversionManager;
import org.whispersystems.textsecuregcm.currency.FixerClient;
import org.whispersystems.textsecuregcm.grpc.ProfileGrpcService;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.filters.RemoteDeprecationFilter;
import org.whispersystems.textsecuregcm.filters.RequestStatisticsFilter;
@ -228,6 +229,7 @@ import software.amazon.awssdk.auth.credentials.WebIdentityTokenFileCredentialsPr
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.s3.S3AsyncClient;
import software.amazon.awssdk.services.s3.S3Client;
public class WhisperServerService extends Application<WhisperServerConfiguration> {
@ -616,6 +618,10 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
.credentialsProvider(cdnCredentialsProvider)
.region(Region.of(config.getCdnConfiguration().region()))
.build();
S3AsyncClient asyncCdnS3Client = S3AsyncClient.builder()
.credentialsProvider(cdnCredentialsProvider)
.region(Region.of(config.getCdnConfiguration().region()))
.build();
final GcsAttachmentGenerator gcsAttachmentGenerator = new GcsAttachmentGenerator(
config.getGcpAttachmentsConfiguration().domain(),
@ -647,7 +653,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
// TODO: specialize metrics with user-agent platform
.intercept(new MetricCollectingServerInterceptor(Metrics.globalRegistry))
.addService(ServerInterceptors.intercept(new KeysGrpcService(accountsManager, keys, rateLimiters), basicCredentialAuthenticationInterceptor))
.addService(new KeysAnonymousGrpcService(accountsManager, keys));
.addService(new KeysAnonymousGrpcService(accountsManager, keys))
.addService(ServerInterceptors.intercept(new ProfileGrpcService(clock, accountsManager, profilesManager, dynamicConfigurationManager, config.getBadges(), asyncCdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner, config.getCdnConfiguration().bucket())));
RemoteDeprecationFilter remoteDeprecationFilter = new RemoteDeprecationFilter(dynamicConfigurationManager);
environment.servlets()

View File

@ -91,6 +91,7 @@ import org.whispersystems.textsecuregcm.entities.ExpiringProfileKeyCredentialPro
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.identity.AciServiceIdentifier;
import org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
@ -199,7 +200,7 @@ public class ProfileController {
final String avatar = switch (request.getAvatarChange()) {
case UNCHANGED -> currentAvatar.orElse(null);
case CLEAR -> null;
case UPDATE -> generateAvatarObjectName();
case UPDATE -> ProfileHelper.generateAvatarObjectName();
};
profilesManager.set(auth.getAccount().getUuid(),
@ -220,7 +221,7 @@ public class ProfileController {
}
final List<AccountBadge> updatedBadges = request.getBadges()
.map(badges -> mergeBadgeIdsWithExistingAccountBadges(badges, auth.getAccount().getBadges()))
.map(badges -> ProfileHelper.mergeBadgeIdsWithExistingAccountBadges(clock, badgeConfigurationMap, badges, auth.getAccount().getBadges()))
.orElseGet(() -> auth.getAccount().getBadges());
accountsManager.update(auth.getAccount(), a -> {
@ -229,7 +230,7 @@ public class ProfileController {
});
if (request.getAvatarChange() == CreateProfileRequest.AvatarChange.UPDATE) {
return Response.ok(generateAvatarUploadForm(avatar)).build();
return Response.ok(ProfileHelper.generateAvatarUploadForm(policyGenerator, policySigner, avatar)).build();
} else {
return Response.ok().build();
}
@ -477,23 +478,6 @@ public class ProfileController {
}
}
private ProfileAvatarUploadAttributes generateAvatarUploadForm(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 ProfileAvatarUploadAttributes(objectName, policy.first(), "private", "AWS4-HMAC-SHA256",
now.format(PostPolicyGenerator.AWS_DATE_TIME), policy.second(), signature);
}
private String generateAvatarObjectName() {
byte[] object = new byte[16];
new SecureRandom().nextBytes(object);
return "profiles/" + Base64.getUrlEncoder().encodeToString(object);
}
private List<Locale> getAcceptableLanguagesForRequest(ContainerRequestContext containerRequestContext) {
try {
return containerRequestContext.getAcceptableLanguages();
@ -509,48 +493,6 @@ public class ProfileController {
}
}
private List<AccountBadge> mergeBadgeIdsWithExistingAccountBadges(
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 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());
}
/**
* Verifies that the requester has permission to view the profile of the account identified by the given ACI.
*

View File

@ -0,0 +1,7 @@
package org.whispersystems.textsecuregcm.entities;
public enum AvatarChange {
AVATAR_CHANGE_UNCHANGED,
AVATAR_CHANGE_CLEAR,
AVATAR_CHANGE_UPDATE
}

View File

@ -0,0 +1,15 @@
package org.whispersystems.textsecuregcm.grpc;
import io.grpc.Status;
import org.whispersystems.textsecuregcm.entities.AvatarChange;
public class AvatarChangeUtil {
public static AvatarChange fromGrpcAvatarChange(final org.signal.chat.profile.SetProfileRequest.AvatarChange avatarChangeType) {
return switch (avatarChangeType) {
case AVATAR_CHANGE_UNCHANGED -> AvatarChange.AVATAR_CHANGE_UNCHANGED;
case AVATAR_CHANGE_CLEAR -> AvatarChange.AVATAR_CHANGE_CLEAR;
case AVATAR_CHANGE_UPDATE -> AvatarChange.AVATAR_CHANGE_UPDATE;
case UNRECOGNIZED -> throw Status.INVALID_ARGUMENT.withDescription("Invalid avatar change value").asRuntimeException();
};
}
}

View File

@ -0,0 +1,170 @@
package org.whispersystems.textsecuregcm.grpc;
import com.google.protobuf.ByteString;
import io.grpc.Status;
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.AuthenticationUtil;
import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;
import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.s3.PolicySigner;
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountBadge;
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 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.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Optional;
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 AccountsManager accountsManager;
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 String bucket;
private record AvatarData(Optional<String> currentAvatar,
Optional<String> finalAvatar,
Optional<ProfileAvatarUploadAttributes> uploadAttributes) {}
public ProfileGrpcService(
Clock clock,
AccountsManager accountsManager,
ProfilesManager profilesManager,
DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
BadgesConfiguration badgesConfiguration,
S3AsyncClient asyncS3client,
PostPolicyGenerator policyGenerator,
PolicySigner policySigner,
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;
}
@Override
public Mono<SetProfileResponse> setProfile(SetProfileRequest request) {
validateRequest(request);
return Mono.fromSupplier(AuthenticationUtil::requireAuthenticatedDevice)
.flatMap(authenticatedDevice -> Mono.zip(
Mono.fromFuture(accountsManager.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))
.map(maybeAccount -> maybeAccount.orElseThrow(Status.UNAUTHENTICATED::asRuntimeException)),
Mono.fromFuture(profilesManager.getAsync(authenticatedDevice.accountIdentifier(), request.getVersion()))
))
.doOnNext(accountAndMaybeProfile -> {
if (!request.getPaymentAddress().isEmpty()) {
final boolean hasDisallowedPrefix =
dynamicConfigurationManager.getConfiguration().getPaymentsConfiguration().getDisallowedPrefixes().stream()
.anyMatch(prefix -> accountAndMaybeProfile.getT1().getNumber().startsWith(prefix));
if (hasDisallowedPrefix && accountAndMaybeProfile.getT2().map(VersionedProfile::getPaymentAddress).isEmpty()) {
throw Status.PERMISSION_DENIED.asRuntimeException();
}
}
})
.flatMap(accountAndMaybeProfile -> {
final Account account = accountAndMaybeProfile.getT1();
final Optional<String> currentAvatar = accountAndMaybeProfile.getT2().map(VersionedProfile::getAvatar)
.filter(avatar -> avatar.startsWith("profiles/"));
final AvatarData avatarData = switch (AvatarChangeUtil.fromGrpcAvatarChange(request.getAvatarChange())) {
case AVATAR_CHANGE_UNCHANGED -> new AvatarData(currentAvatar, currentAvatar, Optional.empty());
case AVATAR_CHANGE_CLEAR -> new AvatarData(currentAvatar, Optional.empty(), Optional.empty());
case AVATAR_CHANGE_UPDATE -> {
final String updateAvatarObjectName = ProfileHelper.generateAvatarObjectName();
yield new AvatarData(currentAvatar, Optional.of(updateAvatarObjectName),
Optional.of(ProfileHelper.generateAvatarUploadFormGrpc(policyGenerator, policySigner, updateAvatarObjectName)));
}
};
final Mono<Void> profileSetMono = Mono.fromFuture(profilesManager.setAsync(account.getUuid(),
new VersionedProfile(
request.getVersion(),
encodeToBase64(request.getName().toByteArray()),
avatarData.finalAvatar().orElse(null),
encodeToBase64(request.getAboutEmoji().toByteArray()),
encodeToBase64(request.getAbout().toByteArray()),
encodeToBase64(request.getPaymentAddress().toByteArray()),
request.getCommitment().toByteArray())));
final List<Mono<?>> updates = new ArrayList<>(2);
final List<AccountBadge> updatedBadges = Optional.of(request.getBadgeIdsList())
.map(badges -> ProfileHelper.mergeBadgeIdsWithExistingAccountBadges(clock, badgeConfigurationMap, badges, account.getBadges()))
.orElseGet(account::getBadges);
updates.add(Mono.fromFuture(accountsManager.updateAsync(account, a -> {
a.setBadges(clock, updatedBadges);
a.setCurrentProfileVersion(request.getVersion());
})));
if (request.getAvatarChange() != AvatarChange.AVATAR_CHANGE_UNCHANGED && avatarData.currentAvatar().isPresent()) {
updates.add(Mono.fromFuture(asyncS3client.deleteObject(DeleteObjectRequest.builder()
.bucket(bucket)
.key(avatarData.currentAvatar().get())
.build())));
}
return profileSetMono.thenMany(Flux.merge(updates)).then(Mono.just(avatarData));
})
.map(avatarData -> avatarData.uploadAttributes()
.map(avatarUploadAttributes -> SetProfileResponse.newBuilder().setAttributes(avatarUploadAttributes).build())
.orElse(SetProfileResponse.newBuilder().build())
);
}
private void validateRequest(SetProfileRequest request) {
if (request.getVersion().isEmpty()) {
throw Status.INVALID_ARGUMENT.withDescription("Missing version").asRuntimeException();
}
if (request.getCommitment().isEmpty()) {
throw Status.INVALID_ARGUMENT.withDescription("Missing profile commitment").asRuntimeException();
}
checkByteStringLength(request.getName(), "Invalid name length", List.of(81, 285));
checkByteStringLength(request.getAboutEmoji(), "Invalid about emoji length", List.of(0, 60));
checkByteStringLength(request.getAbout(), "Invalid about length", List.of(0, 156, 282, 540));
checkByteStringLength(request.getPaymentAddress(), "Invalid mobile coin address length", List.of(0, 582));
}
private static void checkByteStringLength(final ByteString byteString, final String errorMessage, final List<Integer> allowedLengths) {
final int byteStringLength = byteString.toByteArray().length;
for (int allowedLength : allowedLengths) {
if (byteStringLength == allowedLength) {
return;
}
}
throw Status.INVALID_ARGUMENT.withDescription(errorMessage).asRuntimeException();
}
private static String encodeToBase64(byte[] input) {
return Base64.getEncoder().encodeToString(input);
}
}

View File

@ -0,0 +1,104 @@
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);
}
}

View File

@ -0,0 +1,406 @@
syntax = "proto3";
option java_multiple_files = true;
package org.signal.chat.profile;
import "org/signal/chat/common.proto";
/**
* Provides methods for working with profiles and profile-related data.
*/
service Profile {
/**
* Sets profile data and if needed, returns S3 credentials used by clients to upload an avatar.
*
* This RPC may fail with `PERMISSION_DENIED` if it attempts to set the MobileCoin wallet ID
* on an account whose profile does not currently have a MobileCoin wallet ID and
* whose phone number contains a disallowed country prefix.
*/
rpc SetProfile(SetProfileRequest) returns (SetProfileResponse) {}
/**
* Retrieves versioned profile data. Callers with an unidentified access key for the account
* should use the version of this method in `ProfileAnonymous` instead.
*
* This RPC may fail with a `NOT_FOUND` status if the target account was not
* found. It may fail with a `RESOURCE_EXHAUSTED` if a rate limit for fetching profiles has been
* exceeded, in which case a `retry-after` header containing an ISO 8601
* duration string will be present in the response trailers.
*/
rpc GetVersionedProfile(GetVersionedProfileRequest) returns (GetVersionedProfileResponse) {}
/**
* Retrieves unversioned profile data. Callers with an unidentified access key for the account
* should use the version of this method in `ProfileAnonymous` instead.
*
* This RPC may fail with a `NOT_FOUND` status if the target account was not
* found. It may fail with a `RESOURCE_EXHAUSTED` if a rate limit for fetching profiles has been
* exceeded, in which case a `retry-after` header containing an ISO 8601
* duration string will be present in the response trailers.
*/
rpc GetUnversionedProfile(GetUnversionedProfileRequest) returns (GetUnversionedProfileResponse) {}
/**
* Retrieves a profile key credential.
* Callers with an unidentified access key for the account
* should use the version of this method in `ProfileAnonymous` instead.
*
* This RPC may fail with a `NOT_FOUND` status if the target account was not
* found. It may fail with a `RESOURCE_EXHAUSTED` if a rate limit for fetching profiles has been
* exceeded, in which case a `retry-after` header containing an ISO 8601
* duration string will be present in the response trailers. It may also fail with an
* `INVALID_ARGUMENT` status if the given credential type is invalid.
*/
rpc GetExpiringProfileKeyCredential(GetExpiringProfileKeyCredentialRequest) returns (GetExpiringProfileKeyCredentialResponse) {}
}
/**
* Provides methods for working with profiles and profile-related data using "unidentified access"
* credentials. Callers must not submit any self-identifying credentials
* when calling methods in this service and must instead present the targeted account's
* unidentified access key as an anonymous authentication mechanism. Callers
* without an unidentified access key should use the equivalent, authenticated
* methods in `Profile` instead.
*/
service ProfileAnonymous {
/**
* Retrieves versioned profile data.
*
* This RPC may fail with a `NOT_FOUND` status if the target account was not
* found. It may also fail with an `UNAUTHENTICATED` status if the given
* unidentified access key did not match the target account's unidentified
* access key.
*/
rpc GetVersionedProfile(GetVersionedProfileAnonymousRequest) returns (GetVersionedProfileResponse) {}
/**
* Retrieves unversioned profile data.
*
* This RPC may fail with a `NOT_FOUND` status if the target account was not
* found. It may also fail with an `UNAUTHENTICATED` status if the given
* unidentified access key did not match the target account's unidentified
* access key.
*/
rpc GetUnversionedProfile(GetUnversionedProfileAnonymousRequest) returns (GetUnversionedProfileResponse) {}
/**
* Retrieves a profile key credential.
*
* This RPC may fail with a `NOT_FOUND` status if the target account was not
* found. It may also fail with an `UNAUTHENTICATED` status if the given
* unidentified access key did not match the target account's unidentified
* access key, or an `INVALID_ARGUMENT` status if the given credential type is invalid.
*/
rpc GetExpiringProfileKeyCredential(GetExpiringProfileKeyCredentialAnonymousRequest) returns (GetExpiringProfileKeyCredentialResponse) {}
/**
* Checks identity key fingerprints of the target accounts.
*
* Returns a stream of elements, each one representing an account that had a mismatched
* identity key fingerprint with the server and the corresponding identity key stored by the server.
*/
rpc CheckIdentityKeys(stream CheckIdentityKeysRequest) returns (stream CheckIdentityKeysResponse) {}
}
message SetProfileRequest {
enum AvatarChange {
AVATAR_CHANGE_UNCHANGED = 0;
AVATAR_CHANGE_CLEAR = 1;
AVATAR_CHANGE_UPDATE = 2;
}
/**
* The profile version. Must be set.
*/
string version = 1;
/**
* The ciphertext of a name that users must set on the profile.
*/
bytes name = 2;
/**
* An enum to indicate what change, if any, is made to the avatar with this request.
*/
AvatarChange avatarChange = 3;
/**
* The ciphertext of an emoji that users can set on their profile.
*/
bytes about_emoji = 4;
/**
* The ciphertext of a description that users can set on their profile.
*/
bytes about = 5;
/**
* The ciphertext of the MobileCoin wallet ID on the profile.
*/
bytes payment_address = 6;
/**
* A list of badge IDs associated with the profile.
*/
repeated string badge_ids = 7;
/**
* The profile key commitment. Used to issue a profile key credential response.
* Must be set on the request.
*/
bytes commitment = 9;
}
message SetProfileResponse {
/**
* The policy and credential used by clients to upload an avatar to S3.
*/
ProfileAvatarUploadAttributes attributes = 1;
}
message GetVersionedProfileRequest {
/**
* The ACI of the account for which to get profile data.
*/
common.ServiceIdentifier accountIdentifier = 1;
/**
* The profile version to retrieve.
*/
bytes version = 2;
}
message GetVersionedProfileAnonymousRequest {
/**
* Contains the data necessary to request a versioned profile.
*/
GetVersionedProfileRequest request = 1;
/**
* The unidentified access key for the targeted account.
*/
bytes unidentified_access_key = 2;
}
message GetVersionedProfileResponse {
/**
* The ciphertext of the name on the profile.
*/
bytes name = 1;
/**
* The ciphertext of the description on the profile.
*/
bytes about = 2;
/**
* The ciphertext of the emoji on the profile.
*/
bytes about_emoji = 3;
/**
* The S3 path of the avatar on the profile.
*/
string avatar = 4;
/**
* The ciphertext of the MobileCoin wallet ID on the profile.
*/
bytes payment_address = 5;
}
message GetUnversionedProfileRequest {
/**
* The service identifier of the account for which to get profile data.
*/
common.ServiceIdentifier serviceIdentifier = 1;
}
message GetUnversionedProfileAnonymousRequest {
/**
* Contains the data necessary to request an unversioned profile.
*/
GetUnversionedProfileRequest request = 1;
/**
* The unidentified access key for the targeted account.
*/
bytes unidentified_access_key = 2;
}
message GetUnversionedProfileResponse {
/**
* The identity key of the targeted account/identity type.
*/
bytes identity_key = 1;
/**
* A checksum of the unidentified access key for the targeted account.
*/
bytes unidentified_access = 2;
/**
* Whether the account has enabled sealed sender from anyone.
*/
bool unrestricted_unidentified_access = 3;
/**
* A list of capabilities enabled on the account.
*/
UserCapabilities capabilities = 4;
/**
* A list of badges associated with the account.
*/
repeated Badge badges = 5;
}
message GetExpiringProfileKeyCredentialRequest {
/**
* The ACI of the account for which to get a profile key credential.
*/
common.ServiceIdentifier accountIdentifier = 1;
/**
* A zkgroup request for a profile key credential.
*/
bytes credential_request = 2;
/**
* The type of credential being requested.
*/
CredentialType credential_type = 3;
}
message GetExpiringProfileKeyCredentialAnonymousRequest {
/**
* Contains the data necessary to request an expiring profile key credential.
*/
GetExpiringProfileKeyCredentialRequest request = 1;
/**
* The unidentified access key for the targeted account.
*/
bytes unidentified_access_key = 2;
}
message GetExpiringProfileKeyCredentialResponse {
/**
* A zkgroup credential used by a client to prove that it has the profile key
* of a targeted account.
*/
bytes profileKeyCredential = 2;
}
message CheckIdentityKeysRequest {
/**
* The service identifier of the account for which we want to check the associated identity key fingerprint.
*/
common.ServiceIdentifier target_identifier = 1;
/**
* The most significant 4 bytes of the SHA-256 hash of the identity key associated with the target account/identity type.
*/
bytes fingerprint = 2;
}
message CheckIdentityKeysResponse {
/**
* The service identifier of the account for which there is a mismatch between the client and server identity key fingerprints.
*/
common.ServiceIdentifier target_identifier = 1;
/**
* The identity key that is stored by the server for the target account/identity type.
*/
bytes identity_key = 2;
}
message ProfileAvatarUploadAttributes {
/**
* The S3 upload path for the profile's avatar.
*/
string path = 1;
/**
* A scoped credential. Includes the AWS access key, date, region targeted, and AWS service.
*/
string credential = 2;
/**
* The type of access control for the avatar object.
*/
string acl = 3;
/**
* The algorithm used to calculate a signature on the S3 policy.
*/
string algorithm = 4;
/**
* The timestamp at which the S3 policy and signature were generated.
*/
string date = 5;
/**
* The S3 policy used to upload the avatar object.
*/
string policy = 6;
/**
* A digital signature on the S3 policy.
*/
bytes signature = 7;
}
message UserCapabilities {
/**
* Whether all devices linked to the account support the groups v1 migration.
*/
bool gv1_migration = 1;
/**
* Whether all devices linked to the account support sender keys.
*/
bool sender_key = 2;
/**
* Whether all devices linked to the account support announcement groups
* (groups where only the admin can send messages or start calls).
*/
bool announcement_group = 3;
/**
* Whether all devices linked to the account support changing phone number.
*/
bool change_number = 4;
/**
* Whether all devices linked to the account support stories.
*/
bool stories = 5;
/**
* Whether all devices linked to the account support gift badges.
*/
bool gift_badges = 6;
/**
* Whether all devices linked to the account support MobileCoin payments.
*/
bool payment_activation = 7;
/**
* Whether all devices linked to the account support phone number privacy.
*/
bool pni = 8;
}
message Badge {
/**
* An ID that uniquely identifies the badge.
*/
string id = 1;
/**
* The category the badge falls in ("donor" or "other").
*/
string category = 2;
/**
* The badge name.
*/
string name = 3;
/**
* The badge description.
*/
string description = 4;
/**
* Different size badge SVG files.
*/
repeated string sprites6 = 5;
/**
* File name of the scalable vector graphic representing this badge.
*/
string svg = 6;
/**
* Pairs of light/dark SVG files designed for display at different sizes.
*/
repeated BadgeSvg svgs = 7;
}
message BadgeSvg {
/**
* File name of the scalable vector graphic for light mode.
*/
string light = 1;
/**
* File name of the scalable vector graphic for dark mode.
*/
string dark = 2;
}
enum CredentialType {
CREDENTIAL_TYPE_UNSPECIFIED = 0;
CREDENTIAL_TYPE_EXPIRING_PROFILE_KEY = 1;
}

View File

@ -0,0 +1,334 @@
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.ServerInterceptors;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
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.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
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.ServiceId;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.whispersystems.textsecuregcm.auth.grpc.MockAuthenticationInterceptor;
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.entities.BadgeSvg;
import org.whispersystems.textsecuregcm.s3.PolicySigner;
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
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 software.amazon.awssdk.services.s3.S3AsyncClient;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import java.time.Clock;
import java.util.Base64;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
public class ProfileGrpcServiceTest {
private static final UUID AUTHENTICATED_ACI = UUID.randomUUID();
private static final long AUTHENTICATED_DEVICE_ID = Device.MASTER_ID;
private static final String S3_BUCKET = "profileBucket";
private static final String VERSION = "someVersion";
private static final byte[] VALID_NAME = new byte[81];
private ProfilesManager profilesManager;
private DynamicPaymentsConfiguration dynamicPaymentsConfiguration;
private S3AsyncClient asyncS3client;
private VersionedProfile profile;
private Account account;
private ProfileGrpc.ProfileBlockingStub profileBlockingStub;
@RegisterExtension
static final GrpcServerExtension GRPC_SERVER_EXTENSION = new GrpcServerExtension();
@BeforeEach
void setup() {
profilesManager = mock(ProfilesManager.class);
dynamicPaymentsConfiguration = mock(DynamicPaymentsConfiguration.class);
asyncS3client = mock(S3AsyncClient.class);
profile = mock(VersionedProfile.class);
account = mock(Account.class);
final AccountsManager accountsManager = mock(AccountsManager.class);
@SuppressWarnings("unchecked") final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager = mock(DynamicConfigurationManager.class);
final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);
final PolicySigner policySigner = new PolicySigner("accessSecret", "us-west-1");
final PostPolicyGenerator policyGenerator = new PostPolicyGenerator("us-west-1", "profile-bucket", "accessKey");
final BadgesConfiguration badgesConfiguration = new BadgesConfiguration(
List.of(new BadgeConfiguration(
"TEST",
"other",
List.of("l", "m", "h", "x", "xx", "xxx"),
"SVG",
List.of(
new BadgeSvg("sl", "sd"),
new BadgeSvg("ml", "md"),
new BadgeSvg("ll", "ld")
)
)),
List.of("TEST1"),
Map.of(1L, "TEST1", 2L, "TEST2", 3L, "TEST3")
);
final String phoneNumber = PhoneNumberUtil.getInstance().format(
PhoneNumberUtil.getInstance().getExampleNumber("US"),
PhoneNumberUtil.PhoneNumberFormat.E164);
profileBlockingStub = ProfileGrpc.newBlockingStub(GRPC_SERVER_EXTENSION.getChannel());
final ProfileGrpcService profileGrpcService = new ProfileGrpcService(
Clock.systemUTC(),
accountsManager,
profilesManager,
dynamicConfigurationManager,
badgesConfiguration,
asyncS3client,
policyGenerator,
policySigner,
S3_BUCKET
);
final MockAuthenticationInterceptor mockAuthenticationInterceptor = new MockAuthenticationInterceptor();
mockAuthenticationInterceptor.setAuthenticatedDevice(AUTHENTICATED_ACI, AUTHENTICATED_DEVICE_ID);
GRPC_SERVER_EXTENSION.getServiceRegistry()
.addService(ServerInterceptors.intercept(profileGrpcService, mockAuthenticationInterceptor));
when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);
when(dynamicConfiguration.getPaymentsConfiguration()).thenReturn(dynamicPaymentsConfiguration);
when(account.getUuid()).thenReturn(AUTHENTICATED_ACI);
when(account.getNumber()).thenReturn(phoneNumber);
when(account.getBadges()).thenReturn(Collections.emptyList());
when(profile.getPaymentAddress()).thenReturn(null);
when(profile.getAvatar()).thenReturn("");
when(accountsManager.getByAccountIdentifierAsync(any())).thenReturn(CompletableFuture.completedFuture(Optional.of(account)));
when(accountsManager.updateAsync(any(), any())).thenReturn(CompletableFuture.completedFuture(null));
when(profilesManager.getAsync(any(), any())).thenReturn(CompletableFuture.completedFuture(Optional.of(profile)));
when(profilesManager.setAsync(any(), any())).thenReturn(CompletableFuture.completedFuture(null));
when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);
when(dynamicConfiguration.getPaymentsConfiguration()).thenReturn(dynamicPaymentsConfiguration);
when(dynamicPaymentsConfiguration.getDisallowedPrefixes()).thenReturn(Collections.emptyList());
when(asyncS3client.deleteObject(any(DeleteObjectRequest.class))).thenReturn(CompletableFuture.completedFuture(null));
}
@Test
void setProfile() throws InvalidInputException {
final byte[] commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AUTHENTICATED_ACI)).serialize();
final byte[] validAboutEmoji = new byte[60];
final byte[] validAbout = new byte[540];
final byte[] validPaymentAddress = new byte[582];
final SetProfileRequest request = SetProfileRequest.newBuilder()
.setVersion(VERSION)
.setName(ByteString.copyFrom(VALID_NAME))
.setAvatarChange(AvatarChange.AVATAR_CHANGE_UNCHANGED)
.setAboutEmoji(ByteString.copyFrom(validAboutEmoji))
.setAbout(ByteString.copyFrom(validAbout))
.setPaymentAddress(ByteString.copyFrom(validPaymentAddress))
.setCommitment(ByteString.copyFrom(commitment))
.build();
//noinspection ResultOfMethodCallIgnored
profileBlockingStub.setProfile(request);
final ArgumentCaptor<VersionedProfile> profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class);
verify(profilesManager).setAsync(eq(account.getUuid()), profileArgumentCaptor.capture());
final VersionedProfile profile = profileArgumentCaptor.getValue();
assertThat(profile.getCommitment()).isEqualTo(commitment);
assertThat(profile.getAvatar()).isNull();
assertThat(profile.getVersion()).isEqualTo(VERSION);
assertThat(profile.getName()).isEqualTo(encodeToBase64(VALID_NAME));
assertThat(profile.getAboutEmoji()).isEqualTo(encodeToBase64(validAboutEmoji));
assertThat(profile.getAbout()).isEqualTo(encodeToBase64(validAbout));
assertThat(profile.getPaymentAddress()).isEqualTo(encodeToBase64(validPaymentAddress));
}
@ParameterizedTest
@MethodSource
void setProfileUpload(AvatarChange avatarChange, boolean hasPreviousProfile,
boolean expectHasS3UploadPath, boolean expectDeleteS3Object) throws InvalidInputException {
final String currentAvatar = "profiles/currentAvatar";
final byte[] commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AUTHENTICATED_ACI)).serialize();
final SetProfileRequest request = SetProfileRequest.newBuilder()
.setVersion(VERSION)
.setName(ByteString.copyFrom(VALID_NAME))
.setAvatarChange(avatarChange)
.setCommitment(ByteString.copyFrom(commitment))
.build();
when(profile.getAvatar()).thenReturn(currentAvatar);
when(profilesManager.getAsync(any(), anyString())).thenReturn(CompletableFuture.completedFuture(
hasPreviousProfile ? Optional.of(profile) : Optional.empty()));
when(profilesManager.setAsync(any(), any())).thenReturn(CompletableFuture.completedFuture(null));
SetProfileResponse response = profileBlockingStub.setProfile(request);
if (expectHasS3UploadPath) {
assertTrue(response.getAttributes().getPath().startsWith("profiles/"));
} else {
assertEquals(response.getAttributes().getPath(), "");
}
if (expectDeleteS3Object) {
verify(asyncS3client).deleteObject(DeleteObjectRequest.builder()
.bucket(S3_BUCKET)
.key(currentAvatar)
.build());
} else {
verifyNoInteractions(asyncS3client);
}
}
private static Stream<Arguments> setProfileUpload() {
return Stream.of(
// Upload new avatar, no previous avatar
Arguments.of(AvatarChange.AVATAR_CHANGE_UPDATE, false, true, false),
// Upload new avatar, has previous avatar
Arguments.of(AvatarChange.AVATAR_CHANGE_UPDATE, true, true, true),
// Clear avatar on profile, no previous avatar
Arguments.of(AvatarChange.AVATAR_CHANGE_CLEAR, false, false, false),
// Clear avatar on profile, has previous avatar
Arguments.of(AvatarChange.AVATAR_CHANGE_CLEAR, true, false, true),
// Set same avatar, no previous avatar
Arguments.of(AvatarChange.AVATAR_CHANGE_UNCHANGED, false, false, false),
// Set same avatar, has previous avatar
Arguments.of(AvatarChange.AVATAR_CHANGE_UNCHANGED, true, false, false)
);
}
@ParameterizedTest
@MethodSource
void setProfileInvalidRequestData(SetProfileRequest request) {
final StatusRuntimeException exception =
assertThrows(StatusRuntimeException.class, () -> profileBlockingStub.setProfile(request));
assertEquals(Status.INVALID_ARGUMENT.getCode(), exception.getStatus().getCode());
}
private static Stream<Arguments> setProfileInvalidRequestData() throws InvalidInputException{
final byte[] commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID_TWO)).serialize();
final byte[] invalidValue = new byte[42];
final SetProfileRequest prototypeRequest = SetProfileRequest.newBuilder()
.setVersion(VERSION)
.setName(ByteString.copyFrom(VALID_NAME))
.setCommitment(ByteString.copyFrom(commitment))
.build();
return Stream.of(
// Missing version
Arguments.of(SetProfileRequest.newBuilder(prototypeRequest)
.clearVersion()
.build()),
// Missing name
Arguments.of(SetProfileRequest.newBuilder(prototypeRequest)
.clearName()
.build()),
// Invalid name length
Arguments.of(SetProfileRequest.newBuilder(prototypeRequest)
.setName(ByteString.copyFrom(invalidValue))
.build()),
// Invalid about emoji length
Arguments.of(SetProfileRequest.newBuilder(prototypeRequest)
.setAboutEmoji(ByteString.copyFrom(invalidValue))
.build()),
// Invalid about length
Arguments.of(SetProfileRequest.newBuilder(prototypeRequest)
.setAbout(ByteString.copyFrom(invalidValue))
.build()),
// Invalid payment address
Arguments.of(SetProfileRequest.newBuilder(prototypeRequest)
.setPaymentAddress(ByteString.copyFrom(invalidValue))
.build()),
// Missing profile commitment
Arguments.of(SetProfileRequest.newBuilder()
.clearCommitment()
.build())
);
}
@ParameterizedTest
@ValueSource(booleans = {true, false})
void setPaymentAddressDisallowedCountry(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();
final byte[] validPaymentAddress = new byte[582];
if (hasExistingPaymentAddress) {
when(profile.getPaymentAddress()).thenReturn(encodeToBase64(validPaymentAddress));
}
final SetProfileRequest request = SetProfileRequest.newBuilder()
.setVersion(VERSION)
.setName(ByteString.copyFrom(VALID_NAME))
.setAvatarChange(AvatarChange.AVATAR_CHANGE_UNCHANGED)
.setPaymentAddress(ByteString.copyFrom(validPaymentAddress))
.setCommitment(ByteString.copyFrom(commitment))
.build();
final String disallowedCountryCode = String.format("+%d", disallowedPhoneNumber.getCountryCode());
when(dynamicPaymentsConfiguration.getDisallowedPrefixes()).thenReturn(List.of(disallowedCountryCode));
when(account.getNumber()).thenReturn(PhoneNumberUtil.getInstance().format(
disallowedPhoneNumber,
PhoneNumberUtil.PhoneNumberFormat.E164));
when(profilesManager.getAsync(any(), anyString())).thenReturn(CompletableFuture.completedFuture(Optional.of(profile)));
if (hasExistingPaymentAddress) {
assertDoesNotThrow(() -> profileBlockingStub.setProfile(request),
"Payment address changes in disallowed countries should still be allowed if the account already has a valid payment address");
} else {
final StatusRuntimeException exception =
assertThrows(StatusRuntimeException.class, () -> profileBlockingStub.setProfile(request));
assertEquals(Status.PERMISSION_DENIED.getCode(), exception.getStatus().getCode());
}
}
private static String encodeToBase64(byte[] input) {
return Base64.getEncoder().encodeToString(input);
}
}