Allow the "get profile" endpoint to include a PNI credential

This commit is contained in:
Jon Chambers 2021-11-17 17:28:10 -05:00 committed by Jon Chambers
parent 93a7c60a15
commit 68412b3901
3 changed files with 151 additions and 67 deletions

View File

@ -5,8 +5,12 @@
package org.whispersystems.textsecuregcm.controllers; package org.whispersystems.textsecuregcm.controllers;
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
import com.codahale.metrics.annotation.Timed; import com.codahale.metrics.annotation.Timed;
import io.dropwizard.auth.Auth; import io.dropwizard.auth.Auth;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Tags;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.Clock; import java.time.Clock;
import java.time.Duration; import java.time.Duration;
@ -24,7 +28,9 @@ import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import javax.validation.Valid; import javax.validation.Valid;
import javax.validation.valueextraction.Unwrapping; import javax.validation.valueextraction.Unwrapping;
import javax.ws.rs.BadRequestException;
import javax.ws.rs.Consumes; import javax.ws.rs.Consumes;
import javax.ws.rs.DefaultValue;
import javax.ws.rs.GET; import javax.ws.rs.GET;
import javax.ws.rs.HeaderParam; import javax.ws.rs.HeaderParam;
import javax.ws.rs.PUT; 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.MediaType;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status; 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.DecoderException;
import org.apache.commons.codec.binary.Base64; import org.apache.commons.codec.binary.Base64;
import org.apache.commons.codec.binary.Hex; import org.apache.commons.codec.binary.Hex;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.signal.zkgroup.InvalidInputException; import org.signal.zkgroup.InvalidInputException;
import org.signal.zkgroup.VerificationFailedException; import org.signal.zkgroup.VerificationFailedException;
import org.signal.zkgroup.profiles.PniCredentialResponse;
import org.signal.zkgroup.profiles.ProfileKeyCommitment; import org.signal.zkgroup.profiles.ProfileKeyCommitment;
import org.signal.zkgroup.profiles.ProfileKeyCredentialRequest; import org.signal.zkgroup.profiles.ProfileKeyCredentialRequest;
import org.signal.zkgroup.profiles.ProfileKeyCredentialResponse; 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.S3Client;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
@SuppressWarnings("OptionalUsedAsFieldOrParameterType") @SuppressWarnings("OptionalUsedAsFieldOrParameterType")
@Path("/v1/profile") @Path("/v1/profile")
public class ProfileController { public class ProfileController {
@ -103,6 +106,9 @@ public class ProfileController {
private final S3Client s3client; private final S3Client s3client;
private final String bucket; 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"); private static final String LEGACY_GET_PROFILE_COUNTER_NAME = name(ProfileController.class, "legacyGetProfileByPlatform");
public ProfileController( public ProfileController(
@ -205,7 +211,7 @@ public class ProfileController {
@GET @GET
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@Path("/{uuid}/{version}") @Path("/{uuid}/{version}")
public Optional<Profile> getProfile( public Profile getProfile(
@Auth Optional<AuthenticatedAccount> auth, @Auth Optional<AuthenticatedAccount> auth,
@HeaderParam(OptionalAccess.UNIDENTIFIED) Optional<Anonymous> accessKey, @HeaderParam(OptionalAccess.UNIDENTIFIED) Optional<Anonymous> accessKey,
@Context ContainerRequestContext containerRequestContext, @Context ContainerRequestContext containerRequestContext,
@ -214,87 +220,107 @@ public class ProfileController {
throws RateLimitExceededException { throws RateLimitExceededException {
return getVersionedProfile(auth.map(AuthenticatedAccount::getAccount), accessKey, return getVersionedProfile(auth.map(AuthenticatedAccount::getAccount), accessKey,
getAcceptableLanguagesForRequest(containerRequestContext), uuid, getAcceptableLanguagesForRequest(containerRequestContext), uuid,
version, Optional.empty()); version, Optional.empty(), Optional.empty());
} }
@Timed @Timed
@GET @GET
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@Path("/{uuid}/{version}/{credentialRequest}") @Path("/{uuid}/{version}/{credentialRequest}")
public Optional<Profile> getProfile( public Profile getProfile(
@Auth Optional<AuthenticatedAccount> auth, @Auth Optional<AuthenticatedAccount> auth,
@HeaderParam(OptionalAccess.UNIDENTIFIED) Optional<Anonymous> accessKey, @HeaderParam(OptionalAccess.UNIDENTIFIED) Optional<Anonymous> accessKey,
@Context ContainerRequestContext containerRequestContext, @Context ContainerRequestContext containerRequestContext,
@PathParam("uuid") UUID uuid, @PathParam("uuid") UUID uuid,
@PathParam("version") String version, @PathParam("version") String version,
@PathParam("credentialRequest") String credentialRequest) @PathParam("credentialRequest") String credentialRequest,
@QueryParam("credentialType") @DefaultValue(PROFILE_KEY_CREDENTIAL_TYPE) String credentialType)
throws RateLimitExceededException { throws RateLimitExceededException {
return getVersionedProfile(auth.map(AuthenticatedAccount::getAccount), accessKey, return getVersionedProfile(auth.map(AuthenticatedAccount::getAccount), accessKey,
getAcceptableLanguagesForRequest(containerRequestContext), uuid, getAcceptableLanguagesForRequest(containerRequestContext), uuid,
version, Optional.of(credentialRequest)); version, Optional.of(credentialRequest), Optional.of(credentialType));
} }
private Optional<Profile> getVersionedProfile( private Profile getVersionedProfile(
Optional<Account> requestAccount, Optional<Account> requestAccount,
Optional<Anonymous> accessKey, Optional<Anonymous> accessKey,
List<Locale> acceptableLanguages, List<Locale> acceptableLanguages,
UUID uuid, UUID uuid,
String version, String version,
Optional<String> credentialRequest) Optional<String> credentialRequest,
Optional<String> credentialType)
throws RateLimitExceededException { throws RateLimitExceededException {
try { if (requestAccount.isEmpty() && accessKey.isEmpty()) {
if (requestAccount.isEmpty() && accessKey.isEmpty()) { throw new WebApplicationException(Response.Status.UNAUTHORIZED);
throw new WebApplicationException(Response.Status.UNAUTHORIZED); }
}
boolean isSelf = false; boolean isSelf = false;
if (requestAccount.isPresent()) { if (requestAccount.isPresent()) {
UUID authedUuid = requestAccount.get().getUuid(); UUID authedUuid = requestAccount.get().getUuid();
rateLimiters.getProfileLimiter().validate(authedUuid); rateLimiters.getProfileLimiter().validate(authedUuid);
isSelf = uuid.equals(authedUuid); isSelf = uuid.equals(authedUuid);
} }
Optional<Account> accountProfile = accountsManager.getByAccountIdentifier(uuid); Optional<Account> accountProfile = accountsManager.getByAccountIdentifier(uuid);
OptionalAccess.verify(requestAccount, accessKey, accountProfile); OptionalAccess.verify(requestAccount, accessKey, accountProfile);
assert(accountProfile.isPresent()); assert(accountProfile.isPresent());
Optional<String> username = accountProfile.flatMap(Account::getUsername); Optional<String> username = accountProfile.flatMap(Account::getUsername);
Optional<VersionedProfile> profile = profilesManager.get(uuid, version); Optional<VersionedProfile> profile = profilesManager.get(uuid, version);
String name = profile.map(VersionedProfile::getName).orElse(accountProfile.get().getProfileName()); String name = profile.map(VersionedProfile::getName).orElse(accountProfile.get().getProfileName());
String about = profile.map(VersionedProfile::getAbout).orElse(null); String about = profile.map(VersionedProfile::getAbout).orElse(null);
String aboutEmoji = profile.map(VersionedProfile::getAboutEmoji).orElse(null); String aboutEmoji = profile.map(VersionedProfile::getAboutEmoji).orElse(null);
String avatar = profile.map(VersionedProfile::getAvatar).orElse(accountProfile.get().getAvatar()); String avatar = profile.map(VersionedProfile::getAvatar).orElse(accountProfile.get().getAvatar());
Optional<String> currentProfileVersion = accountProfile.get().getCurrentProfileVersion(); Optional<String> currentProfileVersion = accountProfile.get().getCurrentProfileVersion();
// Allow requests where either the version matches the latest version on Account or the latest version on Account // 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. // is empty to read the payment address.
final String paymentAddress = profile final String paymentAddress = profile
.filter(p -> currentProfileVersion.map(v -> v.equals(version)).orElse(true)) .filter(p -> currentProfileVersion.map(v -> v.equals(version)).orElse(true))
.map(VersionedProfile::getPaymentAddress) .map(VersionedProfile::getPaymentAddress)
.orElse(null); .orElse(null);
Optional<ProfileKeyCredentialResponse> credential = getProfileCredential(credentialRequest, profile, uuid); final ProfileKeyCredentialResponse profileKeyCredentialResponse;
final PniCredentialResponse pniCredentialResponse;
return Optional.of(new Profile( if (credentialRequest.isPresent() && credentialType.isPresent() && profile.isPresent() && requestAccount.isPresent()) {
name, if (PNI_CREDENTIAL_TYPE.equals(credentialType.get())) {
about, profileKeyCredentialResponse = null;
aboutEmoji, pniCredentialResponse = getPniCredential(credentialRequest.get(),
avatar, profile.get(),
paymentAddress, requestAccount.get().getUuid(),
accountProfile.get().getIdentityKey(), requestAccount.get().getPhoneNumberIdentifier());
UnidentifiedAccessChecksum.generateFor(accountProfile.get().getUnidentifiedAccessKey()), } else if (PROFILE_KEY_CREDENTIAL_TYPE.equals(credentialType.get())) {
accountProfile.get().isUnrestrictedUnidentifiedAccess(), profileKeyCredentialResponse = getProfileCredential(credentialRequest.get(),
UserCapabilities.createForAccount(accountProfile.get()), profile.get(),
username.orElse(null), requestAccount.get().getUuid());
null,
profileBadgeConverter.convert(acceptableLanguages, accountProfile.get().getBadges(), isSelf), pniCredentialResponse = null;
credential.orElse(null))); } else {
} catch (InvalidInputException e) { throw new BadRequestException();
logger.info("Bad profile request", e); }
throw new WebApplicationException(Response.Status.BAD_REQUEST); } 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), getAcceptableLanguagesForRequest(containerRequestContext),
accountProfile.getBadges(), accountProfile.getBadges(),
isSelf), isSelf),
null,
null); null);
} }
private Optional<ProfileKeyCredentialResponse> getProfileCredential(Optional<String> encodedProfileCredentialRequest, private ProfileKeyCredentialResponse getProfileCredential(final String encodedProfileCredentialRequest,
Optional<VersionedProfile> profile, final VersionedProfile profile,
UUID uuid) final UUID uuid) {
throws InvalidInputException
{
if (encodedProfileCredentialRequest.isEmpty()) return Optional.empty();
if (profile.isEmpty()) return Optional.empty();
try { try {
ProfileKeyCommitment commitment = new ProfileKeyCommitment(profile.get().getCommitment()); final ProfileKeyCommitment commitment = new ProfileKeyCommitment(profile.getCommitment());
ProfileKeyCredentialRequest request = new ProfileKeyCredentialRequest(Hex.decodeHex(encodedProfileCredentialRequest.get())); final ProfileKeyCredentialRequest request = new ProfileKeyCredentialRequest(Hex.decodeHex(encodedProfileCredentialRequest));
ProfileKeyCredentialResponse response = zkProfileOperations.issueProfileKeyCredential(request, uuid, commitment);
return Optional.of(response); return zkProfileOperations.issueProfileKeyCredential(request, uuid, commitment);
} catch (DecoderException | VerificationFailedException e) { } catch (DecoderException | VerificationFailedException | InvalidInputException e) {
throw new WebApplicationException(e, Response.status(Response.Status.BAD_REQUEST).build()); 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) // Old profile endpoints. Replaced by versioned profile endpoints (above)
@ -415,6 +450,7 @@ public class ProfileController {
getAcceptableLanguagesForRequest(containerRequestContext), getAcceptableLanguagesForRequest(containerRequestContext),
accountProfile.get().getBadges(), accountProfile.get().getBadges(),
isSelf), isSelf),
null,
null); null);
} }

View File

@ -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<PniCredentialResponse> {
@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<PniCredentialResponse> {
@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);
}
}
}
}

