diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/MessageController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/MessageController.java index dd1fe82ff..f9e0fce39 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/MessageController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/MessageController.java @@ -241,20 +241,60 @@ public class MessageController { @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) @ManagedAsync + @Operation( + summary = "Send a message", + description = """ + Deliver a message to a single recipient. May be authenticated or unauthenticated; if unauthenticated, + an unidentifed-access key or group-send endorsement token must be provided, unless the message is a story. + """) + @ApiResponse(responseCode="200", description="Message was successfully sent", useReturnTypeSchema=true) + @ApiResponse( + responseCode="401", + description="The message is not a story and the authorization, unauthorized access key, or group send endorsement token is missing or incorrect") + @ApiResponse( + responseCode="404", + description="The message is not a story and some the recipient service ID does not correspond to a registered Signal user") + @ApiResponse( + responseCode = "409", description = "Incorrect set of devices supplied for recipient", + content = @Content(schema = @Schema(implementation = AccountMismatchedDevices[].class))) + @ApiResponse( + responseCode = "410", description = "Mismatched registration ids supplied for some recipient devices", + content = @Content(schema = @Schema(implementation = AccountStaleDevices[].class))) public Response sendMessage(@ReadOnly @Auth Optional source, + @Parameter(description="The recipient's unidentified access key") @HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) Optional accessKey, + + @Parameter(description="A group send endorsement token covering the recipient. Must not be combined with `Unidentified-Access-Key` or set on a story message.") + @HeaderParam(HeaderUtils.GROUP_SEND_TOKEN) + @Nullable GroupSendTokenHeader groupSendToken, + @HeaderParam(HttpHeaders.USER_AGENT) String userAgent, + + @Parameter(description="If true, deliver the message only to recipients that are online when it is sent") @PathParam("destination") ServiceIdentifier destinationIdentifier, + + @Parameter(description="If true, the message is a story; access tokens are not checked and sending to nonexistent recipients is permitted") @QueryParam("story") boolean isStory, + + @Parameter(description="The encrypted message payloads for each recipient device") @NotNull @Valid IncomingMessageList messages, + @Context ContainerRequestContext context) throws RateLimitExceededException { final Sample sample = Timer.start(); try { - if (source.isEmpty() && accessKey.isEmpty() && !isStory) { + if (source.isEmpty() && accessKey.isEmpty() && groupSendToken == null && !isStory) { throw new WebApplicationException(Response.Status.UNAUTHORIZED); } + if (groupSendToken != null) { + if (!source.isEmpty() || !accessKey.isEmpty()) { + throw new BadRequestException("Group send endorsement tokens should not be combined with other authentication"); + } else if (isStory) { + throw new BadRequestException("Group send endorsement tokens should not be sent for story messages"); + } + } + final String senderType; if (source.isPresent()) { if (source.get().getAccount().isIdentifiedBy(destinationIdentifier)) { @@ -316,8 +356,14 @@ public class MessageController { } try { - // Stories will be checked by the client; we bypass access checks here for stories. - if (!isStory) { + if (isStory) { + // Stories will be checked by the client; we bypass access checks here for stories. + } else if (groupSendToken != null) { + checkGroupSendToken(List.of(destinationIdentifier.toLibsignal()), groupSendToken); + if (destination.isEmpty()) { + throw new NotFoundException(); + } + } else { OptionalAccess.verify(source.map(AuthenticatedAccount::getAccount), accessKey, destination); } 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 9c086aebf..3d4e9110a 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/MessageControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/MessageControllerTest.java @@ -44,6 +44,7 @@ import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.time.Duration; import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; @@ -150,18 +151,18 @@ class MessageControllerTest { private static final String SINGLE_DEVICE_RECIPIENT = "+14151111111"; private static final UUID SINGLE_DEVICE_UUID = UUID.randomUUID(); - private static final ServiceIdentifier SINGLE_DEVICE_ACI_ID = new AciServiceIdentifier(SINGLE_DEVICE_UUID); + private static final AciServiceIdentifier SINGLE_DEVICE_ACI_ID = new AciServiceIdentifier(SINGLE_DEVICE_UUID); private static final UUID SINGLE_DEVICE_PNI = UUID.randomUUID(); - private static final ServiceIdentifier SINGLE_DEVICE_PNI_ID = new PniServiceIdentifier(SINGLE_DEVICE_PNI); + private static final PniServiceIdentifier SINGLE_DEVICE_PNI_ID = new PniServiceIdentifier(SINGLE_DEVICE_PNI); private static final byte SINGLE_DEVICE_ID1 = 1; private static final int SINGLE_DEVICE_REG_ID1 = 111; private static final int SINGLE_DEVICE_PNI_REG_ID1 = 1111; private static final String MULTI_DEVICE_RECIPIENT = "+14152222222"; private static final UUID MULTI_DEVICE_UUID = UUID.randomUUID(); - private static final ServiceIdentifier MULTI_DEVICE_ACI_ID = new AciServiceIdentifier(MULTI_DEVICE_UUID); + private static final AciServiceIdentifier MULTI_DEVICE_ACI_ID = new AciServiceIdentifier(MULTI_DEVICE_UUID); private static final UUID MULTI_DEVICE_PNI = UUID.randomUUID(); - private static final ServiceIdentifier MULTI_DEVICE_PNI_ID = new PniServiceIdentifier(MULTI_DEVICE_PNI); + private static final PniServiceIdentifier MULTI_DEVICE_PNI_ID = new PniServiceIdentifier(MULTI_DEVICE_PNI); private static final byte MULTI_DEVICE_ID1 = 1; private static final byte MULTI_DEVICE_ID2 = 2; private static final byte MULTI_DEVICE_ID3 = 3; @@ -173,8 +174,8 @@ class MessageControllerTest { private static final int MULTI_DEVICE_PNI_REG_ID3 = 4444; private static final UUID NONEXISTENT_UUID = UUID.randomUUID(); - private static final ServiceIdentifier NONEXISTENT_ACI_ID = new AciServiceIdentifier(NONEXISTENT_UUID); - private static final ServiceIdentifier NONEXISTENT_PNI_ID = new PniServiceIdentifier(NONEXISTENT_UUID); + private static final AciServiceIdentifier NONEXISTENT_ACI_ID = new AciServiceIdentifier(NONEXISTENT_UUID); + private static final PniServiceIdentifier NONEXISTENT_PNI_ID = new PniServiceIdentifier(NONEXISTENT_UUID); private static final byte[] UNIDENTIFIED_ACCESS_BYTES = "0123456789abcdef".getBytes(); @@ -418,6 +419,71 @@ class MessageControllerTest { assertFalse(captor.getValue().hasSourceDevice()); } + @ParameterizedTest + @MethodSource + void testSingleDeviceCurrentGroupSendEndorsement( + ServiceIdentifier recipient, ServiceIdentifier authorizedRecipient, + Duration timeLeft, boolean includeUak, boolean story, int expectedResponse) throws Exception { + final Instant expiration = Instant.now().truncatedTo(ChronoUnit.DAYS); // expiration times must be UTC midnight or libsignal will reject the endorsement + clock.pin(expiration.minus(timeLeft)); + + Invocation.Builder builder = + resources.getJerseyTest() + .target(String.format("/v1/messages/%s", recipient.toServiceIdentifierString())) + .queryParam("story", story) + .request() + .header(HeaderUtils.GROUP_SEND_TOKEN, + validGroupSendTokenHeader(List.of(authorizedRecipient), expiration)); + + if (includeUak) { + builder = builder.header(HeaderUtils.UNIDENTIFIED_ACCESS_KEY, Base64.getEncoder().encodeToString(UNIDENTIFIED_ACCESS_BYTES)); + } + + Response response = builder + .put(Entity.entity( + SystemMapper.jsonMapper().readValue(jsonFixture("fixtures/current_message_single_device.json"), + IncomingMessageList.class), + MediaType.APPLICATION_JSON_TYPE)); + + assertThat("Good Response", response.getStatus(), is(equalTo(expectedResponse))); + if (expectedResponse == 200) { + verify(messageSender).sendMessage( + any(Account.class), any(Device.class), argThat(env -> !env.hasSourceUuid() && !env.hasSourceDevice()), eq(false)); + } else { + verifyNoMoreInteractions(messageSender); + } + } + + private static Stream testSingleDeviceCurrentGroupSendEndorsement() { + return Stream.of( + // valid endorsement + Arguments.of(SINGLE_DEVICE_ACI_ID, SINGLE_DEVICE_ACI_ID, Duration.ofHours(1), false, false, 200), + + // expired endorsement, not authorized + Arguments.of(SINGLE_DEVICE_ACI_ID, SINGLE_DEVICE_ACI_ID, Duration.ofHours(-1), false, false, 401), + + // endorsement for the wrong recipient, not authorized + Arguments.of(SINGLE_DEVICE_ACI_ID, NONEXISTENT_ACI_ID, Duration.ofHours(1), false, false, 401), + + // expired endorsement for the wrong recipient, not authorized + Arguments.of(SINGLE_DEVICE_ACI_ID, NONEXISTENT_ACI_ID, Duration.ofHours(-1), false, false, 401), + + // valid endorsement for the right recipient but they aren't registered, not found + Arguments.of(NONEXISTENT_ACI_ID, NONEXISTENT_ACI_ID, Duration.ofHours(1), false, false, 404), + + // expired endorsement for the right recipient but they aren't registered, not authorized (NOT not found) + Arguments.of(NONEXISTENT_ACI_ID, NONEXISTENT_ACI_ID, Duration.ofHours(-1), false, false, 401), + + // valid endorsement but also a UAK, bad request + Arguments.of(SINGLE_DEVICE_ACI_ID, SINGLE_DEVICE_ACI_ID, Duration.ofHours(1), true, false, 400), + + // valid endorsement on a story, bad request + Arguments.of(SINGLE_DEVICE_ACI_ID, SINGLE_DEVICE_ACI_ID, Duration.ofHours(1), false, true, 400), + + // valid endorsement on a story with a UAK, bad request + Arguments.of(SINGLE_DEVICE_ACI_ID, SINGLE_DEVICE_ACI_ID, Duration.ofHours(1), true, true, 400)); + } + @Test void testSendBadAuth() throws Exception { Response response = @@ -1273,7 +1339,6 @@ class MessageControllerTest { // initialize our binary payload and create an input stream byte[] buffer = new byte[2048]; InputStream stream = initializeMultiPayload(recipients, buffer, true); - final AciServiceIdentifier senderId = new AciServiceIdentifier(UUID.randomUUID()); clock.pin(Instant.parse("2024-04-09T12:00:00.00Z")); @@ -1287,7 +1352,7 @@ class MessageControllerTest { .request() .header(HttpHeaders.USER_AGENT, "FIXME") .header(HeaderUtils.GROUP_SEND_TOKEN, validGroupSendTokenHeader( - senderId, List.of(SINGLE_DEVICE_ACI_ID, MULTI_DEVICE_ACI_ID), Instant.parse("2024-04-10T00:00:00.00Z"))) + 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))); @@ -1312,7 +1377,6 @@ class MessageControllerTest { // initialize our binary payload and create an input stream byte[] buffer = new byte[2048]; InputStream stream = initializeMultiPayload(recipients, buffer, true); - final AciServiceIdentifier senderId = new AciServiceIdentifier(UUID.randomUUID()); clock.pin(Instant.parse("2024-04-09T12:00:00.00Z")); @@ -1326,7 +1390,7 @@ class MessageControllerTest { .request() .header(HttpHeaders.USER_AGENT, "FIXME") .header(HeaderUtils.GROUP_SEND_TOKEN, validGroupSendTokenHeader( - senderId, List.of(MULTI_DEVICE_ACI_ID), Instant.parse("2024-04-10T00:00:00.00Z"))) + 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))); @@ -1343,7 +1407,6 @@ class MessageControllerTest { // initialize our binary payload and create an input stream byte[] buffer = new byte[2048]; InputStream stream = initializeMultiPayload(recipients, buffer, true); - final AciServiceIdentifier senderId = new AciServiceIdentifier(UUID.randomUUID()); clock.pin(Instant.parse("2024-04-10T12:00:00.00Z")); @@ -1357,20 +1420,21 @@ class MessageControllerTest { .request() .header(HttpHeaders.USER_AGENT, "FIXME") .header(HeaderUtils.GROUP_SEND_TOKEN, validGroupSendTokenHeader( - senderId, List.of(SINGLE_DEVICE_ACI_ID, MULTI_DEVICE_ACI_ID), Instant.parse("2024-04-10T00:00:00.00Z"))) + 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(AciServiceIdentifier sender, List recipients, Instant expiration) throws Exception { + 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); - List groupPlaintexts = Stream.concat(Stream.of(sender), recipients.stream()).map(ServiceIdentifier::toLibsignal).toList(); + 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(); @@ -1380,7 +1444,7 @@ class MessageControllerTest { ReceivedEndorsements endorsements = endorsementsResponse.receive( groupPlaintexts, - sender.toLibsignal(), + sender, expiration.minus(Duration.ofDays(1)), groupSecretParams, serverPublicParams); diff --git a/spam-filter b/spam-filter index b80f6bc20..a5d7763a9 160000 --- a/spam-filter +++ b/spam-filter @@ -1 +1 @@ -Subproject commit b80f6bc203170911b980c78faff6c6f32bd23c87 +Subproject commit a5d7763a9ee3db93b6db9242b7b19db7afbd8d7b