From a5774bf6ff1a0edf27e953d1f01aab3ec4079dc2 Mon Sep 17 00:00:00 2001
From: Jon Chambers <63609320+jon-signal@users.noreply.github.com>
Date: Fri, 23 Feb 2024 11:42:42 -0500
Subject: [PATCH] Introduce a (dormant) Noise/WebSocket for future
client/server communication
---
pom.xml | 5 +
service/config/sample.yml | 2 -
service/pom.xml | 8 +-
.../WhisperServerConfiguration.java | 9 -
.../textsecuregcm/WhisperServerService.java | 89 +++-
.../grpc/GrpcServerManagedWrapper.java | 35 --
.../net/AbstractNoiseHandshakeHandler.java | 124 +++++
.../net/ApplicationWebSocketCloseReason.java | 24 +
.../net/ClientAuthenticationException.java | 7 +
.../textsecuregcm/grpc/net/ErrorHandler.java | 59 +++
.../EstablishLocalGrpcConnectionHandler.java | 96 ++++
.../net/ManagedDefaultEventLoopGroup.java | 16 +
.../grpc/net/ManagedLocalGrpcServer.java | 49 ++
.../grpc/net/ManagedNioEventLoopGroup.java | 16 +
.../grpc/net/NoiseHandshakeCompleteEvent.java | 13 +
.../grpc/net/NoiseHandshakeException.java | 12 +
.../grpc/net/NoiseNXHandshakeHandler.java | 40 ++
.../grpc/net/NoiseStreamHandler.java | 95 ++++
.../grpc/net/NoiseXXHandshakeHandler.java | 178 +++++++
.../textsecuregcm/grpc/net/ProxyHandler.java | 24 +
.../net/RejectUnsupportedMessagesHandler.java | 35 ++
.../net/WebSocketOpeningHandshakeHandler.java | 74 +++
.../WebsocketHandshakeCompleteListener.java | 52 ++
.../grpc/net/WebsocketNoiseTunnelServer.java | 116 +++++
.../textsecuregcm/util/UUIDUtil.java | 13 +-
.../grpc/net/AbstractLeakDetectionTest.java | 21 +
.../grpc/net/AbstractNoiseClientHandler.java | 94 ++++
.../AbstractNoiseHandshakeHandlerTest.java | 141 +++++
.../grpc/net/AuthenticationTypeService.java | 21 +
.../grpc/net/ClientErrorHandler.java | 18 +
.../net/EstablishRemoteConnectionHandler.java | 141 +++++
.../InboundCloseWebSocketFrameHandler.java | 23 +
.../net/NoiseNXClientHandshakeHandler.java | 47 ++
.../grpc/net/NoiseNXHandshakeHandlerTest.java | 84 +++
.../grpc/net/NoiseStreamHandlerTest.java | 135 +++++
.../net/NoiseXXClientHandshakeHandler.java | 89 ++++
.../grpc/net/NoiseXXHandshakeHandlerTest.java | 454 ++++++++++++++++
.../OutboundCloseWebSocketFrameHandler.java | 24 +
.../RejectUnsupportedMessagesHandlerTest.java | 72 +++
.../grpc/net/WebSocketCloseListener.java | 18 +
.../grpc/net/WebSocketNoiseTunnelClient.java | 70 +++
...ocketNoiseTunnelServerIntegrationTest.java | 486 ++++++++++++++++++
.../WebSocketOpeningHandshakeHandlerTest.java | 104 ++++
...ebsocketHandshakeCompleteListenerTest.java | 91 ++++
.../proto/authentication_type_service.proto | 22 +
45 files changed, 3262 insertions(+), 84 deletions(-)
delete mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/GrpcServerManagedWrapper.java
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/AbstractNoiseHandshakeHandler.java
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/ApplicationWebSocketCloseReason.java
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/ClientAuthenticationException.java
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/ErrorHandler.java
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/EstablishLocalGrpcConnectionHandler.java
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/ManagedDefaultEventLoopGroup.java
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/ManagedLocalGrpcServer.java
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/ManagedNioEventLoopGroup.java
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/NoiseHandshakeCompleteEvent.java
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/NoiseHandshakeException.java
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/NoiseNXHandshakeHandler.java
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/NoiseStreamHandler.java
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/NoiseXXHandshakeHandler.java
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/ProxyHandler.java
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/RejectUnsupportedMessagesHandler.java
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/WebSocketOpeningHandshakeHandler.java
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/WebsocketHandshakeCompleteListener.java
create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/WebsocketNoiseTunnelServer.java
create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/grpc/net/AbstractLeakDetectionTest.java
create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/grpc/net/AbstractNoiseClientHandler.java
create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/grpc/net/AbstractNoiseHandshakeHandlerTest.java
create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/grpc/net/AuthenticationTypeService.java
create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/grpc/net/ClientErrorHandler.java
create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/grpc/net/EstablishRemoteConnectionHandler.java
create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/grpc/net/InboundCloseWebSocketFrameHandler.java
create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/grpc/net/NoiseNXClientHandshakeHandler.java
create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/grpc/net/NoiseNXHandshakeHandlerTest.java
create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/grpc/net/NoiseStreamHandlerTest.java
create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/grpc/net/NoiseXXClientHandshakeHandler.java
create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/grpc/net/NoiseXXHandshakeHandlerTest.java
create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/grpc/net/OutboundCloseWebSocketFrameHandler.java
create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/grpc/net/RejectUnsupportedMessagesHandlerTest.java
create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/grpc/net/WebSocketCloseListener.java
create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/grpc/net/WebSocketNoiseTunnelClient.java
create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/grpc/net/WebSocketNoiseTunnelServerIntegrationTest.java
create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/grpc/net/WebSocketOpeningHandshakeHandlerTest.java
create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/grpc/net/WebsocketHandshakeCompleteListenerTest.java
create mode 100644 service/src/test/proto/authentication_type_service.proto
diff --git a/pom.xml b/pom.xml
index 2ba7feaa9..1c5011d46 100644
--- a/pom.xml
+++ b/pom.xml
@@ -286,6 +286,11 @@
libsignal-server
0.39.0
+
+ org.signal.forks
+ noise-java
+ 0.1.0
+
org.apache.logging.log4j
log4j-bom
diff --git a/service/config/sample.yml b/service/config/sample.yml
index e7a32878f..9f6ec4d7d 100644
--- a/service/config/sample.yml
+++ b/service/config/sample.yml
@@ -40,8 +40,6 @@ metrics:
- ^lettuce\..+$
reportOnStop: true
-grpcPort: 8080
-
tlsKeyStore:
password: secret://tlsKeyStore.password
diff --git a/service/pom.xml b/service/pom.xml
index 41fe438f5..bd9f692cd 100644
--- a/service/pom.xml
+++ b/service/pom.xml
@@ -52,6 +52,11 @@
libsignal-server
+
+ org.signal.forks
+ noise-java
+
+
io.dropwizard
dropwizard-core
@@ -242,8 +247,7 @@
io.grpc
- grpc-netty-shaded
- runtime
+ grpc-netty
io.grpc
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java
index 3c60e922e..450ac7581 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java
@@ -298,11 +298,6 @@ public class WhisperServerConfiguration extends Configuration {
@JsonProperty
private TusConfiguration tus;
- @Valid
- @NotNull
- @JsonProperty
- private int grpcPort;
-
@Valid
@NotNull
@JsonProperty
@@ -539,10 +534,6 @@ public class WhisperServerConfiguration extends Configuration {
return tus;
}
- public int getGrpcPort() {
- return grpcPort;
- }
-
public ClientReleaseConfiguration getClientReleaseConfiguration() {
return clientRelease;
}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java
index eb8b680d5..edacc7973 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java
@@ -21,13 +21,13 @@ import io.dropwizard.core.setup.Bootstrap;
import io.dropwizard.core.setup.Environment;
import io.dropwizard.jetty.HttpsConnectorFactory;
import io.grpc.ServerBuilder;
-import io.grpc.ServerInterceptors;
import io.lettuce.core.metrics.MicrometerCommandLatencyRecorder;
import io.lettuce.core.metrics.MicrometerOptions;
import io.lettuce.core.resource.ClientResources;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.binder.grpc.MetricCollectingServerInterceptor;
import io.micrometer.core.instrument.binder.jvm.ExecutorServiceMetrics;
+import io.netty.channel.local.LocalAddress;
import java.net.http.HttpClient;
import java.time.Clock;
import java.time.Duration;
@@ -134,13 +134,14 @@ import org.whispersystems.textsecuregcm.grpc.AccountsGrpcService;
import org.whispersystems.textsecuregcm.grpc.ErrorMappingInterceptor;
import org.whispersystems.textsecuregcm.grpc.ExternalServiceCredentialsAnonymousGrpcService;
import org.whispersystems.textsecuregcm.grpc.ExternalServiceCredentialsGrpcService;
-import org.whispersystems.textsecuregcm.grpc.GrpcServerManagedWrapper;
import org.whispersystems.textsecuregcm.grpc.KeysAnonymousGrpcService;
import org.whispersystems.textsecuregcm.grpc.KeysGrpcService;
import org.whispersystems.textsecuregcm.grpc.PaymentsGrpcService;
import org.whispersystems.textsecuregcm.grpc.ProfileAnonymousGrpcService;
import org.whispersystems.textsecuregcm.grpc.ProfileGrpcService;
import org.whispersystems.textsecuregcm.grpc.UserAgentInterceptor;
+import org.whispersystems.textsecuregcm.grpc.net.ManagedDefaultEventLoopGroup;
+import org.whispersystems.textsecuregcm.grpc.net.ManagedLocalGrpcServer;
import org.whispersystems.textsecuregcm.limits.CardinalityEstimator;
import org.whispersystems.textsecuregcm.limits.PushChallengeManager;
import org.whispersystems.textsecuregcm.limits.RateLimitChallengeManager;
@@ -753,20 +754,67 @@ public class WhisperServerService extends Application grpcServer = ServerBuilder.forPort(config.getGrpcPort())
- .addService(ServerInterceptors.intercept(new AccountsGrpcService(accountsManager, rateLimiters, usernameHashZkProofVerifier, registrationRecoveryPasswordsManager), basicCredentialAuthenticationInterceptor))
- .addService(new AccountsAnonymousGrpcService(accountsManager, rateLimiters))
- .addService(ExternalServiceCredentialsGrpcService.createForAllExternalServices(config, rateLimiters))
- .addService(ExternalServiceCredentialsAnonymousGrpcService.create(accountsManager, config))
- .addService(ServerInterceptors.intercept(new KeysGrpcService(accountsManager, keysManager, rateLimiters), basicCredentialAuthenticationInterceptor))
- .addService(new KeysAnonymousGrpcService(accountsManager, keysManager))
- .addService(new PaymentsGrpcService(currencyManager))
- .addService(ServerInterceptors.intercept(new ProfileGrpcService(clock, accountsManager, profilesManager, dynamicConfigurationManager,
- config.getBadges(), asyncCdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner, profileBadgeConverter, rateLimiters, zkProfileOperations, config.getCdnConfiguration().bucket()), basicCredentialAuthenticationInterceptor))
- .addService(new ProfileAnonymousGrpcService(accountsManager, profilesManager, profileBadgeConverter, zkProfileOperations));
+ final ManagedDefaultEventLoopGroup localEventLoopGroup = new ManagedDefaultEventLoopGroup();
+
+ final RemoteDeprecationFilter remoteDeprecationFilter = new RemoteDeprecationFilter(dynamicConfigurationManager);
+ final MetricCollectingServerInterceptor metricCollectingServerInterceptor =
+ new MetricCollectingServerInterceptor(Metrics.globalRegistry);
+
+ final ErrorMappingInterceptor errorMappingInterceptor = new ErrorMappingInterceptor();
+ final AcceptLanguageInterceptor acceptLanguageInterceptor = new AcceptLanguageInterceptor();
+ final UserAgentInterceptor userAgentInterceptor = new UserAgentInterceptor();
+
+ final LocalAddress anonymousGrpcServerAddress = new LocalAddress("grpc-anonymous");
+ final LocalAddress authenticatedGrpcServerAddress = new LocalAddress("grpc-authenticated");
+
+ final ManagedLocalGrpcServer anonymousGrpcServer = new ManagedLocalGrpcServer(anonymousGrpcServerAddress, localEventLoopGroup) {
+ @Override
+ protected void configureServer(final ServerBuilder> serverBuilder) {
+ // Note: interceptors run in the reverse order they are added; the remote deprecation filter
+ // depends on the user-agent context so it has to come first here!
+ // http://grpc.github.io/grpc-java/javadoc/io/grpc/ServerBuilder.html#intercept-io.grpc.ServerInterceptor-
+ serverBuilder
+ // TODO: specialize metrics with user-agent platform
+ .intercept(metricCollectingServerInterceptor)
+ .intercept(errorMappingInterceptor)
+ .intercept(acceptLanguageInterceptor)
+ .intercept(remoteDeprecationFilter)
+ .intercept(userAgentInterceptor)
+ .addService(new AccountsAnonymousGrpcService(accountsManager, rateLimiters))
+ .addService(new KeysAnonymousGrpcService(accountsManager, keysManager))
+ .addService(new PaymentsGrpcService(currencyManager))
+ .addService(ExternalServiceCredentialsAnonymousGrpcService.create(accountsManager, config))
+ .addService(new ProfileAnonymousGrpcService(accountsManager, profilesManager, profileBadgeConverter, zkProfileOperations));
+ }
+ };
+
+ final ManagedLocalGrpcServer authenticatedGrpcServer = new ManagedLocalGrpcServer(authenticatedGrpcServerAddress, localEventLoopGroup) {
+ @Override
+ protected void configureServer(final ServerBuilder> serverBuilder) {
+ // Note: interceptors run in the reverse order they are added; the remote deprecation filter
+ // depends on the user-agent context so it has to come first here!
+ // http://grpc.github.io/grpc-java/javadoc/io/grpc/ServerBuilder.html#intercept-io.grpc.ServerInterceptor-
+ serverBuilder
+ // TODO: specialize metrics with user-agent platform
+ .intercept(metricCollectingServerInterceptor)
+ .intercept(errorMappingInterceptor)
+ .intercept(acceptLanguageInterceptor)
+ .intercept(remoteDeprecationFilter)
+ .intercept(userAgentInterceptor)
+ .intercept(new BasicCredentialAuthenticationInterceptor(new AccountAuthenticator(accountsManager)))
+ .addService(new AccountsGrpcService(accountsManager, rateLimiters, usernameHashZkProofVerifier, registrationRecoveryPasswordsManager))
+ .addService(ExternalServiceCredentialsGrpcService.createForAllExternalServices(config, rateLimiters))
+ .addService(new KeysGrpcService(accountsManager, keysManager, rateLimiters))
+ .addService(new ProfileGrpcService(clock, accountsManager, profilesManager, dynamicConfigurationManager,
+ config.getBadges(), asyncCdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner, profileBadgeConverter, rateLimiters, zkProfileOperations, config.getCdnConfiguration().bucket()));
+ }
+ };
+
+ environment.lifecycle().manage(localEventLoopGroup);
+ environment.lifecycle().manage(anonymousGrpcServer);
+ environment.lifecycle().manage(authenticatedGrpcServer);
final List filters = new ArrayList<>();
- final RemoteDeprecationFilter remoteDeprecationFilter = new RemoteDeprecationFilter(dynamicConfigurationManager);
filters.add(remoteDeprecationFilter);
filters.add(new RemoteAddressFilter(useRemoteAddress));
@@ -776,19 +824,6 @@ public class WhisperServerService extends Application accountAuthFilter =
new BasicCredentialAuthFilter.Builder()
.setAuthenticator(accountAuthenticator)
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/GrpcServerManagedWrapper.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/GrpcServerManagedWrapper.java
deleted file mode 100644
index 1b88c290d..000000000
--- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/GrpcServerManagedWrapper.java
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * Copyright 2023 Signal Messenger, LLC
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-package org.whispersystems.textsecuregcm.grpc;
-
-import java.io.IOException;
-import java.util.concurrent.TimeUnit;
-
-import io.dropwizard.lifecycle.Managed;
-import io.grpc.Server;
-
-public class GrpcServerManagedWrapper implements Managed {
-
- private final Server server;
-
- public GrpcServerManagedWrapper(final Server server) {
- this.server = server;
- }
-
- @Override
- public void start() throws IOException {
- server.start();
- }
-
- @Override
- public void stop() {
- try {
- server.shutdown().awaitTermination(5, TimeUnit.MINUTES);
- } catch (InterruptedException e) {
- server.shutdownNow();
- }
- }
-}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/AbstractNoiseHandshakeHandler.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/AbstractNoiseHandshakeHandler.java
new file mode 100644
index 000000000..41594644d
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/AbstractNoiseHandshakeHandler.java
@@ -0,0 +1,124 @@
+package org.whispersystems.textsecuregcm.grpc.net;
+
+import com.southernstorm.noise.protocol.HandshakeState;
+import io.netty.buffer.ByteBufUtil;
+import io.netty.buffer.Unpooled;
+import io.netty.channel.ChannelFutureListener;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelInboundHandlerAdapter;
+import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame;
+import io.netty.util.internal.EmptyArrays;
+import java.security.NoSuchAlgorithmException;
+import javax.crypto.BadPaddingException;
+import javax.crypto.ShortBufferException;
+import org.signal.libsignal.protocol.ecc.ECKeyPair;
+
+/**
+ * An abstract base class for XX- and NX-patterned Noise responder handshake handlers.
+ *
+ * @see The Noise Protocol Framework
+ */
+abstract class AbstractNoiseHandshakeHandler extends ChannelInboundHandlerAdapter {
+
+ private final ECKeyPair ecKeyPair;
+ private final byte[] publicKeySignature;
+
+ private final HandshakeState handshakeState;
+
+ private static final int EXPECTED_EPHEMERAL_KEY_MESSAGE_LENGTH = 32;
+
+ /**
+ * Constructs a new Noise handler with the given static server keys and static public key signature. The static public
+ * key must be signed by a trusted root private key whose public key is known to and trusted by authenticating
+ * clients.
+ *
+ * @param noiseProtocolName the name of the Noise protocol implemented by this handshake handler
+ * @param ecKeyPair the static key pair for this server
+ * @param publicKeySignature an Ed25519 signature of the raw bytes of the static public key
+ */
+ AbstractNoiseHandshakeHandler(final String noiseProtocolName,
+ final ECKeyPair ecKeyPair,
+ final byte[] publicKeySignature) {
+
+ this.ecKeyPair = ecKeyPair;
+ this.publicKeySignature = publicKeySignature;
+
+ try {
+ this.handshakeState = new HandshakeState(noiseProtocolName, HandshakeState.RESPONDER);
+ } catch (final NoSuchAlgorithmException e) {
+ throw new AssertionError("Unsupported Noise algorithm: " + noiseProtocolName, e);
+ }
+ }
+
+ protected HandshakeState getHandshakeState() {
+ return handshakeState;
+ }
+
+ /**
+ * Handles an initial ephemeral key message from a client, advancing the handshake state and sending the server's
+ * static keys to the client. Both XX and NX patterns begin with a client sending its ephemeral key to the server.
+ * Clients must not include an additional payload with their ephemeral key message. The server's reply contains its
+ * static keys along with an Ed25519 signature of its public static key by a trusted root key.
+ *
+ * @param context the channel handler context for this message
+ * @param frame the websocket frame containing the ephemeral key message
+ *
+ * @throws NoiseHandshakeException if the ephemeral key message from the client was not of the expected size or if a
+ * general Noise encryption error occurred
+ */
+ protected void handleEphemeralKeyMessage(final ChannelHandlerContext context, final BinaryWebSocketFrame frame)
+ throws NoiseHandshakeException {
+
+ if (frame.content().readableBytes() != EXPECTED_EPHEMERAL_KEY_MESSAGE_LENGTH) {
+ throw new NoiseHandshakeException("Unexpected ephemeral key message length");
+ }
+
+ // Cryptographically initializing a handshake is expensive, and so we defer it until we're confident the client is
+ // making a good-faith effort to perform a handshake (i.e. now). Noise-java in particular will derive a public key
+ // from the supplied private key (and will in fact overwrite any previously-set public key when setting a private
+ // key), so we just set the private key here.
+ handshakeState.getLocalKeyPair().setPrivateKey(ecKeyPair.getPrivateKey().serialize(), 0);
+ handshakeState.start();
+
+ // The initial message from the client should just include a plaintext ephemeral key with no payload. The frame is
+ // coming off the wire and so will be in a direct buffer that doesn't have a backing array.
+ final byte[] ephemeralKeyMessage = ByteBufUtil.getBytes(frame.content());
+ frame.content().readBytes(ephemeralKeyMessage);
+
+ try {
+ handshakeState.readMessage(ephemeralKeyMessage, 0, ephemeralKeyMessage.length, EmptyArrays.EMPTY_BYTES, 0);
+ } catch (final ShortBufferException e) {
+ // This should never happen since we're checking the length of the frame up front
+ throw new NoiseHandshakeException("Unexpected client payload");
+ } catch (final BadPaddingException e) {
+ // It turns out this should basically never happen because (a) we're not using padding and (b) the "bad AEAD tag"
+ // subclass of a bad padding exception can only happen if we have some AD to check, which we don't for an
+ // ephemeral-key-only message
+ throw new NoiseHandshakeException("Invalid keys");
+ }
+
+ // Send our key material and public key signature back to the client; this buffer will include:
+ //
+ // - A 32-byte plaintext ephemeral key
+ // - A 32-byte encrypted static key
+ // - A 16-byte AEAD tag for the static key
+ // - The public key signature payload
+ // - A 16-byte AEAD tag for the payload
+ final byte[] keyMaterial = new byte[32 + 32 + 16 + publicKeySignature.length + 16];
+
+ try {
+ handshakeState.writeMessage(keyMaterial, 0, publicKeySignature, 0, publicKeySignature.length);
+
+ context.writeAndFlush(new BinaryWebSocketFrame(Unpooled.wrappedBuffer(keyMaterial)))
+ .addListener(ChannelFutureListener.FIRE_EXCEPTION_ON_FAILURE);
+ } catch (final ShortBufferException e) {
+ // This should never happen for messages of known length that we control
+ throw new AssertionError("Key material buffer was too short for message", e);
+ }
+ }
+
+ @Override
+ public void handlerRemoved(final ChannelHandlerContext context) {
+ handshakeState.destroy();
+ }
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/ApplicationWebSocketCloseReason.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/ApplicationWebSocketCloseReason.java
new file mode 100644
index 000000000..a0ba4ee0b
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/ApplicationWebSocketCloseReason.java
@@ -0,0 +1,24 @@
+package org.whispersystems.textsecuregcm.grpc.net;
+
+import io.netty.handler.codec.http.websocketx.WebSocketCloseStatus;
+
+enum ApplicationWebSocketCloseReason {
+ NOISE_HANDSHAKE_ERROR(4001),
+ CLIENT_AUTHENTICATION_ERROR(4002),
+ NOISE_ENCRYPTION_ERROR(4003),
+ REAUTHENTICATION_REQUIRED(4004);
+
+ private final int statusCode;
+
+ ApplicationWebSocketCloseReason(final int statusCode) {
+ this.statusCode = statusCode;
+ }
+
+ public int getStatusCode() {
+ return statusCode;
+ }
+
+ WebSocketCloseStatus toWebSocketCloseStatus(final String reason) {
+ return new WebSocketCloseStatus(statusCode, reason);
+ }
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/ClientAuthenticationException.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/ClientAuthenticationException.java
new file mode 100644
index 000000000..f3015b7e8
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/ClientAuthenticationException.java
@@ -0,0 +1,7 @@
+package org.whispersystems.textsecuregcm.grpc.net;
+
+/**
+ * Indicates that an attempt to authenticate a remote client failed for some reason.
+ */
+class ClientAuthenticationException extends Exception {
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/ErrorHandler.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/ErrorHandler.java
new file mode 100644
index 000000000..1828ee689
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/ErrorHandler.java
@@ -0,0 +1,59 @@
+package org.whispersystems.textsecuregcm.grpc.net;
+
+import io.netty.channel.ChannelFutureListener;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelInboundHandlerAdapter;
+import io.netty.handler.codec.http.websocketx.CloseWebSocketFrame;
+import io.netty.handler.codec.http.websocketx.WebSocketCloseStatus;
+import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
+import javax.crypto.BadPaddingException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * An error handler serves as a general backstop for exceptions elsewhere in the pipeline. If the client has completed a
+ * WebSocket handshake, the error handler will send appropriate WebSocket closure codes to the client in an attempt to
+ * identify the problem. If the client has not completed a WebSocket handshake, the handler simply closes the
+ * connection.
+ */
+class ErrorHandler extends ChannelInboundHandlerAdapter {
+
+ private boolean websocketHandshakeComplete = false;
+
+ private static final Logger log = LoggerFactory.getLogger(ErrorHandler.class);
+
+ @Override
+ public void userEventTriggered(final ChannelHandlerContext context, final Object event) throws Exception {
+ if (event instanceof WebSocketServerProtocolHandler.HandshakeComplete) {
+ setWebsocketHandshakeComplete();
+ }
+
+ context.fireUserEventTriggered(event);
+ }
+
+ protected void setWebsocketHandshakeComplete() {
+ this.websocketHandshakeComplete = true;
+ }
+
+ @Override
+ public void exceptionCaught(final ChannelHandlerContext context, final Throwable cause) {
+ if (websocketHandshakeComplete) {
+ final WebSocketCloseStatus webSocketCloseStatus = switch (cause) {
+ case NoiseHandshakeException e -> ApplicationWebSocketCloseReason.NOISE_HANDSHAKE_ERROR.toWebSocketCloseStatus(e.getMessage());
+ case ClientAuthenticationException ignored -> ApplicationWebSocketCloseReason.CLIENT_AUTHENTICATION_ERROR.toWebSocketCloseStatus("Not authenticated");
+ case BadPaddingException ignored -> ApplicationWebSocketCloseReason.NOISE_ENCRYPTION_ERROR.toWebSocketCloseStatus("Noise encryption error");
+ default -> {
+ log.warn("An unexpected exception reached the end of the pipeline", cause);
+ yield WebSocketCloseStatus.INTERNAL_SERVER_ERROR;
+ }
+ };
+
+ context.writeAndFlush(new CloseWebSocketFrame(webSocketCloseStatus))
+ .addListener(ChannelFutureListener.CLOSE_ON_FAILURE);
+ } else {
+ // We haven't completed a websocket handshake, so we can't really communicate errors in a semantically-meaningful
+ // way; just close the connection instead.
+ context.close();
+ }
+ }
+}
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/EstablishLocalGrpcConnectionHandler.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/EstablishLocalGrpcConnectionHandler.java
new file mode 100644
index 000000000..37be75df0
--- /dev/null
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/EstablishLocalGrpcConnectionHandler.java
@@ -0,0 +1,96 @@
+package org.whispersystems.textsecuregcm.grpc.net;
+
+import io.netty.bootstrap.Bootstrap;
+import io.netty.channel.ChannelFutureListener;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelInboundHandlerAdapter;
+import io.netty.channel.ChannelInitializer;
+import io.netty.channel.local.LocalAddress;
+import io.netty.channel.local.LocalChannel;
+import io.netty.handler.codec.http.websocketx.CloseWebSocketFrame;
+import io.netty.handler.codec.http.websocketx.WebSocketCloseStatus;
+import io.netty.util.ReferenceCountUtil;
+import java.util.ArrayList;
+import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * An "establish local connection" handler waits for a Noise handshake to complete upstream in the pipeline, buffering
+ * any inbound messages until the connection is fully-established, and then opens a proxy connection to a local gRPC
+ * server.
+ */
+class EstablishLocalGrpcConnectionHandler extends ChannelInboundHandlerAdapter {
+
+ private final LocalAddress authenticatedGrpcServerAddress;
+ private final LocalAddress anonymousGrpcServerAddress;
+ private final List