diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 836320f02..35852cc24 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -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 { @@ -616,6 +618,10 @@ public class WhisperServerService extends Application 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 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 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 getAcceptableLanguagesForRequest(ContainerRequestContext containerRequestContext) { try { return containerRequestContext.getAcceptableLanguages(); @@ -509,48 +493,6 @@ public class ProfileController { } } - private List mergeBadgeIdsWithExistingAccountBadges( - final List badgeIds, - final List accountBadges) { - LinkedHashMap existingBadges = new LinkedHashMap<>(accountBadges.size()); - for (final AccountBadge accountBadge : accountBadges) { - existingBadges.putIfAbsent(accountBadge.getId(), accountBadge); - } - - LinkedHashMap 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 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. * diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AvatarChange.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AvatarChange.java new file mode 100644 index 000000000..29c218ffb --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AvatarChange.java @@ -0,0 +1,7 @@ +package org.whispersystems.textsecuregcm.entities; + +public enum AvatarChange { + AVATAR_CHANGE_UNCHANGED, + AVATAR_CHANGE_CLEAR, + AVATAR_CHANGE_UPDATE +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/AvatarChangeUtil.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/AvatarChangeUtil.java new file mode 100644 index 000000000..1d3e6fb9e --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/AvatarChangeUtil.java @@ -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(); + }; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcService.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcService.java new file mode 100644 index 000000000..0701df3cc --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcService.java @@ -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 dynamicConfigurationManager; + private final Map badgeConfigurationMap; + private final PolicySigner policySigner; + private final PostPolicyGenerator policyGenerator; + + private final S3AsyncClient asyncS3client; + private final String bucket; + + private record AvatarData(Optional currentAvatar, + Optional finalAvatar, + Optional uploadAttributes) {} + + public ProfileGrpcService( + Clock clock, + AccountsManager accountsManager, + ProfilesManager profilesManager, + DynamicConfigurationManager 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 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 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 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> updates = new ArrayList<>(2); + final List 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 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); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileHelper.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileHelper.java new file mode 100644 index 000000000..fd4948317 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileHelper.java @@ -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 mergeBadgeIdsWithExistingAccountBadges( + final Clock clock, + final Map badgeConfigurationMap, + final List badgeIds, + final List accountBadges) { + LinkedHashMap existingBadges = new LinkedHashMap<>(accountBadges.size()); + for (final AccountBadge accountBadge : accountBadges) { + existingBadges.putIfAbsent(accountBadge.getId(), accountBadge); + } + + LinkedHashMap 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 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 policy = policyGenerator.createFor(now, objectName, 10 * 1024 * 1024); + final String signature = policySigner.getSignature(now, policy.second()); + + return org.signal.chat.profile.ProfileAvatarUploadAttributes.newBuilder() + .setPath(objectName) + .setCredential(policy.first()) + .setAcl("private") + .setAlgorithm("AWS4-HMAC-SHA256") + .setDate(now.format(PostPolicyGenerator.AWS_DATE_TIME)) + .setPolicy(policy.second()) + .setSignature(ByteString.copyFrom(signature.getBytes())) + .build(); + } + + public static org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes generateAvatarUploadForm( + final PostPolicyGenerator policyGenerator, + final PolicySigner policySigner, + final String objectName) { + ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC); + Pair policy = policyGenerator.createFor(now, objectName, 10 * 1024 * 1024); + String signature = policySigner.getSignature(now, policy.second()); + + return new org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes(objectName, policy.first(), "private", "AWS4-HMAC-SHA256", + now.format(PostPolicyGenerator.AWS_DATE_TIME), policy.second(), signature); + + } +} diff --git a/service/src/main/proto/org/signal/chat/profile.proto b/service/src/main/proto/org/signal/chat/profile.proto new file mode 100644 index 000000000..9cfa5cb00 --- /dev/null +++ b/service/src/main/proto/org/signal/chat/profile.proto @@ -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; +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcServiceTest.java new file mode 100644 index 000000000..ac018d788 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcServiceTest.java @@ -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 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 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 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 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); + } +}