From 47550d48e7975e5bf1c2610152e1b52c8cb4af70 Mon Sep 17 00:00:00 2001 From: Chris Eager Date: Wed, 22 Jan 2025 12:01:12 -0600 Subject: [PATCH] Add collation key to registration service session creation rpc call --- service/config/sample-secrets-bundle.yml | 2 ++ service/config/sample.yml | 1 + .../RegistrationServiceConfiguration.java | 7 +++-- .../controllers/VerificationController.java | 7 +++-- .../RegistrationServiceClient.java | 28 +++++++++++++++++-- .../src/main/proto/RegistrationService.proto | 6 ++++ .../StubRegistrationServiceClientFactory.java | 13 ++++++--- .../VerificationControllerTest.java | 17 ++++++----- .../resources/config/test-secrets-bundle.yml | 2 ++ service/src/test/resources/config/test.yml | 1 + 10 files changed, 67 insertions(+), 17 deletions(-) diff --git a/service/config/sample-secrets-bundle.yml b/service/config/sample-secrets-bundle.yml index a0176db19..772bed93c 100644 --- a/service/config/sample-secrets-bundle.yml +++ b/service/config/sample-secrets-bundle.yml @@ -92,6 +92,8 @@ paymentsService.coinGeckoApiKey: unset currentReportingKey.secret: AAAAAAAAAAA= currentReportingKey.salt: AAAAAAAAAAA= +registrationService.collationKeySalt: AAAAAAAAAAA= + turn.secret: AAAAAAAAAAA= turn.cloudflare.apiToken: ABCDEFGHIJKLM diff --git a/service/config/sample.yml b/service/config/sample.yml index dec908daf..3162ca2c4 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -399,6 +399,7 @@ registrationService: "example": "example" } identityTokenAudience: https://registration.example.com + collationKeySalt: secret://registrationService.collationKeySalt registrationCaCertificate: | # Registration service TLS certificate trust root -----BEGIN CERTIFICATE----- ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RegistrationServiceConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RegistrationServiceConfiguration.java index d4f118972..3f230a826 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RegistrationServiceConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RegistrationServiceConfiguration.java @@ -3,9 +3,11 @@ import com.fasterxml.jackson.annotation.JsonTypeName; import io.dropwizard.core.setup.Environment; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import java.io.IOException; import java.util.concurrent.Executor; import java.util.concurrent.ScheduledExecutorService; +import org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes; import org.whispersystems.textsecuregcm.registration.IdentityTokenCallCredentials; import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient; @@ -14,7 +16,8 @@ public record RegistrationServiceConfiguration(@NotBlank String host, int port, @NotBlank String credentialConfigurationJson, @NotBlank String identityTokenAudience, - @NotBlank String registrationCaCertificate) implements + @NotBlank String registrationCaCertificate, + @NotNull SecretBytes collationKeySalt) implements RegistrationServiceClientFactory { @Override @@ -26,7 +29,7 @@ public RegistrationServiceClient build(final Environment environment, final Exec environment.lifecycle().manage(callCredentials); - return new RegistrationServiceClient(host, port, callCredentials, registrationCaCertificate, + return new RegistrationServiceClient(host, port, callCredentials, registrationCaCertificate, collationKeySalt.value(), identityRefreshExecutor); } catch (IOException e) { throw new RuntimeException(e); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/VerificationController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/VerificationController.java index e2294f077..ab8f62ad2 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/VerificationController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/VerificationController.java @@ -173,7 +173,8 @@ public VerificationController(final RegistrationServiceClient registrationServic name = "Retry-After", description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed", schema = @Schema(implementation = Integer.class))) - public VerificationSessionResponse createSession(@NotNull @Valid final CreateVerificationSessionRequest request) + public VerificationSessionResponse createSession(@NotNull @Valid final CreateVerificationSessionRequest request, + @Context final ContainerRequestContext requestContext) throws RateLimitExceededException, ObsoletePhoneNumberFormatException { final Pair pushTokenAndType = validateAndExtractPushToken( @@ -188,7 +189,9 @@ public VerificationSessionResponse createSession(@NotNull @Valid final CreateVer final RegistrationServiceSession registrationServiceSession; try { - registrationServiceSession = registrationServiceClient.createRegistrationSession(phoneNumber, + final String sourceHost = (String) requestContext.getProperty(RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME); + + registrationServiceSession = registrationServiceClient.createRegistrationSession(phoneNumber, sourceHost, accountsManager.getByE164(request.getNumber()).isPresent(), REGISTRATION_RPC_TIMEOUT).join(); } catch (final CancellationException e) { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/registration/RegistrationServiceClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/registration/RegistrationServiceClient.java index dda712b46..2766e278e 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/registration/RegistrationServiceClient.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/registration/RegistrationServiceClient.java @@ -14,12 +14,15 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.security.NoSuchAlgorithmException; import java.time.Duration; +import java.util.Base64; import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; +import javax.crypto.Mac; import org.apache.commons.lang3.StringUtils; import org.checkerframework.checker.nullness.qual.Nullable; import org.signal.registration.rpc.CheckVerificationCodeRequest; @@ -35,9 +38,12 @@ public class RegistrationServiceClient implements Managed { + private static final Base64.Encoder BASE64_UNPADDED_ENCODER = Base64.getEncoder().withoutPadding(); + private final ManagedChannel channel; private final RegistrationServiceGrpc.RegistrationServiceFutureStub stub; private final Executor callbackExecutor; + private final byte[] collationKeySalt; /** * @param from an e164 in a {@code long} representation e.g. {@code 18005550123} @@ -60,6 +66,7 @@ public RegistrationServiceClient(final String host, final int port, final CallCredentials callCredentials, final String caCertificatePem, + final byte[] collationKeySalt, final Executor callbackExecutor) throws IOException { try (final ByteArrayInputStream certificateInputStream = new ByteArrayInputStream(caCertificatePem.getBytes(StandardCharsets.UTF_8))) { @@ -73,19 +80,22 @@ public RegistrationServiceClient(final String host, } this.stub = RegistrationServiceGrpc.newFutureStub(channel).withCallCredentials(callCredentials); - + this.collationKeySalt = collationKeySalt; this.callbackExecutor = callbackExecutor; } public CompletableFuture createRegistrationSession( - final Phonenumber.PhoneNumber phoneNumber, final boolean accountExistsWithPhoneNumber, final Duration timeout) { + final Phonenumber.PhoneNumber phoneNumber, final String sourceHost, final boolean accountExistsWithPhoneNumber, final Duration timeout) { + final long e164 = Long.parseLong( PhoneNumberUtil.getInstance().format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164).substring(1)); + final String rateLimitCollationKey = hmac(sourceHost, collationKeySalt); return CompletableFutureUtil.toCompletableFuture(stub.withDeadline(toDeadline(timeout)) .createSession(CreateRegistrationSessionRequest.newBuilder() .setE164(e164) .setAccountExistsWithE164(accountExistsWithPhoneNumber) + .setRateLimitCollationKey(rateLimitCollationKey) .build()), callbackExecutor) .thenApply(response -> switch (response.getResponseCase()) { case SESSION_METADATA -> buildSessionResponseFromMetadata(response.getSessionMetadata()); @@ -259,4 +269,18 @@ public void stop() throws Exception { channel.shutdown(); } } + + private static String hmac(String sourceHost, byte[] collationKeySalt) { + final Mac hmacSha256; + try { + hmacSha256 = Mac.getInstance("HmacSHA256"); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + + hmacSha256.update(sourceHost.getBytes(StandardCharsets.UTF_8)); + hmacSha256.update(collationKeySalt); + + return BASE64_UNPADDED_ENCODER.encodeToString(hmacSha256.doFinal()); + } } diff --git a/service/src/main/proto/RegistrationService.proto b/service/src/main/proto/RegistrationService.proto index b08aaad0e..d54c744ec 100644 --- a/service/src/main/proto/RegistrationService.proto +++ b/service/src/main/proto/RegistrationService.proto @@ -39,6 +39,12 @@ message CreateRegistrationSessionRequest { * session represents a "re-registration" attempt). */ bool account_exists_with_e164 = 2; + + /** + * The session creation rate limit for the number will be + * collated by this key. + */ + string rate_limit_collation_key = 3; } message CreateRegistrationSessionResponse { diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/configuration/StubRegistrationServiceClientFactory.java b/service/src/test/java/org/whispersystems/textsecuregcm/configuration/StubRegistrationServiceClientFactory.java index dc8e5f69c..80692a2e6 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/configuration/StubRegistrationServiceClientFactory.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/configuration/StubRegistrationServiceClientFactory.java @@ -23,6 +23,7 @@ import java.util.concurrent.Executor; import java.util.concurrent.ScheduledExecutorService; import org.checkerframework.checker.nullness.qual.Nullable; +import org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes; import org.whispersystems.textsecuregcm.entities.RegistrationServiceSession; import org.whispersystems.textsecuregcm.registration.ClientType; import org.whispersystems.textsecuregcm.registration.MessageTransport; @@ -35,12 +36,16 @@ public class StubRegistrationServiceClientFactory implements RegistrationService @NotNull private String registrationCaCertificate; + @JsonProperty + @NotNull + private SecretBytes collationKeySalt; + @Override public RegistrationServiceClient build(final Environment environment, final Executor callbackExecutor, final ScheduledExecutorService identityRefreshExecutor) { try { - return new StubRegistrationServiceClient(registrationCaCertificate); + return new StubRegistrationServiceClient(registrationCaCertificate, collationKeySalt.value()); } catch (IOException e) { throw new RuntimeException(e); } @@ -50,13 +55,13 @@ private static class StubRegistrationServiceClient extends RegistrationServiceCl private final static Map SESSIONS = new ConcurrentHashMap<>(); - public StubRegistrationServiceClient(final String registrationCaCertificate) throws IOException { - super("example.com", 8080, null, registrationCaCertificate, null); + public StubRegistrationServiceClient(final String registrationCaCertificate, final byte[] collationKeySalt) throws IOException { + super("example.com", 8080, null, registrationCaCertificate, collationKeySalt, null); } @Override public CompletableFuture createRegistrationSession( - final Phonenumber.PhoneNumber phoneNumber, final boolean accountExistsWithPhoneNumber, final Duration timeout) { + final Phonenumber.PhoneNumber phoneNumber, final String sourceHost, final boolean accountExistsWithPhoneNumber, final Duration timeout) { final String e164 = PhoneNumberUtil.getInstance() .format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164); diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/VerificationControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/VerificationControllerTest.java index b310111d4..b8856d245 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/VerificationControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/VerificationControllerTest.java @@ -84,6 +84,7 @@ import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager; import org.whispersystems.textsecuregcm.storage.VerificationSessionManager; import org.whispersystems.textsecuregcm.util.SystemMapper; +import org.whispersystems.textsecuregcm.util.TestRemoteAddressFilterProvider; @ExtendWith(DropwizardExtensionsSupport.class) class VerificationControllerTest { @@ -120,6 +121,7 @@ class VerificationControllerTest { .addProvider(new NonNormalizedPhoneNumberExceptionMapper()) .addProvider(new ObsoletePhoneNumberFormatExceptionMapper()) .addProvider(new RegistrationServiceSenderExceptionMapper()) + .addProvider(new TestRemoteAddressFilterProvider("127.0.0.1")) .setMapper(SystemMapper.jsonMapper()) .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) .addResource( @@ -190,7 +192,7 @@ static Stream createSessionInvalidRequestJson() { @Test void createSessionRateLimited() { - when(registrationServiceClient.createRegistrationSession(any(), anyBoolean(), any())) + when(registrationServiceClient.createRegistrationSession(any(), anyString(), anyBoolean(), any())) .thenReturn(CompletableFuture.failedFuture(new RateLimitExceededException(null))); final Invocation.Builder request = resources.getJerseyTest() @@ -204,7 +206,7 @@ void createSessionRateLimited() { @Test void createSessionRegistrationServiceError() { - when(registrationServiceClient.createRegistrationSession(any(), anyBoolean(), any())) + when(registrationServiceClient.createRegistrationSession(any(), anyString(), anyBoolean(), any())) .thenReturn(CompletableFuture.failedFuture(new RuntimeException("expected service error"))); final Invocation.Builder request = resources.getJerseyTest() @@ -219,7 +221,7 @@ void createSessionRegistrationServiceError() { @ParameterizedTest @MethodSource void createBeninSessionSuccess(final String requestedNumber, final String expectedNumber) { - when(registrationServiceClient.createRegistrationSession(any(), anyBoolean(), any())) + when(registrationServiceClient.createRegistrationSession(any(), anyString(), anyBoolean(), any())) .thenReturn( CompletableFuture.completedFuture( new RegistrationServiceSession(SESSION_ID, requestedNumber, false, null, null, null, @@ -236,7 +238,7 @@ void createBeninSessionSuccess(final String requestedNumber, final String expect final ArgumentCaptor phoneNumberArgumentCaptor = ArgumentCaptor.forClass( Phonenumber.PhoneNumber.class); - verify(registrationServiceClient).createRegistrationSession(phoneNumberArgumentCaptor.capture(), anyBoolean(), any()); + verify(registrationServiceClient).createRegistrationSession(phoneNumberArgumentCaptor.capture(), anyString(), anyBoolean(), any()); final Phonenumber.PhoneNumber phoneNumber = phoneNumberArgumentCaptor.getValue(); assertEquals(expectedNumber, PhoneNumberUtil.getInstance().format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164)); @@ -260,7 +262,7 @@ void createBeninSessionFailure() { .format(PhoneNumberUtil.getInstance().getExampleNumber("BJ"), PhoneNumberUtil.PhoneNumberFormat.E164); final String oldFormatBeninE164 = newFormatBeninE164.replaceFirst("01", ""); - when(registrationServiceClient.createRegistrationSession(any(), anyBoolean(), any())) + when(registrationServiceClient.createRegistrationSession(any(), anyString(), anyBoolean(), any())) .thenReturn( CompletableFuture.completedFuture( new RegistrationServiceSession(SESSION_ID, NUMBER, false, null, null, null, @@ -281,7 +283,7 @@ void createBeninSessionFailure() { @MethodSource void createSessionSuccess(final String pushToken, final String pushTokenType, final List expectedRequestedInformation) { - when(registrationServiceClient.createRegistrationSession(any(), anyBoolean(), any())) + when(registrationServiceClient.createRegistrationSession(any(), anyString(), anyBoolean(), any())) .thenReturn( CompletableFuture.completedFuture( new RegistrationServiceSession(SESSION_ID, NUMBER, false, null, null, null, @@ -315,7 +317,7 @@ static Stream createSessionSuccess() { @ParameterizedTest @ValueSource(booleans = {true, false}) void createSessionReregistration(final boolean isReregistration) throws NumberParseException { - when(registrationServiceClient.createRegistrationSession(any(), anyBoolean(), any())) + when(registrationServiceClient.createRegistrationSession(any(), anyString(), anyBoolean(), any())) .thenReturn( CompletableFuture.completedFuture( new RegistrationServiceSession(SESSION_ID, NUMBER, false, null, null, null, @@ -337,6 +339,7 @@ void createSessionReregistration(final boolean isReregistration) throws NumberPa verify(registrationServiceClient).createRegistrationSession( eq(PhoneNumberUtil.getInstance().parse(NUMBER, null)), + anyString(), eq(isReregistration), any() ); diff --git a/service/src/test/resources/config/test-secrets-bundle.yml b/service/src/test/resources/config/test-secrets-bundle.yml index a68a3ec5f..96600b455 100644 --- a/service/src/test/resources/config/test-secrets-bundle.yml +++ b/service/src/test/resources/config/test-secrets-bundle.yml @@ -162,6 +162,8 @@ paymentsService.coinGeckoApiKey: unset currentReportingKey.secret: AAAAAAAAAAA= currentReportingKey.salt: AAAAAAAAAAA= +registrationService.collationKeySalt: AAAAAAAAAAA= + turn.secret: AAAAAAAAAAA= turn.cloudflare.apiToken: ABCDEFGHIJKLM diff --git a/service/src/test/resources/config/test.yml b/service/src/test/resources/config/test.yml index 74ea169c6..baf1b8083 100644 --- a/service/src/test/resources/config/test.yml +++ b/service/src/test/resources/config/test.yml @@ -393,6 +393,7 @@ oneTimeDonations: registrationService: type: stub + collationKeySalt: secret://registrationService.collationKeySalt registrationCaCertificate: | -----BEGIN CERTIFICATE----- MIIDazCCAlOgAwIBAgIUW5lcNWkuynRVc8Rq5pO6mHQBuZAwDQYJKoZIhvcNAQEL