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 40e271d94..a48c68e3e 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java @@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.controllers; import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; import com.codahale.metrics.annotation.Timed; +import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; import io.dropwizard.auth.Auth; import io.micrometer.core.instrument.Counter; @@ -19,8 +20,10 @@ import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.time.Clock; import java.time.Duration; +import java.time.Instant; import java.time.ZoneOffset; import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; @@ -66,6 +69,7 @@ import org.apache.commons.codec.binary.Hex; import org.apache.commons.lang3.StringUtils; import org.signal.libsignal.zkgroup.InvalidInputException; import org.signal.libsignal.zkgroup.VerificationFailedException; +import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredentialResponse; import org.signal.libsignal.zkgroup.profiles.PniCredentialResponse; import org.signal.libsignal.zkgroup.profiles.ProfileKeyCommitment; import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialRequest; @@ -86,6 +90,7 @@ import org.whispersystems.textsecuregcm.entities.BatchIdentityCheckRequest; import org.whispersystems.textsecuregcm.entities.BatchIdentityCheckResponse; import org.whispersystems.textsecuregcm.entities.CreateProfileRequest; import org.whispersystems.textsecuregcm.entities.CredentialProfileResponse; +import org.whispersystems.textsecuregcm.entities.ExpiringProfileKeyCredentialProfileResponse; import org.whispersystems.textsecuregcm.entities.PniCredentialProfileResponse; import org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes; import org.whispersystems.textsecuregcm.entities.ProfileKeyCredentialProfileResponse; @@ -129,8 +134,12 @@ public class ProfileController { private final Executor batchIdentityCheckExecutor; + @VisibleForTesting + static final Duration EXPIRING_PROFILE_KEY_CREDENTIAL_EXPIRATION = Duration.ofDays(7); + private static final String PROFILE_KEY_CREDENTIAL_TYPE = "profileKey"; private static final String PNI_CREDENTIAL_TYPE = "pni"; + private static final String EXPIRING_PROFILE_KEY_CREDENTIAL_TYPE = "expiringProfileKey"; private static final Counter VERSION_NOT_FOUND_COUNTER = Metrics.counter(name(ProfileController.class, "versionNotFound")); private static final String INVALID_ACCEPT_LANGUAGE_COUNTER_NAME = name(ProfileController.class, "invalidAcceptLanguage"); @@ -285,6 +294,15 @@ public class ProfileController { containerRequestContext); } + case EXPIRING_PROFILE_KEY_CREDENTIAL_TYPE -> { + return buildExpiringProfileKeyCredentialProfileResponse(targetAccount, + version, + credentialRequest, + isSelf, + Instant.now().plus(EXPIRING_PROFILE_KEY_CREDENTIAL_EXPIRATION).truncatedTo(ChronoUnit.DAYS), + containerRequestContext); + } + default -> throw new BadRequestException(); } } @@ -415,6 +433,23 @@ public class ProfileController { pniCredentialResponse); } + private ExpiringProfileKeyCredentialProfileResponse buildExpiringProfileKeyCredentialProfileResponse( + final Account account, + final String version, + final String encodedCredentialRequest, + final boolean isSelf, + final Instant expiration, + final ContainerRequestContext containerRequestContext) { + + final ExpiringProfileKeyCredentialResponse expiringProfileKeyCredentialResponse = profilesManager.get(account.getUuid(), version) + .map(profile -> getExpiringProfileKeyCredentialResponse(encodedCredentialRequest, profile, account.getUuid(), expiration)) + .orElse(null); + + return new ExpiringProfileKeyCredentialProfileResponse( + buildVersionedProfileResponse(account, version, isSelf, containerRequestContext), + expiringProfileKeyCredentialResponse); + } + private VersionedProfileResponse buildVersionedProfileResponse(final Account account, final String version, final boolean isSelf, @@ -514,6 +549,22 @@ public class ProfileController { } } + private ExpiringProfileKeyCredentialResponse getExpiringProfileKeyCredentialResponse( + final String encodedCredentialRequest, + final VersionedProfile profile, + final UUID accountIdentifier, + final Instant expiration) { + + try { + final ProfileKeyCommitment commitment = new ProfileKeyCommitment(profile.getCommitment()); + final ProfileKeyCredentialRequest request = new ProfileKeyCredentialRequest(Hex.decodeHex(encodedCredentialRequest)); + + return zkProfileOperations.issueExpiringProfileKeyCredential(request, accountIdentifier, commitment, expiration); + } catch (DecoderException | VerificationFailedException | InvalidInputException e) { + throw new WebApplicationException(e, Response.status(Response.Status.BAD_REQUEST).build()); + } + } + private ProfileAvatarUploadAttributes generateAvatarUploadForm(String objectName) { ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC); Pair policy = policyGenerator.createFor(now, objectName, 10 * 1024 * 1024); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ExpiringProfileKeyCredentialProfileResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ExpiringProfileKeyCredentialProfileResponse.java new file mode 100644 index 000000000..5ac0566a1 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ExpiringProfileKeyCredentialProfileResponse.java @@ -0,0 +1,36 @@ +/* + * Copyright 2013-2022 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.entities; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import javax.annotation.Nullable; +import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredentialResponse; + +public class ExpiringProfileKeyCredentialProfileResponse extends CredentialProfileResponse { + + @JsonProperty + @JsonSerialize(using = ExpiringProfileKeyCredentialResponseAdapter.Serializing.class) + @JsonDeserialize(using = ExpiringProfileKeyCredentialResponseAdapter.Deserializing.class) + @Nullable + private ExpiringProfileKeyCredentialResponse credential; + + public ExpiringProfileKeyCredentialProfileResponse() { + } + + public ExpiringProfileKeyCredentialProfileResponse(final VersionedProfileResponse versionedProfileResponse, + @Nullable final ExpiringProfileKeyCredentialResponse credential) { + + super(versionedProfileResponse); + this.credential = credential; + } + + @Nullable + public ExpiringProfileKeyCredentialResponse getCredential() { + return credential; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ExpiringProfileKeyCredentialResponseAdapter.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ExpiringProfileKeyCredentialResponseAdapter.java new file mode 100644 index 000000000..acca13d68 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ExpiringProfileKeyCredentialResponseAdapter.java @@ -0,0 +1,41 @@ +/* + * Copyright 2013-2022 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.libsignal.zkgroup.InvalidInputException; +import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredentialResponse; + +public class ExpiringProfileKeyCredentialResponseAdapter { + + public static class Serializing extends JsonSerializer { + @Override + public void serialize(ExpiringProfileKeyCredentialResponse 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 ExpiringProfileKeyCredentialResponse deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) + throws IOException { + try { + return new ExpiringProfileKeyCredentialResponse(Base64.getDecoder().decode(jsonParser.getValueAsString())); + } catch (InvalidInputException e) { + throw new IOException(e); + } + } + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/ProfileControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ProfileControllerTest.java similarity index 93% rename from service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/ProfileControllerTest.java rename to service/src/test/java/org/whispersystems/textsecuregcm/controllers/ProfileControllerTest.java index 7ec86f548..fa48ccc47 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/ProfileControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ProfileControllerTest.java @@ -1,9 +1,9 @@ /* - * Copyright 2013-2021 Signal Messenger, LLC + * Copyright 2013-2022 Signal Messenger, LLC * SPDX-License-Identifier: AGPL-3.0-only */ -package org.whispersystems.textsecuregcm.tests.controllers; +package org.whispersystems.textsecuregcm.controllers; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatNoException; @@ -31,6 +31,7 @@ import java.security.SecureRandom; import java.time.Clock; import java.time.Duration; import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; @@ -64,6 +65,7 @@ import org.signal.libsignal.zkgroup.ServerPublicParams; import org.signal.libsignal.zkgroup.ServerSecretParams; import org.signal.libsignal.zkgroup.VerificationFailedException; import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations; +import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredentialResponse; import org.signal.libsignal.zkgroup.profiles.PniCredentialResponse; import org.signal.libsignal.zkgroup.profiles.ProfileKey; import org.signal.libsignal.zkgroup.profiles.ProfileKeyCommitment; @@ -86,6 +88,7 @@ import org.whispersystems.textsecuregcm.entities.BaseProfileResponse; import org.whispersystems.textsecuregcm.entities.BatchIdentityCheckRequest; import org.whispersystems.textsecuregcm.entities.BatchIdentityCheckResponse; import org.whispersystems.textsecuregcm.entities.CreateProfileRequest; +import org.whispersystems.textsecuregcm.entities.ExpiringProfileKeyCredentialProfileResponse; import org.whispersystems.textsecuregcm.entities.PniCredentialProfileResponse; import org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes; import org.whispersystems.textsecuregcm.entities.ProfileKeyCredentialProfileResponse; @@ -1131,6 +1134,76 @@ class ProfileControllerTest { verify(zkProfileOperations, never()).issuePniCredential(any(), any(), any(), any()); } + @ParameterizedTest + @MethodSource + void testGetProfileWithExpiringProfileKeyCredential(final MultivaluedMap authHeaders) + throws VerificationFailedException, InvalidInputException { + final String version = "version"; + final byte[] unidentifiedAccessKey = "test-uak".getBytes(StandardCharsets.UTF_8); + + final ServerSecretParams serverSecretParams = ServerSecretParams.generate(); + final ServerPublicParams serverPublicParams = serverSecretParams.getPublicParams(); + + final ServerZkProfileOperations serverZkProfile = new ServerZkProfileOperations(serverSecretParams); + final ClientZkProfileOperations clientZkProfile = new ClientZkProfileOperations(serverPublicParams); + + final byte[] profileKeyBytes = new byte[32]; + new SecureRandom().nextBytes(profileKeyBytes); + + final ProfileKey profileKey = new ProfileKey(profileKeyBytes); + final ProfileKeyCommitment profileKeyCommitment = profileKey.getCommitment(AuthHelper.VALID_UUID); + + final VersionedProfile versionedProfile = mock(VersionedProfile.class); + when(versionedProfile.getCommitment()).thenReturn(profileKeyCommitment.serialize()); + + final ProfileKeyCredentialRequestContext profileKeyCredentialRequestContext = + clientZkProfile.createProfileKeyCredentialRequestContext(AuthHelper.VALID_UUID, profileKey); + + final ProfileKeyCredentialRequest credentialRequest = profileKeyCredentialRequestContext.getRequest(); + + final Account account = mock(Account.class); + when(account.getUuid()).thenReturn(AuthHelper.VALID_UUID); + when(account.getCurrentProfileVersion()).thenReturn(Optional.of(version)); + when(account.isEnabled()).thenReturn(true); + when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey)); + + final Instant expiration = Instant.now().plus(ProfileController.EXPIRING_PROFILE_KEY_CREDENTIAL_EXPIRATION) + .truncatedTo(ChronoUnit.DAYS); + + final ExpiringProfileKeyCredentialResponse credentialResponse = + serverZkProfile.issueExpiringProfileKeyCredential(credentialRequest, AuthHelper.VALID_UUID, profileKeyCommitment, expiration); + + when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(account)); + when(profilesManager.get(AuthHelper.VALID_UUID, version)).thenReturn(Optional.of(versionedProfile)); + when(zkProfileOperations.issueExpiringProfileKeyCredential(credentialRequest, AuthHelper.VALID_UUID, profileKeyCommitment, expiration)) + .thenReturn(credentialResponse); + + final ExpiringProfileKeyCredentialProfileResponse profile = resources.getJerseyTest() + .target(String.format("/v1/profile/%s/%s/%s", AuthHelper.VALID_UUID, version, Hex.encodeHexString(credentialRequest.serialize()))) + .queryParam("credentialType", "expiringProfileKey") + .request() + .headers(authHeaders) + .get(ExpiringProfileKeyCredentialProfileResponse.class); + + assertThat(profile.getVersionedProfileResponse().getBaseProfileResponse().getUuid()).isEqualTo(AuthHelper.VALID_UUID); + assertThat(profile.getCredential()).isEqualTo(credentialResponse); + + verify(zkProfileOperations).issueExpiringProfileKeyCredential(credentialRequest, AuthHelper.VALID_UUID, profileKeyCommitment, expiration); + verify(zkProfileOperations, never()).issuePniCredential(any(), any(), any(), any()); + + final ClientZkProfileOperations clientZkProfileCipher = new ClientZkProfileOperations(serverPublicParams); + assertThatNoException().isThrownBy(() -> + clientZkProfileCipher.receiveExpiringProfileKeyCredential(profileKeyCredentialRequestContext, profile.getCredential())); + } + + private static Stream testGetProfileWithExpiringProfileKeyCredential() { + return Stream.of( + Arguments.of(new MultivaluedHashMap<>(Map.of(OptionalAccess.UNIDENTIFIED, Base64.getEncoder().encodeToString(UNIDENTIFIED_ACCESS_KEY)))), + Arguments.of(new MultivaluedHashMap<>(Map.of("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)))), + Arguments.of(new MultivaluedHashMap<>(Map.of("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)))) + ); + } + @Test void testGetProfileWithPniCredentialVersionNotFound() throws VerificationFailedException { final Account account = mock(Account.class);