Check for SPQR capability in third-party clients in remote deprecation filter

This commit is contained in:
Jon Chambers
2026-03-24 11:28:53 -04:00
committed by Jon Chambers
parent 73ec57e911
commit 46bfc12869
5 changed files with 193 additions and 15 deletions

View File

@@ -947,7 +947,9 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
log.info("Registered spam filter: {}", filter.getClass().getName());
});
final RemoteDeprecationFilter remoteDeprecationFilter = new RemoteDeprecationFilter(dynamicConfigurationManager);
final RemoteDeprecationFilter remoteDeprecationFilter =
new RemoteDeprecationFilter(accountsManager, accountAuthenticator, dynamicConfigurationManager);
final MetricServerInterceptor metricServerInterceptor = new MetricServerInterceptor(Metrics.globalRegistry, clientReleaseManager);
final ErrorMappingInterceptor errorMappingInterceptor = new ErrorMappingInterceptor();

View File

@@ -25,7 +25,7 @@ import org.whispersystems.textsecuregcm.util.HeaderUtils;
*/
public class RequireAuthenticationInterceptor implements ServerInterceptor {
static final Metadata.Key<String> AUTHORIZATION_METADATA_KEY =
public static final Metadata.Key<String> AUTHORIZATION_METADATA_KEY =
Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER);
private final AccountAuthenticator authenticator;

View File

@@ -16,13 +16,17 @@ public record DynamicRemoteDeprecationConfiguration(
@NotNull Map<ClientPlatform, Semver> minimumVersions,
@NotNull Map<ClientPlatform, Semver> versionsPendingDeprecation,
@NotNull Map<ClientPlatform, Set<Semver>> blockedVersions,
@NotNull Map<ClientPlatform, Set<Semver>> versionsPendingBlock) {
@NotNull Map<ClientPlatform, Set<Semver>> 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();
}
}
}

View File

@@ -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<DynamicConfiguration> 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<DynamicConfiguration> dynamicConfigurationManager) {
public RemoteDeprecationFilter(final AccountsManager accountsManager,
final AccountAuthenticator accountAuthenticator,
final DynamicConfigurationManager<DynamicConfiguration> 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<ClientPlatform, Semver> minimumVersionsByPlatform = configuration.minimumVersions();
@@ -106,12 +124,23 @@ public class RemoteDeprecationFilter implements Filter, ServerInterceptor {
final Map<ClientPlatform, Set<Semver>> blockedVersionsByPlatform = configuration.blockedVersions();
final Map<ClientPlatform, Set<Semver>> 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<AuthenticatedDevice> 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();
}
}

View File

@@ -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<DynamicConfiguration> 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<Arguments> 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<Arguments> 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)
);
}
}