diff --git a/core/src/main/java/com/threerings/nio/conman/Connection.java b/core/src/main/java/com/threerings/nio/conman/Connection.java index 6979ebc0c..c032bcb83 100644 --- a/core/src/main/java/com/threerings/nio/conman/Connection.java +++ b/core/src/main/java/com/threerings/nio/conman/Connection.java @@ -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; diff --git a/core/src/main/java/com/threerings/nio/conman/ConnectionManager.java b/core/src/main/java/com/threerings/nio/conman/ConnectionManager.java index 171eb656e..cf52739e8 100644 --- a/core/src/main/java/com/threerings/nio/conman/ConnectionManager.java +++ b/core/src/main/java/com/threerings/nio/conman/ConnectionManager.java @@ -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) { diff --git a/core/src/main/java/com/threerings/presents/client/BlockingCommunicator.java b/core/src/main/java/com/threerings/presents/client/BlockingCommunicator.java index 34921d4c2..03b667340 100644 --- a/core/src/main/java/com/threerings/presents/client/BlockingCommunicator.java +++ b/core/src/main/java/com/threerings/presents/client/BlockingCommunicator.java @@ -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 _msgq = new Queue(); protected Selector _selector; diff --git a/core/src/main/java/com/threerings/presents/client/Client.java b/core/src/main/java/com/threerings/presents/client/Client.java index 52e99e3c5..6183d7fef 100644 --- a/core/src/main/java/com/threerings/presents/client/Client.java +++ b/core/src/main/java/com/threerings/presents/client/Client.java @@ -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 logon. + */ + 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; diff --git a/core/src/main/java/com/threerings/presents/server/net/DatagramChannelReader.java b/core/src/main/java/com/threerings/presents/server/net/DatagramChannelReader.java index 40a594965..190f423db 100644 --- a/core/src/main/java/com/threerings/presents/server/net/DatagramChannelReader.java +++ b/core/src/main/java/com/threerings/presents/server/net/DatagramChannelReader.java @@ -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); diff --git a/core/src/main/java/com/threerings/presents/server/net/PresentsConnection.java b/core/src/main/java/com/threerings/presents/server/net/PresentsConnection.java index bac340e91..0cd3426f1 100644 --- a/core/src/main/java/com/threerings/presents/server/net/PresentsConnection.java +++ b/core/src/main/java/com/threerings/presents/server/net/PresentsConnection.java @@ -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(); diff --git a/core/src/main/java/com/threerings/presents/server/net/PresentsConnectionManager.java b/core/src/main/java/com/threerings/presents/server/net/PresentsConnectionManager.java index 5ad0cfc5d..f2813118f 100644 --- a/core/src/main/java/com/threerings/presents/server/net/PresentsConnectionManager.java +++ b/core/src/main/java/com/threerings/presents/server/net/PresentsConnectionManager.java @@ -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 _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 _authq = Queue.newQueue(); protected Queue> _connectq = Queue.newQueue();