mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-20 09:47:58 +01:00
Replace XX/NX handshakes with IK/NK
This commit is contained in:
committed by
ravi-signal
parent
c835d85256
commit
542422b7b8
@@ -933,7 +933,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
clientConnectionManager,
|
||||
clientPublicKeysManager,
|
||||
config.getNoiseWebSocketTunnelConfiguration().noiseStaticKeyPair(),
|
||||
config.getNoiseWebSocketTunnelConfiguration().noiseRootPublicKeySignature(),
|
||||
authenticatedGrpcServerAddress,
|
||||
anonymousGrpcServerAddress,
|
||||
config.getNoiseWebSocketTunnelConfiguration().recognizedProxySecret().value());
|
||||
|
||||
@@ -15,7 +15,6 @@ public record NoiseWebSocketTunnelConfiguration(@Positive int port,
|
||||
@Nullable String tlsKeyStoreEntryAlias,
|
||||
@Nullable SecretString tlsKeyStorePassword,
|
||||
@NotNull SecretBytes noiseStaticPrivateKey,
|
||||
@NotNull byte[] noiseRootPublicKeySignature,
|
||||
@NotNull SecretString recognizedProxySecret) {
|
||||
|
||||
public ECKeyPair noiseStaticKeyPair() throws InvalidKeyException {
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
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 <a href="https://noiseprotocol.org/noise.html">The Noise Protocol Framework</a>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
|
||||
import javax.crypto.BadPaddingException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
|
||||
/**
|
||||
* An error handler serves as a general backstop for exceptions elsewhere in the pipeline. If the client has completed a
|
||||
@@ -38,7 +39,7 @@ class ErrorHandler extends ChannelInboundHandlerAdapter {
|
||||
@Override
|
||||
public void exceptionCaught(final ChannelHandlerContext context, final Throwable cause) {
|
||||
if (websocketHandshakeComplete) {
|
||||
final WebSocketCloseStatus webSocketCloseStatus = switch (cause) {
|
||||
final WebSocketCloseStatus webSocketCloseStatus = switch (ExceptionUtils.unwrap(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");
|
||||
@@ -51,6 +52,7 @@ class ErrorHandler extends ChannelInboundHandlerAdapter {
|
||||
context.writeAndFlush(new CloseWebSocketFrame(webSocketCloseStatus))
|
||||
.addListener(ChannelFutureListener.CLOSE_ON_FAILURE);
|
||||
} else {
|
||||
log.debug("Error occurred before websocket handshake complete", cause);
|
||||
// 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();
|
||||
|
||||
@@ -48,12 +48,12 @@ class EstablishLocalGrpcConnectionHandler extends ChannelInboundHandlerAdapter {
|
||||
|
||||
@Override
|
||||
public void userEventTriggered(final ChannelHandlerContext remoteChannelContext, final Object event) {
|
||||
if (event instanceof NoiseHandshakeCompleteEvent noiseHandshakeCompleteEvent) {
|
||||
if (event instanceof NoiseIdentityDeterminedEvent noiseIdentityDeterminedEvent) {
|
||||
// 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
|
||||
// authenticated service.
|
||||
final LocalAddress grpcServerAddress = noiseHandshakeCompleteEvent.authenticatedDevice().isPresent()
|
||||
final LocalAddress grpcServerAddress = noiseIdentityDeterminedEvent.authenticatedDevice().isPresent()
|
||||
? authenticatedGrpcServerAddress
|
||||
: anonymousGrpcServerAddress;
|
||||
|
||||
@@ -72,7 +72,7 @@ class EstablishLocalGrpcConnectionHandler extends ChannelInboundHandlerAdapter {
|
||||
if (localChannelFuture.isSuccess()) {
|
||||
clientConnectionManager.handleConnectionEstablished((LocalChannel) localChannelFuture.channel(),
|
||||
remoteChannelContext.channel(),
|
||||
noiseHandshakeCompleteEvent.authenticatedDevice());
|
||||
noiseIdentityDeterminedEvent.authenticatedDevice());
|
||||
|
||||
// Close the local connection if the remote channel closes and vice versa
|
||||
remoteChannelContext.channel().closeFuture().addListener(closeFuture -> localChannelFuture.channel().close());
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.grpc.net;
|
||||
|
||||
enum HandshakePattern {
|
||||
NK("Noise_NK_25519_ChaChaPoly_BLAKE2b"),
|
||||
IK("Noise_IK_25519_ChaChaPoly_BLAKE2b");
|
||||
|
||||
private final String protocol;
|
||||
|
||||
public String protocol() {
|
||||
return protocol;
|
||||
}
|
||||
|
||||
|
||||
HandshakePattern(String protocol) {
|
||||
this.protocol = protocol;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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.
|
||||
*/
|
||||
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()
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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}.
|
||||
*/
|
||||
class NoiseAuthenticatedHandler extends NoiseHandler {
|
||||
|
||||
private final ClientPublicKeysManager clientPublicKeysManager;
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.grpc.net;
|
||||
|
||||
import com.southernstorm.noise.protocol.CipherState;
|
||||
import com.southernstorm.noise.protocol.CipherStatePair;
|
||||
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.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.util.ExceptionUtils;
|
||||
|
||||
/**
|
||||
* A bidirectional {@link io.netty.channel.ChannelHandler} that establishes a noise session with an initiator, decrypts
|
||||
* inbound messages, and encrypts outbound messages
|
||||
*/
|
||||
abstract class NoiseHandler extends ChannelDuplexHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(NoiseHandler.class);
|
||||
|
||||
private enum State {
|
||||
// Waiting for handshake to complete
|
||||
HANDSHAKE,
|
||||
// Can freely exchange encrypted noise messages on an established session
|
||||
TRANSPORT,
|
||||
// Finished with error
|
||||
ERROR
|
||||
}
|
||||
|
||||
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 BinaryWebSocketFrame frame) {
|
||||
// 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.content()));
|
||||
} else {
|
||||
// Anything except binary WebSocket frames 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()));
|
||||
|
||||
// Now that we've authenticated, write the handshake response
|
||||
byte[] handshakeMessage = handshakeHelper.write(EmptyArrays.EMPTY_BYTES);
|
||||
context.writeAndFlush(new BinaryWebSocketFrame(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);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(final ChannelHandlerContext context, final Object message, final ChannelPromise promise)
|
||||
throws Exception {
|
||||
if (message instanceof ByteBuf plaintext) {
|
||||
try {
|
||||
// TODO Buffer/consolidate Noise writes to avoid sending a bazillion tiny (or empty) frames
|
||||
final CipherState cipherState = cipherStatePair.getSender();
|
||||
final int plaintextLength = plaintext.readableBytes();
|
||||
|
||||
// We've read these bytes from a local connection; although that likely means they're backed by a heap array, the
|
||||
// buffer is read-only and won't grant us access to the underlying array. Instead, we need to copy the bytes to a
|
||||
// mutable array. We also want to encrypt in place, so we allocate enough extra space for the trailing MAC.
|
||||
final byte[] noiseBuffer = new byte[plaintext.readableBytes() + cipherState.getMACLength()];
|
||||
plaintext.readBytes(noiseBuffer, 0, plaintext.readableBytes());
|
||||
|
||||
// Overwrite the plaintext with the ciphertext to avoid an extra allocation for a dedicated ciphertext buffer
|
||||
cipherState.encryptWithAd(null, noiseBuffer, 0, noiseBuffer, 0, plaintextLength);
|
||||
|
||||
context.write(new BinaryWebSocketFrame(Unpooled.wrappedBuffer(noiseBuffer)), promise);
|
||||
|
||||
} finally {
|
||||
ReferenceCountUtil.release(plaintext);
|
||||
}
|
||||
} else {
|
||||
if (!(message instanceof WebSocketFrame)) {
|
||||
// Downstream handlers may write WebSocket frames that don't need to be encrypted (e.g. "close" frames that
|
||||
// get issued in response to exceptions)
|
||||
log.warn("Unexpected object in pipeline: {}", message);
|
||||
}
|
||||
context.write(message, promise);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package org.whispersystems.textsecuregcm.grpc.net;
|
||||
|
||||
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* An event that indicates that a Noise handshake has completed, possibly authenticating a caller in the process.
|
||||
*
|
||||
* @param authenticatedDevice the device authenticated as part of the handshake, or empty if the handshake was not of a
|
||||
* type that performs authentication
|
||||
*/
|
||||
record NoiseHandshakeCompleteEvent(Optional<AuthenticatedDevice> authenticatedDevice) {
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.grpc.net;
|
||||
|
||||
import com.southernstorm.noise.protocol.HandshakeState;
|
||||
import com.southernstorm.noise.protocol.Noise;
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Optional;
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.ShortBufferException;
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair;
|
||||
|
||||
/**
|
||||
* Helper for the responder of a 2-message handshake with a pre-shared responder static key
|
||||
*/
|
||||
class NoiseHandshakeHelper {
|
||||
|
||||
private final static int AEAD_TAG_LENGTH = 16;
|
||||
private final static int KEY_LENGTH = 32;
|
||||
|
||||
private final HandshakePattern handshakePattern;
|
||||
private final ECKeyPair serverStaticKeyPair;
|
||||
private final HandshakeState handshakeState;
|
||||
|
||||
NoiseHandshakeHelper(HandshakePattern handshakePattern, ECKeyPair serverStaticKeyPair) {
|
||||
this.handshakePattern = handshakePattern;
|
||||
this.serverStaticKeyPair = serverStaticKeyPair;
|
||||
try {
|
||||
this.handshakeState = new HandshakeState(handshakePattern.protocol(), HandshakeState.RESPONDER);
|
||||
} catch (final NoSuchAlgorithmException e) {
|
||||
throw new AssertionError("Unsupported Noise algorithm: " + handshakePattern.protocol(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the length of the initiator's keys
|
||||
*
|
||||
* @return length of the handshake message sent by the remote party (the initiator) not including the payload
|
||||
*/
|
||||
private int initiatorHandshakeMessageKeyLength() {
|
||||
return switch (handshakePattern) {
|
||||
// ephemeral key, static key (encrypted), AEAD tag for static key
|
||||
case IK -> KEY_LENGTH + KEY_LENGTH + AEAD_TAG_LENGTH;
|
||||
// ephemeral key only
|
||||
case NK -> KEY_LENGTH;
|
||||
};
|
||||
}
|
||||
|
||||
HandshakeState getHandshakeState() {
|
||||
return this.handshakeState;
|
||||
}
|
||||
|
||||
ByteBuf read(byte[] remoteHandshakeMessage) throws NoiseHandshakeException {
|
||||
if (handshakeState.getAction() != HandshakeState.NO_ACTION) {
|
||||
throw new NoiseHandshakeException("Cannot send more data before handshake is complete");
|
||||
}
|
||||
|
||||
// Length for an empty payload
|
||||
final int minMessageLength = initiatorHandshakeMessageKeyLength() + AEAD_TAG_LENGTH;
|
||||
if (remoteHandshakeMessage.length < minMessageLength || remoteHandshakeMessage.length > Noise.MAX_PACKET_LEN) {
|
||||
throw new NoiseHandshakeException("Unexpected ephemeral key message length");
|
||||
}
|
||||
|
||||
final int payloadLength = remoteHandshakeMessage.length - initiatorHandshakeMessageKeyLength() - AEAD_TAG_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(serverStaticKeyPair.getPrivateKey().serialize(), 0);
|
||||
handshakeState.start();
|
||||
|
||||
int payloadBytesRead;
|
||||
|
||||
try {
|
||||
payloadBytesRead = handshakeState.readMessage(remoteHandshakeMessage, 0, remoteHandshakeMessage.length,
|
||||
remoteHandshakeMessage, 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) {
|
||||
// We aren't using padding but may get this error if the AEAD tag does not match the encrypted client static key
|
||||
// or payload
|
||||
throw new NoiseHandshakeException("Invalid keys or payload");
|
||||
}
|
||||
if (payloadBytesRead != payloadLength) {
|
||||
throw new NoiseHandshakeException(
|
||||
"Unexpected payload length, required " + payloadLength + " but got " + payloadBytesRead);
|
||||
}
|
||||
return Unpooled.wrappedBuffer(remoteHandshakeMessage, 0, payloadBytesRead);
|
||||
}
|
||||
|
||||
byte[] write(byte[] payload) {
|
||||
if (handshakeState.getAction() != HandshakeState.WRITE_MESSAGE) {
|
||||
throw new IllegalStateException("Cannot send data before handshake is complete");
|
||||
}
|
||||
|
||||
// Currently only support handshake patterns where the server static key is known
|
||||
// Send our ephemeral key and the response to the initiator with the encrypted payload
|
||||
final byte[] response = new byte[KEY_LENGTH + payload.length + AEAD_TAG_LENGTH];
|
||||
try {
|
||||
int written = handshakeState.writeMessage(response, 0, payload, 0, payload.length);
|
||||
if (written != response.length) {
|
||||
throw new IllegalStateException("Unexpected handshake response length");
|
||||
}
|
||||
return response;
|
||||
} catch (final ShortBufferException e) {
|
||||
// This should never happen for messages of known length that we control
|
||||
throw new IllegalStateException("Key material buffer was too short for message", e);
|
||||
}
|
||||
}
|
||||
|
||||
Optional<byte[]> remotePublicKey() {
|
||||
return Optional.ofNullable(handshakeState.getRemotePublicKey()).map(dhstate -> {
|
||||
final byte[] publicKeyFromClient = new byte[handshakeState.getRemotePublicKey().getPublicKeyLength()];
|
||||
handshakeState.getRemotePublicKey().getPublicKey(publicKeyFromClient, 0);
|
||||
return publicKeyFromClient;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.whispersystems.textsecuregcm.grpc.net;
|
||||
|
||||
import java.util.Optional;
|
||||
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
|
||||
|
||||
/**
|
||||
* An event that indicates that an identity of a noise handshake initiator has been determined. If the initiator is
|
||||
* connecting anonymously, the identity is empty, otherwise it will be present and already authenticated.
|
||||
*
|
||||
* @param authenticatedDevice the device authenticated as part of the handshake, or empty if the handshake was not of a
|
||||
* type that performs authentication
|
||||
*/
|
||||
record NoiseIdentityDeterminedEvent(Optional<AuthenticatedDevice> authenticatedDevice) {}
|
||||
@@ -1,40 +0,0 @@
|
||||
package org.whispersystems.textsecuregcm.grpc.net;
|
||||
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame;
|
||||
import java.util.Optional;
|
||||
import io.netty.util.ReferenceCountUtil;
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair;
|
||||
|
||||
/**
|
||||
* A Noise NX handler handles the responder side of a Noise NX handshake.
|
||||
*/
|
||||
class NoiseNXHandshakeHandler extends AbstractNoiseHandshakeHandler {
|
||||
|
||||
static final String NOISE_PROTOCOL_NAME = "Noise_NX_25519_ChaChaPoly_BLAKE2b";
|
||||
|
||||
NoiseNXHandshakeHandler(final ECKeyPair ecKeyPair, final byte[] publicKeySignature) {
|
||||
super(NOISE_PROTOCOL_NAME, ecKeyPair, publicKeySignature);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelRead(final ChannelHandlerContext context, final Object message) throws Exception {
|
||||
if (message instanceof BinaryWebSocketFrame frame) {
|
||||
try {
|
||||
handleEphemeralKeyMessage(context, frame);
|
||||
} finally {
|
||||
frame.release();
|
||||
}
|
||||
|
||||
// All we need to do is accept the client's ephemeral key and send our own static keys; after that, we can consider
|
||||
// the handshake complete
|
||||
context.fireUserEventTriggered(new NoiseHandshakeCompleteEvent(Optional.empty()));
|
||||
context.pipeline().replace(NoiseNXHandshakeHandler.this, null, new NoiseTransportHandler(getHandshakeState().split()));
|
||||
} else {
|
||||
// Anything except binary WebSocket frames should have been filtered out of the pipeline by now; treat this as an
|
||||
// error
|
||||
ReferenceCountUtil.release(message);
|
||||
throw new IllegalArgumentException("Unexpected message in pipeline: " + message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package org.whispersystems.textsecuregcm.grpc.net;
|
||||
|
||||
import com.southernstorm.noise.protocol.CipherState;
|
||||
import com.southernstorm.noise.protocol.CipherStatePair;
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.buffer.ByteBufUtil;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import io.netty.channel.ChannelDuplexHandler;
|
||||
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 javax.crypto.BadPaddingException;
|
||||
import javax.crypto.ShortBufferException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* A Noise transport handler manages a bidirectional Noise session after a handshake has completed.
|
||||
*/
|
||||
class NoiseTransportHandler extends ChannelDuplexHandler {
|
||||
|
||||
private final CipherStatePair cipherStatePair;
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(NoiseTransportHandler.class);
|
||||
|
||||
NoiseTransportHandler(CipherStatePair cipherStatePair) {
|
||||
this.cipherStatePair = cipherStatePair;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelRead(final ChannelHandlerContext context, final Object message)
|
||||
throws ShortBufferException, BadPaddingException {
|
||||
|
||||
if (message instanceof BinaryWebSocketFrame frame) {
|
||||
try {
|
||||
final CipherState cipherState = cipherStatePair.getReceiver();
|
||||
|
||||
// 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.
|
||||
final byte[] noiseBuffer = ByteBufUtil.getBytes(frame.content());
|
||||
|
||||
// Overwrite the ciphertext with the plaintext to avoid an extra allocation for a dedicated plaintext buffer
|
||||
final int plaintextLength = cipherState.decryptWithAd(null, noiseBuffer, 0, noiseBuffer, 0, noiseBuffer.length);
|
||||
|
||||
context.fireChannelRead(Unpooled.wrappedBuffer(noiseBuffer, 0, plaintextLength));
|
||||
} finally {
|
||||
frame.release();
|
||||
}
|
||||
} else {
|
||||
// Anything except binary WebSocket frames should have been filtered out of the pipeline by now; treat this as an
|
||||
// error
|
||||
ReferenceCountUtil.release(message);
|
||||
throw new IllegalArgumentException("Unexpected message in pipeline: " + message);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(final ChannelHandlerContext context, final Object message, final ChannelPromise promise) throws Exception {
|
||||
if (message instanceof ByteBuf plaintext) {
|
||||
try {
|
||||
// TODO Buffer/consolidate Noise writes to avoid sending a bazillion tiny (or empty) frames
|
||||
final CipherState cipherState = cipherStatePair.getSender();
|
||||
final int plaintextLength = plaintext.readableBytes();
|
||||
|
||||
// We've read these bytes from a local connection; although that likely means they're backed by a heap array, the
|
||||
// buffer is read-only and won't grant us access to the underlying array. Instead, we need to copy the bytes to a
|
||||
// mutable array. We also want to encrypt in place, so we allocate enough extra space for the trailing MAC.
|
||||
final byte[] noiseBuffer = new byte[plaintext.readableBytes() + cipherState.getMACLength()];
|
||||
plaintext.readBytes(noiseBuffer, 0, plaintext.readableBytes());
|
||||
|
||||
// Overwrite the plaintext with the ciphertext to avoid an extra allocation for a dedicated ciphertext buffer
|
||||
cipherState.encryptWithAd(null, noiseBuffer, 0, noiseBuffer, 0, plaintextLength);
|
||||
|
||||
context.write(new BinaryWebSocketFrame(Unpooled.wrappedBuffer(noiseBuffer)), promise);
|
||||
} finally {
|
||||
plaintext.release();
|
||||
}
|
||||
} else {
|
||||
if (!(message instanceof WebSocketFrame)) {
|
||||
// Downstream handlers may write WebSocket frames that don't need to be encrypted (e.g. "close" frames that
|
||||
// get issued in response to exceptions)
|
||||
log.warn("Unexpected object in pipeline: {}", message);
|
||||
}
|
||||
|
||||
context.write(message, promise);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handlerRemoved(final ChannelHandlerContext context) {
|
||||
cipherStatePair.destroy();
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,6 @@ public class NoiseWebSocketTunnelServer implements Managed {
|
||||
final ClientConnectionManager clientConnectionManager,
|
||||
final ClientPublicKeysManager clientPublicKeysManager,
|
||||
final ECKeyPair ecKeyPair,
|
||||
final byte[] publicKeySignature,
|
||||
final LocalAddress authenticatedGrpcServerAddress,
|
||||
final LocalAddress anonymousGrpcServerAddress,
|
||||
final String recognizedProxySecret) throws SSLException {
|
||||
@@ -107,7 +106,7 @@ public class NoiseWebSocketTunnelServer implements Managed {
|
||||
.addLast(new RejectUnsupportedMessagesHandler())
|
||||
// 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, publicKeySignature, recognizedProxySecret))
|
||||
.addLast(new WebsocketHandshakeCompleteHandler(clientPublicKeysManager, ecKeyPair, recognizedProxySecret))
|
||||
// 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(clientConnectionManager, authenticatedGrpcServerAddress, anonymousGrpcServerAddress))
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
package org.whispersystems.textsecuregcm.grpc.net;
|
||||
|
||||
import com.southernstorm.noise.protocol.HandshakeState;
|
||||
import io.netty.buffer.ByteBufUtil;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame;
|
||||
import io.netty.util.ReferenceCountUtil;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.ShortBufferException;
|
||||
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.UUIDUtil;
|
||||
|
||||
/**
|
||||
* A Noise XX handler handles the responder side of a Noise XX handshake. This implementation expects clients to send
|
||||
* identifying information (an account identifier and device ID) as an additional payload when sending its static key
|
||||
* material. It compares the static public key against the stored public key for the identified device asynchronously,
|
||||
* buffering traffic from the client until the authentication check completes.
|
||||
*/
|
||||
class NoiseXXHandshakeHandler extends AbstractNoiseHandshakeHandler {
|
||||
|
||||
private final ClientPublicKeysManager clientPublicKeysManager;
|
||||
|
||||
private AuthenticationState authenticationState = AuthenticationState.GET_EPHEMERAL_KEY;
|
||||
|
||||
private final List<BinaryWebSocketFrame> pendingInboundFrames = new ArrayList<>();
|
||||
|
||||
static final String NOISE_PROTOCOL_NAME = "Noise_XX_25519_ChaChaPoly_BLAKE2b";
|
||||
|
||||
// When the client sends its static key message, we expect:
|
||||
//
|
||||
// - A 32-byte encrypted static public key
|
||||
// - A 16-byte AEAD tag for the static key
|
||||
// - 17 bytes of identity data in the message payload (a UUID and a one-byte device ID)
|
||||
// - A 16-byte AEAD tag for the identity payload
|
||||
private static final int EXPECTED_CLIENT_STATIC_KEY_MESSAGE_LENGTH = 81;
|
||||
|
||||
private enum AuthenticationState {
|
||||
GET_EPHEMERAL_KEY,
|
||||
GET_STATIC_KEY,
|
||||
CHECK_PUBLIC_KEY,
|
||||
ERROR
|
||||
}
|
||||
|
||||
public NoiseXXHandshakeHandler(final ClientPublicKeysManager clientPublicKeysManager,
|
||||
final ECKeyPair ecKeyPair,
|
||||
final byte[] publicKeySignature) {
|
||||
|
||||
super(NOISE_PROTOCOL_NAME, ecKeyPair, publicKeySignature);
|
||||
|
||||
this.clientPublicKeysManager = clientPublicKeysManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelRead(final ChannelHandlerContext context, final Object message) throws Exception {
|
||||
if (message instanceof BinaryWebSocketFrame frame) {
|
||||
try {
|
||||
switch (authenticationState) {
|
||||
case GET_EPHEMERAL_KEY -> {
|
||||
try {
|
||||
handleEphemeralKeyMessage(context, frame);
|
||||
authenticationState = AuthenticationState.GET_STATIC_KEY;
|
||||
} finally {
|
||||
frame.release();
|
||||
}
|
||||
}
|
||||
case GET_STATIC_KEY -> {
|
||||
try {
|
||||
handleStaticKey(context, frame);
|
||||
authenticationState = AuthenticationState.CHECK_PUBLIC_KEY;
|
||||
} finally {
|
||||
frame.release();
|
||||
}
|
||||
}
|
||||
case CHECK_PUBLIC_KEY -> {
|
||||
// Buffer any inbound traffic until we've finished checking the client's public key
|
||||
pendingInboundFrames.add(frame);
|
||||
}
|
||||
case ERROR -> {
|
||||
// If authentication has failed for any reason, just discard inbound traffic until the channel closes
|
||||
frame.release();
|
||||
}
|
||||
}
|
||||
} catch (final ShortBufferException e) {
|
||||
authenticationState = AuthenticationState.ERROR;
|
||||
throw new NoiseHandshakeException("Unexpected payload length");
|
||||
} catch (final BadPaddingException e) {
|
||||
authenticationState = AuthenticationState.ERROR;
|
||||
throw new ClientAuthenticationException();
|
||||
}
|
||||
} else {
|
||||
// Anything except binary WebSocket frames should have been filtered out of the pipeline by now; treat this as an
|
||||
// error
|
||||
ReferenceCountUtil.release(message);
|
||||
throw new IllegalArgumentException("Unexpected message in pipeline: " + message);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleStaticKey(final ChannelHandlerContext context, final BinaryWebSocketFrame frame)
|
||||
throws NoiseHandshakeException, ShortBufferException, BadPaddingException {
|
||||
|
||||
if (frame.content().readableBytes() != EXPECTED_CLIENT_STATIC_KEY_MESSAGE_LENGTH) {
|
||||
throw new NoiseHandshakeException("Unexpected client static key message length");
|
||||
}
|
||||
|
||||
final HandshakeState handshakeState = getHandshakeState();
|
||||
|
||||
// The websocket frame will have come right off the wire, and so needs to be copied from a non-array-backed direct
|
||||
// buffer into a heap buffer.
|
||||
final byte[] staticKeyAndClientIdentityMessage = ByteBufUtil.getBytes(frame.content());
|
||||
|
||||
// The payload from the client should be a UUID (16 bytes) followed by a device ID (1 byte)
|
||||
final byte[] payload = new byte[17];
|
||||
|
||||
final UUID accountIdentifier;
|
||||
final byte deviceId;
|
||||
|
||||
final int payloadBytesRead = handshakeState.readMessage(staticKeyAndClientIdentityMessage,
|
||||
0, staticKeyAndClientIdentityMessage.length, payload, 0);
|
||||
|
||||
if (payloadBytesRead != 17) {
|
||||
throw new NoiseHandshakeException("Unexpected identity payload length");
|
||||
}
|
||||
|
||||
try {
|
||||
accountIdentifier = UUIDUtil.fromBytes(payload, 0);
|
||||
} catch (final IllegalArgumentException e) {
|
||||
throw new NoiseHandshakeException("Could not parse account identifier");
|
||||
}
|
||||
|
||||
deviceId = payload[16];
|
||||
|
||||
// Verify the identity of the caller by comparing the submitted static public key against the stored public key for
|
||||
// the identified device
|
||||
clientPublicKeysManager.findPublicKey(accountIdentifier, deviceId)
|
||||
.whenCompleteAsync((maybePublicKey, throwable) -> maybePublicKey.ifPresentOrElse(storedPublicKey -> {
|
||||
final byte[] publicKeyFromClient = new byte[handshakeState.getRemotePublicKey().getPublicKeyLength()];
|
||||
handshakeState.getRemotePublicKey().getPublicKey(publicKeyFromClient, 0);
|
||||
|
||||
if (MessageDigest.isEqual(publicKeyFromClient, storedPublicKey.getPublicKeyBytes())) {
|
||||
context.fireUserEventTriggered(new NoiseHandshakeCompleteEvent(
|
||||
Optional.of(new AuthenticatedDevice(accountIdentifier, deviceId))));
|
||||
|
||||
context.pipeline().addAfter(context.name(), null, new NoiseTransportHandler(handshakeState.split()));
|
||||
|
||||
// Flush any buffered reads
|
||||
pendingInboundFrames.forEach(context::fireChannelRead);
|
||||
pendingInboundFrames.clear();
|
||||
|
||||
context.pipeline().remove(NoiseXXHandshakeHandler.this);
|
||||
} else {
|
||||
// We found a key, but it doesn't match what the caller submitted
|
||||
context.fireExceptionCaught(new ClientAuthenticationException());
|
||||
authenticationState = AuthenticationState.ERROR;
|
||||
}
|
||||
},
|
||||
() -> {
|
||||
// We couldn't find a key for the identified account/device
|
||||
context.fireExceptionCaught(new ClientAuthenticationException());
|
||||
authenticationState = AuthenticationState.ERROR;
|
||||
}),
|
||||
context.executor());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handlerRemoved(final ChannelHandlerContext context) {
|
||||
super.handlerRemoved(context);
|
||||
|
||||
pendingInboundFrames.forEach(BinaryWebSocketFrame::release);
|
||||
pendingInboundFrames.clear();
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,6 @@ class WebsocketHandshakeCompleteHandler extends ChannelInboundHandlerAdapter {
|
||||
private final ClientPublicKeysManager clientPublicKeysManager;
|
||||
|
||||
private final ECKeyPair ecKeyPair;
|
||||
private final byte[] publicKeySignature;
|
||||
|
||||
private final byte[] recognizedProxySecret;
|
||||
|
||||
@@ -45,12 +44,10 @@ class WebsocketHandshakeCompleteHandler extends ChannelInboundHandlerAdapter {
|
||||
|
||||
WebsocketHandshakeCompleteHandler(final ClientPublicKeysManager clientPublicKeysManager,
|
||||
final ECKeyPair ecKeyPair,
|
||||
final byte[] publicKeySignature,
|
||||
final String recognizedProxySecret) {
|
||||
|
||||
this.clientPublicKeysManager = clientPublicKeysManager;
|
||||
this.ecKeyPair = ecKeyPair;
|
||||
this.publicKeySignature = publicKeySignature;
|
||||
|
||||
// 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
|
||||
@@ -84,10 +81,10 @@ class WebsocketHandshakeCompleteHandler extends ChannelInboundHandlerAdapter {
|
||||
|
||||
final ChannelHandler noiseHandshakeHandler = switch (handshakeCompleteEvent.requestUri()) {
|
||||
case NoiseWebSocketTunnelServer.AUTHENTICATED_SERVICE_PATH ->
|
||||
new NoiseXXHandshakeHandler(clientPublicKeysManager, ecKeyPair, publicKeySignature);
|
||||
new NoiseAuthenticatedHandler(clientPublicKeysManager, ecKeyPair);
|
||||
|
||||
case NoiseWebSocketTunnelServer.ANONYMOUS_SERVICE_PATH ->
|
||||
new NoiseNXHandshakeHandler(ecKeyPair, publicKeySignature);
|
||||
new NoiseAnonymousHandler(ecKeyPair);
|
||||
|
||||
default -> {
|
||||
// The WebSocketOpeningHandshakeHandler should have caught all of these cases already; we'll consider it an
|
||||
|
||||
Reference in New Issue
Block a user