0
0
mirror of https://github.com/signalapp/Signal-Server.git synced 2024-09-20 03:52:16 +02:00

accept Group Send Endorsements for single-recipient message send

Co-authored-by: Jon Chambers <63609320+jon-signal@users.noreply.github.com>
This commit is contained in:
Jonathan Klabunde Tomer 2024-04-16 15:06:40 -07:00 committed by GitHub
parent 7068d27a8b
commit ada589d0c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 129 additions and 19 deletions

View File

@ -241,20 +241,60 @@ public class MessageController {
@Consumes(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@ManagedAsync @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<AuthenticatedAccount> source, public Response sendMessage(@ReadOnly @Auth Optional<AuthenticatedAccount> source,
@Parameter(description="The recipient's unidentified access key")
@HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) Optional<Anonymous> accessKey, @HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) Optional<Anonymous> 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, @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, @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, @QueryParam("story") boolean isStory,
@Parameter(description="The encrypted message payloads for each recipient device")
@NotNull @Valid IncomingMessageList messages, @NotNull @Valid IncomingMessageList messages,
@Context ContainerRequestContext context) throws RateLimitExceededException { @Context ContainerRequestContext context) throws RateLimitExceededException {
final Sample sample = Timer.start(); final Sample sample = Timer.start();
try { try {
if (source.isEmpty() && accessKey.isEmpty() && !isStory) { if (source.isEmpty() && accessKey.isEmpty() && groupSendToken == null && !isStory) {
throw new WebApplicationException(Response.Status.UNAUTHORIZED); 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; final String senderType;
if (source.isPresent()) { if (source.isPresent()) {
if (source.get().getAccount().isIdentifiedBy(destinationIdentifier)) { if (source.get().getAccount().isIdentifiedBy(destinationIdentifier)) {
@ -316,8 +356,14 @@ public class MessageController {
} }
try { 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); OptionalAccess.verify(source.map(AuthenticatedAccount::getAccount), accessKey, destination);
} }

View File

@ -44,6 +44,7 @@ import java.nio.ByteBuffer;
import java.nio.ByteOrder; import java.nio.ByteOrder;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Base64; import java.util.Base64;
@ -150,18 +151,18 @@ class MessageControllerTest {
private static final String SINGLE_DEVICE_RECIPIENT = "+14151111111"; private static final String SINGLE_DEVICE_RECIPIENT = "+14151111111";
private static final UUID SINGLE_DEVICE_UUID = UUID.randomUUID(); 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 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 byte SINGLE_DEVICE_ID1 = 1;
private static final int SINGLE_DEVICE_REG_ID1 = 111; private static final int SINGLE_DEVICE_REG_ID1 = 111;
private static final int SINGLE_DEVICE_PNI_REG_ID1 = 1111; private static final int SINGLE_DEVICE_PNI_REG_ID1 = 1111;
private static final String MULTI_DEVICE_RECIPIENT = "+14152222222"; private static final String MULTI_DEVICE_RECIPIENT = "+14152222222";
private static final UUID MULTI_DEVICE_UUID = UUID.randomUUID(); 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 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_ID1 = 1;
private static final byte MULTI_DEVICE_ID2 = 2; private static final byte MULTI_DEVICE_ID2 = 2;
private static final byte MULTI_DEVICE_ID3 = 3; 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 int MULTI_DEVICE_PNI_REG_ID3 = 4444;
private static final UUID NONEXISTENT_UUID = UUID.randomUUID(); private static final UUID NONEXISTENT_UUID = UUID.randomUUID();
private static final ServiceIdentifier NONEXISTENT_ACI_ID = new AciServiceIdentifier(NONEXISTENT_UUID); private static final AciServiceIdentifier NONEXISTENT_ACI_ID = new AciServiceIdentifier(NONEXISTENT_UUID);
private static final ServiceIdentifier NONEXISTENT_PNI_ID = new PniServiceIdentifier(NONEXISTENT_UUID); private static final PniServiceIdentifier NONEXISTENT_PNI_ID = new PniServiceIdentifier(NONEXISTENT_UUID);
private static final byte[] UNIDENTIFIED_ACCESS_BYTES = "0123456789abcdef".getBytes(); private static final byte[] UNIDENTIFIED_ACCESS_BYTES = "0123456789abcdef".getBytes();
@ -418,6 +419,71 @@ class MessageControllerTest {
assertFalse(captor.getValue().hasSourceDevice()); 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<Arguments> 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 @Test
void testSendBadAuth() throws Exception { void testSendBadAuth() throws Exception {
Response response = Response response =
@ -1273,7 +1339,6 @@ class MessageControllerTest {
// initialize our binary payload and create an input stream // initialize our binary payload and create an input stream
byte[] buffer = new byte[2048]; byte[] buffer = new byte[2048];
InputStream stream = initializeMultiPayload(recipients, buffer, true); InputStream stream = initializeMultiPayload(recipients, buffer, true);
final AciServiceIdentifier senderId = new AciServiceIdentifier(UUID.randomUUID());
clock.pin(Instant.parse("2024-04-09T12:00:00.00Z")); clock.pin(Instant.parse("2024-04-09T12:00:00.00Z"));
@ -1287,7 +1352,7 @@ class MessageControllerTest {
.request() .request()
.header(HttpHeaders.USER_AGENT, "FIXME") .header(HttpHeaders.USER_AGENT, "FIXME")
.header(HeaderUtils.GROUP_SEND_TOKEN, validGroupSendTokenHeader( .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)); .put(Entity.entity(stream, MultiRecipientMessageProvider.MEDIA_TYPE));
assertThat("Unexpected response", response.getStatus(), is(equalTo(200))); assertThat("Unexpected response", response.getStatus(), is(equalTo(200)));
@ -1312,7 +1377,6 @@ class MessageControllerTest {
// initialize our binary payload and create an input stream // initialize our binary payload and create an input stream
byte[] buffer = new byte[2048]; byte[] buffer = new byte[2048];
InputStream stream = initializeMultiPayload(recipients, buffer, true); InputStream stream = initializeMultiPayload(recipients, buffer, true);
final AciServiceIdentifier senderId = new AciServiceIdentifier(UUID.randomUUID());
clock.pin(Instant.parse("2024-04-09T12:00:00.00Z")); clock.pin(Instant.parse("2024-04-09T12:00:00.00Z"));
@ -1326,7 +1390,7 @@ class MessageControllerTest {
.request() .request()
.header(HttpHeaders.USER_AGENT, "FIXME") .header(HttpHeaders.USER_AGENT, "FIXME")
.header(HeaderUtils.GROUP_SEND_TOKEN, validGroupSendTokenHeader( .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)); .put(Entity.entity(stream, MultiRecipientMessageProvider.MEDIA_TYPE));
assertThat("Unexpected response", response.getStatus(), is(equalTo(401))); assertThat("Unexpected response", response.getStatus(), is(equalTo(401)));
@ -1343,7 +1407,6 @@ class MessageControllerTest {
// initialize our binary payload and create an input stream // initialize our binary payload and create an input stream
byte[] buffer = new byte[2048]; byte[] buffer = new byte[2048];
InputStream stream = initializeMultiPayload(recipients, buffer, true); InputStream stream = initializeMultiPayload(recipients, buffer, true);
final AciServiceIdentifier senderId = new AciServiceIdentifier(UUID.randomUUID());
clock.pin(Instant.parse("2024-04-10T12:00:00.00Z")); clock.pin(Instant.parse("2024-04-10T12:00:00.00Z"));
@ -1357,20 +1420,21 @@ class MessageControllerTest {
.request() .request()
.header(HttpHeaders.USER_AGENT, "FIXME") .header(HttpHeaders.USER_AGENT, "FIXME")
.header(HeaderUtils.GROUP_SEND_TOKEN, validGroupSendTokenHeader( .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)); .put(Entity.entity(stream, MultiRecipientMessageProvider.MEDIA_TYPE));
assertThat("Unexpected response", response.getStatus(), is(equalTo(401))); assertThat("Unexpected response", response.getStatus(), is(equalTo(401)));
verifyNoMoreInteractions(messageSender); verifyNoMoreInteractions(messageSender);
} }
private String validGroupSendTokenHeader(AciServiceIdentifier sender, List<ServiceIdentifier> recipients, Instant expiration) throws Exception { private String validGroupSendTokenHeader(List<ServiceIdentifier> recipients, Instant expiration) throws Exception {
final ServerPublicParams serverPublicParams = serverSecretParams.getPublicParams(); final ServerPublicParams serverPublicParams = serverSecretParams.getPublicParams();
final GroupMasterKey groupMasterKey = new GroupMasterKey(new byte[32]); final GroupMasterKey groupMasterKey = new GroupMasterKey(new byte[32]);
final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey); final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
final ClientZkGroupCipher clientZkGroupCipher = new ClientZkGroupCipher(groupSecretParams); final ClientZkGroupCipher clientZkGroupCipher = new ClientZkGroupCipher(groupSecretParams);
List<ServiceId> groupPlaintexts = Stream.concat(Stream.of(sender), recipients.stream()).map(ServiceIdentifier::toLibsignal).toList(); final ServiceId.Aci sender = new ServiceId.Aci(UUID.randomUUID());
List<ServiceId> groupPlaintexts = Stream.concat(Stream.of(sender), recipients.stream().map(ServiceIdentifier::toLibsignal)).toList();
List<UuidCiphertext> groupCiphertexts = groupPlaintexts.stream() List<UuidCiphertext> groupCiphertexts = groupPlaintexts.stream()
.map(clientZkGroupCipher::encrypt) .map(clientZkGroupCipher::encrypt)
.toList(); .toList();
@ -1380,7 +1444,7 @@ class MessageControllerTest {
ReceivedEndorsements endorsements = ReceivedEndorsements endorsements =
endorsementsResponse.receive( endorsementsResponse.receive(
groupPlaintexts, groupPlaintexts,
sender.toLibsignal(), sender,
expiration.minus(Duration.ofDays(1)), expiration.minus(Duration.ofDays(1)),
groupSecretParams, groupSecretParams,
serverPublicParams); serverPublicParams);

@ -1 +1 @@
Subproject commit b80f6bc203170911b980c78faff6c6f32bd23c87 Subproject commit a5d7763a9ee3db93b6db9242b7b19db7afbd8d7b