From b8f64fe3d4ecd4c1aa0f37d40b07622e46dae0c8 Mon Sep 17 00:00:00 2001 From: Jonathan Klabunde Tomer <125505367+jkt-signal@users.noreply.github.com> Date: Fri, 19 Apr 2024 15:40:46 -0700 Subject: [PATCH] Group Send Endorsement support for pre-key fetch endpoint --- .../textsecuregcm/WhisperServerService.java | 4 +- .../controllers/KeysController.java | 40 ++- .../grpc/GroupSendTokenUtil.java | 45 +++ .../grpc/KeysAnonymousGrpcService.java | 38 ++- .../src/main/proto/org/signal/chat/keys.proto | 14 +- .../controllers/KeysControllerTest.java | 83 ++++- .../controllers/MessageControllerTest.java | 39 +-- .../grpc/KeysAnonymousGrpcServiceTest.java | 283 +++++++++++++----- .../textsecuregcm/tests/util/AuthHelper.java | 45 +++ 9 files changed, 468 insertions(+), 123 deletions(-) create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/GroupSendTokenUtil.java diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 5da9abb2b..cd2c2f9ce 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -795,7 +795,7 @@ public class WhisperServerService extends Application[] EMPTY_FUTURE_ARRAY = new CompletableFuture[0]; - public KeysController(RateLimiters rateLimiters, KeysManager keysManager, AccountsManager accounts) { + public KeysController(RateLimiters rateLimiters, KeysManager keysManager, AccountsManager accounts, ServerSecretParams serverSecretParams, Clock clock) { this.rateLimiters = rateLimiters; this.keysManager = keysManager; this.accounts = accounts; + this.serverSecretParams = serverSecretParams; + this.clock = clock; } @GET @@ -298,7 +311,8 @@ public class KeysController { @Operation(summary = "Fetch public keys for another user", description = "Retrieves the public identity key and available device prekeys for a specified account or phone-number identity") @ApiResponse(responseCode = "200", description = "Indicates at least one prekey was available for at least one requested device.", useReturnTypeSchema = true) - @ApiResponse(responseCode = "401", description = "Account authentication check failed and unidentified-access key was not supplied or invalid.") + @ApiResponse(responseCode = "400", description = "A group send endorsement and other authorization (account authentication or unidentified-access key) were both provided.") + @ApiResponse(responseCode = "401", description = "Account authentication check failed and unidentified-access key or group send endorsement token was not supplied or invalid.") @ApiResponse(responseCode = "404", description = "Requested identity or device does not exist, is not active, or has no available prekeys.") @ApiResponse(responseCode = "429", description = "Rate limit exceeded.", headers = @Header( name = "Retry-After", @@ -306,6 +320,7 @@ public class KeysController { public PreKeyResponse getDeviceKeys( @ReadOnly @Auth Optional auth, @HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) Optional accessKey, + @HeaderParam(HeaderUtils.GROUP_SEND_TOKEN) Optional groupSendToken, @Parameter(description="the account or phone-number identifier to retrieve keys for") @PathParam("identifier") ServiceIdentifier targetIdentifier, @@ -316,20 +331,27 @@ public class KeysController { @HeaderParam(HttpHeaders.USER_AGENT) String userAgent) throws RateLimitExceededException { - if (auth.isEmpty() && accessKey.isEmpty()) { + if (auth.isEmpty() && accessKey.isEmpty() && groupSendToken.isEmpty()) { throw new WebApplicationException(Response.Status.UNAUTHORIZED); } final Optional account = auth.map(AuthenticatedAccount::getAccount); + final Optional maybeTarget = accounts.getByServiceIdentifier(targetIdentifier); - final Account target; - { - final Optional maybeTarget = accounts.getByServiceIdentifier(targetIdentifier); - + if (groupSendToken.isPresent()) { + if (auth.isPresent() || accessKey.isPresent()) { + throw new BadRequestException(); + } + try { + final GroupSendFullToken token = groupSendToken.get().token(); + token.verify(List.of(targetIdentifier.toLibsignal()), clock.instant(), GroupSendDerivedKeyPair.forExpiration(token.getExpiration(), serverSecretParams)); + } catch (VerificationFailedException e) { + throw new NotAuthorizedException(e); + } + } else { OptionalAccess.verify(account, accessKey, maybeTarget, deviceId); - - target = maybeTarget.orElseThrow(); } + final Account target = maybeTarget.orElseThrow(NotFoundException::new); if (account.isPresent()) { rateLimiters.getPreKeysLimiter().validate( diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/GroupSendTokenUtil.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/GroupSendTokenUtil.java new file mode 100644 index 000000000..b021388f3 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/GroupSendTokenUtil.java @@ -0,0 +1,45 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.grpc; + +import com.google.protobuf.ByteString; +import io.grpc.Status; + +import java.time.Clock; +import java.util.List; +import org.signal.libsignal.protocol.ServiceId; +import org.signal.libsignal.zkgroup.InvalidInputException; +import org.signal.libsignal.zkgroup.ServerSecretParams; +import org.signal.libsignal.zkgroup.VerificationFailedException; +import org.signal.libsignal.zkgroup.groupsend.GroupSendDerivedKeyPair; +import org.signal.libsignal.zkgroup.groupsend.GroupSendFullToken; +import org.whispersystems.textsecuregcm.identity.ServiceIdentifier; + +import reactor.core.publisher.Mono; + +public class GroupSendTokenUtil { + + private final ServerSecretParams serverSecretParams; + private final Clock clock; + + public GroupSendTokenUtil(final ServerSecretParams serverSecretParams, final Clock clock) { + this.serverSecretParams = serverSecretParams; + this.clock = clock; + } + + public Mono checkGroupSendToken(final ByteString serializedGroupSendToken, List serviceIdentifiers) { + try { + final GroupSendFullToken token = new GroupSendFullToken(serializedGroupSendToken.toByteArray()); + final List serviceIds = serviceIdentifiers.stream().map(ServiceIdentifier::toLibsignal).toList(); + token.verify(serviceIds, clock.instant(), GroupSendDerivedKeyPair.forExpiration(token.getExpiration(), serverSecretParams)); + return Mono.empty(); + } catch (InvalidInputException e) { + return Mono.error(Status.INVALID_ARGUMENT.asException()); + } catch (VerificationFailedException e) { + return Mono.error(Status.UNAUTHENTICATED.asException()); + } + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/KeysAnonymousGrpcService.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/KeysAnonymousGrpcService.java index 43dbd2b7c..6b2c81ec3 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/KeysAnonymousGrpcService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/KeysAnonymousGrpcService.java @@ -9,15 +9,20 @@ import com.google.protobuf.ByteString; import io.grpc.Status; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.time.Clock; import java.util.Arrays; +import java.util.List; + import org.signal.chat.keys.CheckIdentityKeyRequest; import org.signal.chat.keys.CheckIdentityKeyResponse; import org.signal.chat.keys.GetPreKeysAnonymousRequest; import org.signal.chat.keys.GetPreKeysResponse; import org.signal.chat.keys.ReactorKeysAnonymousGrpc; import org.signal.libsignal.protocol.IdentityKey; +import org.signal.libsignal.zkgroup.ServerSecretParams; import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil; import org.whispersystems.textsecuregcm.identity.ServiceIdentifier; +import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.KeysManager; import reactor.core.publisher.Flux; @@ -28,11 +33,14 @@ public class KeysAnonymousGrpcService extends ReactorKeysAnonymousGrpc.KeysAnony private final AccountsManager accountsManager; private final KeysManager keysManager; + private final GroupSendTokenUtil groupSendTokenUtil; - public KeysAnonymousGrpcService(final AccountsManager accountsManager, final KeysManager keysManager) { + public KeysAnonymousGrpcService( + final AccountsManager accountsManager, final KeysManager keysManager, final ServerSecretParams serverSecretParams, final Clock clock) { this.accountsManager = accountsManager; this.keysManager = keysManager; - } + this.groupSendTokenUtil = new GroupSendTokenUtil(serverSecretParams, clock); +} @Override public Mono getPreKeys(final GetPreKeysAnonymousRequest request) { @@ -41,13 +49,19 @@ public class KeysAnonymousGrpcService extends ReactorKeysAnonymousGrpc.KeysAnony final byte deviceId = DeviceIdUtil.validate(request.getRequest().getDeviceId()); - return Mono.fromFuture(() -> accountsManager.getByServiceIdentifierAsync(serviceIdentifier)) - .flatMap(Mono::justOrEmpty) - .switchIfEmpty(Mono.error(Status.UNAUTHENTICATED.asException())) - .flatMap(targetAccount -> - UnidentifiedAccessUtil.checkUnidentifiedAccess(targetAccount, request.getUnidentifiedAccessKey().toByteArray()) - ? KeysGrpcHelper.getPreKeys(targetAccount, serviceIdentifier.identityType(), deviceId, keysManager) - : Mono.error(Status.UNAUTHENTICATED.asException())); + return switch (request.getAuthorizationCase()) { + case GROUP_SEND_TOKEN -> + groupSendTokenUtil.checkGroupSendToken(request.getGroupSendToken(), List.of(serviceIdentifier)) + .then(lookUpAccount(serviceIdentifier, Status.NOT_FOUND)) + .flatMap(targetAccount -> KeysGrpcHelper.getPreKeys(targetAccount, serviceIdentifier.identityType(), deviceId, keysManager)); + case UNIDENTIFIED_ACCESS_KEY -> + lookUpAccount(serviceIdentifier, Status.UNAUTHENTICATED) + .flatMap(targetAccount -> + UnidentifiedAccessUtil.checkUnidentifiedAccess(targetAccount, request.getUnidentifiedAccessKey().toByteArray()) + ? KeysGrpcHelper.getPreKeys(targetAccount, serviceIdentifier.identityType(), deviceId, keysManager) + : Mono.error(Status.UNAUTHENTICATED.asException())); + default -> Mono.error(Status.INVALID_ARGUMENT.asException()); + }; } @Override @@ -69,6 +83,12 @@ public class KeysAnonymousGrpcService extends ReactorKeysAnonymousGrpc.KeysAnony ); } + private Mono lookUpAccount(final ServiceIdentifier serviceIdentifier, final Status onNotFound) { + return Mono.fromFuture(() -> accountsManager.getByServiceIdentifierAsync(serviceIdentifier)) + .flatMap(Mono::justOrEmpty) + .switchIfEmpty(Mono.error(onNotFound.asException())); + } + private static boolean fingerprintMatches(final IdentityKey identityKey, final byte[] fingerprint) { final byte[] digest; try { diff --git a/service/src/main/proto/org/signal/chat/keys.proto b/service/src/main/proto/org/signal/chat/keys.proto index d900caf86..a1e2fa81d 100644 --- a/service/src/main/proto/org/signal/chat/keys.proto +++ b/service/src/main/proto/org/signal/chat/keys.proto @@ -164,9 +164,19 @@ message GetPreKeysAnonymousRequest { GetPreKeysRequest request = 1; /** - * The unidentified access key (UAK) for the targeted account. + * A means to authorize the request. */ - bytes unidentified_access_key = 2; + oneof authorization { + /** + * The unidentified access key (UAK) for the targeted account. + */ + bytes unidentified_access_key = 2; + + /** + * A group send endorsement token for the targeted account. + */ + bytes group_send_token = 3; + } } message GetPreKeysResponse { diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/KeysControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/KeysControllerTest.java index a7349455e..25b03efb4 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/KeysControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/KeysControllerTest.java @@ -29,13 +29,18 @@ import java.nio.ByteBuffer; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.OptionalInt; import java.util.UUID; import java.util.concurrent.CompletableFuture; +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.Response; import org.glassfish.jersey.server.ServerProperties; @@ -52,6 +57,7 @@ import org.mockito.ArgumentCaptor; import org.signal.libsignal.protocol.IdentityKey; import org.signal.libsignal.protocol.ecc.Curve; import org.signal.libsignal.protocol.ecc.ECKeyPair; +import org.signal.libsignal.zkgroup.ServerSecretParams; import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; import org.whispersystems.textsecuregcm.entities.CheckKeysRequest; import org.whispersystems.textsecuregcm.entities.ECPreKey; @@ -64,6 +70,7 @@ import org.whispersystems.textsecuregcm.entities.SignedPreKey; import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier; import org.whispersystems.textsecuregcm.identity.IdentityType; import org.whispersystems.textsecuregcm.identity.PniServiceIdentifier; +import org.whispersystems.textsecuregcm.identity.ServiceIdentifier; import org.whispersystems.textsecuregcm.limits.RateLimiter; import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper; @@ -79,6 +86,7 @@ import org.whispersystems.textsecuregcm.tests.util.KeysHelper; import org.whispersystems.textsecuregcm.util.ByteArrayAdapter; import org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil; import org.whispersystems.textsecuregcm.util.HeaderUtils; +import org.whispersystems.textsecuregcm.util.TestClock; @ExtendWith(DropwizardExtensionsSupport.class) class KeysControllerTest { @@ -86,8 +94,13 @@ class KeysControllerTest { private static final String EXISTS_NUMBER = "+14152222222"; private static final UUID EXISTS_UUID = UUID.randomUUID(); private static final UUID EXISTS_PNI = UUID.randomUUID(); + private static final AciServiceIdentifier EXISTS_ACI = new AciServiceIdentifier(EXISTS_UUID); + + private static final UUID OTHER_UUID = UUID.randomUUID(); + private static final AciServiceIdentifier OTHER_ACI = new AciServiceIdentifier(OTHER_UUID); private static final UUID NOT_EXISTS_UUID = UUID.randomUUID(); + private static final AciServiceIdentifier NOT_EXISTS_ACI = new AciServiceIdentifier(NOT_EXISTS_UUID); private static final byte SAMPLE_DEVICE_ID = 1; private static final byte SAMPLE_DEVICE_ID2 = 2; @@ -136,6 +149,10 @@ class KeysControllerTest { private static final RateLimiters rateLimiters = mock(RateLimiters.class); private static final RateLimiter rateLimiter = mock(RateLimiter.class ); + private static final ServerSecretParams serverSecretParams = ServerSecretParams.generate(); + + private static final TestClock clock = TestClock.now(); + private static final ResourceExtension resources = ResourceExtension.builder() .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE) .addProvider(AuthHelper.getAuthFilter()) @@ -143,7 +160,7 @@ class KeysControllerTest { .addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedAccount.class)) .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) .addResource(new ServerRejectedExceptionMapper()) - .addResource(new KeysController(rateLimiters, KEYS, accounts)) + .addResource(new KeysController(rateLimiters, KEYS, accounts, serverSecretParams, clock)) .addResource(new RateLimitExceededExceptionMapper()) .build(); @@ -183,6 +200,8 @@ class KeysControllerTest { @BeforeEach void setup() { + clock.unpin(); + sampleDevice = mock(Device.class); final Device sampleDevice2 = mock(Device.class); final Device sampleDevice3 = mock(Device.class); @@ -529,6 +548,68 @@ class KeysControllerTest { verifyNoMoreInteractions(KEYS); } + @ParameterizedTest + @MethodSource + void testGetKeysWithGroupSendEndorsement( + ServiceIdentifier target, ServiceIdentifier 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(String.format("/v2/keys/%s/1", target.toServiceIdentifierString())) + .queryParam("pq", "true") + .request() + .header(HeaderUtils.GROUP_SEND_TOKEN, AuthHelper.validGroupSendTokenHeader(serverSecretParams, List.of(authorizedTarget), expiration)); + + if (includeUak) { + builder = builder.header(HeaderUtils.UNIDENTIFIED_ACCESS_KEY, AuthHelper.getUnidentifiedAccessHeader("1337".getBytes())); + } + + Response response = builder.get(); + assertThat(response.getStatus()).isEqualTo(expectedResponse); + + if (expectedResponse == 200) { + PreKeyResponse result = response.readEntity(PreKeyResponse.class); + + assertThat(result.getIdentityKey()).isEqualTo(existsAccount.getIdentityKey(IdentityType.ACI)); + assertThat(result.getDevicesCount()).isEqualTo(1); + assertEquals(SAMPLE_KEY, result.getDevice(SAMPLE_DEVICE_ID).getPreKey()); + assertEquals(SAMPLE_PQ_KEY, result.getDevice(SAMPLE_DEVICE_ID).getPqPreKey()); + assertEquals(SAMPLE_SIGNED_KEY, result.getDevice(SAMPLE_DEVICE_ID).getSignedPreKey()); + + verify(KEYS).takeEC(EXISTS_UUID, SAMPLE_DEVICE_ID); + verify(KEYS).takePQ(EXISTS_UUID, SAMPLE_DEVICE_ID); + verify(KEYS).getEcSignedPreKey(EXISTS_UUID, SAMPLE_DEVICE_ID); + } + + verifyNoMoreInteractions(KEYS); + } + + private static Stream testGetKeysWithGroupSendEndorsement() { + return Stream.of( + // valid endorsement + Arguments.of(EXISTS_ACI, EXISTS_ACI, Duration.ofHours(1), false, 200), + + // expired endorsement, not authorized + Arguments.of(EXISTS_ACI, EXISTS_ACI, Duration.ofHours(-1), false, 401), + + // endorsement for the wrong recipient, not authorized + Arguments.of(EXISTS_ACI, OTHER_ACI, Duration.ofHours(1), false, 401), + + // expired endorsement for the wrong recipient, not authorized + Arguments.of(EXISTS_ACI, OTHER_ACI, Duration.ofHours(-1), false, 401), + + // valid endorsement for the right recipient but they aren't registered, not found + Arguments.of(NOT_EXISTS_ACI, NOT_EXISTS_ACI, Duration.ofHours(1), false, 404), + + // expired endorsement for the right recipient but they aren't registered, not authorized (NOT not found) + Arguments.of(NOT_EXISTS_ACI, NOT_EXISTS_ACI, Duration.ofHours(-1), false, 401), + + // valid endorsement but also a UAK, bad request + Arguments.of(EXISTS_ACI, EXISTS_ACI, Duration.ofHours(1), true, 400)); + } + @Test void testNoDevices() { diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/MessageControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/MessageControllerTest.java index 3d4e9110a..0b8b716bf 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/MessageControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/MessageControllerTest.java @@ -433,7 +433,7 @@ class MessageControllerTest { .queryParam("story", story) .request() .header(HeaderUtils.GROUP_SEND_TOKEN, - validGroupSendTokenHeader(List.of(authorizedRecipient), expiration)); + AuthHelper.validGroupSendTokenHeader(serverSecretParams, List.of(authorizedRecipient), expiration)); if (includeUak) { builder = builder.header(HeaderUtils.UNIDENTIFIED_ACCESS_KEY, Base64.getEncoder().encodeToString(UNIDENTIFIED_ACCESS_BYTES)); @@ -1351,8 +1351,8 @@ class MessageControllerTest { .queryParam("urgent", false) .request() .header(HttpHeaders.USER_AGENT, "FIXME") - .header(HeaderUtils.GROUP_SEND_TOKEN, validGroupSendTokenHeader( - List.of(SINGLE_DEVICE_ACI_ID, MULTI_DEVICE_ACI_ID), Instant.parse("2024-04-10T00:00:00.00Z"))) + .header(HeaderUtils.GROUP_SEND_TOKEN, AuthHelper.validGroupSendTokenHeader( + serverSecretParams, List.of(SINGLE_DEVICE_ACI_ID, MULTI_DEVICE_ACI_ID), Instant.parse("2024-04-10T00:00:00.00Z"))) .put(Entity.entity(stream, MultiRecipientMessageProvider.MEDIA_TYPE)); assertThat("Unexpected response", response.getStatus(), is(equalTo(200))); @@ -1389,8 +1389,8 @@ class MessageControllerTest { .queryParam("urgent", false) .request() .header(HttpHeaders.USER_AGENT, "FIXME") - .header(HeaderUtils.GROUP_SEND_TOKEN, validGroupSendTokenHeader( - List.of(MULTI_DEVICE_ACI_ID), Instant.parse("2024-04-10T00:00:00.00Z"))) + .header(HeaderUtils.GROUP_SEND_TOKEN, AuthHelper.validGroupSendTokenHeader( + serverSecretParams, List.of(MULTI_DEVICE_ACI_ID), Instant.parse("2024-04-10T00:00:00.00Z"))) .put(Entity.entity(stream, MultiRecipientMessageProvider.MEDIA_TYPE)); assertThat("Unexpected response", response.getStatus(), is(equalTo(401))); @@ -1419,39 +1419,14 @@ class MessageControllerTest { .queryParam("urgent", false) .request() .header(HttpHeaders.USER_AGENT, "FIXME") - .header(HeaderUtils.GROUP_SEND_TOKEN, validGroupSendTokenHeader( - List.of(SINGLE_DEVICE_ACI_ID, MULTI_DEVICE_ACI_ID), Instant.parse("2024-04-10T00:00:00.00Z"))) + .header(HeaderUtils.GROUP_SEND_TOKEN, AuthHelper.validGroupSendTokenHeader( + serverSecretParams, List.of(SINGLE_DEVICE_ACI_ID, MULTI_DEVICE_ACI_ID), Instant.parse("2024-04-10T00:00:00.00Z"))) .put(Entity.entity(stream, MultiRecipientMessageProvider.MEDIA_TYPE)); assertThat("Unexpected response", response.getStatus(), is(equalTo(401))); verifyNoMoreInteractions(messageSender); } - private String validGroupSendTokenHeader(List recipients, Instant expiration) throws Exception { - final ServerPublicParams serverPublicParams = serverSecretParams.getPublicParams(); - final GroupMasterKey groupMasterKey = new GroupMasterKey(new byte[32]); - final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey); - final ClientZkGroupCipher clientZkGroupCipher = new ClientZkGroupCipher(groupSecretParams); - - final ServiceId.Aci sender = new ServiceId.Aci(UUID.randomUUID()); - List groupPlaintexts = Stream.concat(Stream.of(sender), recipients.stream().map(ServiceIdentifier::toLibsignal)).toList(); - List groupCiphertexts = groupPlaintexts.stream() - .map(clientZkGroupCipher::encrypt) - .toList(); - GroupSendDerivedKeyPair keyPair = GroupSendDerivedKeyPair.forExpiration(expiration, serverSecretParams); - GroupSendEndorsementsResponse endorsementsResponse = - GroupSendEndorsementsResponse.issue(groupCiphertexts, keyPair); - ReceivedEndorsements endorsements = - endorsementsResponse.receive( - groupPlaintexts, - sender, - expiration.minus(Duration.ofDays(1)), - groupSecretParams, - serverPublicParams); - GroupSendFullToken token = endorsements.combinedEndorsement().toFullToken(groupSecretParams, expiration); - return Base64.getEncoder().encodeToString(token.serialize()); - } - @ParameterizedTest @ValueSource(booleans = {true, false}) void testMultiRecipientRedisBombProtection(final boolean useExplicitIdentifier) throws Exception { diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/KeysAnonymousGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/KeysAnonymousGrpcServiceTest.java index f5183c9b2..1ae81f07b 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/KeysAnonymousGrpcServiceTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/KeysAnonymousGrpcServiceTest.java @@ -6,23 +6,27 @@ package org.whispersystems.textsecuregcm.grpc; 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.anyByte; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verifyNoInteractions; import static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertStatusException; import com.google.protobuf.ByteString; import io.grpc.Status; -import io.grpc.StatusRuntimeException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +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; import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; @@ -41,6 +45,7 @@ import org.signal.libsignal.protocol.IdentityKey; import org.signal.libsignal.protocol.InvalidKeyException; import org.signal.libsignal.protocol.ecc.Curve; import org.signal.libsignal.protocol.ecc.ECKeyPair; +import org.signal.libsignal.zkgroup.ServerSecretParams; import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil; import org.whispersystems.textsecuregcm.entities.ECPreKey; import org.whispersystems.textsecuregcm.entities.ECSignedPreKey; @@ -52,7 +57,10 @@ import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.Device; import org.whispersystems.textsecuregcm.storage.KeysManager; +import org.whispersystems.textsecuregcm.tests.util.AuthHelper; +import org.whispersystems.textsecuregcm.tests.util.DevicesHelper; import org.whispersystems.textsecuregcm.tests.util.KeysHelper; +import org.whispersystems.textsecuregcm.util.TestClock; import org.whispersystems.textsecuregcm.util.TestRandomUtil; import org.whispersystems.textsecuregcm.util.UUIDUtil; import org.whispersystems.textsecuregcm.util.Util; @@ -60,6 +68,9 @@ import reactor.core.publisher.Flux; class KeysAnonymousGrpcServiceTest extends SimpleBaseGrpcTest { + private static final ServerSecretParams SERVER_SECRET_PARAMS = ServerSecretParams.generate(); + private static final TestClock CLOCK = TestClock.now(); + @Mock private AccountsManager accountsManager; @@ -68,71 +79,54 @@ class KeysAnonymousGrpcServiceTest extends SimpleBaseGrpcTest unauthenticatedServiceStub().getPreKeys(GetPreKeysAnonymousRequest.newBuilder() + final ECPreKey ecPreKey = new ECPreKey(1, Curve.generateKeyPair().getPublicKey()); + final ECSignedPreKey ecSignedPreKey = KeysHelper.signedECPreKey(2, identityKeyPair); + final KEMSignedPreKey kemSignedPreKey = KeysHelper.signedKEMPreKey(3, identityKeyPair); + + when(keysManager.takeEC(uuid, Device.PRIMARY_ID)) + .thenReturn(CompletableFuture.completedFuture(Optional.of(ecPreKey))); + + when(keysManager.takePQ(uuid, Device.PRIMARY_ID)) + .thenReturn(CompletableFuture.completedFuture(Optional.of(kemSignedPreKey))); + + when(keysManager.getEcSignedPreKey(uuid, Device.PRIMARY_ID)) + .thenReturn(CompletableFuture.completedFuture(Optional.of(ecSignedPreKey))); + + // Expirations must be on day boundaries or libsignal will refuse to create or verify the token + final Instant expiration = Instant.now().truncatedTo(ChronoUnit.DAYS); + CLOCK.pin(expiration.minus(Duration.ofHours(1))); // set time so the credential isn't expired yet + final byte[] token = AuthHelper.validGroupSendToken(SERVER_SECRET_PARAMS, List.of(identifier), expiration); + + final GetPreKeysResponse response = unauthenticatedServiceStub().getPreKeys(GetPreKeysAnonymousRequest.newBuilder() + .setGroupSendToken(ByteString.copyFrom(token)) .setRequest(GetPreKeysRequest.newBuilder() - .setTargetIdentifier(ServiceIdentifier.newBuilder() - .setIdentityType(org.signal.chat.common.IdentityType.IDENTITY_TYPE_ACI) - .setUuid(UUIDUtil.toByteString(identifier)) - .build()) - .setDeviceId(Device.PRIMARY_ID) + .setTargetIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(identifier)) + .setDeviceId(Device.PRIMARY_ID)) + .build()); + + final GetPreKeysResponse expectedResponse = GetPreKeysResponse.newBuilder() + .setIdentityKey(ByteString.copyFrom(identityKey.serialize())) + .putPreKeys(Device.PRIMARY_ID, GetPreKeysResponse.PreKeyBundle.newBuilder() + .setEcOneTimePreKey(toGrpcEcPreKey(ecPreKey)) + .setEcSignedPreKey(toGrpcEcSignedPreKey(ecSignedPreKey)) + .setKemOneTimePreKey(toGrpcKemSignedPreKey(kemSignedPreKey)) .build()) - .build())); + .build(); + + assertEquals(expectedResponse, response); } @Test - void getPreKeysAccountNotFound() { - when(accountsManager.getByServiceIdentifierAsync(any())) + void getPreKeysNoAuth() { + assertGetKeysFailure(Status.INVALID_ARGUMENT, GetPreKeysAnonymousRequest.newBuilder() + .setRequest(GetPreKeysRequest.newBuilder() + .setTargetIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(UUID.randomUUID()))) + .setDeviceId(Device.PRIMARY_ID)) + .build()); + + verifyNoInteractions(accountsManager); + verifyNoInteractions(keysManager); + } + + @Test + void getPreKeysIncorrectUnidentifiedAccessKey() { + final Account targetAccount = mock(Account.class); + + final UUID uuid = UUID.randomUUID(); + final AciServiceIdentifier identifier = new AciServiceIdentifier(uuid); + final byte[] unidentifiedAccessKey = TestRandomUtil.nextBytes(UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH); + + when(targetAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey)); + when(accountsManager.getByServiceIdentifierAsync(identifier)) + .thenReturn(CompletableFuture.completedFuture(Optional.of(targetAccount))); + + assertGetKeysFailure(Status.UNAUTHENTICATED, GetPreKeysAnonymousRequest.newBuilder() + .setUnidentifiedAccessKey(UUIDUtil.toByteString(UUID.randomUUID())) + .setRequest(GetPreKeysRequest.newBuilder() + .setTargetIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(identifier)) + .setDeviceId(Device.PRIMARY_ID)) + .build()); + + verifyNoInteractions(keysManager); + } + + @Test + void getPreKeysExpiredGroupSendEndorsement() throws Exception { + final UUID uuid = UUID.randomUUID(); + final AciServiceIdentifier identifier = new AciServiceIdentifier(uuid); + + // Expirations must be on day boundaries or libsignal will refuse to create or verify the token + final Instant expiration = Instant.now().truncatedTo(ChronoUnit.DAYS); + CLOCK.pin(expiration.plus(Duration.ofHours(1))); // set time so our token is already expired + + final byte[] token = AuthHelper.validGroupSendToken(SERVER_SECRET_PARAMS, List.of(identifier), expiration); + + assertGetKeysFailure(Status.UNAUTHENTICATED, GetPreKeysAnonymousRequest.newBuilder() + .setGroupSendToken(ByteString.copyFrom(token)) + .setRequest(GetPreKeysRequest.newBuilder() + .setTargetIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(identifier)) + .setDeviceId(Device.PRIMARY_ID)) + .build()); + + verifyNoInteractions(accountsManager); + verifyNoInteractions(keysManager); + } + + @Test + void getPreKeysIncorrectGroupSendEndorsement() throws Exception { + final AciServiceIdentifier authorizedIdentifier = new AciServiceIdentifier(UUID.randomUUID()); + final AciServiceIdentifier targetIdentifier = new AciServiceIdentifier(UUID.randomUUID()); + + // Expirations must be on day boundaries or libsignal will refuse to create or verify the token + final Instant expiration = Instant.now().truncatedTo(ChronoUnit.DAYS); + CLOCK.pin(expiration.minus(Duration.ofHours(1))); // set time so the credential isn't expired yet + + final AciServiceIdentifier wrongAci = new AciServiceIdentifier(UUID.randomUUID()); + final byte[] token = AuthHelper.validGroupSendToken(SERVER_SECRET_PARAMS, List.of(authorizedIdentifier), expiration); + + assertGetKeysFailure(Status.UNAUTHENTICATED, GetPreKeysAnonymousRequest.newBuilder() + .setGroupSendToken(ByteString.copyFrom(token)) + .setRequest(GetPreKeysRequest.newBuilder() + .setTargetIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(targetIdentifier)) + .setDeviceId(Device.PRIMARY_ID)) + .build()); + + verifyNoInteractions(accountsManager); + verifyNoInteractions(keysManager); + } + + @Test + void getPreKeysAccountNotFoundUnidentifiedAccessKey() { + final AciServiceIdentifier nonexistentAci = new AciServiceIdentifier(UUID.randomUUID()); + when(accountsManager.getByServiceIdentifierAsync(nonexistentAci)) .thenReturn(CompletableFuture.completedFuture(Optional.empty())); - final StatusRuntimeException exception = - assertThrows(StatusRuntimeException.class, - () -> unauthenticatedServiceStub().getPreKeys(GetPreKeysAnonymousRequest.newBuilder() - .setUnidentifiedAccessKey(UUIDUtil.toByteString(UUID.randomUUID())) - .setRequest(GetPreKeysRequest.newBuilder() - .setTargetIdentifier(ServiceIdentifier.newBuilder() - .setIdentityType(org.signal.chat.common.IdentityType.IDENTITY_TYPE_ACI) - .setUuid(UUIDUtil.toByteString(UUID.randomUUID())) - .build()) - .build()) - .build())); + assertGetKeysFailure(Status.UNAUTHENTICATED, + GetPreKeysAnonymousRequest.newBuilder() + .setUnidentifiedAccessKey(UUIDUtil.toByteString(UUID.randomUUID())) + .setRequest(GetPreKeysRequest.newBuilder() + .setTargetIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(nonexistentAci))) + .build()); - assertEquals(Status.Code.UNAUTHENTICATED, exception.getStatus().getCode()); + verifyNoInteractions(keysManager); + } + + @Test + void getPreKeysAccountNotFoundGroupSendEndorsement() throws Exception { + final AciServiceIdentifier nonexistentAci = new AciServiceIdentifier(UUID.randomUUID()); + + // Expirations must be on day boundaries or libsignal will refuse to create or verify the token + final Instant expiration = Instant.now().truncatedTo(ChronoUnit.DAYS); + CLOCK.pin(expiration.minus(Duration.ofHours(1))); // set time so the credential isn't expired yet + + final byte[] token = AuthHelper.validGroupSendToken(SERVER_SECRET_PARAMS, List.of(nonexistentAci), expiration); + + when(accountsManager.getByServiceIdentifierAsync(nonexistentAci)) + .thenReturn(CompletableFuture.completedFuture(Optional.empty())); + + assertGetKeysFailure(Status.NOT_FOUND, + GetPreKeysAnonymousRequest.newBuilder() + .setGroupSendToken(ByteString.copyFrom(token)) + .setRequest(GetPreKeysRequest.newBuilder() + .setTargetIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(nonexistentAci))) + .build()); + + verifyNoInteractions(keysManager); } @ParameterizedTest @@ -203,16 +324,14 @@ class KeysAnonymousGrpcServiceTest extends SimpleBaseGrpcTest unauthenticatedServiceStub().getPreKeys(GetPreKeysAnonymousRequest.newBuilder() + assertGetKeysFailure(Status.NOT_FOUND, GetPreKeysAnonymousRequest.newBuilder() .setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey)) .setRequest(GetPreKeysRequest.newBuilder() .setTargetIdentifier(ServiceIdentifier.newBuilder() .setIdentityType(org.signal.chat.common.IdentityType.IDENTITY_TYPE_ACI) - .setUuid(UUIDUtil.toByteString(accountIdentifier)) - .build()) - .setDeviceId(deviceId) - .build()) - .build())); + .setUuid(UUIDUtil.toByteString(accountIdentifier))) + .setDeviceId(deviceId)) + .build()); } @Test @@ -291,4 +410,32 @@ class KeysAnonymousGrpcServiceTest extends SimpleBaseGrpcTest unauthenticatedServiceStub().getPreKeys(request)); + } + + private static EcPreKey toGrpcEcPreKey(final ECPreKey preKey) { + return EcPreKey.newBuilder() + .setKeyId(preKey.keyId()) + .setPublicKey(ByteString.copyFrom(preKey.publicKey().serialize())) + .build(); + } + + private static EcSignedPreKey toGrpcEcSignedPreKey(final ECSignedPreKey preKey) { + return EcSignedPreKey.newBuilder() + .setKeyId(preKey.keyId()) + .setPublicKey(ByteString.copyFrom(preKey.publicKey().serialize())) + .setSignature(ByteString.copyFrom(preKey.signature())) + .build(); + } + + private static KemSignedPreKey toGrpcKemSignedPreKey(final KEMSignedPreKey preKey) { + return KemSignedPreKey.newBuilder() + .setKeyId(preKey.keyId()) + .setPublicKey(ByteString.copyFrom(preKey.publicKey().serialize())) + .setSignature(ByteString.copyFrom(preKey.signature())) + .build(); + } + } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/AuthHelper.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/AuthHelper.java index 116e3c1eb..3a0786575 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/AuthHelper.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/AuthHelper.java @@ -18,6 +18,8 @@ import io.dropwizard.auth.PolymorphicAuthDynamicFeature; import io.dropwizard.auth.basic.BasicCredentialAuthFilter; import io.dropwizard.auth.basic.BasicCredentials; import java.security.Principal; +import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.Base64; import java.util.Collection; @@ -26,17 +28,31 @@ import java.util.List; import java.util.Optional; import java.util.Random; import java.util.UUID; +import java.util.stream.Stream; + import org.junit.jupiter.api.extension.AfterEachCallback; import org.junit.jupiter.api.extension.ExtensionContext; 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.ServerPublicParams; +import org.signal.libsignal.zkgroup.ServerSecretParams; +import org.signal.libsignal.zkgroup.groups.ClientZkGroupCipher; +import org.signal.libsignal.zkgroup.groups.GroupMasterKey; +import org.signal.libsignal.zkgroup.groups.GroupSecretParams; +import org.signal.libsignal.zkgroup.groups.UuidCiphertext; +import org.signal.libsignal.zkgroup.groupsend.GroupSendDerivedKeyPair; +import org.signal.libsignal.zkgroup.groupsend.GroupSendEndorsementsResponse; +import org.signal.libsignal.zkgroup.groupsend.GroupSendFullToken; +import org.signal.libsignal.zkgroup.groupsend.GroupSendEndorsementsResponse.ReceivedEndorsements; import org.whispersystems.textsecuregcm.auth.AccountAuthenticator; import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; import org.whispersystems.textsecuregcm.auth.SaltedTokenHash; import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier; import org.whispersystems.textsecuregcm.identity.IdentityType; import org.whispersystems.textsecuregcm.identity.PniServiceIdentifier; +import org.whispersystems.textsecuregcm.identity.ServiceIdentifier; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.Device; @@ -317,4 +333,33 @@ public class AuthHelper { EXTENSION_TEST_ACCOUNTS.clear(); } } + + public static byte[] validGroupSendToken(ServerSecretParams serverSecretParams, List recipients, Instant expiration) throws Exception { + final ServerPublicParams serverPublicParams = serverSecretParams.getPublicParams(); + final GroupMasterKey groupMasterKey = new GroupMasterKey(new byte[32]); + final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey); + final ClientZkGroupCipher clientZkGroupCipher = new ClientZkGroupCipher(groupSecretParams); + + final ServiceId.Aci sender = new ServiceId.Aci(UUID.randomUUID()); + List groupPlaintexts = Stream.concat(Stream.of(sender), recipients.stream().map(ServiceIdentifier::toLibsignal)).toList(); + List groupCiphertexts = groupPlaintexts.stream() + .map(clientZkGroupCipher::encrypt) + .toList(); + GroupSendDerivedKeyPair keyPair = GroupSendDerivedKeyPair.forExpiration(expiration, serverSecretParams); + GroupSendEndorsementsResponse endorsementsResponse = + GroupSendEndorsementsResponse.issue(groupCiphertexts, keyPair); + ReceivedEndorsements endorsements = + endorsementsResponse.receive( + groupPlaintexts, + sender, + expiration.minus(Duration.ofDays(1)), + groupSecretParams, + serverPublicParams); + GroupSendFullToken token = endorsements.combinedEndorsement().toFullToken(groupSecretParams, expiration); + return token.serialize(); + } + + public static String validGroupSendTokenHeader(ServerSecretParams serverSecretParams, List recipients, Instant expiration) throws Exception { + return Base64.getEncoder().encodeToString(validGroupSendToken(serverSecretParams, recipients, expiration)); + } }