mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-21 04:58:06 +01:00
Update noise-gRPC protocol errors
This commit is contained in:
@@ -10,8 +10,12 @@ import org.whispersystems.textsecuregcm.storage.Device;
|
||||
|
||||
public class DeviceIdUtil {
|
||||
|
||||
public static boolean isValid(int deviceId) {
|
||||
return deviceId >= Device.PRIMARY_ID && deviceId <= Byte.MAX_VALUE;
|
||||
}
|
||||
|
||||
static byte validate(int deviceId) {
|
||||
if (deviceId < Device.PRIMARY_ID || deviceId > Byte.MAX_VALUE) {
|
||||
if (!isValid(deviceId)) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription("Device ID is out of range").asRuntimeException();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
package org.whispersystems.textsecuregcm.grpc.net;
|
||||
|
||||
import org.whispersystems.textsecuregcm.util.NoStackTraceException;
|
||||
|
||||
/**
|
||||
* Indicates that an attempt to authenticate a remote client failed for some reason.
|
||||
*/
|
||||
public class ClientAuthenticationException extends NoStackTraceException {
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
package org.whispersystems.textsecuregcm.grpc.net;
|
||||
|
||||
import io.netty.channel.ChannelDuplexHandler;
|
||||
import io.netty.channel.ChannelFutureListener;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import javax.crypto.BadPaddingException;
|
||||
import io.netty.channel.ChannelInboundHandlerAdapter;
|
||||
import javax.crypto.BadPaddingException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
@@ -16,9 +15,6 @@ import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
public class ErrorHandler extends ChannelInboundHandlerAdapter {
|
||||
private static final Logger log = LoggerFactory.getLogger(ErrorHandler.class);
|
||||
|
||||
private static OutboundCloseErrorMessage UNAUTHENTICATED_CLOSE = new OutboundCloseErrorMessage(
|
||||
OutboundCloseErrorMessage.Code.AUTHENTICATION_ERROR,
|
||||
"Not authenticated");
|
||||
private static OutboundCloseErrorMessage NOISE_ENCRYPTION_ERROR_CLOSE = new OutboundCloseErrorMessage(
|
||||
OutboundCloseErrorMessage.Code.NOISE_ERROR,
|
||||
"Noise encryption error");
|
||||
@@ -29,7 +25,6 @@ public class ErrorHandler extends ChannelInboundHandlerAdapter {
|
||||
case NoiseHandshakeException e -> new OutboundCloseErrorMessage(
|
||||
OutboundCloseErrorMessage.Code.NOISE_HANDSHAKE_ERROR,
|
||||
e.getMessage());
|
||||
case ClientAuthenticationException ignored -> UNAUTHENTICATED_CLOSE;
|
||||
case BadPaddingException ignored -> NOISE_ENCRYPTION_ERROR_CLOSE;
|
||||
case NoiseException ignored -> NOISE_ENCRYPTION_ERROR_CLOSE;
|
||||
default -> {
|
||||
|
||||
@@ -8,6 +8,7 @@ import io.netty.channel.ChannelInitializer;
|
||||
import io.netty.channel.local.LocalAddress;
|
||||
import io.netty.channel.local.LocalChannel;
|
||||
import io.netty.util.ReferenceCountUtil;
|
||||
import java.net.InetAddress;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -48,7 +49,9 @@ public class EstablishLocalGrpcConnectionHandler extends ChannelInboundHandlerAd
|
||||
|
||||
@Override
|
||||
public void userEventTriggered(final ChannelHandlerContext remoteChannelContext, final Object event) {
|
||||
if (event instanceof NoiseIdentityDeterminedEvent(final Optional<AuthenticatedDevice> authenticatedDevice)) {
|
||||
if (event instanceof NoiseIdentityDeterminedEvent(
|
||||
final Optional<AuthenticatedDevice> authenticatedDevice,
|
||||
InetAddress remoteAddress, String userAgent, String acceptLanguage)) {
|
||||
// We assume that we'll only get a completed handshake event if the handshake met all authentication requirements
|
||||
// for the requested service. If the handshake doesn't have an authenticated device, we assume we're trying to
|
||||
// connect to the anonymous service. If it does have an authenticated device, we assume we're aiming for the
|
||||
@@ -57,6 +60,9 @@ public class EstablishLocalGrpcConnectionHandler extends ChannelInboundHandlerAd
|
||||
? authenticatedGrpcServerAddress
|
||||
: anonymousGrpcServerAddress;
|
||||
|
||||
GrpcClientConnectionManager.handleHandshakeInitiated(
|
||||
remoteChannelContext.channel(), remoteAddress, userAgent, acceptLanguage);
|
||||
|
||||
new Bootstrap()
|
||||
.remoteAddress(grpcServerAddress)
|
||||
.channel(LocalChannel.class)
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package org.whispersystems.textsecuregcm.grpc.net;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair;
|
||||
|
||||
/**
|
||||
* A NoiseAnonymousHandler is a netty pipeline element that handles the responder side of an unauthenticated handshake
|
||||
* and noise encryption/decryption.
|
||||
* <p>
|
||||
* A noise NK handshake must be used for unauthenticated connections. Optionally, the initiator can also include an
|
||||
* initial request in their payload. If provided, this allows the server to begin processing the request without an
|
||||
* initial message delay (fast open).
|
||||
* <p>
|
||||
* Once the handler receives the handshake initiator message, it will fire a {@link NoiseIdentityDeterminedEvent}
|
||||
* indicating that initiator connected anonymously.
|
||||
*/
|
||||
public class NoiseAnonymousHandler extends NoiseHandler {
|
||||
|
||||
public NoiseAnonymousHandler(final ECKeyPair ecKeyPair) {
|
||||
super(new NoiseHandshakeHelper(HandshakePattern.NK, ecKeyPair));
|
||||
}
|
||||
|
||||
@Override
|
||||
CompletableFuture<HandshakeResult> handleHandshakePayload(final ChannelHandlerContext context,
|
||||
final Optional<byte[]> initiatorPublicKey, final ByteBuf handshakePayload) {
|
||||
return CompletableFuture.completedFuture(new HandshakeResult(
|
||||
handshakePayload,
|
||||
Optional.empty()
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package org.whispersystems.textsecuregcm.grpc.net;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.util.ReferenceCountUtil;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair;
|
||||
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.storage.ClientPublicKeysManager;
|
||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
|
||||
/**
|
||||
* A NoiseAuthenticatedHandler is a netty pipeline element that handles the responder side of an authenticated handshake
|
||||
* and noise encryption/decryption. Authenticated handshakes are noise IK handshakes where the initiator's static public
|
||||
* key is authenticated by the responder.
|
||||
* <p>
|
||||
* The authenticated handshake requires the initiator to provide a payload with their first handshake message that
|
||||
* includes their account identifier and device id in network byte-order. Optionally, the initiator can also include an
|
||||
* initial request in their payload. If provided, this allows the server to begin processing the request without an
|
||||
* initial message delay (fast open).
|
||||
* <pre>
|
||||
* +-----------------+----------------+------------------------+
|
||||
* | UUID (16) | deviceId (1) | request bytes (N) |
|
||||
* +-----------------+----------------+------------------------+
|
||||
* </pre>
|
||||
* <p>
|
||||
* For a successful handshake, the static key provided in the handshake message must match the server's stored public
|
||||
* key for the device identified by the provided ACI and deviceId.
|
||||
* <p>
|
||||
* As soon as the handler authenticates the caller, it will fire a {@link NoiseIdentityDeterminedEvent}.
|
||||
*/
|
||||
public class NoiseAuthenticatedHandler extends NoiseHandler {
|
||||
|
||||
private final ClientPublicKeysManager clientPublicKeysManager;
|
||||
|
||||
public NoiseAuthenticatedHandler(final ClientPublicKeysManager clientPublicKeysManager,
|
||||
final ECKeyPair ecKeyPair) {
|
||||
super(new NoiseHandshakeHelper(HandshakePattern.IK, ecKeyPair));
|
||||
this.clientPublicKeysManager = clientPublicKeysManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
CompletableFuture<HandshakeResult> handleHandshakePayload(
|
||||
final ChannelHandlerContext context,
|
||||
final Optional<byte[]> initiatorPublicKey,
|
||||
final ByteBuf handshakePayload) throws NoiseHandshakeException {
|
||||
if (handshakePayload.readableBytes() < 17) {
|
||||
throw new NoiseHandshakeException("Invalid handshake payload");
|
||||
}
|
||||
|
||||
final byte[] publicKeyFromClient = initiatorPublicKey
|
||||
.orElseThrow(() -> new IllegalStateException("No remote public key"));
|
||||
|
||||
// Advances the read index by 16 bytes
|
||||
final UUID accountIdentifier = parseUUID(handshakePayload);
|
||||
|
||||
// Advances the read index by 1 byte
|
||||
final byte deviceId = handshakePayload.readByte();
|
||||
|
||||
final ByteBuf fastOpenRequest = handshakePayload.slice();
|
||||
return clientPublicKeysManager
|
||||
.findPublicKey(accountIdentifier, deviceId)
|
||||
.handleAsync((storedPublicKey, throwable) -> {
|
||||
if (throwable != null) {
|
||||
ReferenceCountUtil.release(fastOpenRequest);
|
||||
throw ExceptionUtils.wrap(throwable);
|
||||
}
|
||||
final boolean valid = storedPublicKey
|
||||
.map(spk -> MessageDigest.isEqual(publicKeyFromClient, spk.getPublicKeyBytes()))
|
||||
.orElse(false);
|
||||
if (!valid) {
|
||||
throw ExceptionUtils.wrap(new ClientAuthenticationException());
|
||||
}
|
||||
return new HandshakeResult(
|
||||
fastOpenRequest,
|
||||
Optional.of(new AuthenticatedDevice(accountIdentifier, deviceId)));
|
||||
}, context.executor());
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a {@link UUID} out of bytes, advancing the readerIdx by 16
|
||||
*
|
||||
* @param bytes The {@link ByteBuf} to read from
|
||||
* @return The parsed UUID
|
||||
* @throws NoiseHandshakeException If a UUID could not be parsed from bytes
|
||||
*/
|
||||
private UUID parseUUID(final ByteBuf bytes) throws NoiseHandshakeException {
|
||||
if (bytes.readableBytes() < 16) {
|
||||
throw new NoiseHandshakeException("Could not parse account identifier");
|
||||
}
|
||||
return new UUID(bytes.readLong(), bytes.readLong());
|
||||
}
|
||||
}
|
||||
@@ -11,159 +11,59 @@ import io.netty.buffer.ByteBuf;
|
||||
import io.netty.buffer.ByteBufUtil;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import io.netty.channel.ChannelDuplexHandler;
|
||||
import io.netty.channel.ChannelFutureListener;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.channel.ChannelPromise;
|
||||
import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame;
|
||||
import io.netty.handler.codec.http.websocketx.WebSocketFrame;
|
||||
import io.netty.util.ReferenceCountUtil;
|
||||
import io.netty.util.concurrent.PromiseCombiner;
|
||||
import io.netty.util.internal.EmptyArrays;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.ShortBufferException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.noisedirect.NoiseDirectFrame;
|
||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
|
||||
/**
|
||||
* A bidirectional {@link io.netty.channel.ChannelHandler} that establishes a noise session with an initiator, decrypts
|
||||
* inbound messages, and encrypts outbound messages
|
||||
* A bidirectional {@link io.netty.channel.ChannelHandler} that decrypts inbound messages, and encrypts outbound
|
||||
* messages
|
||||
*/
|
||||
public abstract class NoiseHandler extends ChannelDuplexHandler {
|
||||
public class NoiseHandler extends ChannelDuplexHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(NoiseHandler.class);
|
||||
private final CipherStatePair cipherStatePair;
|
||||
|
||||
private enum State {
|
||||
// Waiting for handshake to complete
|
||||
HANDSHAKE,
|
||||
// Can freely exchange encrypted noise messages on an established session
|
||||
TRANSPORT,
|
||||
// Finished with error
|
||||
ERROR
|
||||
NoiseHandler(CipherStatePair cipherStatePair) {
|
||||
this.cipherStatePair = cipherStatePair;
|
||||
}
|
||||
|
||||
private final NoiseHandshakeHelper handshakeHelper;
|
||||
|
||||
private State state = State.HANDSHAKE;
|
||||
private CipherStatePair cipherStatePair;
|
||||
|
||||
NoiseHandler(NoiseHandshakeHelper handshakeHelper) {
|
||||
this.handshakeHelper = handshakeHelper;
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of processing an initiator handshake payload
|
||||
*
|
||||
* @param fastOpenRequest A fast-open request included in the handshake. If none was present, this should be an
|
||||
* empty ByteBuf
|
||||
* @param authenticatedDevice If present, the successfully authenticated initiator identity
|
||||
*/
|
||||
record HandshakeResult(ByteBuf fastOpenRequest, Optional<AuthenticatedDevice> authenticatedDevice) {}
|
||||
|
||||
/**
|
||||
* Parse and potentially authenticate the initiator handshake message
|
||||
*
|
||||
* @param context A {@link ChannelHandlerContext}
|
||||
* @param initiatorPublicKey The initiator's static public key, if a handshake pattern that includes it was used
|
||||
* @param handshakePayload The handshake payload provided in the initiator message
|
||||
* @return A {@link HandshakeResult} that includes an authenticated device and a parsed fast-open request if one was
|
||||
* present in the handshake payload.
|
||||
* @throws NoiseHandshakeException If the handshake payload was invalid
|
||||
* @throws ClientAuthenticationException If the initiatorPublicKey could not be authenticated
|
||||
*/
|
||||
abstract CompletableFuture<HandshakeResult> handleHandshakePayload(
|
||||
final ChannelHandlerContext context,
|
||||
final Optional<byte[]> initiatorPublicKey,
|
||||
final ByteBuf handshakePayload) throws NoiseHandshakeException, ClientAuthenticationException;
|
||||
|
||||
@Override
|
||||
public void channelRead(final ChannelHandlerContext context, final Object message) throws Exception {
|
||||
try {
|
||||
if (message instanceof ByteBuf frame) {
|
||||
if (frame.readableBytes() > Noise.MAX_PACKET_LEN) {
|
||||
final String error = "Invalid noise message length " + frame.readableBytes();
|
||||
throw state == State.HANDSHAKE ? new NoiseHandshakeException(error) : new NoiseException(error);
|
||||
throw new NoiseException("Invalid noise message length " + frame.readableBytes());
|
||||
}
|
||||
// We've read this frame off the wire, and so it's most likely a direct buffer that's not backed by an array.
|
||||
// We'll need to copy it to a heap buffer.
|
||||
handleInboundMessage(context, ByteBufUtil.getBytes(frame));
|
||||
handleInboundDataMessage(context, ByteBufUtil.getBytes(frame));
|
||||
} else {
|
||||
// Anything except ByteBufs should have been filtered out of the pipeline by now; treat this as an error
|
||||
throw new IllegalArgumentException("Unexpected message in pipeline: " + message);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
fail(context, e);
|
||||
} finally {
|
||||
ReferenceCountUtil.release(message);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleInboundMessage(final ChannelHandlerContext context, final byte[] frameBytes)
|
||||
throws NoiseHandshakeException, ShortBufferException, BadPaddingException, ClientAuthenticationException {
|
||||
switch (state) {
|
||||
|
||||
// Got an initiator handshake message
|
||||
case HANDSHAKE -> {
|
||||
final ByteBuf payload = handshakeHelper.read(frameBytes);
|
||||
handleHandshakePayload(context, handshakeHelper.remotePublicKey(), payload).whenCompleteAsync(
|
||||
(result, throwable) -> {
|
||||
if (state == State.ERROR) {
|
||||
return;
|
||||
}
|
||||
if (throwable != null) {
|
||||
fail(context, ExceptionUtils.unwrap(throwable));
|
||||
return;
|
||||
}
|
||||
context.fireUserEventTriggered(new NoiseIdentityDeterminedEvent(result.authenticatedDevice()));
|
||||
private void handleInboundDataMessage(final ChannelHandlerContext context, final byte[] frameBytes)
|
||||
throws ShortBufferException, BadPaddingException {
|
||||
final CipherState cipherState = cipherStatePair.getReceiver();
|
||||
// Overwrite the ciphertext with the plaintext to avoid an extra allocation for a dedicated plaintext buffer
|
||||
final int plaintextLength = cipherState.decryptWithAd(null,
|
||||
frameBytes, 0,
|
||||
frameBytes, 0,
|
||||
frameBytes.length);
|
||||
|
||||
// Now that we've authenticated, write the handshake response
|
||||
byte[] handshakeMessage = handshakeHelper.write(EmptyArrays.EMPTY_BYTES);
|
||||
context.writeAndFlush(Unpooled.wrappedBuffer(handshakeMessage))
|
||||
.addListener(ChannelFutureListener.FIRE_EXCEPTION_ON_FAILURE);
|
||||
|
||||
// The handshake is complete. We can start intercepting read/write for noise encryption/decryption
|
||||
this.state = State.TRANSPORT;
|
||||
this.cipherStatePair = handshakeHelper.getHandshakeState().split();
|
||||
if (result.fastOpenRequest().isReadable()) {
|
||||
// The handshake had a fast-open request. Forward the plaintext of the request to the server, we'll
|
||||
// encrypt the response when the server writes back through us
|
||||
context.fireChannelRead(result.fastOpenRequest());
|
||||
} else {
|
||||
ReferenceCountUtil.release(result.fastOpenRequest());
|
||||
}
|
||||
}, context.executor());
|
||||
}
|
||||
|
||||
// Got a client message that should be decrypted and forwarded
|
||||
case TRANSPORT -> {
|
||||
final CipherState cipherState = cipherStatePair.getReceiver();
|
||||
// Overwrite the ciphertext with the plaintext to avoid an extra allocation for a dedicated plaintext buffer
|
||||
final int plaintextLength = cipherState.decryptWithAd(null,
|
||||
frameBytes, 0,
|
||||
frameBytes, 0,
|
||||
frameBytes.length);
|
||||
|
||||
// Forward the decrypted plaintext along
|
||||
context.fireChannelRead(Unpooled.wrappedBuffer(frameBytes, 0, plaintextLength));
|
||||
}
|
||||
|
||||
// The session is already in an error state, drop the message
|
||||
case ERROR -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the state to the error state (so subsequent messages fast-fail) and propagate the failure reason on the
|
||||
* context
|
||||
*/
|
||||
private void fail(final ChannelHandlerContext context, final Throwable cause) {
|
||||
this.state = State.ERROR;
|
||||
context.fireExceptionCaught(cause);
|
||||
// Forward the decrypted plaintext along
|
||||
context.fireChannelRead(Unpooled.wrappedBuffer(frameBytes, 0, plaintextLength));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -208,4 +108,12 @@ public abstract class NoiseHandler extends ChannelDuplexHandler {
|
||||
context.write(message, promise);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handlerRemoved(ChannelHandlerContext var1) {
|
||||
if (cipherStatePair != null) {
|
||||
cipherStatePair.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.grpc.net;
|
||||
|
||||
import com.southernstorm.noise.protocol.Noise;
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.buffer.ByteBufInputStream;
|
||||
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.util.ReferenceCountUtil;
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair;
|
||||
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.grpc.DeviceIdUtil;
|
||||
import org.whispersystems.textsecuregcm.storage.ClientPublicKeysManager;
|
||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
import org.whispersystems.textsecuregcm.util.UUIDUtil;
|
||||
|
||||
/**
|
||||
* Handles the responder side of a noise handshake and then replaces itself with a {@link NoiseHandler} which will
|
||||
* encrypt/decrypt subsequent data frames
|
||||
* <p>
|
||||
* The handler expects to receive a single inbound message, a {@link NoiseHandshakeInit} that includes the initiator
|
||||
* handshake message, connection metadata, and the type of handshake determined by the framing layer. This handler
|
||||
* currently supports two types of handshakes.
|
||||
* <p>
|
||||
* The first are IK handshakes where the initiator's static public key is authenticated by the responder. The initiator
|
||||
* handshake message must contain the ACI and deviceId of the initiator. To be authenticated, the static key provided in
|
||||
* the handshake message must match the server's stored public key for the device identified by the provided ACI and
|
||||
* deviceId.
|
||||
* <p>
|
||||
* The second are NK handshakes which are anonymous.
|
||||
* <p>
|
||||
* Optionally, the initiator can also include an initial request in their payload. If provided, this allows the server
|
||||
* to begin processing the request without an initial message delay (fast open).
|
||||
* <p>
|
||||
* Once the handshake has been validated, a {@link NoiseIdentityDeterminedEvent} will be fired. For an IK handshake,
|
||||
* this will include the {@link org.whispersystems.textsecuregcm.auth.AuthenticatedDevice} of the initiator. This
|
||||
* handler will then replace itself with a {@link NoiseHandler} with a noise state pair ready to encrypt/decrypt data
|
||||
* frames.
|
||||
*/
|
||||
public class NoiseHandshakeHandler extends ChannelInboundHandlerAdapter {
|
||||
|
||||
private static final byte[] HANDSHAKE_WRONG_PK = NoiseTunnelProtos.HandshakeResponse.newBuilder()
|
||||
.setCode(NoiseTunnelProtos.HandshakeResponse.Code.WRONG_PUBLIC_KEY)
|
||||
.build().toByteArray();
|
||||
private static final byte[] HANDSHAKE_OK = NoiseTunnelProtos.HandshakeResponse.newBuilder()
|
||||
.setCode(NoiseTunnelProtos.HandshakeResponse.Code.OK)
|
||||
.build().toByteArray();
|
||||
|
||||
// We might get additional messages while we're waiting to process a handshake, so keep track of where we are
|
||||
private boolean receivedHandshakeInit = false;
|
||||
|
||||
private final ClientPublicKeysManager clientPublicKeysManager;
|
||||
private final ECKeyPair ecKeyPair;
|
||||
|
||||
public NoiseHandshakeHandler(final ClientPublicKeysManager clientPublicKeysManager, final ECKeyPair ecKeyPair) {
|
||||
this.clientPublicKeysManager = clientPublicKeysManager;
|
||||
this.ecKeyPair = ecKeyPair;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelRead(final ChannelHandlerContext context, final Object message) throws Exception {
|
||||
try {
|
||||
if (!(message instanceof NoiseHandshakeInit handshakeInit)) {
|
||||
// Anything except HandshakeInit should have been filtered out of the pipeline by now; treat this as an error
|
||||
throw new IllegalArgumentException("Unexpected message in pipeline: " + message);
|
||||
}
|
||||
if (receivedHandshakeInit) {
|
||||
throw new NoiseHandshakeException("Should not receive messages until handshake complete");
|
||||
}
|
||||
receivedHandshakeInit = true;
|
||||
|
||||
if (handshakeInit.content().readableBytes() > Noise.MAX_PACKET_LEN) {
|
||||
throw new NoiseHandshakeException("Invalid noise message length " + handshakeInit.content().readableBytes());
|
||||
}
|
||||
|
||||
// We've read this frame off the wire, and so it's most likely a direct buffer that's not backed by an array.
|
||||
// We'll need to copy it to a heap buffer
|
||||
handleInboundHandshake(context,
|
||||
handshakeInit.getRemoteAddress(),
|
||||
handshakeInit.getHandshakePattern(),
|
||||
ByteBufUtil.getBytes(handshakeInit.content()));
|
||||
} finally {
|
||||
ReferenceCountUtil.release(message);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleInboundHandshake(
|
||||
final ChannelHandlerContext context,
|
||||
final InetAddress remoteAddress,
|
||||
final HandshakePattern handshakePattern,
|
||||
final byte[] frameBytes) throws NoiseHandshakeException {
|
||||
final NoiseHandshakeHelper handshakeHelper = new NoiseHandshakeHelper(handshakePattern, ecKeyPair);
|
||||
final ByteBuf payload = handshakeHelper.read(frameBytes);
|
||||
|
||||
// Parse the handshake message
|
||||
final NoiseTunnelProtos.HandshakeInit handshakeInit;
|
||||
try {
|
||||
handshakeInit = NoiseTunnelProtos.HandshakeInit.parseFrom(new ByteBufInputStream(payload));
|
||||
} catch (IOException e) {
|
||||
throw new NoiseHandshakeException("Failed to parse handshake message");
|
||||
}
|
||||
|
||||
switch (handshakePattern) {
|
||||
case NK -> {
|
||||
if (handshakeInit.getDeviceId() != 0 || !handshakeInit.getAci().isEmpty()) {
|
||||
throw new NoiseHandshakeException("Anonymous handshake should not include identifiers");
|
||||
}
|
||||
handleAuthenticated(context, handshakeHelper, remoteAddress, handshakeInit, Optional.empty());
|
||||
}
|
||||
case IK -> {
|
||||
final byte[] publicKeyFromClient = handshakeHelper.remotePublicKey()
|
||||
.orElseThrow(() -> new IllegalStateException("No remote public key"));
|
||||
final UUID accountIdentifier = aci(handshakeInit);
|
||||
final byte deviceId = deviceId(handshakeInit);
|
||||
clientPublicKeysManager
|
||||
.findPublicKey(accountIdentifier, deviceId)
|
||||
.whenCompleteAsync((storedPublicKey, throwable) -> {
|
||||
if (throwable != null) {
|
||||
context.fireExceptionCaught(ExceptionUtils.unwrap(throwable));
|
||||
return;
|
||||
}
|
||||
final boolean valid = storedPublicKey
|
||||
.map(spk -> MessageDigest.isEqual(publicKeyFromClient, spk.getPublicKeyBytes()))
|
||||
.orElse(false);
|
||||
if (!valid) {
|
||||
// Write a handshake response indicating that the client used the wrong public key
|
||||
final byte[] handshakeMessage = handshakeHelper.write(HANDSHAKE_WRONG_PK);
|
||||
context.writeAndFlush(Unpooled.wrappedBuffer(handshakeMessage))
|
||||
.addListener(ChannelFutureListener.CLOSE_ON_FAILURE);
|
||||
|
||||
context.fireExceptionCaught(new NoiseHandshakeException("Bad public key"));
|
||||
return;
|
||||
}
|
||||
handleAuthenticated(context,
|
||||
handshakeHelper, remoteAddress, handshakeInit,
|
||||
Optional.of(new AuthenticatedDevice(accountIdentifier, deviceId)));
|
||||
}, context.executor());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void handleAuthenticated(final ChannelHandlerContext context,
|
||||
final NoiseHandshakeHelper handshakeHelper,
|
||||
final InetAddress remoteAddress,
|
||||
final NoiseTunnelProtos.HandshakeInit handshakeInit,
|
||||
final Optional<AuthenticatedDevice> maybeAuthenticatedDevice) {
|
||||
context.fireUserEventTriggered(new NoiseIdentityDeterminedEvent(
|
||||
maybeAuthenticatedDevice,
|
||||
remoteAddress,
|
||||
handshakeInit.getUserAgent(),
|
||||
handshakeInit.getAcceptLanguage()));
|
||||
|
||||
// Now that we've authenticated, write the handshake response
|
||||
final byte[] handshakeMessage = handshakeHelper.write(HANDSHAKE_OK);
|
||||
context.writeAndFlush(Unpooled.wrappedBuffer(handshakeMessage))
|
||||
.addListener(ChannelFutureListener.FIRE_EXCEPTION_ON_FAILURE);
|
||||
|
||||
// The handshake is complete. We can start intercepting read/write for noise encryption/decryption
|
||||
// Note: It may be tempting to swap the before/remove for a replace, but then when we forward the fast open
|
||||
// request it will go through the NoiseHandler. We want to skip the NoiseHandler because we've already
|
||||
// decrypted the fastOpen request
|
||||
context.pipeline()
|
||||
.addBefore(context.name(), null, new NoiseHandler(handshakeHelper.getHandshakeState().split()));
|
||||
context.pipeline().remove(NoiseHandshakeHandler.class);
|
||||
if (!handshakeInit.getFastOpenRequest().isEmpty()) {
|
||||
// The handshake had a fast-open request. Forward the plaintext of the request to the server, we'll
|
||||
// encrypt the response when the server writes back through us
|
||||
context.fireChannelRead(Unpooled.wrappedBuffer(handshakeInit.getFastOpenRequest().asReadOnlyByteBuffer()));
|
||||
}
|
||||
}
|
||||
|
||||
private static UUID aci(final NoiseTunnelProtos.HandshakeInit handshakePayload) throws NoiseHandshakeException {
|
||||
try {
|
||||
return UUIDUtil.fromByteString(handshakePayload.getAci());
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new NoiseHandshakeException("Could not parse aci");
|
||||
}
|
||||
}
|
||||
|
||||
private static byte deviceId(final NoiseTunnelProtos.HandshakeInit handshakePayload) throws NoiseHandshakeException {
|
||||
if (!DeviceIdUtil.isValid(handshakePayload.getDeviceId())) {
|
||||
throw new NoiseHandshakeException("Invalid deviceId");
|
||||
}
|
||||
return (byte) handshakePayload.getDeviceId();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.whispersystems.textsecuregcm.grpc.net;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.buffer.DefaultByteBufHolder;
|
||||
import java.net.InetAddress;
|
||||
|
||||
/**
|
||||
* A message that includes the initiator's handshake message, connection metadata, and the handshake type. The metadata
|
||||
* and handshake type are extracted from the framing layer, so this allows receivers to be framing layer agnostic.
|
||||
*/
|
||||
public class NoiseHandshakeInit extends DefaultByteBufHolder {
|
||||
|
||||
private final InetAddress remoteAddress;
|
||||
private final HandshakePattern handshakePattern;
|
||||
|
||||
public NoiseHandshakeInit(
|
||||
final InetAddress remoteAddress,
|
||||
final HandshakePattern handshakePattern,
|
||||
final ByteBuf initiatorHandshakeMessage) {
|
||||
super(initiatorHandshakeMessage);
|
||||
this.remoteAddress = remoteAddress;
|
||||
this.handshakePattern = handshakePattern;
|
||||
}
|
||||
|
||||
public InetAddress getRemoteAddress() {
|
||||
return remoteAddress;
|
||||
}
|
||||
|
||||
public HandshakePattern getHandshakePattern() {
|
||||
return handshakePattern;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.whispersystems.textsecuregcm.grpc.net;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.util.Optional;
|
||||
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
|
||||
|
||||
@@ -9,5 +10,12 @@ import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
|
||||
*
|
||||
* @param authenticatedDevice the device authenticated as part of the handshake, or empty if the handshake was not of a
|
||||
* type that performs authentication
|
||||
* @param remoteAddress the remote address of the connecting client
|
||||
* @param userAgent the client supplied userAgent
|
||||
* @param acceptLanguage the client supplied acceptLanguage
|
||||
*/
|
||||
public record NoiseIdentityDeterminedEvent(Optional<AuthenticatedDevice> authenticatedDevice) {}
|
||||
public record NoiseIdentityDeterminedEvent(
|
||||
Optional<AuthenticatedDevice> authenticatedDevice,
|
||||
InetAddress remoteAddress,
|
||||
String userAgent,
|
||||
String acceptLanguage) {}
|
||||
|
||||
@@ -9,6 +9,7 @@ package org.whispersystems.textsecuregcm.grpc.net;
|
||||
*/
|
||||
public record OutboundCloseErrorMessage(Code code, String message) {
|
||||
public enum Code {
|
||||
|
||||
/**
|
||||
* The server decided to close the connection. This could be because the server is going away, or it could be
|
||||
* because the credentials for the connected client have been updated.
|
||||
@@ -25,11 +26,6 @@ public record OutboundCloseErrorMessage(Code code, String message) {
|
||||
*/
|
||||
NOISE_HANDSHAKE_ERROR,
|
||||
|
||||
/**
|
||||
* The provided credentials were not valid
|
||||
*/
|
||||
AUTHENTICATION_ERROR,
|
||||
|
||||
INTERNAL_SERVER_ERROR
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,12 +30,12 @@ public class NoiseDirectFrame extends DefaultByteBufHolder {
|
||||
|
||||
public enum FrameType {
|
||||
/**
|
||||
* The payload is the initiator message or the responder message for a Noise NK handshake. If established, the
|
||||
* The payload is the initiator message for a Noise NK handshake. If established, the
|
||||
* session will be unauthenticated.
|
||||
*/
|
||||
NK_HANDSHAKE((byte) 1),
|
||||
/**
|
||||
* The payload is the initiator message or the responder message for a Noise IK handshake. If established, the
|
||||
* The payload is the initiator message for a Noise IK handshake. If established, the
|
||||
* session will be authenticated.
|
||||
*/
|
||||
IK_HANDSHAKE((byte) 2),
|
||||
@@ -44,9 +44,10 @@ public class NoiseDirectFrame extends DefaultByteBufHolder {
|
||||
*/
|
||||
DATA((byte) 3),
|
||||
/**
|
||||
* A framing layer error occurred. The payload carries error details.
|
||||
* A frame sent before the connection is closed. The payload is a protobuf indicating why the connection is being
|
||||
* closed.
|
||||
*/
|
||||
ERROR((byte) 4);
|
||||
CLOSE((byte) 4);
|
||||
|
||||
private final byte frameType;
|
||||
|
||||
@@ -64,7 +65,7 @@ public class NoiseDirectFrame extends DefaultByteBufHolder {
|
||||
public boolean isHandshake() {
|
||||
return switch (this) {
|
||||
case IK_HANDSHAKE, NK_HANDSHAKE -> true;
|
||||
case DATA, ERROR -> false;
|
||||
case DATA, CLOSE -> false;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ public class NoiseDirectFrameCodec extends ChannelDuplexHandler {
|
||||
case 1 -> NoiseDirectFrame.FrameType.NK_HANDSHAKE;
|
||||
case 2 -> NoiseDirectFrame.FrameType.IK_HANDSHAKE;
|
||||
case 3 -> NoiseDirectFrame.FrameType.DATA;
|
||||
case 4 -> NoiseDirectFrame.FrameType.ERROR;
|
||||
case 4 -> NoiseDirectFrame.FrameType.CLOSE;
|
||||
default -> throw new NoiseHandshakeException("Invalid NoiseDirect frame type: " + frameTypeBits);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,66 +4,44 @@
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.grpc.net.noisedirect;
|
||||
|
||||
import io.netty.channel.ChannelDuplexHandler;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.channel.ChannelInboundHandlerAdapter;
|
||||
import io.netty.util.ReferenceCountUtil;
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.GrpcClientConnectionManager;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.NoiseAnonymousHandler;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.NoiseAuthenticatedHandler;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.NoiseHandshakeException;
|
||||
import org.whispersystems.textsecuregcm.storage.ClientPublicKeysManager;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.HandshakePattern;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.NoiseHandshakeException;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.NoiseHandshakeInit;
|
||||
|
||||
/**
|
||||
* Waits for a Handshake {@link NoiseDirectFrame} and then installs a {@link NoiseDirectDataFrameCodec} and
|
||||
* {@link org.whispersystems.textsecuregcm.grpc.net.NoiseHandler} and removes itself
|
||||
* Waits for a Handshake {@link NoiseDirectFrame} and then replaces itself with a {@link NoiseDirectDataFrameCodec} and
|
||||
* forwards the handshake frame along as a {@link NoiseHandshakeInit} message
|
||||
*/
|
||||
public class NoiseDirectHandshakeSelector extends ChannelInboundHandlerAdapter {
|
||||
|
||||
private final ClientPublicKeysManager clientPublicKeysManager;
|
||||
private final ECKeyPair ecKeyPair;
|
||||
|
||||
public NoiseDirectHandshakeSelector(final ClientPublicKeysManager clientPublicKeysManager, final ECKeyPair ecKeyPair) {
|
||||
this.clientPublicKeysManager = clientPublicKeysManager;
|
||||
this.ecKeyPair = ecKeyPair;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void channelRead(final ChannelHandlerContext ctx, final Object msg) throws Exception {
|
||||
if (msg instanceof NoiseDirectFrame frame) {
|
||||
try {
|
||||
// We've received an inbound handshake frame so we know what kind of NoiseHandler we need (authenticated or
|
||||
// anonymous). We construct it here, and then remember the handshake type so we can annotate our handshake
|
||||
// response with the correct frame type whenever we receive it.
|
||||
final ChannelDuplexHandler noiseHandler = switch (frame.frameType()) {
|
||||
case DATA, ERROR ->
|
||||
throw new NoiseHandshakeException("Invalid frame type for first message " + frame.frameType());
|
||||
case IK_HANDSHAKE -> new NoiseAuthenticatedHandler(clientPublicKeysManager, ecKeyPair);
|
||||
case NK_HANDSHAKE -> new NoiseAnonymousHandler(ecKeyPair);
|
||||
};
|
||||
if (ctx.channel().remoteAddress() instanceof InetSocketAddress inetSocketAddress) {
|
||||
// TODO: Provide connection metadata / headers in handshake payload
|
||||
GrpcClientConnectionManager.handleHandshakeInitiated(ctx.channel(),
|
||||
inetSocketAddress.getAddress(),
|
||||
"NoiseDirect",
|
||||
"");
|
||||
|
||||
} else {
|
||||
if (!(ctx.channel().remoteAddress() instanceof InetSocketAddress inetSocketAddress)) {
|
||||
throw new IOException("Could not determine remote address");
|
||||
}
|
||||
// We've received an inbound handshake frame. Pull the framing-protocol specific data the downstream handler
|
||||
// needs into a NoiseHandshakeInit message and forward that along
|
||||
final NoiseHandshakeInit handshakeMessage = new NoiseHandshakeInit(inetSocketAddress.getAddress(),
|
||||
switch (frame.frameType()) {
|
||||
case DATA -> throw new NoiseHandshakeException("First message must have handshake frame type");
|
||||
case CLOSE -> throw new IllegalStateException("Close frames should not reach handshake selector");
|
||||
case IK_HANDSHAKE -> HandshakePattern.IK;
|
||||
case NK_HANDSHAKE -> HandshakePattern.NK;
|
||||
}, frame.content());
|
||||
|
||||
// Subsequent inbound messages and outbound should be data type frames or close frames. Inbound data frames
|
||||
// should be unwrapped and forwarded to the noise handler, outbound buffers should be wrapped and forwarded
|
||||
// for network serialization. Note that we need to install the Data frame handler before firing the read,
|
||||
// because we may receive an outbound message from the noiseHandler
|
||||
ctx.pipeline().addAfter(ctx.name(), null, noiseHandler);
|
||||
ctx.pipeline().replace(ctx.name(), null, new NoiseDirectDataFrameCodec());
|
||||
ctx.fireChannelRead(frame.content());
|
||||
ctx.fireChannelRead(handshakeMessage);
|
||||
} catch (Exception e) {
|
||||
ReferenceCountUtil.release(msg);
|
||||
throw e;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.grpc.net.noisedirect;
|
||||
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.netty.buffer.ByteBufUtil;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.channel.ChannelInboundHandlerAdapter;
|
||||
import io.netty.util.ReferenceCountUtil;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
|
||||
|
||||
/**
|
||||
* Watches for inbound close frames and closes the connection in response
|
||||
*/
|
||||
public class NoiseDirectInboundCloseHandler extends ChannelInboundHandlerAdapter {
|
||||
private static String CLIENT_CLOSE_COUNTER_NAME = MetricsUtil.name(ChannelInboundHandlerAdapter.class, "clientClose");
|
||||
@Override
|
||||
public void channelRead(final ChannelHandlerContext ctx, final Object msg) throws Exception {
|
||||
if (msg instanceof NoiseDirectFrame ndf && ndf.frameType() == NoiseDirectFrame.FrameType.CLOSE) {
|
||||
try {
|
||||
final NoiseDirectProtos.CloseReason closeReason = NoiseDirectProtos.CloseReason
|
||||
.parseFrom(ByteBufUtil.getBytes(ndf.content()));
|
||||
|
||||
Metrics.counter(CLIENT_CLOSE_COUNTER_NAME, "reason", closeReason.getCode().name()).increment();
|
||||
} finally {
|
||||
ReferenceCountUtil.release(msg);
|
||||
ctx.close();
|
||||
}
|
||||
} else {
|
||||
ctx.fireChannelRead(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,20 +17,19 @@ class NoiseDirectOutboundErrorHandler extends ChannelOutboundHandlerAdapter {
|
||||
@Override
|
||||
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
|
||||
if (msg instanceof OutboundCloseErrorMessage err) {
|
||||
final NoiseDirectProtos.Error.Type type = switch (err.code()) {
|
||||
case SERVER_CLOSED -> NoiseDirectProtos.Error.Type.UNAVAILABLE;
|
||||
case NOISE_ERROR -> NoiseDirectProtos.Error.Type.ENCRYPTION_ERROR;
|
||||
case NOISE_HANDSHAKE_ERROR -> NoiseDirectProtos.Error.Type.HANDSHAKE_ERROR;
|
||||
case AUTHENTICATION_ERROR -> NoiseDirectProtos.Error.Type.AUTHENTICATION_ERROR;
|
||||
case INTERNAL_SERVER_ERROR -> NoiseDirectProtos.Error.Type.INTERNAL_ERROR;
|
||||
final NoiseDirectProtos.CloseReason.Code code = switch (err.code()) {
|
||||
case SERVER_CLOSED -> NoiseDirectProtos.CloseReason.Code.UNAVAILABLE;
|
||||
case NOISE_ERROR -> NoiseDirectProtos.CloseReason.Code.ENCRYPTION_ERROR;
|
||||
case NOISE_HANDSHAKE_ERROR -> NoiseDirectProtos.CloseReason.Code.HANDSHAKE_ERROR;
|
||||
case INTERNAL_SERVER_ERROR -> NoiseDirectProtos.CloseReason.Code.INTERNAL_ERROR;
|
||||
};
|
||||
final NoiseDirectProtos.Error proto = NoiseDirectProtos.Error.newBuilder()
|
||||
.setType(type)
|
||||
final NoiseDirectProtos.CloseReason proto = NoiseDirectProtos.CloseReason.newBuilder()
|
||||
.setCode(code)
|
||||
.setMessage(err.message())
|
||||
.build();
|
||||
final ByteBuf byteBuf = ctx.alloc().buffer(proto.getSerializedSize());
|
||||
proto.writeTo(new ByteBufOutputStream(byteBuf));
|
||||
ctx.writeAndFlush(new NoiseDirectFrame(NoiseDirectFrame.FrameType.ERROR, byteBuf))
|
||||
ctx.writeAndFlush(new NoiseDirectFrame(NoiseDirectFrame.FrameType.CLOSE, byteBuf))
|
||||
.addListener(ChannelFutureListener.CLOSE);
|
||||
} else {
|
||||
ctx.write(msg, promise);
|
||||
|
||||
@@ -19,6 +19,7 @@ import org.whispersystems.textsecuregcm.grpc.net.ErrorHandler;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.EstablishLocalGrpcConnectionHandler;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.GrpcClientConnectionManager;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.HAProxyMessageHandler;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.NoiseHandshakeHandler;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.ProxyProtocolDetectionHandler;
|
||||
import org.whispersystems.textsecuregcm.storage.ClientPublicKeysManager;
|
||||
|
||||
@@ -50,18 +51,20 @@ public class NoiseDirectTunnelServer implements Managed {
|
||||
protected void initChannel(SocketChannel socketChannel) {
|
||||
socketChannel.pipeline()
|
||||
.addLast(new ProxyProtocolDetectionHandler())
|
||||
.addLast(new HAProxyMessageHandler());
|
||||
|
||||
socketChannel.pipeline()
|
||||
.addLast(new HAProxyMessageHandler())
|
||||
// frame byte followed by a 2-byte length field
|
||||
.addLast(new LengthFieldBasedFrameDecoder(Noise.MAX_PACKET_LEN, 1, 2))
|
||||
// Parses NoiseDirectFrames from wire bytes and vice versa
|
||||
.addLast(new NoiseDirectFrameCodec())
|
||||
// Terminate the connection if the client sends us a close frame
|
||||
.addLast(new NoiseDirectInboundCloseHandler())
|
||||
// Turn generic OutboundCloseErrorMessages into noise direct error frames
|
||||
.addLast(new NoiseDirectOutboundErrorHandler())
|
||||
// Waits for the handshake to finish and then replaces itself with a NoiseDirectFrameCodec and a
|
||||
// NoiseHandler to handle noise encryption/decryption
|
||||
.addLast(new NoiseDirectHandshakeSelector(clientPublicKeysManager, ecKeyPair))
|
||||
// Forwards the first payload supplemented with handshake metadata, and then replaces itself with a
|
||||
// NoiseDirectDataFrameCodec to handle subsequent data frames
|
||||
.addLast(new NoiseDirectHandshakeSelector())
|
||||
// Performs the noise handshake and then replace itself with a NoiseHandler
|
||||
.addLast(new NoiseHandshakeHandler(clientPublicKeysManager, ecKeyPair))
|
||||
// This handler will open a local connection to the appropriate gRPC server and install a ProxyHandler
|
||||
// once the Noise handshake has completed
|
||||
.addLast(new EstablishLocalGrpcConnectionHandler(
|
||||
|
||||
@@ -4,8 +4,7 @@ import io.netty.handler.codec.http.websocketx.WebSocketCloseStatus;
|
||||
|
||||
enum ApplicationWebSocketCloseReason {
|
||||
NOISE_HANDSHAKE_ERROR(4001),
|
||||
CLIENT_AUTHENTICATION_ERROR(4002),
|
||||
NOISE_ENCRYPTION_ERROR(4003);
|
||||
NOISE_ENCRYPTION_ERROR(4002);
|
||||
|
||||
private final int statusCode;
|
||||
|
||||
|
||||
@@ -108,9 +108,12 @@ public class NoiseWebSocketTunnelServer implements Managed {
|
||||
.addLast(new WebSocketOutboundErrorHandler())
|
||||
.addLast(new RejectUnsupportedMessagesHandler())
|
||||
.addLast(new WebsocketPayloadCodec())
|
||||
// The WebSocket handshake complete listener will replace itself with an appropriate Noise handshake handler once
|
||||
// a WebSocket handshake has been completed
|
||||
.addLast(new WebsocketHandshakeCompleteHandler(clientPublicKeysManager, ecKeyPair, recognizedProxySecret))
|
||||
// The WebSocket handshake complete listener will forward the first payload supplemented with
|
||||
// data from the websocket handshake completion event, and then remove itself from the pipeline
|
||||
.addLast(new WebsocketHandshakeCompleteHandler(recognizedProxySecret))
|
||||
// The NoiseHandshakeHandler will perform the noise handshake and then replace itself with a
|
||||
// NoiseHandler
|
||||
.addLast(new NoiseHandshakeHandler(clientPublicKeysManager, ecKeyPair))
|
||||
// This handler will open a local connection to the appropriate gRPC server and install a ProxyHandler
|
||||
// once the Noise handshake has completed
|
||||
.addLast(new EstablishLocalGrpcConnectionHandler(grpcClientConnectionManager, authenticatedGrpcServerAddress, anonymousGrpcServerAddress))
|
||||
|
||||
@@ -7,14 +7,9 @@ import io.netty.channel.ChannelPromise;
|
||||
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;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.ClientAuthenticationException;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.NoiseException;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.NoiseHandshakeException;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.OutboundCloseErrorMessage;
|
||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
|
||||
/**
|
||||
* Converts {@link OutboundCloseErrorMessage}s written to the pipeline into WebSocket close frames
|
||||
@@ -46,7 +41,6 @@ class WebSocketOutboundErrorHandler extends ChannelDuplexHandler {
|
||||
case SERVER_CLOSED -> WebSocketCloseStatus.SERVICE_RESTART.code();
|
||||
case NOISE_ERROR -> ApplicationWebSocketCloseReason.NOISE_ENCRYPTION_ERROR.getStatusCode();
|
||||
case NOISE_HANDSHAKE_ERROR -> ApplicationWebSocketCloseReason.NOISE_HANDSHAKE_ERROR.getStatusCode();
|
||||
case AUTHENTICATION_ERROR -> ApplicationWebSocketCloseReason.CLIENT_AUTHENTICATION_ERROR.getStatusCode();
|
||||
case INTERNAL_SERVER_ERROR -> WebSocketCloseStatus.INTERNAL_SERVER_ERROR.code();
|
||||
};
|
||||
ctx.write(new CloseWebSocketFrame(new WebSocketCloseStatus(status, err.message())), promise)
|
||||
|
||||
@@ -2,14 +2,14 @@ package org.whispersystems.textsecuregcm.grpc.net.websocket;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.common.net.InetAddresses;
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.channel.ChannelFutureListener;
|
||||
import io.netty.channel.ChannelHandler;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.channel.ChannelInboundHandlerAdapter;
|
||||
import io.netty.handler.codec.http.HttpHeaderNames;
|
||||
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 io.netty.util.ReferenceCountUtil;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -17,13 +17,10 @@ import java.security.MessageDigest;
|
||||
import java.util.Optional;
|
||||
import javax.annotation.Nullable;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.GrpcClientConnectionManager;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.NoiseAnonymousHandler;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.NoiseAuthenticatedHandler;
|
||||
import org.whispersystems.textsecuregcm.storage.ClientPublicKeysManager;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.HandshakePattern;
|
||||
import org.whispersystems.textsecuregcm.grpc.net.NoiseHandshakeInit;
|
||||
|
||||
/**
|
||||
* A WebSocket handshake handler waits for a WebSocket handshake to complete, then replaces itself with the appropriate
|
||||
@@ -31,10 +28,6 @@ import org.whispersystems.textsecuregcm.storage.ClientPublicKeysManager;
|
||||
*/
|
||||
class WebsocketHandshakeCompleteHandler extends ChannelInboundHandlerAdapter {
|
||||
|
||||
private final ClientPublicKeysManager clientPublicKeysManager;
|
||||
|
||||
private final ECKeyPair ecKeyPair;
|
||||
|
||||
private final byte[] recognizedProxySecret;
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(WebsocketHandshakeCompleteHandler.class);
|
||||
@@ -45,12 +38,10 @@ class WebsocketHandshakeCompleteHandler extends ChannelInboundHandlerAdapter {
|
||||
@VisibleForTesting
|
||||
static final String FORWARDED_FOR_HEADER = "X-Forwarded-For";
|
||||
|
||||
WebsocketHandshakeCompleteHandler(final ClientPublicKeysManager clientPublicKeysManager,
|
||||
final ECKeyPair ecKeyPair,
|
||||
final String recognizedProxySecret) {
|
||||
private InetAddress remoteAddress = null;
|
||||
private HandshakePattern handshakePattern = null;
|
||||
|
||||
this.clientPublicKeysManager = clientPublicKeysManager;
|
||||
this.ecKeyPair = ecKeyPair;
|
||||
WebsocketHandshakeCompleteHandler(final String recognizedProxySecret) {
|
||||
|
||||
// The recognized proxy secret is an arbitrary string, and not an encoded byte sequence (i.e. a base64- or hex-
|
||||
// encoded value). We convert it into a byte array here for easier constant-time comparisons via
|
||||
@@ -61,53 +52,58 @@ class WebsocketHandshakeCompleteHandler extends ChannelInboundHandlerAdapter {
|
||||
@Override
|
||||
public void userEventTriggered(final ChannelHandlerContext context, final Object event) {
|
||||
if (event instanceof WebSocketServerProtocolHandler.HandshakeComplete handshakeCompleteEvent) {
|
||||
final InetAddress preferredRemoteAddress;
|
||||
{
|
||||
final Optional<InetAddress> maybePreferredRemoteAddress =
|
||||
getPreferredRemoteAddress(context, handshakeCompleteEvent);
|
||||
final Optional<InetAddress> maybePreferredRemoteAddress =
|
||||
getPreferredRemoteAddress(context, handshakeCompleteEvent);
|
||||
|
||||
if (maybePreferredRemoteAddress.isEmpty()) {
|
||||
context.writeAndFlush(new CloseWebSocketFrame(WebSocketCloseStatus.INTERNAL_SERVER_ERROR,
|
||||
"Could not determine remote address"))
|
||||
.addListener(ChannelFutureListener.CLOSE_ON_FAILURE);
|
||||
if (maybePreferredRemoteAddress.isEmpty()) {
|
||||
context.writeAndFlush(new CloseWebSocketFrame(WebSocketCloseStatus.INTERNAL_SERVER_ERROR,
|
||||
"Could not determine remote address"))
|
||||
.addListener(ChannelFutureListener.CLOSE_ON_FAILURE);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
preferredRemoteAddress = maybePreferredRemoteAddress.get();
|
||||
return;
|
||||
}
|
||||
|
||||
GrpcClientConnectionManager.handleHandshakeInitiated(context.channel(),
|
||||
preferredRemoteAddress,
|
||||
handshakeCompleteEvent.requestHeaders().getAsString(HttpHeaderNames.USER_AGENT),
|
||||
handshakeCompleteEvent.requestHeaders().getAsString(HttpHeaderNames.ACCEPT_LANGUAGE));
|
||||
|
||||
final ChannelHandler noiseHandshakeHandler = switch (handshakeCompleteEvent.requestUri()) {
|
||||
case NoiseWebSocketTunnelServer.AUTHENTICATED_SERVICE_PATH ->
|
||||
new NoiseAuthenticatedHandler(clientPublicKeysManager, ecKeyPair);
|
||||
|
||||
case NoiseWebSocketTunnelServer.ANONYMOUS_SERVICE_PATH ->
|
||||
new NoiseAnonymousHandler(ecKeyPair);
|
||||
|
||||
default -> {
|
||||
// The WebSocketOpeningHandshakeHandler should have caught all of these cases already; we'll consider it an
|
||||
// internal error if something slipped through.
|
||||
throw new IllegalArgumentException("Unexpected URI: " + handshakeCompleteEvent.requestUri());
|
||||
}
|
||||
remoteAddress = maybePreferredRemoteAddress.get();
|
||||
handshakePattern = switch (handshakeCompleteEvent.requestUri()) {
|
||||
case NoiseWebSocketTunnelServer.AUTHENTICATED_SERVICE_PATH -> HandshakePattern.IK;
|
||||
case NoiseWebSocketTunnelServer.ANONYMOUS_SERVICE_PATH -> HandshakePattern.NK;
|
||||
// The WebSocketOpeningHandshakeHandler should have caught all of these cases already; we'll consider it an
|
||||
// internal error if something slipped through.
|
||||
default -> throw new IllegalArgumentException("Unexpected URI: " + handshakeCompleteEvent.requestUri());
|
||||
};
|
||||
|
||||
context.pipeline().replace(WebsocketHandshakeCompleteHandler.this, null, noiseHandshakeHandler);
|
||||
}
|
||||
|
||||
context.fireUserEventTriggered(event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelRead(final ChannelHandlerContext context, final Object msg) {
|
||||
try {
|
||||
if (!(msg instanceof ByteBuf frame)) {
|
||||
throw new IllegalStateException("Unexpected msg type: " + msg.getClass());
|
||||
}
|
||||
|
||||
if (handshakePattern == null || remoteAddress == null) {
|
||||
throw new IllegalStateException("Received payload before websocket handshake complete");
|
||||
}
|
||||
|
||||
final NoiseHandshakeInit handshakeMessage =
|
||||
new NoiseHandshakeInit(remoteAddress, handshakePattern, frame);
|
||||
|
||||
context.pipeline().remove(WebsocketHandshakeCompleteHandler.class);
|
||||
context.fireChannelRead(handshakeMessage);
|
||||
} catch (Exception e) {
|
||||
ReferenceCountUtil.release(msg);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<InetAddress> getPreferredRemoteAddress(final ChannelHandlerContext context,
|
||||
final WebSocketServerProtocolHandler.HandshakeComplete handshakeCompleteEvent) {
|
||||
|
||||
final byte[] recognizedProxySecretFromHeader =
|
||||
handshakeCompleteEvent.requestHeaders().get(RECOGNIZED_PROXY_SECRET_HEADER, "")
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
final boolean trustForwardedFor = MessageDigest.isEqual(recognizedProxySecret, recognizedProxySecretFromHeader);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user