From 6a37b73463d680148d588eba824ef53b40cc034d Mon Sep 17 00:00:00 2001 From: Katherine Yen Date: Wed, 30 Aug 2023 14:56:43 -0700 Subject: [PATCH] Profile gRPC: Define `getExpiringProfileKeyCredential` endpoint --- .../textsecuregcm/WhisperServerService.java | 4 +- .../controllers/ProfileController.java | 40 +--- .../grpc/ProfileAnonymousGrpcService.java | 37 ++- .../textsecuregcm/grpc/ProfileGrpcHelper.java | 30 +++ .../grpc/ProfileGrpcService.java | 27 +++ .../textsecuregcm/util/ProfileHelper.java | 27 +++ .../main/proto/org/signal/chat/profile.proto | 6 +- .../controllers/ProfileControllerTest.java | 158 +++++++----- .../grpc/ProfileAnonymousGrpcServiceTest.java | 225 +++++++++++++++++- .../grpc/ProfileGrpcServiceTest.java | 190 ++++++++++++++- .../storage/ProfilesManagerTest.java | 26 +- .../textsecuregcm/storage/ProfilesTest.java | 92 +++---- ...fileHelper.java => ProfileTestHelper.java} | 2 +- 13 files changed, 696 insertions(+), 168 deletions(-) rename service/src/test/java/org/whispersystems/textsecuregcm/tests/util/{ProfileHelper.java => ProfileTestHelper.java} (94%) diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 0e6cc0556..a580728dc 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -648,8 +648,8 @@ public class WhisperServerService extends Application getExpiringProfileKeyCredentialResponse(encodedCredentialRequest, profile, new ServiceId.Aci(account.getUuid()), expiration)) + .map(profile -> { + final ExpiringProfileKeyCredentialResponse profileKeyCredentialResponse; + try { + profileKeyCredentialResponse = ProfileHelper.getExpiringProfileKeyCredential(HexFormat.of().parseHex(encodedCredentialRequest), + profile, new ServiceId.Aci(account.getUuid()), zkProfileOperations); + } catch (VerificationFailedException | InvalidInputException e) { + throw new BadRequestException(Response.status(Response.Status.BAD_REQUEST).build(), e); + } + return profileKeyCredentialResponse; + }) .orElse(null); return new ExpiringProfileKeyCredentialProfileResponse( @@ -453,23 +450,6 @@ public class ProfileController { new PniServiceIdentifier(account.getPhoneNumberIdentifier())); } - private ExpiringProfileKeyCredentialResponse getExpiringProfileKeyCredentialResponse( - final String encodedCredentialRequest, - final VersionedProfile profile, - final ServiceId.Aci accountIdentifier, - final Instant expiration) { - - try { - final ProfileKeyCommitment commitment = new ProfileKeyCommitment(profile.commitment()); - final ProfileKeyCredentialRequest request = new ProfileKeyCredentialRequest( - HexFormat.of().parseHex(encodedCredentialRequest)); - - return zkProfileOperations.issueExpiringProfileKeyCredential(request, accountIdentifier, commitment, expiration); - } catch (IllegalArgumentException | VerificationFailedException | InvalidInputException e) { - throw new WebApplicationException(e, Response.status(Response.Status.BAD_REQUEST).build()); - } - } - private List getAcceptableLanguagesForRequest(final ContainerRequestContext containerRequestContext) { try { return containerRequestContext.getAcceptableLanguages(); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcService.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcService.java index f41333f10..9c7974418 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcService.java @@ -1,11 +1,15 @@ package org.whispersystems.textsecuregcm.grpc; import io.grpc.Status; +import org.signal.chat.profile.CredentialType; +import org.signal.chat.profile.GetExpiringProfileKeyCredentialAnonymousRequest; +import org.signal.chat.profile.GetExpiringProfileKeyCredentialResponse; import org.signal.chat.profile.GetUnversionedProfileAnonymousRequest; import org.signal.chat.profile.GetUnversionedProfileResponse; import org.signal.chat.profile.GetVersionedProfileAnonymousRequest; import org.signal.chat.profile.GetVersionedProfileResponse; import org.signal.chat.profile.ReactorProfileAnonymousGrpc; +import org.signal.libsignal.zkgroup.profiles.ServerZkProfileOperations; import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil; import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter; import org.whispersystems.textsecuregcm.identity.IdentityType; @@ -19,14 +23,17 @@ public class ProfileAnonymousGrpcService extends ReactorProfileAnonymousGrpc.Pro private final AccountsManager accountsManager; private final ProfilesManager profilesManager; private final ProfileBadgeConverter profileBadgeConverter; + private final ServerZkProfileOperations zkProfileOperations; public ProfileAnonymousGrpcService( final AccountsManager accountsManager, final ProfilesManager profilesManager, - final ProfileBadgeConverter profileBadgeConverter) { + final ProfileBadgeConverter profileBadgeConverter, + final ServerZkProfileOperations zkProfileOperations) { this.accountsManager = accountsManager; this.profilesManager = profilesManager; this.profileBadgeConverter = profileBadgeConverter; + this.zkProfileOperations = zkProfileOperations; } @Override @@ -58,10 +65,28 @@ public class ProfileAnonymousGrpcService extends ReactorProfileAnonymousGrpc.Pro .flatMap(targetAccount -> ProfileGrpcHelper.getVersionedProfile(targetAccount, profilesManager, request.getRequest().getVersion())); } - private Mono getTargetAccountAndValidateUnidentifiedAccess(final ServiceIdentifier targetIdentifier, final byte[] unidentifiedAccessKey) { - return Mono.fromFuture(() -> accountsManager.getByServiceIdentifierAsync(targetIdentifier)) - .flatMap(Mono::justOrEmpty) - .filter(targetAccount -> UnidentifiedAccessUtil.checkUnidentifiedAccess(targetAccount, unidentifiedAccessKey)) - .switchIfEmpty(Mono.error(Status.UNAUTHENTICATED.asException())); + @Override + public Mono getExpiringProfileKeyCredential( + final GetExpiringProfileKeyCredentialAnonymousRequest request) { + final ServiceIdentifier targetIdentifier = ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getRequest().getAccountIdentifier()); + + if (targetIdentifier.identityType() != IdentityType.ACI) { + throw Status.INVALID_ARGUMENT.withDescription("Expected ACI service identifier").asRuntimeException(); + } + + if (request.getRequest().getCredentialType() != CredentialType.CREDENTIAL_TYPE_EXPIRING_PROFILE_KEY) { + throw Status.INVALID_ARGUMENT.withDescription("Expected expiring profile key credential type").asRuntimeException(); + } + + return getTargetAccountAndValidateUnidentifiedAccess(targetIdentifier, request.getUnidentifiedAccessKey().toByteArray()) + .flatMap(account -> ProfileGrpcHelper.getExpiringProfileKeyCredentialResponse(account.getUuid(), + request.getRequest().getVersion(), request.getRequest().getCredentialRequest().toByteArray(), profilesManager, zkProfileOperations)); + } + + private Mono getTargetAccountAndValidateUnidentifiedAccess(final ServiceIdentifier targetIdentifier, final byte[] unidentifiedAccessKey) { + return Mono.fromFuture(() -> accountsManager.getByServiceIdentifierAsync(targetIdentifier)) + .flatMap(Mono::justOrEmpty) + .filter(targetAccount -> UnidentifiedAccessUtil.checkUnidentifiedAccess(targetAccount, unidentifiedAccessKey)) + .switchIfEmpty(Mono.error(Status.UNAUTHENTICATED.asException())); } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcHelper.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcHelper.java index 65aac798a..3913bfe1b 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcHelper.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcHelper.java @@ -8,9 +8,15 @@ import java.util.UUID; import io.grpc.Status; import org.signal.chat.profile.Badge; import org.signal.chat.profile.BadgeSvg; +import org.signal.chat.profile.GetExpiringProfileKeyCredentialResponse; import org.signal.chat.profile.GetUnversionedProfileResponse; import org.signal.chat.profile.GetVersionedProfileResponse; import org.signal.chat.profile.UserCapabilities; +import org.signal.libsignal.protocol.ServiceId; +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.ServerZkProfileOperations; import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessChecksum; import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter; import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier; @@ -119,4 +125,28 @@ public class ProfileGrpcHelper { return responseBuilder.build(); } + + static Mono getExpiringProfileKeyCredentialResponse( + final UUID targetUuid, + final String version, + final byte[] encodedCredentialRequest, + final ProfilesManager profilesManager, + final ServerZkProfileOperations zkProfileOperations) { + return Mono.fromFuture(profilesManager.getAsync(targetUuid, version)) + .flatMap(Mono::justOrEmpty) + .map(profile -> { + final ExpiringProfileKeyCredentialResponse profileKeyCredentialResponse; + try { + profileKeyCredentialResponse = ProfileHelper.getExpiringProfileKeyCredential(encodedCredentialRequest, + profile, new ServiceId.Aci(targetUuid), zkProfileOperations); + } catch (VerificationFailedException | InvalidInputException e) { + throw Status.INVALID_ARGUMENT.withCause(e).asRuntimeException(); + } + + return GetExpiringProfileKeyCredentialResponse.newBuilder() + .setProfileKeyCredential(ByteString.copyFrom(profileKeyCredentialResponse.serialize())) + .build(); + }) + .switchIfEmpty(Mono.error(Status.NOT_FOUND.withDescription("Profile version not found").asException())); + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcService.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcService.java index 3a175bc10..626a68542 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcService.java @@ -2,6 +2,9 @@ package org.whispersystems.textsecuregcm.grpc; import com.google.protobuf.ByteString; import io.grpc.Status; +import org.signal.chat.profile.CredentialType; +import org.signal.chat.profile.GetExpiringProfileKeyCredentialRequest; +import org.signal.chat.profile.GetExpiringProfileKeyCredentialResponse; import org.signal.chat.profile.GetUnversionedProfileRequest; import org.signal.chat.profile.GetUnversionedProfileResponse; import org.signal.chat.profile.GetVersionedProfileRequest; @@ -11,6 +14,7 @@ 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.signal.libsignal.zkgroup.profiles.ServerZkProfileOperations; import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice; import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil; import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter; @@ -56,6 +60,7 @@ public class ProfileGrpcService extends ReactorProfileGrpc.ProfileImplBase { private final PolicySigner policySigner; private final ProfileBadgeConverter profileBadgeConverter; private final RateLimiters rateLimiters; + private final ServerZkProfileOperations zkProfileOperations; private final String bucket; private record AvatarData(Optional currentAvatar, @@ -73,6 +78,7 @@ public class ProfileGrpcService extends ReactorProfileGrpc.ProfileImplBase { final PolicySigner policySigner, final ProfileBadgeConverter profileBadgeConverter, final RateLimiters rateLimiters, + final ServerZkProfileOperations zkProfileOperations, final String bucket) { this.clock = clock; this.accountsManager = accountsManager; @@ -85,6 +91,7 @@ public class ProfileGrpcService extends ReactorProfileGrpc.ProfileImplBase { this.policySigner = policySigner; this.profileBadgeConverter = profileBadgeConverter; this.rateLimiters = rateLimiters; + this.zkProfileOperations = zkProfileOperations; this.bucket = bucket; } @@ -186,6 +193,26 @@ public class ProfileGrpcService extends ReactorProfileGrpc.ProfileImplBase { .flatMap(account -> ProfileGrpcHelper.getVersionedProfile(account, profilesManager, request.getVersion())); } + @Override + public Mono getExpiringProfileKeyCredential( + final GetExpiringProfileKeyCredentialRequest request) { + final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice(); + final ServiceIdentifier targetIdentifier = ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getAccountIdentifier()); + + if (targetIdentifier.identityType() != IdentityType.ACI) { + throw Status.INVALID_ARGUMENT.withDescription("Expected ACI service identifier").asRuntimeException(); + } + + if (request.getCredentialType() != CredentialType.CREDENTIAL_TYPE_EXPIRING_PROFILE_KEY) { + throw Status.INVALID_ARGUMENT.withDescription("Expected expiring profile key credential type").asRuntimeException(); + } + + return validateRateLimitAndGetAccount(authenticatedDevice.accountIdentifier(), targetIdentifier) + .flatMap(targetAccount -> ProfileGrpcHelper.getExpiringProfileKeyCredentialResponse(targetAccount.getUuid(), + request.getVersion(), request.getCredentialRequest().toByteArray(), profilesManager, zkProfileOperations)); + } + + private Mono validateRateLimitAndGetAccount(final UUID requesterUuid, final ServiceIdentifier targetIdentifier) { return rateLimiters.getProfileLimiter().validateReactive(requesterUuid) diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ProfileHelper.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ProfileHelper.java index 724a41a4a..ad9bce015 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/ProfileHelper.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/ProfileHelper.java @@ -1,12 +1,23 @@ package org.whispersystems.textsecuregcm.util; +import com.google.common.annotations.VisibleForTesting; +import org.signal.libsignal.protocol.ServiceId; +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.ProfileKeyCommitment; +import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialRequest; +import org.signal.libsignal.zkgroup.profiles.ServerZkProfileOperations; import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration; import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier; import org.whispersystems.textsecuregcm.storage.AccountBadge; +import org.whispersystems.textsecuregcm.storage.VersionedProfile; import javax.annotation.Nullable; 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.Base64; import java.util.LinkedHashMap; @@ -16,6 +27,9 @@ import java.util.UUID; public class ProfileHelper { public static int MAX_PROFILE_AVATAR_SIZE_BYTES = 10 * 1024 * 1024; + @VisibleForTesting + public static final Duration EXPIRING_PROFILE_KEY_CREDENTIAL_EXPIRATION = Duration.ofDays(7); + public static List mergeBadgeIdsWithExistingAccountBadges( final Clock clock, final Map badgeConfigurationMap, @@ -70,4 +84,17 @@ public class ProfileHelper { public static boolean isSelfProfileRequest(@Nullable final UUID requesterUuid, final AciServiceIdentifier targetIdentifier) { return targetIdentifier.uuid().equals(requesterUuid); } + + public static ExpiringProfileKeyCredentialResponse getExpiringProfileKeyCredential( + final byte[] encodedCredentialRequest, + final VersionedProfile profile, + final ServiceId.Aci accountIdentifier, + final ServerZkProfileOperations zkProfileOperations) throws InvalidInputException, VerificationFailedException { + final Instant expiration = Instant.now().plus(EXPIRING_PROFILE_KEY_CREDENTIAL_EXPIRATION).truncatedTo(ChronoUnit.DAYS); + final ProfileKeyCommitment commitment = new ProfileKeyCommitment(profile.commitment()); + final ProfileKeyCredentialRequest request = new ProfileKeyCredentialRequest( + encodedCredentialRequest); + + return zkProfileOperations.issueExpiringProfileKeyCredential(request, accountIdentifier, commitment, expiration); + } } diff --git a/service/src/main/proto/org/signal/chat/profile.proto b/service/src/main/proto/org/signal/chat/profile.proto index fb175aead..063074459 100644 --- a/service/src/main/proto/org/signal/chat/profile.proto +++ b/service/src/main/proto/org/signal/chat/profile.proto @@ -253,6 +253,10 @@ message GetExpiringProfileKeyCredentialRequest { * The type of credential being requested. */ CredentialType credential_type = 3; + /** + * The profile version for which to generate a profile key credential. + */ + string version = 4; } message GetExpiringProfileKeyCredentialAnonymousRequest { @@ -271,7 +275,7 @@ message GetExpiringProfileKeyCredentialResponse { * A zkgroup credential used by a client to prove that it has the profile key * of a targeted account. */ - bytes profileKeyCredential = 2; + bytes profileKeyCredential = 1; } message CheckIdentityKeysRequest { diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ProfileControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ProfileControllerTest.java index 81b21f0b0..a8902242a 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ProfileControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ProfileControllerTest.java @@ -7,6 +7,7 @@ package org.whispersystems.textsecuregcm.controllers; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.refEq; import static org.mockito.Mockito.any; import static org.mockito.Mockito.clearInvocations; @@ -107,7 +108,7 @@ import org.whispersystems.textsecuregcm.storage.ProfilesManager; import org.whispersystems.textsecuregcm.storage.VersionedProfile; import org.whispersystems.textsecuregcm.tests.util.AccountsHelper; import org.whispersystems.textsecuregcm.tests.util.AuthHelper; -import org.whispersystems.textsecuregcm.tests.util.ProfileHelper; +import org.whispersystems.textsecuregcm.tests.util.ProfileTestHelper; import org.whispersystems.textsecuregcm.util.SystemMapper; import org.whispersystems.textsecuregcm.util.TestClock; import org.whispersystems.textsecuregcm.util.Util; @@ -222,9 +223,9 @@ class ProfileControllerTest { when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(capabilitiesAccount)); when(accountsManager.getByServiceIdentifier(new AciServiceIdentifier(AuthHelper.VALID_UUID))).thenReturn(Optional.of(capabilitiesAccount)); - final byte[] name = ProfileHelper.generateRandomByteArray(81); - final byte[] emoji = ProfileHelper.generateRandomByteArray(60); - final byte[] about = ProfileHelper.generateRandomByteArray(156); + final byte[] name = ProfileTestHelper.generateRandomByteArray(81); + final byte[] emoji = ProfileTestHelper.generateRandomByteArray(60); + final byte[] about = ProfileTestHelper.generateRandomByteArray(156); when(profilesManager.get(eq(AuthHelper.VALID_UUID), eq("someversion"))).thenReturn(Optional.empty()); when(profilesManager.get(eq(AuthHelper.VALID_UUID_TWO), eq("validversion"))).thenReturn(Optional.of(new VersionedProfile( @@ -409,14 +410,14 @@ class ProfileControllerTest { @Test void testSetProfileWantAvatarUpload() throws InvalidInputException { final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID)); - final byte[] name = ProfileHelper.generateRandomByteArray(81); + final byte[] name = ProfileTestHelper.generateRandomByteArray(81); final ProfileAvatarUploadAttributes uploadAttributes = resources.getJerseyTest() .target("/v1/profile/") .request() .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) .put(Entity.entity(new CreateProfileRequest(commitment, "someversion", - ProfileHelper.encodeToBase64(name), null, null, + ProfileTestHelper.encodeToBase64(name), null, null, null, true, false, List.of()), MediaType.APPLICATION_JSON_TYPE), ProfileAvatarUploadAttributes.class); final ArgumentCaptor profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class); @@ -437,7 +438,7 @@ class ProfileControllerTest { @Test void testSetProfileWantAvatarUploadWithBadProfileSize() throws InvalidInputException { final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID)); - final String name = ProfileHelper.generateRandomBase64FromByteArray(82); + final String name = ProfileTestHelper.generateRandomBase64FromByteArray(82); try (final Response response = resources.getJerseyTest() .target("/v1/profile/") @@ -453,7 +454,7 @@ class ProfileControllerTest { @Test void testSetProfileWithoutAvatarUpload() throws InvalidInputException { final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID)); - final byte[] name = ProfileHelper.generateRandomByteArray(81); + final byte[] name = ProfileTestHelper.generateRandomByteArray(81); clearInvocations(AuthHelper.VALID_ACCOUNT_TWO); @@ -461,7 +462,7 @@ class ProfileControllerTest { .target("/v1/profile/") .request() .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)) - .put(Entity.entity(new CreateProfileRequest(commitment, "anotherversion", ProfileHelper.encodeToBase64(name), null, null, + .put(Entity.entity(new CreateProfileRequest(commitment, "anotherversion", ProfileTestHelper.encodeToBase64(name), null, null, null, false, false, List.of()), MediaType.APPLICATION_JSON_TYPE))) { assertThat(response.getStatus()).isEqualTo(200); @@ -486,14 +487,14 @@ class ProfileControllerTest { @Test void testSetProfileWithAvatarUploadAndPreviousAvatar() throws InvalidInputException { final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID_TWO)); - final byte[] name = ProfileHelper.generateRandomByteArray(81); + final byte[] name = ProfileTestHelper.generateRandomByteArray(81); resources.getJerseyTest() .target("/v1/profile/") .request() .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)) .put(Entity.entity(new CreateProfileRequest(commitment, "validversion", - ProfileHelper.encodeToBase64(name), null, null, + ProfileTestHelper.encodeToBase64(name), null, null, null, true, false, List.of()), MediaType.APPLICATION_JSON_TYPE), ProfileAvatarUploadAttributes.class); final ArgumentCaptor profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class); @@ -513,13 +514,13 @@ class ProfileControllerTest { @Test void testSetProfileClearPreviousAvatar() throws InvalidInputException { final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID_TWO)); - final byte[] name = ProfileHelper.generateRandomByteArray(81); + final byte[] name = ProfileTestHelper.generateRandomByteArray(81); try (final Response response = resources.getJerseyTest() .target("/v1/profile/") .request() .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)) - .put(Entity.entity(new CreateProfileRequest(commitment, "validversion", ProfileHelper.encodeToBase64(name), + .put(Entity.entity(new CreateProfileRequest(commitment, "validversion", ProfileTestHelper.encodeToBase64(name), null, null, null, false, false, List.of()), MediaType.APPLICATION_JSON_TYPE))) { assertThat(response.getStatus()).isEqualTo(200); @@ -543,13 +544,13 @@ class ProfileControllerTest { @Test void testSetProfileWithSameAvatar() throws InvalidInputException { final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID_TWO)); - final byte[] name = ProfileHelper.generateRandomByteArray(81); + final byte[] name = ProfileTestHelper.generateRandomByteArray(81); try (final Response response = resources.getJerseyTest() .target("/v1/profile/") .request() .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)) - .put(Entity.entity(new CreateProfileRequest(commitment, "validversion", ProfileHelper.encodeToBase64(name), + .put(Entity.entity(new CreateProfileRequest(commitment, "validversion", ProfileTestHelper.encodeToBase64(name), null, null, null, true, true, List.of()), MediaType.APPLICATION_JSON_TYPE))) { assertThat(response.getStatus()).isEqualTo(200); @@ -573,13 +574,13 @@ class ProfileControllerTest { @Test void testSetProfileClearPreviousAvatarDespiteSameAvatarFlagSet() throws InvalidInputException { final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID_TWO)); - final byte[] name = ProfileHelper.generateRandomByteArray(81); + final byte[] name = ProfileTestHelper.generateRandomByteArray(81); try (final Response ignored = resources.getJerseyTest() .target("/v1/profile/") .request() .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)) - .put(Entity.entity(new CreateProfileRequest(commitment, "validversion", ProfileHelper.encodeToBase64(name), + .put(Entity.entity(new CreateProfileRequest(commitment, "validversion", ProfileTestHelper.encodeToBase64(name), null, null, null, false, true, List.of()), MediaType.APPLICATION_JSON_TYPE))) { @@ -601,13 +602,13 @@ class ProfileControllerTest { @Test void testSetProfileWithSameAvatarDespiteNoPreviousAvatar() throws InvalidInputException { final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID)); - final byte[] name = ProfileHelper.generateRandomByteArray(81); + final byte[] name = ProfileTestHelper.generateRandomByteArray(81); try (final Response response = resources.getJerseyTest() .target("/v1/profile/") .request() .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity(new CreateProfileRequest(commitment, "validversion", ProfileHelper.encodeToBase64(name), + .put(Entity.entity(new CreateProfileRequest(commitment, "validversion", ProfileTestHelper.encodeToBase64(name), null, null, null, true, true, List.of()), MediaType.APPLICATION_JSON_TYPE))) { assertThat(response.getStatus()).isEqualTo(200); @@ -632,14 +633,14 @@ class ProfileControllerTest { void testSetProfileExtendedName() throws InvalidInputException { final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID_TWO)); - final byte[] name = ProfileHelper.generateRandomByteArray(285); + final byte[] name = ProfileTestHelper.generateRandomByteArray(285); resources.getJerseyTest() .target("/v1/profile/") .request() .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)) .put(Entity.entity( - new CreateProfileRequest(commitment, "validversion", ProfileHelper.encodeToBase64(name), + new CreateProfileRequest(commitment, "validversion", ProfileTestHelper.encodeToBase64(name), null, null, null, true, false, List.of()), MediaType.APPLICATION_JSON_TYPE), ProfileAvatarUploadAttributes.class); @@ -663,17 +664,17 @@ class ProfileControllerTest { clearInvocations(AuthHelper.VALID_ACCOUNT_TWO); - final byte[] name = ProfileHelper.generateRandomByteArray(81); - final byte[] emoji = ProfileHelper.generateRandomByteArray(60); - final byte[] about = ProfileHelper.generateRandomByteArray(156); + final byte[] name = ProfileTestHelper.generateRandomByteArray(81); + final byte[] emoji = ProfileTestHelper.generateRandomByteArray(60); + final byte[] about = ProfileTestHelper.generateRandomByteArray(156); try (final Response response = resources.getJerseyTest() .target("/v1/profile/") .request() .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)) .put(Entity.entity( - new CreateProfileRequest(commitment, "anotherversion", ProfileHelper.encodeToBase64(name), - ProfileHelper.encodeToBase64(emoji), ProfileHelper.encodeToBase64(about), null, false, false, List.of()), + new CreateProfileRequest(commitment, "anotherversion", ProfileTestHelper.encodeToBase64(name), + ProfileTestHelper.encodeToBase64(emoji), ProfileTestHelper.encodeToBase64(about), null, false, false, List.of()), MediaType.APPLICATION_JSON_TYPE))) { assertThat(response.getStatus()).isEqualTo(200); @@ -703,16 +704,16 @@ class ProfileControllerTest { clearInvocations(AuthHelper.VALID_ACCOUNT_TWO); - final byte[] name = ProfileHelper.generateRandomByteArray(81); - final byte[] paymentAddress = ProfileHelper.generateRandomByteArray(582); + final byte[] name = ProfileTestHelper.generateRandomByteArray(81); + final byte[] paymentAddress = ProfileTestHelper.generateRandomByteArray(582); try (final Response response = resources.getJerseyTest() .target("/v1/profile") .request() .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)) .put(Entity.entity( - new CreateProfileRequest(commitment, "yetanotherversion", ProfileHelper.encodeToBase64(name), - null, null, ProfileHelper.encodeToBase64(paymentAddress), false, false, + new CreateProfileRequest(commitment, "yetanotherversion", ProfileTestHelper.encodeToBase64(name), + null, null, ProfileTestHelper.encodeToBase64(paymentAddress), false, false, List.of()), MediaType.APPLICATION_JSON_TYPE))) { assertThat(response.getStatus()).isEqualTo(200); @@ -745,8 +746,8 @@ class ProfileControllerTest { clearInvocations(AuthHelper.VALID_ACCOUNT_TWO); - final String name = ProfileHelper.generateRandomBase64FromByteArray(81); - final String paymentAddress = ProfileHelper.generateRandomBase64FromByteArray(582); + final String name = ProfileTestHelper.generateRandomBase64FromByteArray(81); + final String paymentAddress = ProfileTestHelper.generateRandomBase64FromByteArray(582); try (final Response response = resources.getJerseyTest() .target("/v1/profile") @@ -772,15 +773,15 @@ class ProfileControllerTest { .thenReturn(List.of(AuthHelper.VALID_NUMBER_TWO.substring(0, 3))); final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID)); - final byte[] name = ProfileHelper.generateRandomByteArray(81); - final byte[] paymentAddress = ProfileHelper.generateRandomByteArray(582); + final byte[] name = ProfileTestHelper.generateRandomByteArray(81); + final byte[] paymentAddress = ProfileTestHelper.generateRandomByteArray(582); clearInvocations(AuthHelper.VALID_ACCOUNT_TWO); when(profilesManager.get(eq(AuthHelper.VALID_UUID_TWO), any())) .thenReturn(Optional.of( new VersionedProfile("1", name, null, null, null, - existingPaymentAddressOnProfile ? ProfileHelper.generateRandomByteArray(582) : null, + existingPaymentAddressOnProfile ? ProfileTestHelper.generateRandomByteArray(582) : null, commitment.serialize()))); @@ -789,8 +790,8 @@ class ProfileControllerTest { .request() .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)) .put(Entity.entity( - new CreateProfileRequest(commitment, "yetanotherversion", ProfileHelper.encodeToBase64(name), - null, null, ProfileHelper.encodeToBase64(paymentAddress), false, false, + new CreateProfileRequest(commitment, "yetanotherversion", ProfileTestHelper.encodeToBase64(name), + null, null, ProfileTestHelper.encodeToBase64(paymentAddress), false, false, List.of()), MediaType.APPLICATION_JSON_TYPE))) { if (existingPaymentAddressOnProfile) { @@ -823,9 +824,9 @@ class ProfileControllerTest { @Test void testGetProfileByVersion() throws RateLimitExceededException { - final byte[] name = ProfileHelper.generateRandomByteArray(81); - final byte[] emoji = ProfileHelper.generateRandomByteArray(60); - final byte[] about = ProfileHelper.generateRandomByteArray(156); + final byte[] name = ProfileTestHelper.generateRandomByteArray(81); + final byte[] emoji = ProfileTestHelper.generateRandomByteArray(60); + final byte[] about = ProfileTestHelper.generateRandomByteArray(156); when(profilesManager.get(eq(AuthHelper.VALID_UUID_TWO), eq("validversion"))).thenReturn(Optional.of(new VersionedProfile( "validversion", name, "profiles/validavatar", emoji, about, null, "validcommitmnet".getBytes()))); @@ -837,9 +838,9 @@ class ProfileControllerTest { .get(VersionedProfileResponse.class); assertThat(profile.getBaseProfileResponse().getIdentityKey()).isEqualTo(ACCOUNT_TWO_IDENTITY_KEY); - assertThat(profile.getName()).isEqualTo(ProfileHelper.encodeToBase64(name)); - assertThat(profile.getAbout()).isEqualTo(ProfileHelper.encodeToBase64(about)); - assertThat(profile.getAboutEmoji()).isEqualTo(ProfileHelper.encodeToBase64(emoji)); + assertThat(profile.getName()).isEqualTo(ProfileTestHelper.encodeToBase64(name)); + assertThat(profile.getAbout()).isEqualTo(ProfileTestHelper.encodeToBase64(about)); + assertThat(profile.getAboutEmoji()).isEqualTo(ProfileTestHelper.encodeToBase64(emoji)); assertThat(profile.getAvatar()).isEqualTo("profiles/validavatar"); assertThat(profile.getBaseProfileResponse().getCapabilities().gv1Migration()).isTrue(); assertThat(profile.getBaseProfileResponse().getUuid()).isEqualTo(new AciServiceIdentifier(AuthHelper.VALID_UUID_TWO)); @@ -858,8 +859,8 @@ class ProfileControllerTest { clearInvocations(AuthHelper.VALID_ACCOUNT_TWO); - final String name = ProfileHelper.generateRandomBase64FromByteArray(81); - final String paymentAddress = ProfileHelper.generateRandomBase64FromByteArray(582); + final String name = ProfileTestHelper.generateRandomBase64FromByteArray(81); + final String paymentAddress = ProfileTestHelper.generateRandomBase64FromByteArray(582); try (final Response response = resources.getJerseyTest() .target("/v1/profile") @@ -878,7 +879,7 @@ class ProfileControllerTest { @Test void testGetProfileReturnsNoPaymentAddressIfCurrentVersionMismatch() { - final byte[] paymentAddress = ProfileHelper.generateRandomByteArray(582); + final byte[] paymentAddress = ProfileTestHelper.generateRandomByteArray(582); when(profilesManager.get(AuthHelper.VALID_UUID_TWO, "validversion")).thenReturn( Optional.of(new VersionedProfile(null, null, null, null, null, paymentAddress, null))); @@ -889,7 +890,7 @@ class ProfileControllerTest { .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) .get(VersionedProfileResponse.class); - assertThat(profile.getPaymentAddress()).isEqualTo(ProfileHelper.encodeToBase64(paymentAddress)); + assertThat(profile.getPaymentAddress()).isEqualTo(ProfileTestHelper.encodeToBase64(paymentAddress)); } when(profileAccount.getCurrentProfileVersion()).thenReturn(Optional.of("validversion")); @@ -901,7 +902,7 @@ class ProfileControllerTest { .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) .get(VersionedProfileResponse.class); - assertThat(profile.getPaymentAddress()).isEqualTo(ProfileHelper.encodeToBase64(paymentAddress)); + assertThat(profile.getPaymentAddress()).isEqualTo(ProfileTestHelper.encodeToBase64(paymentAddress)); } when(profileAccount.getCurrentProfileVersion()).thenReturn(Optional.of("someotherversion")); @@ -948,9 +949,9 @@ class ProfileControllerTest { clearInvocations(AuthHelper.VALID_ACCOUNT_TWO); - final String name = ProfileHelper.generateRandomBase64FromByteArray(81); - final String emoji = ProfileHelper.generateRandomBase64FromByteArray(60); - final String about = ProfileHelper.generateRandomBase64FromByteArray(156); + final String name = ProfileTestHelper.generateRandomBase64FromByteArray(81); + final String emoji = ProfileTestHelper.generateRandomBase64FromByteArray(60); + final String about = ProfileTestHelper.generateRandomBase64FromByteArray(156); try (final Response response = resources.getJerseyTest() .target("/v1/profile/") @@ -1081,7 +1082,7 @@ class ProfileControllerTest { when(account.isEnabled()).thenReturn(true); when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(UNIDENTIFIED_ACCESS_KEY)); - final Instant expiration = Instant.now().plus(ProfileController.EXPIRING_PROFILE_KEY_CREDENTIAL_EXPIRATION) + final Instant expiration = Instant.now().plus(org.whispersystems.textsecuregcm.util.ProfileHelper.EXPIRING_PROFILE_KEY_CREDENTIAL_EXPIRATION) .truncatedTo(ChronoUnit.DAYS); final ExpiringProfileKeyCredentialResponse credentialResponse = @@ -1089,7 +1090,7 @@ class ProfileControllerTest { 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, new ServiceId.Aci(AuthHelper.VALID_UUID), profileKeyCommitment, expiration)) + when(zkProfileOperations.issueExpiringProfileKeyCredential(eq(credentialRequest), eq(new ServiceId.Aci(AuthHelper.VALID_UUID)), eq(profileKeyCommitment), any())) .thenReturn(credentialResponse); final ExpiringProfileKeyCredentialProfileResponse profile = resources.getJerseyTest() @@ -1119,15 +1120,60 @@ class ProfileControllerTest { ); } + @Test + void testGetProfileWithExpiringProfileKeyCredentialBadRequest() + throws VerificationFailedException, InvalidInputException { + final String version = "version"; + + final ServerSecretParams serverSecretParams = ServerSecretParams.generate(); + final ServerPublicParams serverPublicParams = serverSecretParams.getPublicParams(); + + 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(new ServiceId.Aci(AuthHelper.VALID_UUID)); + + final VersionedProfile versionedProfile = mock(VersionedProfile.class); + when(versionedProfile.commitment()).thenReturn(profileKeyCommitment.serialize()); + + final ProfileKeyCredentialRequestContext profileKeyCredentialRequestContext = + clientZkProfile.createProfileKeyCredentialRequestContext(new ServiceId.Aci(AuthHelper.VALID_UUID), profileKey); + + final ProfileKeyCredentialRequest credentialRequest = profileKeyCredentialRequestContext.getRequest(); + + final Account account = mock(Account.class); + when(account.getUuid()).thenReturn(AuthHelper.VALID_UUID); + when(account.isEnabled()).thenReturn(true); + when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(UNIDENTIFIED_ACCESS_KEY)); + + when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(account)); + when(profilesManager.get(AuthHelper.VALID_UUID, version)).thenReturn(Optional.of(versionedProfile)); + when(zkProfileOperations.issueExpiringProfileKeyCredential(any(), any(), any(), any())) + .thenThrow(new VerificationFailedException()); + + final Response response = resources.getJerseyTest() + .target(String.format("/v1/profile/%s/%s/%s", AuthHelper.VALID_UUID, version, + HexFormat.of().formatHex(credentialRequest.serialize()))) + .queryParam("credentialType", "expiringProfileKey") + .request() + .headers(new MultivaluedHashMap<>(Map.of(OptionalAccess.UNIDENTIFIED, Base64.getEncoder().encodeToString(UNIDENTIFIED_ACCESS_KEY)))) + .get(); + + assertEquals(400, response.getStatus()); + } + @Test void testSetProfileBadgesMissingFromRequest() throws InvalidInputException { final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID)); clearInvocations(AuthHelper.VALID_ACCOUNT_TWO); - final String name = ProfileHelper.generateRandomBase64FromByteArray(81); - final String emoji = ProfileHelper.generateRandomBase64FromByteArray(60); - final String text = ProfileHelper.generateRandomBase64FromByteArray(156); + final String name = ProfileTestHelper.generateRandomBase64FromByteArray(81); + final String emoji = ProfileTestHelper.generateRandomBase64FromByteArray(60); + final String text = ProfileTestHelper.generateRandomBase64FromByteArray(156); when(AuthHelper.VALID_ACCOUNT_TWO.getBadges()).thenReturn(List.of( new AccountBadge("TEST", Instant.ofEpochSecond(42 + 86400), true) diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcServiceTest.java index 82ad9e0b8..c129ac8df 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcServiceTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcServiceTest.java @@ -1,17 +1,23 @@ package org.whispersystems.textsecuregcm.grpc; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatNoException; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import com.google.protobuf.ByteString; import io.grpc.Metadata; import io.grpc.Status; +import java.nio.charset.StandardCharsets; import java.security.SecureRandom; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Collections; import java.util.List; import java.util.Optional; @@ -28,6 +34,10 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.signal.chat.common.IdentityType; import org.signal.chat.common.ServiceIdentifier; +import org.signal.chat.profile.CredentialType; +import org.signal.chat.profile.GetExpiringProfileKeyCredentialAnonymousRequest; +import org.signal.chat.profile.GetExpiringProfileKeyCredentialRequest; +import org.signal.chat.profile.GetExpiringProfileKeyCredentialResponse; import org.signal.chat.profile.GetUnversionedProfileAnonymousRequest; import org.signal.chat.profile.GetUnversionedProfileRequest; import org.signal.chat.profile.GetUnversionedProfileResponse; @@ -36,8 +46,20 @@ import org.signal.chat.profile.GetVersionedProfileRequest; import org.signal.chat.profile.GetVersionedProfileResponse; import org.signal.chat.profile.ProfileAnonymousGrpc; import org.signal.libsignal.protocol.IdentityKey; +import org.signal.libsignal.protocol.ServiceId; import org.signal.libsignal.protocol.ecc.Curve; import org.signal.libsignal.protocol.ecc.ECKeyPair; +import org.signal.libsignal.zkgroup.InvalidInputException; +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.ProfileKey; +import org.signal.libsignal.zkgroup.profiles.ProfileKeyCommitment; +import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialRequest; +import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialRequestContext; +import org.signal.libsignal.zkgroup.profiles.ServerZkProfileOperations; import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessChecksum; import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter; import org.whispersystems.textsecuregcm.entities.Badge; @@ -48,7 +70,7 @@ import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.ProfilesManager; import org.whispersystems.textsecuregcm.storage.VersionedProfile; -import org.whispersystems.textsecuregcm.tests.util.ProfileHelper; +import org.whispersystems.textsecuregcm.tests.util.ProfileTestHelper; import org.whispersystems.textsecuregcm.util.UUIDUtil; import javax.annotation.Nullable; @@ -58,6 +80,7 @@ public class ProfileAnonymousGrpcServiceTest { private ProfilesManager profilesManager; private ProfileBadgeConverter profileBadgeConverter; private ProfileAnonymousGrpc.ProfileAnonymousBlockingStub profileAnonymousBlockingStub; + private ServerZkProfileOperations serverZkProfileOperations; @RegisterExtension static final GrpcServerExtension GRPC_SERVER_EXTENSION = new GrpcServerExtension(); @@ -68,6 +91,7 @@ public class ProfileAnonymousGrpcServiceTest { accountsManager = mock(AccountsManager.class); profilesManager = mock(ProfilesManager.class); profileBadgeConverter = mock(ProfileBadgeConverter.class); + serverZkProfileOperations = mock(ServerZkProfileOperations.class); final Metadata metadata = new Metadata(); metadata.put(AcceptLanguageInterceptor.ACCEPTABLE_LANGUAGES_GRPC_HEADER, "en-us"); @@ -79,7 +103,8 @@ public class ProfileAnonymousGrpcServiceTest { final ProfileAnonymousGrpcService profileAnonymousGrpcService = new ProfileAnonymousGrpcService( accountsManager, profilesManager, - profileBadgeConverter + profileBadgeConverter, + serverZkProfileOperations ); GRPC_SERVER_EXTENSION.getServiceRegistry() @@ -187,11 +212,11 @@ public class ProfileAnonymousGrpcServiceTest { new SecureRandom().nextBytes(unidentifiedAccessKey); final VersionedProfile profile = mock(VersionedProfile.class); - final byte[] name = ProfileHelper.generateRandomByteArray(81); - final byte[] emoji = ProfileHelper.generateRandomByteArray(60); - final byte[] about = ProfileHelper.generateRandomByteArray(156); - final byte[] paymentAddress = ProfileHelper.generateRandomByteArray(582); - final String avatar = "profiles/" + ProfileHelper.generateRandomBase64FromByteArray(16); + final byte[] name = ProfileTestHelper.generateRandomByteArray(81); + final byte[] emoji = ProfileTestHelper.generateRandomByteArray(60); + final byte[] about = ProfileTestHelper.generateRandomByteArray(156); + final byte[] paymentAddress = ProfileTestHelper.generateRandomByteArray(582); + final String avatar = "profiles/" + ProfileTestHelper.generateRandomBase64FromByteArray(16); when(profile.name()).thenReturn(name); when(profile.aboutEmoji()).thenReturn(emoji); @@ -326,4 +351,190 @@ public class ProfileAnonymousGrpcServiceTest { assertEquals(Status.INVALID_ARGUMENT.getCode(), statusRuntimeException.getStatus().getCode()); } + + @Test + void getExpiringProfileKeyCredential() throws InvalidInputException, VerificationFailedException { + final byte[] unidentifiedAccessKey = new byte[16]; + new SecureRandom().nextBytes(unidentifiedAccessKey); + final UUID targetUuid = UUID.randomUUID(); + + 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(new ServiceId.Aci(targetUuid)); + final ProfileKeyCredentialRequestContext profileKeyCredentialRequestContext = + clientZkProfile.createProfileKeyCredentialRequestContext(new ServiceId.Aci(targetUuid), profileKey); + + final VersionedProfile profile = mock(VersionedProfile.class); + when(profile.commitment()).thenReturn(profileKeyCommitment.serialize()); + + when(account.getUuid()).thenReturn(targetUuid); + when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey)); + when(accountsManager.getByServiceIdentifierAsync(new AciServiceIdentifier(targetUuid))).thenReturn(CompletableFuture.completedFuture(Optional.of(account))); + when(profilesManager.getAsync(targetUuid, "someVersion")).thenReturn(CompletableFuture.completedFuture(Optional.of(profile))); + + final ProfileKeyCredentialRequest credentialRequest = profileKeyCredentialRequestContext.getRequest(); + + final Instant expiration = Instant.now().plus(org.whispersystems.textsecuregcm.util.ProfileHelper.EXPIRING_PROFILE_KEY_CREDENTIAL_EXPIRATION) + .truncatedTo(ChronoUnit.DAYS); + + final ExpiringProfileKeyCredentialResponse credentialResponse = + serverZkProfile.issueExpiringProfileKeyCredential(credentialRequest, new ServiceId.Aci(targetUuid), profileKeyCommitment, expiration); + + when(serverZkProfileOperations.issueExpiringProfileKeyCredential(credentialRequest, new ServiceId.Aci(targetUuid), profileKeyCommitment, expiration)) + .thenReturn(credentialResponse); + + final GetExpiringProfileKeyCredentialAnonymousRequest request = GetExpiringProfileKeyCredentialAnonymousRequest.newBuilder() + .setRequest(GetExpiringProfileKeyCredentialRequest.newBuilder() + .setAccountIdentifier(ServiceIdentifier.newBuilder() + .setIdentityType(IdentityType.IDENTITY_TYPE_ACI) + .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(targetUuid))) + .build()) + .setCredentialRequest(ByteString.copyFrom(credentialRequest.serialize())) + .setCredentialType(CredentialType.CREDENTIAL_TYPE_EXPIRING_PROFILE_KEY) + .setVersion("someVersion") + .build()) + .setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey)) + .build(); + + final GetExpiringProfileKeyCredentialResponse response = profileAnonymousBlockingStub.getExpiringProfileKeyCredential(request); + + assertArrayEquals(credentialResponse.serialize(), response.getProfileKeyCredential().toByteArray()); + + verify(serverZkProfileOperations).issueExpiringProfileKeyCredential(credentialRequest, new ServiceId.Aci(targetUuid), profileKeyCommitment, expiration); + + final ClientZkProfileOperations clientZkProfileCipher = new ClientZkProfileOperations(serverPublicParams); + assertThatNoException().isThrownBy(() -> + clientZkProfileCipher.receiveExpiringProfileKeyCredential(profileKeyCredentialRequestContext, new ExpiringProfileKeyCredentialResponse(response.getProfileKeyCredential().toByteArray()))); + } + + @ParameterizedTest + @MethodSource + void getExpiringProfileKeyCredentialUnauthenticated(final boolean missingAccount, final boolean missingUnidentifiedAccessKey) { + final byte[] unidentifiedAccessKey = new byte[16]; + new SecureRandom().nextBytes(unidentifiedAccessKey); + final UUID targetUuid = UUID.randomUUID(); + + when(account.getUuid()).thenReturn(targetUuid); + when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey)); + when(accountsManager.getByServiceIdentifierAsync(new AciServiceIdentifier(targetUuid))).thenReturn( + CompletableFuture.completedFuture(missingAccount ? Optional.empty() : Optional.of(account))); + + final GetExpiringProfileKeyCredentialAnonymousRequest.Builder requestBuilder = GetExpiringProfileKeyCredentialAnonymousRequest.newBuilder() + .setRequest(GetExpiringProfileKeyCredentialRequest.newBuilder() + .setAccountIdentifier(ServiceIdentifier.newBuilder() + .setIdentityType(IdentityType.IDENTITY_TYPE_ACI) + .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(targetUuid))) + .build()) + .setCredentialRequest(ByteString.copyFrom("credentialRequest".getBytes(StandardCharsets.UTF_8))) + .setCredentialType(CredentialType.CREDENTIAL_TYPE_EXPIRING_PROFILE_KEY) + .setVersion("someVersion") + .build()); + + if (!missingUnidentifiedAccessKey) { + requestBuilder.setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey)); + } + + final StatusRuntimeException statusRuntimeException = assertThrows(StatusRuntimeException.class, + () -> profileAnonymousBlockingStub.getExpiringProfileKeyCredential(requestBuilder.build())); + + assertEquals(Status.UNAUTHENTICATED.getCode(), statusRuntimeException.getStatus().getCode()); + + verifyNoInteractions(profilesManager); + } + + private static Stream getExpiringProfileKeyCredentialUnauthenticated() { + return Stream.of( + Arguments.of(true, false), + Arguments.of(false, true) + ); + } + + + @Test + void getExpiringProfileKeyCredentialProfileNotFound() { + final byte[] unidentifiedAccessKey = new byte[16]; + new SecureRandom().nextBytes(unidentifiedAccessKey); + final UUID targetUuid = UUID.randomUUID(); + + when(account.getUuid()).thenReturn(targetUuid); + when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey)); + when(accountsManager.getByServiceIdentifierAsync(new AciServiceIdentifier(targetUuid))).thenReturn( + CompletableFuture.completedFuture(Optional.of(account))); + when(profilesManager.getAsync(targetUuid, "someVersion")).thenReturn(CompletableFuture.completedFuture(Optional.empty())); + + final GetExpiringProfileKeyCredentialAnonymousRequest request = GetExpiringProfileKeyCredentialAnonymousRequest.newBuilder() + .setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey)) + .setRequest(GetExpiringProfileKeyCredentialRequest.newBuilder() + .setAccountIdentifier(ServiceIdentifier.newBuilder() + .setIdentityType(IdentityType.IDENTITY_TYPE_ACI) + .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(targetUuid))) + .build()) + .setCredentialRequest(ByteString.copyFrom("credentialRequest".getBytes(StandardCharsets.UTF_8))) + .setCredentialType(CredentialType.CREDENTIAL_TYPE_EXPIRING_PROFILE_KEY) + .setVersion("someVersion") + .build()) + .build(); + + final StatusRuntimeException statusRuntimeException = assertThrows(StatusRuntimeException.class, + () -> profileAnonymousBlockingStub.getExpiringProfileKeyCredential(request)); + + assertEquals(Status.NOT_FOUND.getCode(), statusRuntimeException.getStatus().getCode()); + } + + @ParameterizedTest + @MethodSource + void getExpiringProfileKeyCredentialInvalidArgument(final IdentityType identityType, final CredentialType credentialType, + final boolean throwZkVerificationException) throws VerificationFailedException { + final UUID targetUuid = UUID.randomUUID(); + final byte[] unidentifiedAccessKey = new byte[16]; + new SecureRandom().nextBytes(unidentifiedAccessKey); + + if (throwZkVerificationException) { + when(serverZkProfileOperations.issueExpiringProfileKeyCredential(any(), any(), any(), any())).thenThrow(new VerificationFailedException()); + } + + final VersionedProfile profile = mock(VersionedProfile.class); + when(profile.commitment()).thenReturn("commitment".getBytes(StandardCharsets.UTF_8)); + when(account.getUuid()).thenReturn(targetUuid); + when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey)); + when(accountsManager.getByServiceIdentifierAsync(new AciServiceIdentifier(targetUuid))).thenReturn(CompletableFuture.completedFuture(Optional.of(account))); + when(profilesManager.getAsync(targetUuid, "someVersion")).thenReturn(CompletableFuture.completedFuture(Optional.of(profile))); + + final GetExpiringProfileKeyCredentialAnonymousRequest request = GetExpiringProfileKeyCredentialAnonymousRequest.newBuilder() + .setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey)) + .setRequest(GetExpiringProfileKeyCredentialRequest.newBuilder() + .setAccountIdentifier(ServiceIdentifier.newBuilder() + .setIdentityType(identityType) + .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(targetUuid))) + .build()) + .setCredentialRequest(ByteString.copyFrom("credentialRequest".getBytes(StandardCharsets.UTF_8))) + .setCredentialType(credentialType) + .setVersion("someVersion") + .build()) + .build(); + + final StatusRuntimeException statusRuntimeException = assertThrows(StatusRuntimeException.class, + () -> profileAnonymousBlockingStub.getExpiringProfileKeyCredential(request)); + + assertEquals(Status.INVALID_ARGUMENT.getCode(), statusRuntimeException.getStatus().getCode()); + } + + private static Stream getExpiringProfileKeyCredentialInvalidArgument() { + return Stream.of( + // Credential type unspecified + Arguments.of(IdentityType.IDENTITY_TYPE_ACI, CredentialType.CREDENTIAL_TYPE_UNSPECIFIED, false), + // Illegal identity type + Arguments.of(IdentityType.IDENTITY_TYPE_PNI, CredentialType.CREDENTIAL_TYPE_EXPIRING_PROFILE_KEY, false), + // Artificially fails zero knowledge verification + Arguments.of(IdentityType.IDENTITY_TYPE_ACI, CredentialType.CREDENTIAL_TYPE_EXPIRING_PROFILE_KEY, true) + ); + } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcServiceTest.java index d49a6cbac..fea517424 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcServiceTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcServiceTest.java @@ -19,6 +19,9 @@ import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import org.signal.chat.common.IdentityType; import org.signal.chat.common.ServiceIdentifier; +import org.signal.chat.profile.CredentialType; +import org.signal.chat.profile.GetExpiringProfileKeyCredentialRequest; +import org.signal.chat.profile.GetExpiringProfileKeyCredentialResponse; import org.signal.chat.profile.GetUnversionedProfileRequest; import org.signal.chat.profile.GetUnversionedProfileResponse; import org.signal.chat.profile.GetVersionedProfileRequest; @@ -32,7 +35,16 @@ import org.signal.libsignal.protocol.ServiceId; import org.signal.libsignal.protocol.ecc.Curve; import org.signal.libsignal.protocol.ecc.ECKeyPair; import org.signal.libsignal.zkgroup.InvalidInputException; +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.ProfileKey; +import org.signal.libsignal.zkgroup.profiles.ProfileKeyCommitment; +import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialRequest; +import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialRequestContext; +import org.signal.libsignal.zkgroup.profiles.ServerZkProfileOperations; import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessChecksum; import org.whispersystems.textsecuregcm.auth.grpc.MockAuthenticationInterceptor; import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter; @@ -57,15 +69,18 @@ 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 org.whispersystems.textsecuregcm.tests.util.ProfileHelper; +import org.whispersystems.textsecuregcm.tests.util.ProfileTestHelper; import org.whispersystems.textsecuregcm.util.UUIDUtil; import reactor.core.publisher.Mono; import software.amazon.awssdk.services.s3.S3AsyncClient; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; import javax.annotation.Nullable; +import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.time.Clock; import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Collections; import java.util.List; import java.util.Map; @@ -75,6 +90,8 @@ import java.util.concurrent.CompletableFuture; import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatNoException; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -103,6 +120,7 @@ public class ProfileGrpcServiceTest { private Account account; private RateLimiter rateLimiter; private ProfileBadgeConverter profileBadgeConverter; + private ServerZkProfileOperations serverZkProfileOperations; private ProfileGrpc.ProfileBlockingStub profileBlockingStub; @RegisterExtension @@ -118,6 +136,7 @@ public class ProfileGrpcServiceTest { account = mock(Account.class); rateLimiter = mock(RateLimiter.class); profileBadgeConverter = mock(ProfileBadgeConverter.class); + serverZkProfileOperations = mock(ServerZkProfileOperations.class); @SuppressWarnings("unchecked") final DynamicConfigurationManager dynamicConfigurationManager = mock(DynamicConfigurationManager.class); final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class); @@ -160,6 +179,7 @@ public class ProfileGrpcServiceTest { policySigner, profileBadgeConverter, rateLimiters, + serverZkProfileOperations, S3_BUCKET ); @@ -482,11 +502,11 @@ public class ProfileGrpcServiceTest { @MethodSource void getVersionedProfile(final String requestVersion, @Nullable final String accountVersion, final boolean expectResponseHasPaymentAddress) { final VersionedProfile profile = mock(VersionedProfile.class); - final byte[] name = ProfileHelper.generateRandomByteArray(81); - final byte[] emoji = ProfileHelper.generateRandomByteArray(60); - final byte[] about = ProfileHelper.generateRandomByteArray(156); - final byte[] paymentAddress = ProfileHelper.generateRandomByteArray(582); - final String avatar = "profiles/" + ProfileHelper.generateRandomBase64FromByteArray(16); + final byte[] name = ProfileTestHelper.generateRandomByteArray(81); + final byte[] emoji = ProfileTestHelper.generateRandomByteArray(60); + final byte[] about = ProfileTestHelper.generateRandomByteArray(156); + final byte[] paymentAddress = ProfileTestHelper.generateRandomByteArray(582); + final String avatar = "profiles/" + ProfileTestHelper.generateRandomBase64FromByteArray(16); final GetVersionedProfileRequest request = GetVersionedProfileRequest.newBuilder() .setAccountIdentifier(ServiceIdentifier.newBuilder() @@ -594,4 +614,162 @@ public class ProfileGrpcServiceTest { () -> profileBlockingStub.getVersionedProfile(request)); assertEquals(Status.INVALID_ARGUMENT.getCode(), exception.getStatus().getCode()); } + + @Test + void getExpiringProfileKeyCredential() throws InvalidInputException, VerificationFailedException { + final UUID targetUuid = UUID.randomUUID(); + + 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(new ServiceId.Aci(targetUuid)); + final ProfileKeyCredentialRequestContext profileKeyCredentialRequestContext = + clientZkProfile.createProfileKeyCredentialRequestContext(new ServiceId.Aci(targetUuid), profileKey); + + when(account.getUuid()).thenReturn(targetUuid); + when(profile.commitment()).thenReturn(profileKeyCommitment.serialize()); + when(accountsManager.getByServiceIdentifierAsync(new AciServiceIdentifier(targetUuid))).thenReturn(CompletableFuture.completedFuture(Optional.of(account))); + when(profilesManager.getAsync(targetUuid, "someVersion")).thenReturn(CompletableFuture.completedFuture(Optional.of(profile))); + + final ProfileKeyCredentialRequest credentialRequest = profileKeyCredentialRequestContext.getRequest(); + + final Instant expiration = Instant.now().plus(org.whispersystems.textsecuregcm.util.ProfileHelper.EXPIRING_PROFILE_KEY_CREDENTIAL_EXPIRATION) + .truncatedTo(ChronoUnit.DAYS); + + final ExpiringProfileKeyCredentialResponse credentialResponse = + serverZkProfile.issueExpiringProfileKeyCredential(credentialRequest, new ServiceId.Aci(targetUuid), profileKeyCommitment, expiration); + + when(serverZkProfileOperations.issueExpiringProfileKeyCredential(credentialRequest, new ServiceId.Aci(targetUuid), profileKeyCommitment, expiration)) + .thenReturn(credentialResponse); + + final GetExpiringProfileKeyCredentialRequest request = GetExpiringProfileKeyCredentialRequest.newBuilder() + .setAccountIdentifier(ServiceIdentifier.newBuilder() + .setIdentityType(IdentityType.IDENTITY_TYPE_ACI) + .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(targetUuid))) + .build()) + .setCredentialRequest(ByteString.copyFrom(credentialRequest.serialize())) + .setCredentialType(CredentialType.CREDENTIAL_TYPE_EXPIRING_PROFILE_KEY) + .setVersion("someVersion") + .build(); + + final GetExpiringProfileKeyCredentialResponse response = profileBlockingStub.getExpiringProfileKeyCredential(request); + + assertArrayEquals(credentialResponse.serialize(), response.getProfileKeyCredential().toByteArray()); + + verify(serverZkProfileOperations).issueExpiringProfileKeyCredential(credentialRequest, new ServiceId.Aci(targetUuid), profileKeyCommitment, expiration); + + final ClientZkProfileOperations clientZkProfileCipher = new ClientZkProfileOperations(serverPublicParams); + assertThatNoException().isThrownBy(() -> + clientZkProfileCipher.receiveExpiringProfileKeyCredential(profileKeyCredentialRequestContext, new ExpiringProfileKeyCredentialResponse(response.getProfileKeyCredential().toByteArray()))); + } + + @Test + void getExpiringProfileKeyCredentialRateLimited() { + final Duration retryAfterDuration = Duration.ofMinutes(5); + when(rateLimiter.validateReactive(AUTHENTICATED_ACI)) + .thenReturn(Mono.error(new RateLimitExceededException(retryAfterDuration, false))); + when(accountsManager.getByServiceIdentifierAsync(any())).thenReturn(CompletableFuture.completedFuture(Optional.of(account))); + + final GetExpiringProfileKeyCredentialRequest request = GetExpiringProfileKeyCredentialRequest.newBuilder() + .setAccountIdentifier(ServiceIdentifier.newBuilder() + .setIdentityType(IdentityType.IDENTITY_TYPE_ACI) + .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(UUID.randomUUID()))) + .build()) + .setCredentialRequest(ByteString.copyFrom("credentialRequest".getBytes(StandardCharsets.UTF_8))) + .setCredentialType(CredentialType.CREDENTIAL_TYPE_EXPIRING_PROFILE_KEY) + .setVersion("someVersion") + .build(); + + StatusRuntimeException exception = assertThrows(StatusRuntimeException.class, + () -> profileBlockingStub.getExpiringProfileKeyCredential(request)); + + assertEquals(Status.Code.RESOURCE_EXHAUSTED, exception.getStatus().getCode()); + assertNotNull(exception.getTrailers()); + assertEquals(retryAfterDuration, exception.getTrailers().get(RateLimitUtil.RETRY_AFTER_DURATION_KEY)); + + verifyNoInteractions(profilesManager); + } + + @ParameterizedTest + @MethodSource + void getExpiringProfileKeyCredentialAccountOrProfileNotFound(final boolean missingAccount, + final boolean missingProfile) { + final UUID targetUuid = UUID.randomUUID(); + + when(account.getUuid()).thenReturn(targetUuid); + when(accountsManager.getByServiceIdentifierAsync(new AciServiceIdentifier(targetUuid))).thenReturn(CompletableFuture.completedFuture( + missingAccount ? Optional.empty() : Optional.of(account))); + when(profilesManager.getAsync(targetUuid, "someVersion")).thenReturn(CompletableFuture.completedFuture(missingProfile ? Optional.empty() : Optional.of(profile))); + + final GetExpiringProfileKeyCredentialRequest request = GetExpiringProfileKeyCredentialRequest.newBuilder() + .setAccountIdentifier(ServiceIdentifier.newBuilder() + .setIdentityType(IdentityType.IDENTITY_TYPE_ACI) + .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(targetUuid))) + .build()) + .setCredentialRequest(ByteString.copyFrom("credentialRequest".getBytes(StandardCharsets.UTF_8))) + .setCredentialType(CredentialType.CREDENTIAL_TYPE_EXPIRING_PROFILE_KEY) + .setVersion("someVersion") + .build(); + + final StatusRuntimeException statusRuntimeException = assertThrows(StatusRuntimeException.class, + () -> profileBlockingStub.getExpiringProfileKeyCredential(request)); + + assertEquals(Status.Code.NOT_FOUND, statusRuntimeException.getStatus().getCode()); + } + + private static Stream getExpiringProfileKeyCredentialAccountOrProfileNotFound() { + return Stream.of( + Arguments.of(true, false), + Arguments.of(false, true) + ); + } + + @ParameterizedTest + @MethodSource + void getExpiringProfileKeyCredentialInvalidArgument(final IdentityType identityType, final CredentialType credentialType, + final boolean throwZkVerificationException) throws VerificationFailedException { + final UUID targetUuid = UUID.randomUUID(); + + if (throwZkVerificationException) { + when(serverZkProfileOperations.issueExpiringProfileKeyCredential(any(), any(), any(), any())).thenThrow(new VerificationFailedException()); + } + + when(account.getUuid()).thenReturn(targetUuid); + when(profile.commitment()).thenReturn("commitment".getBytes(StandardCharsets.UTF_8)); + when(accountsManager.getByServiceIdentifierAsync(new AciServiceIdentifier(targetUuid))).thenReturn(CompletableFuture.completedFuture(Optional.of(account))); + when(profilesManager.getAsync(targetUuid, "someVersion")).thenReturn(CompletableFuture.completedFuture(Optional.of(profile))); + + final GetExpiringProfileKeyCredentialRequest request = GetExpiringProfileKeyCredentialRequest.newBuilder() + .setAccountIdentifier(ServiceIdentifier.newBuilder() + .setIdentityType(identityType) + .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(targetUuid))) + .build()) + .setCredentialRequest(ByteString.copyFrom("credentialRequest".getBytes(StandardCharsets.UTF_8))) + .setCredentialType(credentialType) + .setVersion("someVersion") + .build(); + + StatusRuntimeException exception = assertThrows(StatusRuntimeException.class, + () -> profileBlockingStub.getExpiringProfileKeyCredential(request)); + + assertEquals(Status.Code.INVALID_ARGUMENT, exception.getStatus().getCode()); + } + + private static Stream getExpiringProfileKeyCredentialInvalidArgument() { + return Stream.of( + // Credential type unspecified + Arguments.of(IdentityType.IDENTITY_TYPE_ACI, CredentialType.CREDENTIAL_TYPE_UNSPECIFIED, false), + // Illegal identity type + Arguments.of(IdentityType.IDENTITY_TYPE_PNI, CredentialType.CREDENTIAL_TYPE_EXPIRING_PROFILE_KEY, false), + // Artificially fails zero knowledge verification + Arguments.of(IdentityType.IDENTITY_TYPE_ACI, CredentialType.CREDENTIAL_TYPE_EXPIRING_PROFILE_KEY, true) + ); + } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/ProfilesManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/ProfilesManagerTest.java index 5f2c8c424..3069c1964 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/ProfilesManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/ProfilesManagerTest.java @@ -32,7 +32,7 @@ import org.signal.libsignal.zkgroup.InvalidInputException; import org.signal.libsignal.zkgroup.profiles.ProfileKey; import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; import org.whispersystems.textsecuregcm.tests.util.MockRedisFuture; -import org.whispersystems.textsecuregcm.tests.util.ProfileHelper; +import org.whispersystems.textsecuregcm.tests.util.ProfileTestHelper; import org.whispersystems.textsecuregcm.tests.util.RedisClusterHelper; @Timeout(value = 10, threadMode = Timeout.ThreadMode.SEPARATE_THREAD) @@ -62,12 +62,12 @@ public class ProfilesManagerTest { @Test public void testGetProfileInCache() throws InvalidInputException { final UUID uuid = UUID.randomUUID(); - final byte[] name = ProfileHelper.generateRandomByteArray(81); + final byte[] name = ProfileTestHelper.generateRandomByteArray(81); final byte[] commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(uuid)).serialize(); when(commands.hget(eq("profiles::" + uuid), eq("someversion"))).thenReturn(String.format( "{\"version\": \"someversion\", \"name\": \"%s\", \"avatar\": \"someavatar\", \"commitment\":\"%s\"}", - ProfileHelper.encodeToBase64(name), - ProfileHelper.encodeToBase64(commitment))); + ProfileTestHelper.encodeToBase64(name), + ProfileTestHelper.encodeToBase64(commitment))); Optional profile = profilesManager.get(uuid, "someversion"); @@ -84,13 +84,13 @@ public class ProfilesManagerTest { @Test public void testGetProfileAsyncInCache() throws InvalidInputException { final UUID uuid = UUID.randomUUID(); - final byte[] name = ProfileHelper.generateRandomByteArray(81); + final byte[] name = ProfileTestHelper.generateRandomByteArray(81); final byte[] commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(uuid)).serialize(); when(asyncCommands.hget(eq("profiles::" + uuid), eq("someversion"))).thenReturn( MockRedisFuture.completedFuture(String.format("{\"version\": \"someversion\", \"name\": \"%s\", \"avatar\": \"someavatar\", \"commitment\":\"%s\"}", - ProfileHelper.encodeToBase64(name), - ProfileHelper.encodeToBase64(commitment)))); + ProfileTestHelper.encodeToBase64(name), + ProfileTestHelper.encodeToBase64(commitment)))); Optional profile = profilesManager.getAsync(uuid, "someversion").join(); @@ -107,7 +107,7 @@ public class ProfilesManagerTest { @Test public void testGetProfileNotInCache() { final UUID uuid = UUID.randomUUID(); - final byte[] name = ProfileHelper.generateRandomByteArray(81); + final byte[] name = ProfileTestHelper.generateRandomByteArray(81); final VersionedProfile profile = new VersionedProfile("someversion", name, "someavatar", null, null, null, "somecommitment".getBytes()); @@ -130,7 +130,7 @@ public class ProfilesManagerTest { @Test public void testGetProfileAsyncNotInCache() { final UUID uuid = UUID.randomUUID(); - final byte[] name = ProfileHelper.generateRandomByteArray(81); + final byte[] name = ProfileTestHelper.generateRandomByteArray(81); final VersionedProfile profile = new VersionedProfile("someversion", name, "someavatar", null, null, null, "somecommitment".getBytes()); @@ -154,7 +154,7 @@ public class ProfilesManagerTest { @Test public void testGetProfileBrokenCache() { final UUID uuid = UUID.randomUUID(); - final byte[] name = ProfileHelper.generateRandomByteArray(81); + final byte[] name = ProfileTestHelper.generateRandomByteArray(81); final VersionedProfile profile = new VersionedProfile("someversion", name, "someavatar", null, null, null, "somecommitment".getBytes()); @@ -177,7 +177,7 @@ public class ProfilesManagerTest { @Test public void testGetProfileAsyncBrokenCache() { final UUID uuid = UUID.randomUUID(); - final byte[] name = ProfileHelper.generateRandomByteArray(81); + final byte[] name = ProfileTestHelper.generateRandomByteArray(81); final VersionedProfile profile = new VersionedProfile("someversion", name, "someavatar", null, null, null, "somecommitment".getBytes()); @@ -201,7 +201,7 @@ public class ProfilesManagerTest { @Test public void testSet() { final UUID uuid = UUID.randomUUID(); - final byte[] name = ProfileHelper.generateRandomByteArray(81); + final byte[] name = ProfileTestHelper.generateRandomByteArray(81); final VersionedProfile profile = new VersionedProfile("someversion", name, "someavatar", null, null, null, "somecommitment".getBytes()); @@ -217,7 +217,7 @@ public class ProfilesManagerTest { @Test public void testSetAsync() { final UUID uuid = UUID.randomUUID(); - final byte[] name = ProfileHelper.generateRandomByteArray(81); + final byte[] name = ProfileTestHelper.generateRandomByteArray(81); final VersionedProfile profile = new VersionedProfile("someversion", name, "someavatar", null, null, null, "somecommitment".getBytes()); diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/ProfilesTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/ProfilesTest.java index 94c6e2160..d67229951 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/ProfilesTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/ProfilesTest.java @@ -15,7 +15,7 @@ import org.junit.jupiter.params.provider.MethodSource; import org.signal.libsignal.protocol.ServiceId; import org.signal.libsignal.zkgroup.InvalidInputException; import org.signal.libsignal.zkgroup.profiles.ProfileKey; -import org.whispersystems.textsecuregcm.tests.util.ProfileHelper; +import org.whispersystems.textsecuregcm.tests.util.ProfileTestHelper; import org.whispersystems.textsecuregcm.util.AttributeValues; import org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema.Tables; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; @@ -44,10 +44,10 @@ public class ProfilesTest { Tables.PROFILES.tableName()); final byte[] commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(ACI)).serialize(); final String version = "someVersion"; - final byte[] name = ProfileHelper.generateRandomByteArray(81); - final byte[] validAboutEmoji = ProfileHelper.generateRandomByteArray(60); - final byte[] validAbout = ProfileHelper.generateRandomByteArray(156); - final String avatar = "profiles/" + ProfileHelper.generateRandomBase64FromByteArray(16); + final byte[] name = ProfileTestHelper.generateRandomByteArray(81); + final byte[] validAboutEmoji = ProfileTestHelper.generateRandomByteArray(60); + final byte[] validAbout = ProfileTestHelper.generateRandomByteArray(156); + final String avatar = "profiles/" + ProfileTestHelper.generateRandomBase64FromByteArray(16); validProfile = new VersionedProfile(version, name, avatar, validAboutEmoji, validAbout, null, commitment); } @@ -87,12 +87,12 @@ public class ProfilesTest { profiles.deleteAll(ACI); final String version = "someVersion"; - final byte[] name = ProfileHelper.generateRandomByteArray(81); - final String differentAvatar = "profiles/" + ProfileHelper.generateRandomBase64FromByteArray(16); - final byte[] differentEmoji = ProfileHelper.generateRandomByteArray(60); - final byte[] differentAbout = ProfileHelper.generateRandomByteArray(156); - final byte[] paymentAddress = ProfileHelper.generateRandomByteArray(582); - final byte[] commitment = new ProfileKey(ProfileHelper.generateRandomByteArray(32)).getCommitment(new ServiceId.Aci(ACI)).serialize(); + final byte[] name = ProfileTestHelper.generateRandomByteArray(81); + final String differentAvatar = "profiles/" + ProfileTestHelper.generateRandomBase64FromByteArray(16); + final byte[] differentEmoji = ProfileTestHelper.generateRandomByteArray(60); + final byte[] differentAbout = ProfileTestHelper.generateRandomByteArray(156); + final byte[] paymentAddress = ProfileTestHelper.generateRandomByteArray(582); + final byte[] commitment = new ProfileKey(ProfileTestHelper.generateRandomByteArray(32)).getCommitment(new ServiceId.Aci(ACI)).serialize(); VersionedProfile updatedProfile = new VersionedProfile(version, name, differentAvatar, differentEmoji, differentAbout, paymentAddress, commitment); @@ -112,8 +112,8 @@ public class ProfilesTest { @Test void testSetGetNullOptionalFields() throws InvalidInputException { final String version = "someVersion"; - final byte[] name = ProfileHelper.generateRandomByteArray(81); - final byte[] commitment = new ProfileKey(ProfileHelper.generateRandomByteArray(32)).getCommitment(new ServiceId.Aci(ACI)).serialize(); + final byte[] name = ProfileTestHelper.generateRandomByteArray(81); + final byte[] commitment = new ProfileKey(ProfileTestHelper.generateRandomByteArray(32)).getCommitment(new ServiceId.Aci(ACI)).serialize(); VersionedProfile profile = new VersionedProfile(version, name, null, null, null, null, commitment); @@ -143,11 +143,11 @@ public class ProfilesTest { assertThat(retrieved.get().aboutEmoji()).isEqualTo(validProfile.aboutEmoji()); assertThat(retrieved.get().paymentAddress()).isNull(); - final byte[] differentName = ProfileHelper.generateRandomByteArray(81); - final byte[] differentEmoji = ProfileHelper.generateRandomByteArray(60); - final byte[] differentAbout = ProfileHelper.generateRandomByteArray(156); - final String differentAvatar = "profiles/" + ProfileHelper.generateRandomBase64FromByteArray(16); - final byte[] differentCommitment = new ProfileKey(ProfileHelper.generateRandomByteArray(32)).getCommitment(new ServiceId.Aci(ACI)).serialize(); + final byte[] differentName = ProfileTestHelper.generateRandomByteArray(81); + final byte[] differentEmoji = ProfileTestHelper.generateRandomByteArray(60); + final byte[] differentAbout = ProfileTestHelper.generateRandomByteArray(156); + final String differentAvatar = "profiles/" + ProfileTestHelper.generateRandomBase64FromByteArray(16); + final byte[] differentCommitment = new ProfileKey(ProfileTestHelper.generateRandomByteArray(32)).getCommitment(new ServiceId.Aci(ACI)).serialize(); VersionedProfile updated = new VersionedProfile(validProfile.version(), differentName, differentAvatar, differentEmoji, differentAbout, null, differentCommitment); @@ -170,17 +170,17 @@ public class ProfilesTest { final String versionOne = "versionOne"; final String versionTwo = "versionTwo"; - final byte[] nameOne = ProfileHelper.generateRandomByteArray(81); - final byte[] nameTwo = ProfileHelper.generateRandomByteArray(81); + final byte[] nameOne = ProfileTestHelper.generateRandomByteArray(81); + final byte[] nameTwo = ProfileTestHelper.generateRandomByteArray(81); - final String avatarOne = "profiles/" + ProfileHelper.generateRandomBase64FromByteArray(16); - final String avatarTwo = "profiles/" + ProfileHelper.generateRandomBase64FromByteArray(16); + final String avatarOne = "profiles/" + ProfileTestHelper.generateRandomBase64FromByteArray(16); + final String avatarTwo = "profiles/" + ProfileTestHelper.generateRandomBase64FromByteArray(16); - final byte[] aboutEmoji = ProfileHelper.generateRandomByteArray(60); - final byte[] about = ProfileHelper.generateRandomByteArray(156); + final byte[] aboutEmoji = ProfileTestHelper.generateRandomByteArray(60); + final byte[] about = ProfileTestHelper.generateRandomByteArray(156); - final byte[] commitmentOne = new ProfileKey(ProfileHelper.generateRandomByteArray(32)).getCommitment(new ServiceId.Aci(ACI)).serialize(); - final byte[] commitmentTwo = new ProfileKey(ProfileHelper.generateRandomByteArray(32)).getCommitment(new ServiceId.Aci(ACI)).serialize(); + final byte[] commitmentOne = new ProfileKey(ProfileTestHelper.generateRandomByteArray(32)).getCommitment(new ServiceId.Aci(ACI)).serialize(); + final byte[] commitmentTwo = new ProfileKey(ProfileTestHelper.generateRandomByteArray(32)).getCommitment(new ServiceId.Aci(ACI)).serialize(); VersionedProfile profileOne = new VersionedProfile(versionOne, nameOne, avatarOne, null, null, null, commitmentOne); @@ -223,17 +223,17 @@ public class ProfilesTest { final String versionOne = "versionOne"; final String versionTwo = "versionTwo"; - final byte[] nameOne = ProfileHelper.generateRandomByteArray(81); - final byte[] nameTwo = ProfileHelper.generateRandomByteArray(81); + final byte[] nameOne = ProfileTestHelper.generateRandomByteArray(81); + final byte[] nameTwo = ProfileTestHelper.generateRandomByteArray(81); - final byte[] aboutEmoji = ProfileHelper.generateRandomByteArray(60); - final byte[] about = ProfileHelper.generateRandomByteArray(156); + final byte[] aboutEmoji = ProfileTestHelper.generateRandomByteArray(60); + final byte[] about = ProfileTestHelper.generateRandomByteArray(156); - final String avatarOne = "profiles/" + ProfileHelper.generateRandomBase64FromByteArray(16); - final String avatarTwo = "profiles/" + ProfileHelper.generateRandomBase64FromByteArray(16); + final String avatarOne = "profiles/" + ProfileTestHelper.generateRandomBase64FromByteArray(16); + final String avatarTwo = "profiles/" + ProfileTestHelper.generateRandomBase64FromByteArray(16); - final byte[] commitmentOne = new ProfileKey(ProfileHelper.generateRandomByteArray(32)).getCommitment(new ServiceId.Aci(ACI)).serialize(); - final byte[] commitmentTwo = new ProfileKey(ProfileHelper.generateRandomByteArray(32)).getCommitment(new ServiceId.Aci(ACI)).serialize(); + final byte[] commitmentOne = new ProfileKey(ProfileTestHelper.generateRandomByteArray(32)).getCommitment(new ServiceId.Aci(ACI)).serialize(); + final byte[] commitmentTwo = new ProfileKey(ProfileTestHelper.generateRandomByteArray(32)).getCommitment(new ServiceId.Aci(ACI)).serialize(); VersionedProfile profileOne = new VersionedProfile(versionOne, nameOne, avatarOne, null, null, null, commitmentOne); @@ -261,12 +261,12 @@ public class ProfilesTest { private static Stream buildUpdateExpression() throws InvalidInputException { final String version = "someVersion"; - final byte[] name = ProfileHelper.generateRandomByteArray(81); - final String avatar = "profiles/" + ProfileHelper.generateRandomBase64FromByteArray(16);; - final byte[] emoji = ProfileHelper.generateRandomByteArray(60); - final byte[] about = ProfileHelper.generateRandomByteArray(156); - final byte[] paymentAddress = ProfileHelper.generateRandomByteArray(582); - final byte[] commitment = new ProfileKey(ProfileHelper.generateRandomByteArray(32)).getCommitment(new ServiceId.Aci(ACI)).serialize(); + final byte[] name = ProfileTestHelper.generateRandomByteArray(81); + final String avatar = "profiles/" + ProfileTestHelper.generateRandomBase64FromByteArray(16);; + final byte[] emoji = ProfileTestHelper.generateRandomByteArray(60); + final byte[] about = ProfileTestHelper.generateRandomByteArray(156); + final byte[] paymentAddress = ProfileTestHelper.generateRandomByteArray(582); + final byte[] commitment = new ProfileKey(ProfileTestHelper.generateRandomByteArray(32)).getCommitment(new ServiceId.Aci(ACI)).serialize(); return Stream.of( Arguments.of( @@ -303,12 +303,12 @@ public class ProfilesTest { private static Stream buildUpdateExpressionAttributeValues() throws InvalidInputException { final String version = "someVersion"; - final byte[] name = ProfileHelper.generateRandomByteArray(81); - final String avatar = "profiles/" + ProfileHelper.generateRandomBase64FromByteArray(16);; - final byte[] emoji = ProfileHelper.generateRandomByteArray(60); - final byte[] about = ProfileHelper.generateRandomByteArray(156); - final byte[] paymentAddress = ProfileHelper.generateRandomByteArray(582); - final byte[] commitment = new ProfileKey(ProfileHelper.generateRandomByteArray(32)).getCommitment(new ServiceId.Aci(ACI)).serialize(); + final byte[] name = ProfileTestHelper.generateRandomByteArray(81); + final String avatar = "profiles/" + ProfileTestHelper.generateRandomBase64FromByteArray(16);; + final byte[] emoji = ProfileTestHelper.generateRandomByteArray(60); + final byte[] about = ProfileTestHelper.generateRandomByteArray(156); + final byte[] paymentAddress = ProfileTestHelper.generateRandomByteArray(582); + final byte[] commitment = new ProfileKey(ProfileTestHelper.generateRandomByteArray(32)).getCommitment(new ServiceId.Aci(ACI)).serialize(); return Stream.of( Arguments.of( diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/ProfileHelper.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/ProfileTestHelper.java similarity index 94% rename from service/src/test/java/org/whispersystems/textsecuregcm/tests/util/ProfileHelper.java rename to service/src/test/java/org/whispersystems/textsecuregcm/tests/util/ProfileTestHelper.java index e8b721997..5024c52ec 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/ProfileHelper.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/ProfileTestHelper.java @@ -3,7 +3,7 @@ package org.whispersystems.textsecuregcm.tests.util; import java.util.Base64; import java.util.Random; -public class ProfileHelper { +public class ProfileTestHelper { public static String generateRandomBase64FromByteArray(final int byteArrayLength) { return encodeToBase64(generateRandomByteArray(byteArrayLength)); }