From 68412b39013195ae51d04a8290cb616b692f4b43 Mon Sep 17 00:00:00 2001 From: Jon Chambers Date: Wed, 17 Nov 2021 17:28:10 -0500 Subject: [PATCH] Allow the "get profile" endpoint to include a PNI credential --- .../controllers/ProfileController.java | 168 +++++++++++------- .../PniCredentialResponseAdapter.java | 41 +++++ .../textsecuregcm/entities/Profile.java | 9 +- 3 files changed, 151 insertions(+), 67 deletions(-) create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/entities/PniCredentialResponseAdapter.java diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java index e850d4b15..2282ee38b 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java @@ -5,8 +5,12 @@ package org.whispersystems.textsecuregcm.controllers; +import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; + import com.codahale.metrics.annotation.Timed; import io.dropwizard.auth.Auth; +import io.micrometer.core.instrument.Metrics; +import io.micrometer.core.instrument.Tags; import java.security.SecureRandom; import java.time.Clock; import java.time.Duration; @@ -24,7 +28,9 @@ import java.util.function.Function; import java.util.stream.Collectors; import javax.validation.Valid; import javax.validation.valueextraction.Unwrapping; +import javax.ws.rs.BadRequestException; import javax.ws.rs.Consumes; +import javax.ws.rs.DefaultValue; import javax.ws.rs.GET; import javax.ws.rs.HeaderParam; import javax.ws.rs.PUT; @@ -39,14 +45,13 @@ import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import javax.ws.rs.core.Response.Status; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Tags; import org.apache.commons.codec.DecoderException; import org.apache.commons.codec.binary.Base64; import org.apache.commons.codec.binary.Hex; import org.apache.commons.lang3.StringUtils; import org.signal.zkgroup.InvalidInputException; import org.signal.zkgroup.VerificationFailedException; +import org.signal.zkgroup.profiles.PniCredentialResponse; import org.signal.zkgroup.profiles.ProfileKeyCommitment; import org.signal.zkgroup.profiles.ProfileKeyCredentialRequest; import org.signal.zkgroup.profiles.ProfileKeyCredentialResponse; @@ -80,8 +85,6 @@ import org.whispersystems.textsecuregcm.util.Pair; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; -import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; - @SuppressWarnings("OptionalUsedAsFieldOrParameterType") @Path("/v1/profile") public class ProfileController { @@ -103,6 +106,9 @@ public class ProfileController { private final S3Client s3client; private final String bucket; + private static final String PROFILE_KEY_CREDENTIAL_TYPE = "profileKey"; + private static final String PNI_CREDENTIAL_TYPE = "pni"; + private static final String LEGACY_GET_PROFILE_COUNTER_NAME = name(ProfileController.class, "legacyGetProfileByPlatform"); public ProfileController( @@ -205,7 +211,7 @@ public class ProfileController { @GET @Produces(MediaType.APPLICATION_JSON) @Path("/{uuid}/{version}") - public Optional getProfile( + public Profile getProfile( @Auth Optional auth, @HeaderParam(OptionalAccess.UNIDENTIFIED) Optional accessKey, @Context ContainerRequestContext containerRequestContext, @@ -214,87 +220,107 @@ public class ProfileController { throws RateLimitExceededException { return getVersionedProfile(auth.map(AuthenticatedAccount::getAccount), accessKey, getAcceptableLanguagesForRequest(containerRequestContext), uuid, - version, Optional.empty()); + version, Optional.empty(), Optional.empty()); } @Timed @GET @Produces(MediaType.APPLICATION_JSON) @Path("/{uuid}/{version}/{credentialRequest}") - public Optional getProfile( + public Profile getProfile( @Auth Optional auth, @HeaderParam(OptionalAccess.UNIDENTIFIED) Optional accessKey, @Context ContainerRequestContext containerRequestContext, @PathParam("uuid") UUID uuid, @PathParam("version") String version, - @PathParam("credentialRequest") String credentialRequest) + @PathParam("credentialRequest") String credentialRequest, + @QueryParam("credentialType") @DefaultValue(PROFILE_KEY_CREDENTIAL_TYPE) String credentialType) throws RateLimitExceededException { return getVersionedProfile(auth.map(AuthenticatedAccount::getAccount), accessKey, getAcceptableLanguagesForRequest(containerRequestContext), uuid, - version, Optional.of(credentialRequest)); + version, Optional.of(credentialRequest), Optional.of(credentialType)); } - private Optional getVersionedProfile( + private Profile getVersionedProfile( Optional requestAccount, Optional accessKey, List acceptableLanguages, UUID uuid, String version, - Optional credentialRequest) + Optional credentialRequest, + Optional credentialType) throws RateLimitExceededException { - try { - if (requestAccount.isEmpty() && accessKey.isEmpty()) { - throw new WebApplicationException(Response.Status.UNAUTHORIZED); - } + if (requestAccount.isEmpty() && accessKey.isEmpty()) { + throw new WebApplicationException(Response.Status.UNAUTHORIZED); + } - boolean isSelf = false; - if (requestAccount.isPresent()) { - UUID authedUuid = requestAccount.get().getUuid(); - rateLimiters.getProfileLimiter().validate(authedUuid); - isSelf = uuid.equals(authedUuid); - } + boolean isSelf = false; + if (requestAccount.isPresent()) { + UUID authedUuid = requestAccount.get().getUuid(); + rateLimiters.getProfileLimiter().validate(authedUuid); + isSelf = uuid.equals(authedUuid); + } - Optional accountProfile = accountsManager.getByAccountIdentifier(uuid); - OptionalAccess.verify(requestAccount, accessKey, accountProfile); + Optional accountProfile = accountsManager.getByAccountIdentifier(uuid); + OptionalAccess.verify(requestAccount, accessKey, accountProfile); - assert(accountProfile.isPresent()); + assert(accountProfile.isPresent()); Optional username = accountProfile.flatMap(Account::getUsername); Optional profile = profilesManager.get(uuid, version); - String name = profile.map(VersionedProfile::getName).orElse(accountProfile.get().getProfileName()); - String about = profile.map(VersionedProfile::getAbout).orElse(null); - String aboutEmoji = profile.map(VersionedProfile::getAboutEmoji).orElse(null); - String avatar = profile.map(VersionedProfile::getAvatar).orElse(accountProfile.get().getAvatar()); - Optional currentProfileVersion = accountProfile.get().getCurrentProfileVersion(); + String name = profile.map(VersionedProfile::getName).orElse(accountProfile.get().getProfileName()); + String about = profile.map(VersionedProfile::getAbout).orElse(null); + String aboutEmoji = profile.map(VersionedProfile::getAboutEmoji).orElse(null); + String avatar = profile.map(VersionedProfile::getAvatar).orElse(accountProfile.get().getAvatar()); + Optional currentProfileVersion = accountProfile.get().getCurrentProfileVersion(); - // Allow requests where either the version matches the latest version on Account or the latest version on Account - // is empty to read the payment address. - final String paymentAddress = profile - .filter(p -> currentProfileVersion.map(v -> v.equals(version)).orElse(true)) - .map(VersionedProfile::getPaymentAddress) - .orElse(null); + // Allow requests where either the version matches the latest version on Account or the latest version on Account + // is empty to read the payment address. + final String paymentAddress = profile + .filter(p -> currentProfileVersion.map(v -> v.equals(version)).orElse(true)) + .map(VersionedProfile::getPaymentAddress) + .orElse(null); - Optional credential = getProfileCredential(credentialRequest, profile, uuid); + final ProfileKeyCredentialResponse profileKeyCredentialResponse; + final PniCredentialResponse pniCredentialResponse; - return Optional.of(new Profile( - name, - about, - aboutEmoji, - avatar, - paymentAddress, - accountProfile.get().getIdentityKey(), - UnidentifiedAccessChecksum.generateFor(accountProfile.get().getUnidentifiedAccessKey()), - accountProfile.get().isUnrestrictedUnidentifiedAccess(), - UserCapabilities.createForAccount(accountProfile.get()), - username.orElse(null), - null, - profileBadgeConverter.convert(acceptableLanguages, accountProfile.get().getBadges(), isSelf), - credential.orElse(null))); - } catch (InvalidInputException e) { - logger.info("Bad profile request", e); - throw new WebApplicationException(Response.Status.BAD_REQUEST); + if (credentialRequest.isPresent() && credentialType.isPresent() && profile.isPresent() && requestAccount.isPresent()) { + if (PNI_CREDENTIAL_TYPE.equals(credentialType.get())) { + profileKeyCredentialResponse = null; + pniCredentialResponse = getPniCredential(credentialRequest.get(), + profile.get(), + requestAccount.get().getUuid(), + requestAccount.get().getPhoneNumberIdentifier()); + } else if (PROFILE_KEY_CREDENTIAL_TYPE.equals(credentialType.get())) { + profileKeyCredentialResponse = getProfileCredential(credentialRequest.get(), + profile.get(), + requestAccount.get().getUuid()); + + pniCredentialResponse = null; + } else { + throw new BadRequestException(); + } + } else { + profileKeyCredentialResponse = null; + pniCredentialResponse = null; } + + return new Profile( + name, + about, + aboutEmoji, + avatar, + paymentAddress, + accountProfile.get().getIdentityKey(), + UnidentifiedAccessChecksum.generateFor(accountProfile.get().getUnidentifiedAccessKey()), + accountProfile.get().isUnrestrictedUnidentifiedAccess(), + UserCapabilities.createForAccount(accountProfile.get()), + username.orElse(null), + null, + profileBadgeConverter.convert(acceptableLanguages, accountProfile.get().getBadges(), isSelf), + profileKeyCredentialResponse, + pniCredentialResponse); } @@ -332,28 +358,37 @@ public class ProfileController { getAcceptableLanguagesForRequest(containerRequestContext), accountProfile.getBadges(), isSelf), + null, null); } - private Optional getProfileCredential(Optional encodedProfileCredentialRequest, - Optional profile, - UUID uuid) - throws InvalidInputException - { - if (encodedProfileCredentialRequest.isEmpty()) return Optional.empty(); - if (profile.isEmpty()) return Optional.empty(); - + private ProfileKeyCredentialResponse getProfileCredential(final String encodedProfileCredentialRequest, + final VersionedProfile profile, + final UUID uuid) { try { - ProfileKeyCommitment commitment = new ProfileKeyCommitment(profile.get().getCommitment()); - ProfileKeyCredentialRequest request = new ProfileKeyCredentialRequest(Hex.decodeHex(encodedProfileCredentialRequest.get())); - ProfileKeyCredentialResponse response = zkProfileOperations.issueProfileKeyCredential(request, uuid, commitment); + final ProfileKeyCommitment commitment = new ProfileKeyCommitment(profile.getCommitment()); + final ProfileKeyCredentialRequest request = new ProfileKeyCredentialRequest(Hex.decodeHex(encodedProfileCredentialRequest)); - return Optional.of(response); - } catch (DecoderException | VerificationFailedException e) { + return zkProfileOperations.issueProfileKeyCredential(request, uuid, commitment); + } catch (DecoderException | VerificationFailedException | InvalidInputException e) { throw new WebApplicationException(e, Response.status(Response.Status.BAD_REQUEST).build()); } } + private PniCredentialResponse getPniCredential(final String encodedCredentialRequest, + final VersionedProfile profile, + final UUID accountIdentifier, + final UUID phoneNumberIdentifier) { + + try { + final ProfileKeyCommitment commitment = new ProfileKeyCommitment(profile.getCommitment()); + final ProfileKeyCredentialRequest request = new ProfileKeyCredentialRequest(Hex.decodeHex(encodedCredentialRequest)); + + return zkProfileOperations.issuePniCredential(request, accountIdentifier, phoneNumberIdentifier, commitment); + } catch (DecoderException | VerificationFailedException | InvalidInputException e) { + throw new WebApplicationException(e, Response.status(Response.Status.BAD_REQUEST).build()); + } + } // Old profile endpoints. Replaced by versioned profile endpoints (above) @@ -415,6 +450,7 @@ public class ProfileController { getAcceptableLanguagesForRequest(containerRequestContext), accountProfile.get().getBadges(), isSelf), + null, null); } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/PniCredentialResponseAdapter.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/PniCredentialResponseAdapter.java new file mode 100644 index 000000000..d2000e7d3 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/PniCredentialResponseAdapter.java @@ -0,0 +1,41 @@ +/* + * Copyright 2013-2020 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.entities; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import java.io.IOException; +import java.util.Base64; +import org.signal.zkgroup.InvalidInputException; +import org.signal.zkgroup.profiles.PniCredentialResponse; + +public class PniCredentialResponseAdapter { + + public static class Serializing extends JsonSerializer { + @Override + public void serialize(PniCredentialResponse response, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) + throws IOException { + if (response == null) jsonGenerator.writeNull(); + else jsonGenerator.writeString(Base64.getEncoder().encodeToString(response.serialize())); + } + } + + public static class Deserializing extends JsonDeserializer { + @Override + public PniCredentialResponse deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) + throws IOException { + try { + return new PniCredentialResponse(Base64.getDecoder().decode(jsonParser.getValueAsString())); + } catch (InvalidInputException e) { + throw new IOException(e); + } + } + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/Profile.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/Profile.java index 640377516..8782e05bd 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/Profile.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/Profile.java @@ -11,6 +11,7 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.google.common.annotations.VisibleForTesting; import java.util.List; import java.util.UUID; +import org.signal.zkgroup.profiles.PniCredentialResponse; import org.signal.zkgroup.profiles.ProfileKeyCredentialResponse; public class Profile { @@ -56,12 +57,17 @@ public class Profile { @JsonDeserialize(using = ProfileKeyCredentialResponseAdapter.Deserializing.class) private ProfileKeyCredentialResponse credential; + @JsonProperty + @JsonSerialize(using = PniCredentialResponseAdapter.Serializing.class) + @JsonDeserialize(using = PniCredentialResponseAdapter.Deserializing.class) + private PniCredentialResponse pniCredential; + public Profile() {} public Profile( String name, String about, String aboutEmoji, String avatar, String paymentAddress, String identityKey, String unidentifiedAccess, boolean unrestrictedUnidentifiedAccess, UserCapabilities capabilities, String username, - UUID uuid, List badges, ProfileKeyCredentialResponse credential) + UUID uuid, List badges, ProfileKeyCredentialResponse credential, PniCredentialResponse pniCredential) { this.name = name; this.about = about; @@ -76,6 +82,7 @@ public class Profile { this.uuid = uuid; this.badges = badges; this.credential = credential; + this.pniCredential = pniCredential; } @VisibleForTesting