TLS transport wiring: full-session TLS over the Presents NIO stream

Wraps the entire client<->server TCP object stream in TLS, strictly opt-in via
an injected SSLContext (null = byte-for-byte the original plaintext behavior).

Connection: a ByteChannel _ioChannel indirection (defaults to the raw socket;
a TLS-wrapping channel when enabled). Selector registration, connection-pending
checks and close still use the raw SocketChannel.

Server (PresentsConnectionManager/PresentsConnection/ConnectionManager):
setSSLContext/isTlsEnabled; accepted sockets are wrapped in a non-blocking
ServerTlsChannel after init+registration; reads/writes route through the io
channel. tls-channel NeedsWrite/NeedsRead on write are treated as partial writes
(stashed in the existing per-connection overflow queue, retried each timed-select
tick — the server never needed OP_WRITE); on read, NeedsRead stays OP_READ and
NeedsWrite adds OP_WRITE so the re-dispatch pumps the handshake flush, cleared
after a clean read. processAuthedConnections carries the established io channel
from the authing connection to the running connection (init() would otherwise
revert it to the raw socket mid-session).

Client (Client/BlockingCommunicator): Client.setSSLContext; the connected
blocking socket is wrapped in a ClientTlsChannel (handshake driven transparently
in blocking mode); the Reader/Writer threads share it (one-unwrap + one-wrap, the
concurrency tls-channel allows).

