diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index cd2c2f9ce..7668f1e7c 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -798,7 +798,7 @@ public class WhisperServerService extends Application dynamicConfigurationManager; private final ProfileBadgeConverter profileBadgeConverter; private final Map badgeConfigurationMap; - private final PolicySigner policySigner; - private final PostPolicyGenerator policyGenerator; + private final PolicySigner policySigner; + private final PostPolicyGenerator policyGenerator; + private final ServerSecretParams serverSecretParams; private final ServerZkProfileOperations zkProfileOperations; - private final S3Client s3client; - private final String bucket; + private final S3Client s3client; + private final String bucket; private final Executor batchIdentityCheckExecutor; @@ -142,21 +147,23 @@ public class ProfileController { PostPolicyGenerator policyGenerator, PolicySigner policySigner, String bucket, + ServerSecretParams serverSecretParams, ServerZkProfileOperations zkProfileOperations, Executor batchIdentityCheckExecutor) { this.clock = clock; - this.rateLimiters = rateLimiters; - this.accountsManager = accountsManager; - this.profilesManager = profilesManager; + this.rateLimiters = rateLimiters; + this.accountsManager = accountsManager; + this.profilesManager = profilesManager; this.dynamicConfigurationManager = dynamicConfigurationManager; this.profileBadgeConverter = profileBadgeConverter; this.badgeConfigurationMap = badgesConfiguration.getBadges().stream().collect(Collectors.toMap( BadgeConfiguration::getId, Function.identity())); + this.serverSecretParams = serverSecretParams; this.zkProfileOperations = zkProfileOperations; - this.bucket = bucket; - this.s3client = s3client; - this.policyGenerator = policyGenerator; - this.policySigner = policySigner; + this.bucket = bucket; + this.s3client = s3client; + this.policyGenerator = policyGenerator; + this.policySigner = policySigner; this.batchIdentityCheckExecutor = Preconditions.checkNotNull(batchIdentityCheckExecutor); } @@ -282,6 +289,7 @@ public class ProfileController { public BaseProfileResponse getUnversionedProfile( @ReadOnly @Auth Optional auth, @HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) Optional accessKey, + @HeaderParam(HeaderUtils.GROUP_SEND_TOKEN) Optional groupSendToken, @Context ContainerRequestContext containerRequestContext, @HeaderParam(HttpHeaders.USER_AGENT) String userAgent, @PathParam("identifier") ServiceIdentifier identifier, @@ -290,8 +298,22 @@ public class ProfileController { final Optional maybeRequester = auth.map(AuthenticatedAccount::getAccount); - final Account targetAccount = verifyPermissionToReceiveProfile( - maybeRequester, accessKey.filter(ignored -> identifier.identityType() == IdentityType.ACI), identifier); + final Account targetAccount; + if (groupSendToken.isPresent()) { + if (accessKey.isPresent()) { + throw new BadRequestException("may not provide both group send token and unidentified access key"); + } + try { + final GroupSendFullToken token = groupSendToken.get().token(); + token.verify(List.of(identifier.toLibsignal()), clock.instant(), GroupSendDerivedKeyPair.forExpiration(token.getExpiration(), serverSecretParams)); + targetAccount = accountsManager.getByServiceIdentifier(identifier).orElseThrow(NotFoundException::new); + } catch (VerificationFailedException e) { + throw new NotAuthorizedException(e); + } + } else { + targetAccount = verifyPermissionToReceiveProfile( + maybeRequester, accessKey.filter(ignored -> identifier.identityType() == IdentityType.ACI), identifier); + } return switch (identifier.identityType()) { case ACI -> { yield buildBaseProfileResponseForAccountIdentity(targetAccount, 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 a8f89a257..48c411b02 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcService.java @@ -6,6 +6,10 @@ package org.whispersystems.textsecuregcm.grpc; import io.grpc.Status; + +import java.time.Clock; +import java.util.List; + import org.signal.chat.profile.CredentialType; import org.signal.chat.profile.GetExpiringProfileKeyCredentialAnonymousRequest; import org.signal.chat.profile.GetExpiringProfileKeyCredentialResponse; @@ -14,6 +18,7 @@ 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.ServerSecretParams; import org.signal.libsignal.zkgroup.profiles.ServerZkProfileOperations; import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil; import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter; @@ -29,16 +34,18 @@ public class ProfileAnonymousGrpcService extends ReactorProfileAnonymousGrpc.Pro private final ProfilesManager profilesManager; private final ProfileBadgeConverter profileBadgeConverter; private final ServerZkProfileOperations zkProfileOperations; + private final GroupSendTokenUtil groupSendTokenUtil; public ProfileAnonymousGrpcService( final AccountsManager accountsManager, final ProfilesManager profilesManager, final ProfileBadgeConverter profileBadgeConverter, - final ServerZkProfileOperations zkProfileOperations) { + final ServerSecretParams serverSecretParams) { this.accountsManager = accountsManager; this.profilesManager = profilesManager; this.profileBadgeConverter = profileBadgeConverter; - this.zkProfileOperations = zkProfileOperations; + this.zkProfileOperations = new ServerZkProfileOperations(serverSecretParams); + this.groupSendTokenUtil = new GroupSendTokenUtil(serverSecretParams, Clock.systemUTC()); } @Override @@ -51,8 +58,18 @@ public class ProfileAnonymousGrpcService extends ReactorProfileAnonymousGrpc.Pro throw Status.UNAUTHENTICATED.asRuntimeException(); } - return getTargetAccountAndValidateUnidentifiedAccess(targetIdentifier, request.getUnidentifiedAccessKey().toByteArray()) - .map(targetAccount -> ProfileGrpcHelper.buildUnversionedProfileResponse(targetIdentifier, + final Mono account = switch (request.getAuthenticationCase()) { + case GROUP_SEND_TOKEN -> + groupSendTokenUtil.checkGroupSendToken(request.getGroupSendToken(), List.of(targetIdentifier)) + .then(Mono.fromFuture(() -> accountsManager.getByServiceIdentifierAsync(targetIdentifier))) + .flatMap(Mono::justOrEmpty) + .switchIfEmpty(Mono.error(Status.NOT_FOUND.asException())); + case UNIDENTIFIED_ACCESS_KEY -> + getTargetAccountAndValidateUnidentifiedAccess(targetIdentifier, request.getUnidentifiedAccessKey().toByteArray()); + default -> Mono.error(Status.INVALID_ARGUMENT.asException()); + }; + + return account.map(targetAccount -> ProfileGrpcHelper.buildUnversionedProfileResponse(targetIdentifier, null, targetAccount, profileBadgeConverter)); diff --git a/service/src/main/proto/org/signal/chat/profile.proto b/service/src/main/proto/org/signal/chat/profile.proto index d7e5d149b..48f414baa 100644 --- a/service/src/main/proto/org/signal/chat/profile.proto +++ b/service/src/main/proto/org/signal/chat/profile.proto @@ -211,10 +211,18 @@ message GetUnversionedProfileAnonymousRequest { * Contains the data necessary to request an unversioned profile. */ GetUnversionedProfileRequest request = 1; - /** - * The unidentified access key for the targeted account. - */ - bytes unidentified_access_key = 2; + + oneof authentication { + /** + * The unidentified access key for the targeted account. + */ + bytes unidentified_access_key = 2; + + /** + * A group send endorsement token for the targeted account. + */ + bytes group_send_token = 3; + } } message GetUnversionedProfileResponse { 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 8b2589cfa..f0f3dbd33 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ProfileControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ProfileControllerTest.java @@ -44,6 +44,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executors; import java.util.stream.Stream; import javax.ws.rs.client.Entity; +import javax.ws.rs.client.Invocation; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MultivaluedHashMap; import javax.ws.rs.core.MultivaluedMap; @@ -117,7 +118,7 @@ import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; @ExtendWith(DropwizardExtensionsSupport.class) class ProfileControllerTest { - private static final Clock clock = TestClock.pinned(Instant.ofEpochSecond(42)); + private static final TestClock clock = TestClock.now(); private static final AccountsManager accountsManager = mock(AccountsManager.class); private static final ProfilesManager profilesManager = mock(ProfilesManager.class); private static final RateLimiters rateLimiters = mock(RateLimiters.class); @@ -129,6 +130,7 @@ class ProfileControllerTest { "accessKey"); private static final PolicySigner policySigner = new PolicySigner("accessSecret", "us-west-1"); private static final ServerZkProfileOperations zkProfileOperations = mock(ServerZkProfileOperations.class); + private static final ServerSecretParams serverSecretParams = ServerSecretParams.generate(); private static final byte[] UNIDENTIFIED_ACCESS_KEY = "sixteenbytes1234".getBytes(StandardCharsets.UTF_8); private static final IdentityKey ACCOUNT_IDENTITY_KEY = new IdentityKey(Curve.generateKeyPair().getPublicKey()); @@ -170,6 +172,7 @@ class ProfileControllerTest { postPolicyGenerator, policySigner, "profilesBucket", + serverSecretParams, zkProfileOperations, Executors.newSingleThreadExecutor())) .build(); @@ -177,7 +180,7 @@ class ProfileControllerTest { @BeforeEach void setup() { reset(s3client); - + clock.pin(Instant.ofEpochSecond(42)); AccountsHelper.setupMockUpdate(accountsManager); dynamicPaymentsConfiguration = mock(DynamicPaymentsConfiguration.class); @@ -311,6 +314,65 @@ class ProfileControllerTest { assertThat(response.getStatus()).isEqualTo(401); } + @ParameterizedTest + @MethodSource + void testProfileGetWithGroupSendEndorsement( + UUID target, UUID authorizedTarget, Duration timeLeft, boolean includeUak, int expectedResponse) throws Exception { + + final Instant expiration = Instant.now().truncatedTo(ChronoUnit.DAYS); + clock.pin(expiration.minus(timeLeft)); + + Invocation.Builder builder = resources.getJerseyTest() + .target("/v1/profile/" + target) + .queryParam("pq", "true") + .request() + .header( + HeaderUtils.GROUP_SEND_TOKEN, + AuthHelper.validGroupSendTokenHeader(serverSecretParams, List.of(new AciServiceIdentifier(authorizedTarget)), expiration)); + + if (includeUak) { + builder = builder.header(HeaderUtils.UNIDENTIFIED_ACCESS_KEY, AuthHelper.getUnidentifiedAccessHeader(UNIDENTIFIED_ACCESS_KEY)); + } + + Response response = builder.get(); + assertThat(response.getStatus()).isEqualTo(expectedResponse); + + if (expectedResponse == 200) { + final BaseProfileResponse profile = response.readEntity(BaseProfileResponse.class); + assertThat(profile.getIdentityKey()).isEqualTo(ACCOUNT_TWO_IDENTITY_KEY); + assertThat(profile.getBadges()).hasSize(1).element(0).has(new Condition<>( + badge -> "Test Badge".equals(badge.getName()), "has badge with expected name")); + } + + verifyNoMoreInteractions(rateLimiter); + } + + private static Stream testProfileGetWithGroupSendEndorsement() { + UUID notExistsUuid = UUID.randomUUID(); + + return Stream.of( + // valid endorsement + Arguments.of(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_UUID_TWO, Duration.ofHours(1), false, 200), + + // expired endorsement, not authorized + Arguments.of(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_UUID_TWO, Duration.ofHours(-1), false, 401), + + // endorsement for the wrong recipient, not authorized + Arguments.of(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_UUID, Duration.ofHours(1), false, 401), + + // expired endorsement for the wrong recipient, not authorized + Arguments.of(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_UUID, Duration.ofHours(-1), false, 401), + + // valid endorsement for the right recipient but they aren't registered, not found + Arguments.of(notExistsUuid, notExistsUuid, Duration.ofHours(1), false, 404), + + // expired endorsement for the right recipient but they aren't registered, not authorized (NOT not found) + Arguments.of(notExistsUuid, notExistsUuid, Duration.ofHours(-1), false, 401), + + // valid endorsement but also a UAK, bad request + Arguments.of(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_UUID_TWO, Duration.ofHours(1), true, 400)); + } + @Test void testProfileGetByPni() throws RateLimitExceededException { final BaseProfileResponse profile = resources.getJerseyTest() 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 180293b11..905c539b3 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcServiceTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcServiceTest.java @@ -19,6 +19,7 @@ import static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertStatusEx import com.google.protobuf.ByteString; import io.grpc.Status; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Collections; @@ -73,7 +74,9 @@ 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.AuthHelper; import org.whispersystems.textsecuregcm.tests.util.ProfileTestHelper; +import org.whispersystems.textsecuregcm.util.TestClock; import org.whispersystems.textsecuregcm.util.TestRandomUtil; import org.whispersystems.textsecuregcm.util.UUIDUtil; import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException; @@ -81,6 +84,8 @@ import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil; public class ProfileAnonymousGrpcServiceTest extends SimpleBaseGrpcTest { + private final ServerSecretParams SERVER_SECRET_PARAMS = ServerSecretParams.generate(); + @Mock private Account account; @@ -93,10 +98,6 @@ public class ProfileAnonymousGrpcServiceTest extends SimpleBaseGrpcTest badges = List.of(new Badge( + "TEST", + "other", + "Test Badge", + "This badge is in unit tests.", + List.of("l", "m", "h", "x", "xx", "xxx"), + "SVG", + List.of( + new BadgeSvg("sl", "sd"), + new BadgeSvg("ml", "md"), + new BadgeSvg("ll", "ld"))) + ); + + when(account.getBadges()).thenReturn(Collections.emptyList()); + when(profileBadgeConverter.convert(any(), any(), anyBoolean())).thenReturn(badges); + when(account.isUnrestrictedUnidentifiedAccess()).thenReturn(false); + when(account.getIdentityKey(org.whispersystems.textsecuregcm.identity.IdentityType.ACI)).thenReturn(identityKey); + when(accountsManager.getByServiceIdentifierAsync(serviceIdentifier)).thenReturn(CompletableFuture.completedFuture(Optional.of(account))); + + final GetUnversionedProfileAnonymousRequest request = GetUnversionedProfileAnonymousRequest.newBuilder() + .setGroupSendToken(ByteString.copyFrom(token)) + .setRequest(GetUnversionedProfileRequest.newBuilder() + .setServiceIdentifier( + ServiceIdentifierUtil.toGrpcServiceIdentifier(serviceIdentifier)) + .build()) + .build(); + + final GetUnversionedProfileResponse response = unauthenticatedServiceStub().getUnversionedProfile(request); + + final GetUnversionedProfileResponse expectedResponse = GetUnversionedProfileResponse.newBuilder() + .setIdentityKey(ByteString.copyFrom(identityKey.serialize())) + .setUnrestrictedUnidentifiedAccess(false) + .setCapabilities(ProfileGrpcHelper.buildUserCapabilities(new UserCapabilities())) + .addAllBadges(ProfileGrpcHelper.buildBadges(badges)) + .build(); + + verify(accountsManager).getByServiceIdentifierAsync(serviceIdentifier); + assertEquals(expectedResponse, response); + } + + @Test + void getUnversionedProfileNoAuth() { + final GetUnversionedProfileAnonymousRequest request = GetUnversionedProfileAnonymousRequest.newBuilder() + .setRequest(GetUnversionedProfileRequest.newBuilder() + .setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(UUID.randomUUID())))) + .build(); + + assertStatusException(Status.INVALID_ARGUMENT, () -> unauthenticatedServiceStub().getUnversionedProfile(request)); + } + @ParameterizedTest @MethodSource - void getUnversionedProfileUnauthenticated(final IdentityType identityType, final boolean missingUnidentifiedAccessKey, final boolean accountNotFound) { + void getUnversionedProfileIncorrectUnidentifiedAccessKey(final IdentityType identityType, final boolean wrongUnidentifiedAccessKey, final boolean accountNotFound) { final byte[] unidentifiedAccessKey = TestRandomUtil.nextBytes(UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH); when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey)); @@ -179,22 +242,21 @@ public class ProfileAnonymousGrpcServiceTest extends SimpleBaseGrpcTest unauthenticatedServiceStub().getUnversionedProfile(requestBuilder.build())); + assertStatusException(Status.UNAUTHENTICATED, () -> unauthenticatedServiceStub().getUnversionedProfile(request)); } - private static Stream getUnversionedProfileUnauthenticated() { + private static Stream getUnversionedProfileIncorrectUnidentifiedAccessKey() { return Stream.of( Arguments.of(IdentityType.IDENTITY_TYPE_PNI, false, false), Arguments.of(IdentityType.IDENTITY_TYPE_ACI, true, false), @@ -202,6 +264,62 @@ public class ProfileAnonymousGrpcServiceTest extends SimpleBaseGrpcTest unauthenticatedServiceStub().getUnversionedProfile(request)); + } + + @Test + void getUnversionedProfileIncorrectGroupSendEndorsement() throws Exception { + final AciServiceIdentifier targetServiceIdentifier = new AciServiceIdentifier(UUID.randomUUID()); + final AciServiceIdentifier authorizedServiceIdentifier = new AciServiceIdentifier(UUID.randomUUID()); + + // Expiration must be on a day boundary; we want one in the future + final Instant expiration = Instant.now().plus(Duration.ofDays(1)).truncatedTo(ChronoUnit.DAYS); + final byte[] token = AuthHelper.validGroupSendToken(SERVER_SECRET_PARAMS, List.of(authorizedServiceIdentifier), expiration); + + when(accountsManager.getByServiceIdentifierAsync(any())).thenReturn( + CompletableFuture.completedFuture(Optional.empty())); + final GetUnversionedProfileAnonymousRequest request = GetUnversionedProfileAnonymousRequest.newBuilder() + .setGroupSendToken(ByteString.copyFrom(token)) + .setRequest(GetUnversionedProfileRequest.newBuilder() + .setServiceIdentifier( + ServiceIdentifierUtil.toGrpcServiceIdentifier(targetServiceIdentifier))) + .build(); + + assertStatusException(Status.UNAUTHENTICATED, () -> unauthenticatedServiceStub().getUnversionedProfile(request)); + } + + @Test + void getUnversionedProfileGroupSendEndorsementAccountNotFound() throws Exception { + final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID()); + + // Expiration must be on a day boundary; we want one in the future + final Instant expiration = Instant.now().plus(Duration.ofDays(1)).truncatedTo(ChronoUnit.DAYS); + final byte[] token = AuthHelper.validGroupSendToken(SERVER_SECRET_PARAMS, List.of(serviceIdentifier), expiration); + + when(accountsManager.getByServiceIdentifierAsync(any())).thenReturn(CompletableFuture.completedFuture(Optional.empty())); + final GetUnversionedProfileAnonymousRequest request = GetUnversionedProfileAnonymousRequest.newBuilder() + .setGroupSendToken(ByteString.copyFrom(token)) + .setRequest(GetUnversionedProfileRequest.newBuilder() + .setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(serviceIdentifier))) + .build(); + + assertStatusException(Status.NOT_FOUND, () -> unauthenticatedServiceStub().getUnversionedProfile(request)); + } + @ParameterizedTest @MethodSource void getVersionedProfile(final String requestVersion, @@ -343,11 +461,7 @@ public class ProfileAnonymousGrpcServiceTest extends SimpleBaseGrpcTest - clientZkProfileCipher.receiveExpiringProfileKeyCredential(profileKeyCredentialRequestContext, new ExpiringProfileKeyCredentialResponse(response.getProfileKeyCredential().toByteArray()))); + clientZkProfile.receiveExpiringProfileKeyCredential(profileKeyCredentialRequestContext, new ExpiringProfileKeyCredentialResponse(response.getProfileKeyCredential().toByteArray()))); } @ParameterizedTest @@ -471,10 +574,6 @@ public class ProfileAnonymousGrpcServiceTest extends SimpleBaseGrpcTest