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;
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<Profile> getProfile(
public Profile getProfile(
@Auth Optional<AuthenticatedAccount> auth,
@HeaderParam(OptionalAccess.UNIDENTIFIED) Optional<Anonymous> 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<Profile> getProfile(
public Profile getProfile(
@Auth Optional<AuthenticatedAccount> auth,
@HeaderParam(OptionalAccess.UNIDENTIFIED) Optional<Anonymous> 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<Profile> getVersionedProfile(
private Profile getVersionedProfile(
Optional<Account> requestAccount,
Optional<Anonymous> accessKey,
List<Locale> acceptableLanguages,
UUID uuid,
String version,
Optional<String> credentialRequest)
Optional<String> credentialRequest,
Optional<String> 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<Account> accountProfile = accountsManager.getByAccountIdentifier(uuid);
OptionalAccess.verify(requestAccount, accessKey, accountProfile);
Optional<Account> accountProfile = accountsManager.getByAccountIdentifier(uuid);
OptionalAccess.verify(requestAccount, accessKey, accountProfile);
assert(accountProfile.isPresent());
assert(accountProfile.isPresent());
Optional<String> username = accountProfile.flatMap(Account::getUsername);
Optional<VersionedProfile> 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<String> 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<String> 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<ProfileKeyCredentialResponse> 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<ProfileKeyCredentialResponse> getProfileCredential(Optional<String> encodedProfileCredentialRequest,
Optional<VersionedProfile> 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);
}

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 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<Badge> badges, ProfileKeyCredentialResponse credential)
UUID uuid, List<Badge> 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