mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-17 19:13:27 +01:00
Check for SPQR capability in third-party clients in remote deprecation filter
This commit is contained in:
committed by
Jon Chambers
parent
73ec57e911
commit
46bfc12869
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user