From 7ba86b40aa0923f673c73cb96fa0539ea0689879 Mon Sep 17 00:00:00 2001 From: Katherine Yen Date: Thu, 4 May 2023 14:33:45 -0700 Subject: [PATCH] Create call link credential endpoint --- .../textsecuregcm/WhisperServerService.java | 5 +- .../controllers/CallLinkController.java | 70 +++++++----- .../entities/CreateCallLinkCredential.java | 3 + .../GetCreateCallLinkCredentialsRequest.java | 5 + .../textsecuregcm/limits/RateLimiters.java | 6 + .../controllers/CallLinkControllerTest.java | 107 +++++++++++++++--- 6 files changed, 149 insertions(+), 47 deletions(-) create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/entities/CreateCallLinkCredential.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/entities/GetCreateCallLinkCredentialsRequest.java diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 2242a5d33..f49829b54 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -467,9 +467,6 @@ public class WhisperServerService extends Application timestamp.truncatedTo(ChronoUnit.DAYS), ANONYMOUS_CREDENTIAL_PREFIX) - .build(); - } @Timed - @GET - @Path("/auth") + @POST + @Path("/create-auth") @Produces(MediaType.APPLICATION_JSON) @Operation( - summary = "Generate credentials for calling frontend", + summary = "Generate a credential for creating call links", description = """ - These credentials enable clients to prove to calling frontend that they were a Signal user within the last day. - For client privacy, timestamps are truncated to 1 day granularity and the token does not include or derive from an ACI. + Generate a credential over a truncated timestamp, room ID, and account UUID. With zero knowledge + group infrastructure, the server does not know the room ID. """ ) @ApiResponse(responseCode = "200", description = "`JSON` with generated credentials.", useReturnTypeSchema = true) + @ApiResponse(responseCode = "400", description = "Invalid create call link credential request.") @ApiResponse(responseCode = "401", description = "Account authentication check failed.") - public ExternalServiceCredentials getAuth(@Auth AuthenticatedAccount auth) { - return callingFrontendServiceCredentialGenerator.generateWithTimestampAsUsername(); + @ApiResponse(responseCode = "422", description = "Invalid request format.") + @ApiResponse(responseCode = "429", description = "Ratelimited.") + public CreateCallLinkCredential getCreateAuth( + final @Auth AuthenticatedAccount auth, + final @NotNull GetCreateCallLinkCredentialsRequest request + ) throws RateLimitExceededException { + + rateLimiters.getCreateCallLinkLimiter().validate(auth.getAccount().getUuid()); + + final Instant truncatedDayTimestamp = Instant.now().truncatedTo(ChronoUnit.DAYS); + + CreateCallLinkCredentialRequest createCallLinkCredentialRequest; + try { + createCallLinkCredentialRequest = new CreateCallLinkCredentialRequest(request.createCallLinkCredentialRequest()); + } catch (InvalidInputException e) { + throw new BadRequestException("Invalid create call link credential request", e); + } + + return new CreateCallLinkCredential( + createCallLinkCredentialRequest.issueCredential(auth.getAccount().getUuid(), truncatedDayTimestamp, genericServerSecretParams).serialize(), + truncatedDayTimestamp.getEpochSecond() + ); } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/CreateCallLinkCredential.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/CreateCallLinkCredential.java new file mode 100644 index 000000000..4532e7387 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/CreateCallLinkCredential.java @@ -0,0 +1,3 @@ +package org.whispersystems.textsecuregcm.entities; + +public record CreateCallLinkCredential(byte[] credential, long redemptionTime){} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/GetCreateCallLinkCredentialsRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/GetCreateCallLinkCredentialsRequest.java new file mode 100644 index 000000000..3da4adfb2 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/GetCreateCallLinkCredentialsRequest.java @@ -0,0 +1,5 @@ +package org.whispersystems.textsecuregcm.entities; + +import javax.validation.constraints.NotNull; + +public record GetCreateCallLinkCredentialsRequest(@NotNull byte[] createCallLinkCredentialRequest) {} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java index e25522704..6d6860e30 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java @@ -73,6 +73,8 @@ public class RateLimiters extends BaseRateLimiters { PUSH_CHALLENGE_ATTEMPT("pushChallengeAttempt", true, new RateLimiterConfig(10, 10.0 / (60 * 24))), PUSH_CHALLENGE_SUCCESS("pushChallengeSuccess", true, new RateLimiterConfig(2, 2.0 / (60 * 24))), + + CREATE_CALL_LINK("createCallLink", false, new RateLimiterConfig(100, 100 / (60 * 24))); ; private final String id; @@ -232,4 +234,8 @@ public class RateLimiters extends BaseRateLimiters { public RateLimiter getVerificationCaptchaLimiter() { return forDescriptor(For.VERIFICATION_CAPTCHA); } + + public RateLimiter getCreateCallLinkLimiter() { + return forDescriptor(For.CREATE_CALL_LINK); + } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/CallLinkControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/CallLinkControllerTest.java index 1e46d987a..64ed4fa55 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/CallLinkControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/CallLinkControllerTest.java @@ -1,44 +1,117 @@ package org.whispersystems.textsecuregcm.tests.controllers; +import com.google.common.collect.ImmutableSet; +import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; import io.dropwizard.testing.junit5.ResourceExtension; import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mockito; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; -import org.whispersystems.textsecuregcm.configuration.CallLinkConfiguration; + +import org.signal.libsignal.protocol.util.Hex; +import org.signal.libsignal.zkgroup.GenericServerSecretParams; +import org.signal.libsignal.zkgroup.calllinks.CreateCallLinkCredentialRequestContext; +import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; +import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount; import org.whispersystems.textsecuregcm.controllers.CallLinkController; +import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; +import org.whispersystems.textsecuregcm.entities.GetCreateCallLinkCredentialsRequest; +import org.whispersystems.textsecuregcm.limits.RateLimiter; +import org.whispersystems.textsecuregcm.limits.RateLimiters; +import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper; import org.whispersystems.textsecuregcm.tests.util.AuthHelper; -import org.whispersystems.textsecuregcm.util.MockUtils; import org.whispersystems.textsecuregcm.util.SystemMapper; +import javax.ws.rs.client.Entity; +import javax.ws.rs.core.Response; + import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; @ExtendWith(DropwizardExtensionsSupport.class) public class CallLinkControllerTest { - private static final CallLinkConfiguration callLinkConfiguration = MockUtils.buildMock( - CallLinkConfiguration.class, - cfg -> Mockito.when(cfg.userAuthenticationTokenSharedSecret()).thenReturn(new byte[32]) - ); - private static final ExternalServiceCredentialsGenerator callLinkCredentialsGenerator = CallLinkController.credentialsGenerator(callLinkConfiguration); + private static final GenericServerSecretParams genericServerSecretParams = GenericServerSecretParams.generate(); + private static final RateLimiters rateLimiters = mock(RateLimiters.class); + private static final RateLimiter createCallLinkLimiter = mock(RateLimiter.class); + private static final byte[] roomId = Hex.fromStringCondensedAssert("c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7"); + private static final CreateCallLinkCredentialRequestContext createCallLinkRequestContext = CreateCallLinkCredentialRequestContext.forRoom(roomId); + private static final byte[] createCallLinkRequestSerialized = createCallLinkRequestContext.getRequest().serialize(); private static final ResourceExtension resources = ResourceExtension.builder() + .addProvider(AuthHelper.getAuthFilter()) + .addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>( + ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class))) + .addProvider(new RateLimitExceededExceptionMapper()) .setMapper(SystemMapper.jsonMapper()) .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) - .addResource(new CallLinkController(callLinkCredentialsGenerator)) + .addResource(new CallLinkController(rateLimiters, genericServerSecretParams)) .build(); + @BeforeEach + void setup() { + when(rateLimiters.getCreateCallLinkLimiter()).thenReturn(createCallLinkLimiter); + } + @Test - void testGetAuth() { - ExternalServiceCredentials credentials = resources.getJerseyTest() - .target("/v1/call-link/auth") + void testGetCreateAuth() { + try (Response response = resources.getJerseyTest() + .target("/v1/call-link/create-auth") .request() .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(ExternalServiceCredentials.class); + .post(Entity.json(new GetCreateCallLinkCredentialsRequest(createCallLinkRequestSerialized)))) { + assertThat(response.getStatus()).isEqualTo(200); + } + } - assertThat(credentials.username()).isNotEmpty(); - assertThat(credentials.password()).isNotEmpty(); + @Test + void testGetCreateAuthInvalidInput() { + try (Response response = resources.getJerseyTest() + .target("/v1/call-link/create-auth") + .request() + .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) + .post(Entity.json(new GetCreateCallLinkCredentialsRequest(new byte[10])))) { + assertThat(response.getStatus()).isEqualTo(400); + } + } + + @Test + void testGetCreateAuthInvalidAuth() { + try (Response response = resources.getJerseyTest() + .target("/v1/call-link/create-auth") + .request() + .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.INVALID_UUID, AuthHelper.VALID_PASSWORD)) + .post(Entity.json(new GetCreateCallLinkCredentialsRequest(createCallLinkRequestSerialized)))) { + assertThat(response.getStatus()).isEqualTo(401); + } + } + + @Test + void testGetCreateAuthInvalidRequest() { + try (Response response = resources.getJerseyTest() + .target("/v1/call-link/create-auth") + .request() + .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) + .post(Entity.json(""))) { + + assertThat(response.getStatus()).isEqualTo(422); + } + } + + @Test + void testGetCreateAuthRatelimited() throws RateLimitExceededException{ + doThrow(new RateLimitExceededException(null, false)) + .when(createCallLinkLimiter).validate(AuthHelper.VALID_UUID); + + try (Response response = resources.getJerseyTest() + .target("/v1/call-link/create-auth") + .request() + .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) + .post(Entity.json(new GetCreateCallLinkCredentialsRequest(createCallLinkRequestSerialized)))) { + + assertThat(response.getStatus()).isEqualTo(429); + } } }