DatagramChannelReader: fails closed — refuses to bind datagram ports while TLS is
enabled (we have no DTLS, so plaintext datagrams alongside TLS would be a footgun;
Bang binds zero datagram ports, so this never triggers today).
This commit is contained in:
2026-06-29 22:02:52 +12:00
parent 93a2dc35d0
commit ce4360f210
7 changed files with 233 additions and 8 deletions
@@ -9,6 +9,7 @@ import static com.threerings.NaryaLog.log;
import java.io.IOException;
import java.net.InetAddress;
import java.nio.channels.ByteChannel;
import java.nio.channels.SelectionKey;
import java.nio.channels.SocketChannel;
@@ -33,6 +34,9 @@ public abstract class Connection implements NetEventHandler
{
_cmgr = cmgr;
_channel = channel;
// by default we do plaintext I/O directly on the raw socket channel; if TLS is enabled
// a TLS-wrapping ByteChannel will be installed via setIoChannel() before any I/O happens
_ioChannel = channel;
_lastEvent = createStamp;
_connectionId = ++_lastConnectionId;
}
@@ -53,6 +57,26 @@ public abstract class Connection implements NetEventHandler
return _channel;
}
/**
* Returns the byte channel through which application data should be read and written. This is
* the raw socket channel for plaintext connections, or a TLS-wrapping channel when TLS is
* enabled. Selector registration, connection-pending checks and close still operate on the raw
* {@link #getChannel} socket channel.
*/
public ByteChannel getIoChannel ()
{
return _ioChannel;
}
/**
* Installs the byte channel through which application data is read and written. Used to wrap
* the raw socket channel with a TLS channel when TLS is enabled.
*/
public void setIoChannel (ByteChannel ioChannel)
{
_ioChannel = ioChannel;
}
/**
* Returns the address associated with this connection or null if it has no underlying socket
* channel.
@@ -163,6 +187,16 @@ public abstract class Connection implements NetEventHandler
}
log.debug("Closing channel " + this + ".");
// if we wrapped the raw socket in a TLS channel, close it first (best effort) so it can
// emit its close_notify; closing the raw channel afterward is idempotent
if (_ioChannel != null && _ioChannel != _channel) {
try {
_ioChannel.close();
} catch (IOException ioe) {
// best effort; we're tearing down anyway
}
}
_ioChannel = null;
try {
_channel.close();
} catch (IOException ioe) {
@@ -176,6 +210,10 @@ public abstract class Connection implements NetEventHandler
protected ConnectionManager _cmgr;
protected SocketChannel _channel;
/** The channel through which we do application I/O. Defaults to {@link #_channel} (plaintext);
* replaced with a TLS-wrapping channel when TLS is enabled. */
protected ByteChannel _ioChannel;
protected long _lastEvent;
protected int _connectionId;
@@ -29,6 +29,9 @@ import com.samskivert.util.Tuple;
import com.threerings.nio.SelectorIterable;
import tlschannel.NeedsReadException;
import tlschannel.NeedsWriteException;
import static com.threerings.NaryaLog.log;
/**
@@ -378,8 +381,10 @@ public abstract class ConnectionManager extends LoopingThread
return false;
}
// then write the data to the socket
int wrote = sochan.write(_outbuf);
// then write the data to the io channel (the raw socket for plaintext connections, or
// the TLS-wrapping channel when TLS is enabled); the connection-pending guard above
// still uses the raw socket channel
int wrote = conn.getIoChannel().write(_outbuf);
noteWrite(1, wrote);
// if we didn't write our entire message, deal with the leftover bytes
@@ -388,6 +393,18 @@ public abstract class ConnectionManager extends LoopingThread
pwh.handlePartialWrite(conn, _outbuf);
}
} catch (NeedsWriteException nwe) {
// the TLS layer couldn't flush all its bytes to the socket right now; treat this just
// like a partial write and stash the remainder in the overflow queue for the next tick
fully = false;
pwh.handlePartialWrite(conn, _outbuf);
} catch (NeedsReadException nre) {
// the TLS layer needs inbound bytes (handshake) before it can write; treat as a partial
// write. OP_READ is always set so the inbound data will arrive and the next tick retries
fully = false;
pwh.handlePartialWrite(conn, _outbuf);
} catch (NotYetConnectedException nyce) {
// this should be caught by isConnectionPending() but awesomely it's not
pwh.handlePartialWrite(conn, _outbuf);
@@ -537,8 +554,18 @@ public abstract class ConnectionManager extends LoopingThread
return false; // not ready to write to this connection yet
}
// write all we can of our partial buffer
int wrote = sochan.write(_partial);
// write all we can of our partial buffer through the io channel (raw socket for
// plaintext, TLS-wrapping channel when TLS is enabled)
int wrote;
try {
wrote = conn.getIoChannel().write(_partial);
} catch (NeedsWriteException nwe) {
// TLS can't flush right now; leave the partial for the next tick
return false;
} catch (NeedsReadException nre) {
// TLS needs inbound handshake bytes first; leave the partial for the next tick
return false;
}
noteWrite(0, wrote);
if (_partial.remaining() == 0) {
@@ -14,6 +14,7 @@ import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousCloseException;
import java.nio.channels.ByteChannel;
import java.nio.channels.DatagramChannel;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
@@ -22,6 +23,8 @@ import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import javax.net.ssl.SSLContext;
import com.samskivert.util.LoopingThread;
import com.samskivert.util.Queue;
import com.samskivert.util.StringUtil;
@@ -289,6 +292,17 @@ public class BlockingCommunicator extends Communicator
if (_channel != null) {
log.debug("Closing socket channel.");
// if we wrapped the socket in a TLS channel, close it first (best effort) so it can
// emit its close_notify; closing the raw channel afterward is idempotent
if (_ioChannel != null && _ioChannel != _channel) {
try {
_ioChannel.close();
} catch (IOException ioe) {
// best effort; we're tearing down anyway
}
}
_ioChannel = null;
try {
_channel.close();
} catch (IOException ioe) {
@@ -407,7 +421,9 @@ public class BlockingCommunicator extends Communicator
protected int writeMessage (ByteBuffer buf)
throws IOException
{
return _channel.write(buf);
// write through the io channel: the raw socket for plaintext, or the TLS channel when TLS
// is enabled. In blocking mode the TLS channel drives the handshake and blocks transparently
return _ioChannel.write(buf);
}
/**
@@ -502,7 +518,9 @@ public class BlockingCommunicator extends Communicator
protected boolean readFrame ()
throws IOException
{
return _fin.readFrame(_channel);
// read through the io channel: the raw socket for plaintext, or the TLS channel when TLS is
// enabled (which decrypts and drives the handshake transparently in blocking mode)
return _fin.readFrame(_ioChannel);
}
/**
@@ -664,6 +682,18 @@ public class BlockingCommunicator extends Communicator
_channel.configureBlocking(true);
_channel.socket().setKeepAlive(true);
// if a TLS context was injected, wrap the connected (blocking) socket in a client TLS
// channel; the handshake is driven transparently on the first read/write. Otherwise we
// do plaintext I/O directly on the raw socket, exactly as before. Both the Reader and
// Writer threads use this same instance: the reader only reads and the writer only
// writes, which is the one-unwrap + one-wrap concurrency that tls-channel allows.
SSLContext sslContext = _client.getSSLContext();
if (sslContext != null) {
_ioChannel = tlschannel.ClientTlsChannel.newBuilder(_channel, sslContext).build();
} else {
_ioChannel = _channel;
}
// our messages are framed (preceded by their length), so we use these helper streams
// to manage the framing
_fin = new FramedInputStream();
@@ -1008,6 +1038,11 @@ public class BlockingCommunicator extends Communicator
protected DatagramWriter _datagramWriter;
protected SocketChannel _channel;
/** The channel through which we do application I/O: the raw {@link #_channel} for plaintext, or
* a TLS-wrapping channel when TLS is enabled. Shared by the reader and writer threads. */
protected ByteChannel _ioChannel;
protected Queue<UpstreamMessage> _msgq = new Queue<UpstreamMessage>();
protected Selector _selector;
@@ -8,6 +8,8 @@ package com.threerings.presents.client;
import java.security.PublicKey;
import java.util.HashSet;
import javax.net.ssl.SSLContext;
import com.google.common.collect.Sets;
import com.samskivert.util.Interval;
@@ -220,6 +222,26 @@ public class Client
return key == null ? false : setPublicKey(SecureUtil.stringToRSAPublicKey(key));
}
/**
* Returns the {@link SSLContext} used to wrap the connection to the server in full-session TLS,
* or null if TLS is not enabled.
*/
public SSLContext getSSLContext ()
{
return _sslContext;
}
/**
* Sets the {@link SSLContext} used to wrap the entire connection to the server in TLS. When set
* (non-null), the communicator wraps its socket in a client TLS channel before authenticating;
* when null (the default) the connection remains plaintext and behaves exactly as before. TLS
* is strictly opt-in. This must be set before any call to <code>logon</code>.
*/
public void setSSLContext (SSLContext ctx)
{
_sslContext = ctx;
}
/**
* Sets if we require a secure authentication.
*/
@@ -1061,6 +1083,9 @@ public class Client
/** Our public key. */
protected PublicKey _publicKey;
/** The TLS context used to wrap our connection to the server, or null if TLS is disabled. */
protected SSLContext _sslContext;
/** Our session secret key. */
protected byte[] _secret;
@@ -42,6 +42,17 @@ public class DatagramChannelReader
public void bind()
{
// fail closed: we have no DTLS, so binding datagram ports while the TCP session is
// TLS-encrypted would silently send these datagrams in plaintext, defeating the point of
// TLS. Refuse to bind rather than create that footgun. (Bang passes zero datagram ports, so
// this guard never triggers today; it prevents a future regression.)
if (_datagramPorts.length > 0 && _conMan.isTlsEnabled()) {
log.error("Refusing to bind datagram ports while TLS is enabled: UDP datagrams are " +
"not encrypted (no DTLS) and must not run alongside the encrypted TLS " +
"session.", "ports", _datagramPorts.length);
return;
}
for (int port : _datagramPorts) {
try {
acceptDatagrams(port);
@@ -12,7 +12,9 @@ import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ByteChannel;
import java.nio.channels.DatagramChannel;
import java.nio.channels.SelectionKey;
import java.nio.channels.SocketChannel;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
@@ -30,6 +32,9 @@ import com.threerings.presents.util.DatagramSequencer;
import com.threerings.nio.conman.Connection;
import com.threerings.nio.conman.ConnectionManager;
import tlschannel.NeedsReadException;
import tlschannel.NeedsWriteException;
import static com.threerings.presents.Log.log;
/**
@@ -207,9 +212,14 @@ public class PresentsConnection extends Connection
}
}
// read frames through the io channel: the raw socket for plaintext connections, or the
// TLS-wrapping channel when TLS is enabled (which decrypts and drives the handshake)
ByteChannel ioChannel = getIoChannel();
boolean tls = (ioChannel != _channel);
// there may be more than one frame in the buffer, so we keep reading them until we run
// out of data
while (_fin.readFrame(_channel)) {
while (_fin.readFrame(ioChannel)) {
// make a note of how many bytes are in this frame (including the frame length
// bytes which aren't reported in available())
bytesIn = _fin.available() + 4;
@@ -220,6 +230,28 @@ public class PresentsConnection extends Connection
_handler.handleMessage(msg);
}
// we read all we could without the TLS layer asking us to write; if we previously set
// OP_WRITE to flush a pending handshake write, clear it again so we don't spin (OP_READ
// always stays set). No-op for plaintext connections, which never touch OP_WRITE.
if (tls && selkey != null && (selkey.interestOps() & SelectionKey.OP_WRITE) != 0) {
selkey.interestOps(selkey.interestOps() & ~SelectionKey.OP_WRITE);
}
} catch (NeedsReadException nre) {
// the TLS layer has no complete frame for us yet (or the handshake needs more inbound
// bytes); stop reading this turn. The key stays OP_READ so the next readable event
// resumes us. Return whatever whole frames we managed to read above.
return bytesIn;
} catch (NeedsWriteException nwe) {
// the TLS layer needs to write (handshake) but the socket isn't writable right now; add
// OP_WRITE interest so handleEvent is re-invoked when writable (processIncomingEvents
// dispatches on any ready key), at which point our read pump flushes the handshake write
if (selkey != null) {
selkey.interestOps(selkey.interestOps() | SelectionKey.OP_WRITE);
}
return bytesIn;
} catch (EOFException eofe) {
// close down the socket gracefully
close();
@@ -17,6 +17,8 @@ import java.nio.channels.SocketChannel;
import java.security.PrivateKey;
import javax.net.ssl.SSLContext;
import com.google.common.collect.Lists;
import com.google.inject.Inject;
import com.google.inject.Singleton;
@@ -193,6 +195,34 @@ public class PresentsConnectionManager extends ConnectionManager
return _privateKey;
}
/**
* Sets the {@link SSLContext} used to wrap accepted client connections in full-session TLS. If
* this is null (the default) connections remain plaintext and behave exactly as before; TLS is
* strictly opt-in. This must be set before the connection manager starts accepting sockets.
*/
public void setSSLContext (SSLContext ctx)
{
_sslContext = ctx;
}
/**
* Returns the {@link SSLContext} used to wrap accepted connections in TLS, or null if TLS is
* not enabled.
*/
public SSLContext getSSLContext ()
{
return _sslContext;
}
/**
* Returns true if full-session TLS is enabled on this connection manager (i.e. an
* {@link SSLContext} has been injected).
*/
public boolean isTlsEnabled ()
{
return _sslContext != null;
}
/**
* Called when a datagram message is ready to be read off its channel.
*/
@@ -475,7 +505,25 @@ public class PresentsConnectionManager extends ConnectionManager
@Override
protected void handleAcceptedSocket (SocketChannel channel)
{
handleAcceptedSocket(channel, new AuthingConnection());
AuthingConnection conn = new AuthingConnection();
// the super implementation configures the channel non-blocking, inits the connection and
// registers it for OP_READ; afterwards the connection's io channel is the raw socket
handleAcceptedSocket(channel, conn);
// if TLS is enabled, wrap the (now non-blocking) raw channel in a server-side TLS channel
// and install it as the connection's io channel before any read or write happens. This
// must happen here, after init+registration, so the AuthingConnection's very first frame is
// read through the TLS channel and the handshake is driven transparently
if (_sslContext != null && !conn.isClosed()) {
try {
tlschannel.ServerTlsChannel tlsChannel =
tlschannel.ServerTlsChannel.newBuilder(channel, _sslContext).build();
conn.setIoChannel(tlsChannel);
} catch (Exception e) {
log.warning("Failed to wrap accepted socket in TLS", "channel", channel, e);
conn.close();
}
}
}
/**
@@ -493,6 +541,12 @@ public class PresentsConnectionManager extends ConnectionManager
rconn.init(this, conn.getChannel(), iterStamp);
rconn.selkey = conn.selkey;
// carry over the io channel from the authing connection: if TLS is enabled this is
// the TLS channel with an established session, and the running connection must keep
// using it rather than reverting to the raw (plaintext) socket that init() defaults
// to. For plaintext connections this is just the raw socket channel (a no-op).
rconn.setIoChannel(conn.getIoChannel());
// we need to keep using the same object input and output streams from the
// beginning of the session because they have context that needs to be preserved
rconn.inheritStreams(conn);
@@ -607,6 +661,9 @@ public class PresentsConnectionManager extends ConnectionManager
protected List<ChainedAuthenticator> _authors = Lists.newArrayList();
protected PrivateKey _privateKey;
/** The TLS context used to wrap accepted connections, or null if TLS is disabled. */
protected SSLContext _sslContext;
protected Queue<AuthingConnection> _authq = Queue.newQueue();
protected Queue<Tuple<Connection, InetSocketAddress>> _connectq = Queue.newQueue();