View File

@ -11,6 +11,7 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.google.common.annotations.VisibleForTesting; import com.google.common.annotations.VisibleForTesting;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import org.signal.zkgroup.profiles.PniCredentialResponse;
import org.signal.zkgroup.profiles.ProfileKeyCredentialResponse; import org.signal.zkgroup.profiles.ProfileKeyCredentialResponse;
public class Profile { public class Profile {
@ -56,12 +57,17 @@ public class Profile {
@JsonDeserialize(using = ProfileKeyCredentialResponseAdapter.Deserializing.class) @JsonDeserialize(using = ProfileKeyCredentialResponseAdapter.Deserializing.class)
private ProfileKeyCredentialResponse credential; private ProfileKeyCredentialResponse credential;
@JsonProperty
@JsonSerialize(using = PniCredentialResponseAdapter.Serializing.class)
@JsonDeserialize(using = PniCredentialResponseAdapter.Deserializing.class)
private PniCredentialResponse pniCredential;
public Profile() {} public Profile() {}
public Profile( public Profile(
String name, String about, String aboutEmoji, String avatar, String paymentAddress, String identityKey, String name, String about, String aboutEmoji, String avatar, String paymentAddress, String identityKey,
String unidentifiedAccess, boolean unrestrictedUnidentifiedAccess, UserCapabilities capabilities, String username, String unidentifiedAccess, boolean unrestrictedUnidentifiedAccess, UserCapabilities capabilities, String username,
UUID uuid, List<Badge> badges, ProfileKeyCredentialResponse credential) UUID uuid, List<Badge> badges, ProfileKeyCredentialResponse credential, PniCredentialResponse pniCredential)
{ {
this.name = name; this.name = name;
this.about = about; this.about = about;
@ -76,6 +82,7 @@ public class Profile {
this.uuid = uuid; this.uuid = uuid;
this.badges = badges; this.badges = badges;
this.credential = credential; this.credential = credential;
this.pniCredential = pniCredential;
} }
@VisibleForTesting @VisibleForTesting