diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index d4681276b..899d76506 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -947,7 +947,9 @@ public class WhisperServerService extends Application AUTHORIZATION_METADATA_KEY = + public static final Metadata.Key AUTHORIZATION_METADATA_KEY = Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER); private final AccountAuthenticator authenticator; diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicRemoteDeprecationConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicRemoteDeprecationConfiguration.java index 42db9d838..c6e7f3c6c 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicRemoteDeprecationConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicRemoteDeprecationConfiguration.java @@ -16,13 +16,17 @@ public record DynamicRemoteDeprecationConfiguration( @NotNull Map minimumVersions, @NotNull Map versionsPendingDeprecation, @NotNull Map> blockedVersions, - @NotNull Map> versionsPendingBlock) { + @NotNull Map> versionsPendingBlock, + @NotNull Boolean spqrEnforcementPending, + @NotNull Boolean requireSpqr) { public static DynamicRemoteDeprecationConfiguration DEFAULT = new DynamicRemoteDeprecationConfiguration( Collections.emptyMap(), Collections.emptyMap(), Collections.emptyMap(), - Collections.emptyMap()); + Collections.emptyMap(), + false, + false); public DynamicRemoteDeprecationConfiguration { if (minimumVersions == null) { @@ -40,5 +44,13 @@ public record DynamicRemoteDeprecationConfiguration( if (versionsPendingBlock == null) { versionsPendingBlock = DEFAULT.versionsPendingBlock(); } + + if (spqrEnforcementPending == null) { + spqrEnforcementPending = DEFAULT.spqrEnforcementPending(); + } + + if (requireSpqr == null) { + requireSpqr = DEFAULT.requireSpqr(); + } } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/filters/RemoteDeprecationFilter.java b/service/src/main/java/org/whispersystems/textsecuregcm/filters/RemoteDeprecationFilter.java index 5363ff5e8..b3b9a4f04 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/filters/RemoteDeprecationFilter.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/filters/RemoteDeprecationFilter.java @@ -7,6 +7,7 @@ package org.whispersystems.textsecuregcm.filters; import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; +import com.google.common.annotations.VisibleForTesting; import com.google.common.net.HttpHeaders; import com.vdurmont.semver4j.Semver; import io.grpc.Metadata; @@ -23,14 +24,21 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.Map; +import java.util.Optional; import java.util.Set; import javax.annotation.Nullable; +import org.whispersystems.textsecuregcm.auth.AccountAuthenticator; +import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice; +import org.whispersystems.textsecuregcm.auth.grpc.RequireAuthenticationInterceptor; import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicRemoteDeprecationConfiguration; import org.whispersystems.textsecuregcm.grpc.GrpcExceptions; import org.whispersystems.textsecuregcm.grpc.RequestAttributesUtil; import org.whispersystems.textsecuregcm.grpc.ServerInterceptorUtil; +import org.whispersystems.textsecuregcm.storage.AccountsManager; +import org.whispersystems.textsecuregcm.storage.DeviceCapability; import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; +import org.whispersystems.textsecuregcm.util.HeaderUtils; import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException; import org.whispersystems.textsecuregcm.util.ua.UserAgent; @@ -44,6 +52,8 @@ import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil; */ public class RemoteDeprecationFilter implements Filter, ServerInterceptor { + private final AccountsManager accountsManager; + private final AccountAuthenticator accountAuthenticator; private final DynamicConfigurationManager dynamicConfigurationManager; private static final String DEPRECATED_CLIENT_COUNTER_NAME = name(RemoteDeprecationFilter.class, "deprecated"); @@ -52,8 +62,14 @@ public class RemoteDeprecationFilter implements Filter, ServerInterceptor { private static final String REASON_TAG_NAME = "reason"; private static final String EXPIRED_CLIENT_REASON = "expired"; private static final String BLOCKED_CLIENT_REASON = "blocked"; + private static final String SPQR_NOT_SUPPORTED_REASON = "spqr"; - public RemoteDeprecationFilter(final DynamicConfigurationManager dynamicConfigurationManager) { + public RemoteDeprecationFilter(final AccountsManager accountsManager, + final AccountAuthenticator accountAuthenticator, + final DynamicConfigurationManager dynamicConfigurationManager) { + + this.accountsManager = accountsManager; + this.accountAuthenticator = accountAuthenticator; this.dynamicConfigurationManager = dynamicConfigurationManager; } @@ -68,7 +84,7 @@ public class RemoteDeprecationFilter implements Filter, ServerInterceptor { userAgent = null; } - if (shouldBlock(userAgent)) { + if (shouldBlock(userAgent, ((HttpServletRequest) request).getHeader(HttpHeaders.AUTHORIZATION))) { ((HttpServletResponse) response).sendError(499); } else { chain.doFilter(request, response); @@ -90,14 +106,16 @@ public class RemoteDeprecationFilter implements Filter, ServerInterceptor { } }).orElse(null); - if (shouldBlock(userAgent)) { + if (shouldBlock(userAgent, headers.get(RequireAuthenticationInterceptor.AUTHORIZATION_METADATA_KEY))) { return ServerInterceptorUtil.closeWithStatusException(call, GrpcExceptions.upgradeRequired()); } else { return next.startCall(call, headers); } } - private boolean shouldBlock(@Nullable final UserAgent userAgent) { + @VisibleForTesting + boolean shouldBlock(@Nullable final UserAgent userAgent, @Nullable final String authHeader) { + final DynamicRemoteDeprecationConfiguration configuration = dynamicConfigurationManager .getConfiguration().getRemoteDeprecationConfiguration(); final Map minimumVersionsByPlatform = configuration.minimumVersions(); @@ -106,12 +124,23 @@ public class RemoteDeprecationFilter implements Filter, ServerInterceptor { final Map> blockedVersionsByPlatform = configuration.blockedVersions(); final Map> versionsPendingBlockByPlatform = configuration.versionsPendingBlock(); - boolean shouldBlock = false; - if (userAgent == null) { + // In first-party clients, we can enforce SPQR presence via minimum client version. For third-party clients, we + // need to do a more expensive check for the actual capability. + if ((configuration.spqrEnforcementPending() || configuration.requireSpqr()) && isMissingSpqrCapability(authHeader)) { + if (configuration.requireSpqr()) { + recordDeprecation(null, SPQR_NOT_SUPPORTED_REASON); + return true; + } + + recordPendingDeprecation(null, SPQR_NOT_SUPPORTED_REASON); + } + return false; } + boolean shouldBlock = false; + if (blockedVersionsByPlatform.containsKey(userAgent.platform())) { if (blockedVersionsByPlatform.get(userAgent.platform()).contains(userAgent.version())) { recordDeprecation(userAgent, BLOCKED_CLIENT_REASON); @@ -141,15 +170,37 @@ public class RemoteDeprecationFilter implements Filter, ServerInterceptor { return shouldBlock; } - private void recordDeprecation(final UserAgent userAgent, final String reason) { + /// Tests whether the device identified by the given authentication header (if any) is definitively missing the SPQR + /// capability. + /// + /// @return `true` if the authenticated device is definitively missing the SPQR capability or `false` if the + /// capability is present, no credentials are presented, or if authentication failed for any reason + @VisibleForTesting + boolean isMissingSpqrCapability(@Nullable final String authHeader) { + if (authHeader == null) { + return false; + } + + final Optional maybeAuthenticatedDevice = + HeaderUtils.basicCredentialsFromAuthHeader(authHeader) + .flatMap(accountAuthenticator::authenticate); + + return maybeAuthenticatedDevice + .flatMap(authenticatedDevice -> accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())) + .flatMap(account -> account.getDevice(maybeAuthenticatedDevice.get().deviceId())) + .map(device -> !device.hasCapability(DeviceCapability.SPARSE_POST_QUANTUM_RATCHET)) + .orElse(false); + } + + private void recordDeprecation(@Nullable final UserAgent userAgent, final String reason) { Metrics.counter(DEPRECATED_CLIENT_COUNTER_NAME, PLATFORM_TAG, userAgent != null ? userAgent.platform().name().toLowerCase() : "unrecognized", REASON_TAG_NAME, reason).increment(); } - private void recordPendingDeprecation(final UserAgent userAgent, final String reason) { + private void recordPendingDeprecation(@Nullable final UserAgent userAgent, final String reason) { Metrics.counter(PENDING_DEPRECATION_COUNTER_NAME, - PLATFORM_TAG, userAgent.platform().name().toLowerCase(), + PLATFORM_TAG, userAgent != null ? userAgent.platform().name().toLowerCase() : "unrecognized", REASON_TAG_NAME, reason).increment(); } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/filters/RemoteDeprecationFilterTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/filters/RemoteDeprecationFilterTest.java index f15e3f371..9abb00a6e 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/filters/RemoteDeprecationFilterTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/filters/RemoteDeprecationFilterTest.java @@ -9,6 +9,7 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; 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.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -21,6 +22,7 @@ import com.google.protobuf.ByteString; import com.google.rpc.ErrorInfo; import com.google.rpc.Status; import com.vdurmont.semver4j.Semver; +import io.dropwizard.auth.basic.BasicCredentials; import io.grpc.ManagedChannel; import io.grpc.Server; import io.grpc.StatusRuntimeException; @@ -32,10 +34,16 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import java.time.Instant; import java.util.EnumMap; +import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; +import java.util.UUID; import java.util.stream.Stream; +import javax.annotation.Nullable; +import org.apache.commons.lang3.RandomStringUtils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -43,13 +51,20 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.signal.chat.rpc.EchoRequest; import org.signal.chat.rpc.EchoServiceGrpc; +import org.whispersystems.textsecuregcm.auth.AccountAuthenticator; +import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice; import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicRemoteDeprecationConfiguration; import org.whispersystems.textsecuregcm.grpc.EchoServiceImpl; import org.whispersystems.textsecuregcm.grpc.GrpcExceptions; import org.whispersystems.textsecuregcm.grpc.MockRequestAttributesInterceptor; import org.whispersystems.textsecuregcm.grpc.RequestAttributes; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.AccountsManager; +import org.whispersystems.textsecuregcm.storage.Device; +import org.whispersystems.textsecuregcm.storage.DeviceCapability; import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; +import org.whispersystems.textsecuregcm.tests.util.AuthHelper; import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; class RemoteDeprecationFilterTest { @@ -58,8 +73,34 @@ class RemoteDeprecationFilterTest { private RemoteDeprecationFilter remoteDeprecationFilter; + private static final UUID ACCOUNT_IDENTIFIER_WITH_SPQR = UUID.randomUUID(); + private static final UUID ACCOUNT_IDENTIFIER_WITHOUT_SPQR = UUID.randomUUID(); + + private static final String PASSWORD = RandomStringUtils.insecure().nextAlphanumeric(16); + @BeforeEach void setUp() { + final AccountsManager accountsManager = mock(AccountsManager.class); + when(accountsManager.getByAccountIdentifier(any())).thenReturn(Optional.empty()); + + final Account accountWithSpqr = buildMockAccount(true); + final Account accountWithoutSpqr = buildMockAccount(false); + + when(accountsManager.getByAccountIdentifier(ACCOUNT_IDENTIFIER_WITH_SPQR)) + .thenReturn(Optional.of(accountWithSpqr)); + + when(accountsManager.getByAccountIdentifier(ACCOUNT_IDENTIFIER_WITHOUT_SPQR)) + .thenReturn(Optional.of(accountWithoutSpqr)); + + final AccountAuthenticator accountAuthenticator = mock(AccountAuthenticator.class); + when(accountAuthenticator.authenticate(any())).thenReturn(Optional.empty()); + + when(accountAuthenticator.authenticate(new BasicCredentials(ACCOUNT_IDENTIFIER_WITH_SPQR.toString(), PASSWORD))) + .thenReturn(Optional.of(new AuthenticatedDevice(ACCOUNT_IDENTIFIER_WITH_SPQR, Device.PRIMARY_ID, Instant.now()))); + + when(accountAuthenticator.authenticate(new BasicCredentials(ACCOUNT_IDENTIFIER_WITHOUT_SPQR.toString(), PASSWORD))) + .thenReturn(Optional.of(new AuthenticatedDevice(ACCOUNT_IDENTIFIER_WITHOUT_SPQR, Device.PRIMARY_ID, Instant.now()))); + @SuppressWarnings("unchecked") final DynamicConfigurationManager dynamicConfigurationManager = mock(DynamicConfigurationManager.class); @@ -69,7 +110,20 @@ class RemoteDeprecationFilterTest { when(dynamicConfiguration.getRemoteDeprecationConfiguration()) .thenReturn(DynamicRemoteDeprecationConfiguration.DEFAULT); - remoteDeprecationFilter = new RemoteDeprecationFilter(dynamicConfigurationManager); + remoteDeprecationFilter = + new RemoteDeprecationFilter(accountsManager, accountAuthenticator, dynamicConfigurationManager); + } + + private static Account buildMockAccount(final boolean hasSpqr) { + final Device device = mock(Device.class); + when(device.hasCapability(any())).thenReturn(false); + when(device.hasCapability(DeviceCapability.SPARSE_POST_QUANTUM_RATCHET)).thenReturn(hasSpqr); + + final Account account = mock(Account.class); + when(account.getDevice(anyByte())).thenReturn(Optional.empty()); + when(account.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(device)); + + return account; } @Test @@ -111,7 +165,9 @@ class RemoteDeprecationFilterTest { minimumVersionsByPlatform, versionsPendingDeprecationByPlatform, blockedVersionsByPlatform, - versionsPendingBlockByPlatform); + versionsPendingBlockByPlatform, + false, + false); } @ParameterizedTest @@ -200,4 +256,61 @@ class RemoteDeprecationFilterTest { Arguments.of("Signal-iOS/8.0.0-beta.2", false)); } + @ParameterizedTest + @MethodSource + void isMissingSpqrCapability(@Nullable final String authHeader, final boolean expectMissingCapability) { + assertEquals(expectMissingCapability, remoteDeprecationFilter.isMissingSpqrCapability(authHeader)); + } + + private static List isMissingSpqrCapability() { + final String password = RandomStringUtils.insecure().nextAlphanumeric(16); + AuthHelper.getAuthHeader(ACCOUNT_IDENTIFIER_WITH_SPQR, password); + + return List.of( + Arguments.argumentSet("No authentication header", null, false), + + Arguments.argumentSet("Authentication header for device with SPQR", + AuthHelper.getAuthHeader(ACCOUNT_IDENTIFIER_WITH_SPQR, PASSWORD), false), + + Arguments.argumentSet("Authentication header for device without SPQR", + AuthHelper.getAuthHeader(ACCOUNT_IDENTIFIER_WITHOUT_SPQR, PASSWORD), true) + ); + } + + @ParameterizedTest + @MethodSource + void shouldBlockSpqr(final boolean enforcementPending, final boolean spqrRequired, final String authHeader, final boolean expectBlock) { + when(dynamicConfiguration.getRemoteDeprecationConfiguration()) + .thenReturn(new DynamicRemoteDeprecationConfiguration( + DynamicRemoteDeprecationConfiguration.DEFAULT.minimumVersions(), + DynamicRemoteDeprecationConfiguration.DEFAULT.versionsPendingDeprecation(), + DynamicRemoteDeprecationConfiguration.DEFAULT.blockedVersions(), + DynamicRemoteDeprecationConfiguration.DEFAULT.versionsPendingBlock(), + enforcementPending, + spqrRequired)); + + assertEquals(expectBlock, remoteDeprecationFilter.shouldBlock(null, authHeader)); + } + + private static List shouldBlockSpqr() { + return List.of( + Arguments.argumentSet("Has capability, no enforcement", + false, false, AuthHelper.getAuthHeader(ACCOUNT_IDENTIFIER_WITH_SPQR, PASSWORD), false), + + Arguments.argumentSet("Has capability, enforcement pending", + true, false, AuthHelper.getAuthHeader(ACCOUNT_IDENTIFIER_WITH_SPQR, PASSWORD), false), + + Arguments.argumentSet("Has capability, enforcement active", + false, true, AuthHelper.getAuthHeader(ACCOUNT_IDENTIFIER_WITH_SPQR, PASSWORD), false), + + Arguments.argumentSet("Missing capability, no enforcement", + false, false, AuthHelper.getAuthHeader(ACCOUNT_IDENTIFIER_WITHOUT_SPQR, PASSWORD), false), + + Arguments.argumentSet("Missing capability, enforcement pending", + true, false, AuthHelper.getAuthHeader(ACCOUNT_IDENTIFIER_WITHOUT_SPQR, PASSWORD), false), + + Arguments.argumentSet("Missing capability, enforcement active", + false, true, AuthHelper.getAuthHeader(ACCOUNT_IDENTIFIER_WITHOUT_SPQR, PASSWORD), true) + ); + } }