From 93a2dc35d0a2329c42904a23d41c9598951de575 Mon Sep 17 00:00:00 2001 From: Tim Claridge Date: Mon, 29 Jun 2026 21:52:48 +1200 Subject: [PATCH 1/4] TLS foundation: tls-channel dep + TlsContextFactory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the marianobarrios:tls-channel:1.0.0 dependency (thin ByteChannel-over- SSLEngine wrapper, MIT, zero transitive deps, Java 8 bytecode) and a TlsContextFactory that builds server (keystore) and client (pinned truststore) SSLContexts, TLS 1.3 only. No transport wiring yet — this just compiles in the building blocks for full-session encryption of the Presents NIO stream. --- core/pom.xml | 8 ++ .../presents/net/TlsContextFactory.java | 107 ++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 core/src/main/java/com/threerings/presents/net/TlsContextFactory.java diff --git a/core/pom.xml b/core/pom.xml index 59f7655fd..fa0428cb0 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -23,6 +23,14 @@ guice 5.1.0 + + + com.github.marianobarrios + tls-channel + 1.0.0 + diff --git a/core/src/main/java/com/threerings/presents/net/TlsContextFactory.java b/core/src/main/java/com/threerings/presents/net/TlsContextFactory.java new file mode 100644 index 000000000..84d3a310f --- /dev/null +++ b/core/src/main/java/com/threerings/presents/net/TlsContextFactory.java @@ -0,0 +1,107 @@ +// +// $Id$ +// +// Narya library - tools for developing networked games +// Copyright (C) 2002-2012 Three Rings Design, Inc., All Rights Reserved +// https://github.com/threerings/narya/blob/master/LICENSE + +package com.threerings.presents.net; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.GeneralSecurityException; +import java.security.KeyStore; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; + +import static com.threerings.presents.Log.log; + +/** + * Builds the {@link SSLContext}s used to give the Presents NIO transport full-session TLS. + * + *

The server side loads a keystore holding its certificate + private key. The client side + * loads a truststore holding the certificate(s) it is willing to trust (for a self-hosted / + * community game with no public CA, this is the pinned server certificate). Both default to + * TLS 1.3 only. + * + *

This is deliberately a thin wrapper over standard JSSE: the actual TLS is driven by an + * {@code SSLEngine} (wrapped by the tls-channel library) in the connection manager and the + * client communicator. Nothing here rolls its own crypto. + */ +public class TlsContextFactory +{ + /** The TLS protocol we negotiate. TLS 1.3 gives us forward secrecy and authenticated + * encryption with none of the legacy-cipher / IV footguns. */ + public static final String PROTOCOL = "TLSv1.3"; + + /** + * Builds a server {@link SSLContext} from a keystore containing the server certificate and its + * private key. + * + * @param keystorePath path to a PKCS12 (or JKS) keystore. + * @param storePassword password protecting the keystore (and assumed to also protect the key + * entry; standard for a single-entry server keystore). + */ + public static SSLContext serverContext (String keystorePath, char[] storePassword) + throws GeneralSecurityException, IOException + { + KeyStore ks = loadStore(keystorePath, storePassword); + KeyManagerFactory kmf = KeyManagerFactory.getInstance( + KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(ks, storePassword); + + SSLContext ctx = SSLContext.getInstance(PROTOCOL); + ctx.init(kmf.getKeyManagers(), null, null); + log.info("Initialized server TLS context", "protocol", PROTOCOL, "keystore", keystorePath); + return ctx; + } + + /** + * Builds a client {@link SSLContext} that trusts exactly the certificate(s) in the supplied + * truststore. For our self-signed deployment this is effectively certificate pinning: the + * client trusts only the server cert we shipped it, not the public CA roots. + * + * @param truststorePath path to a PKCS12 (or JKS) truststore. + * @param storePassword password protecting the truststore. + */ + public static SSLContext clientContext (String truststorePath, char[] storePassword) + throws GeneralSecurityException, IOException + { + KeyStore ts = loadStore(truststorePath, storePassword); + TrustManagerFactory tmf = TrustManagerFactory.getInstance( + TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(ts); + + SSLContext ctx = SSLContext.getInstance(PROTOCOL); + ctx.init(null, tmf.getTrustManagers(), null); + log.info("Initialized client TLS context", "protocol", PROTOCOL, "truststore", + truststorePath); + return ctx; + } + + /** + * Loads a keystore/truststore, inferring the type (PKCS12 vs JKS) from the file extension and + * falling back to the platform default. + */ + protected static KeyStore loadStore (String path, char[] password) + throws GeneralSecurityException, IOException + { + Path p = Paths.get(path); + if (!Files.isReadable(p)) { + throw new IOException("TLS keystore not found or unreadable: " + path); + } + String type = path.endsWith(".jks") ? "JKS" : + (path.endsWith(".p12") || path.endsWith(".pfx") || path.endsWith(".pkcs12")) ? + "PKCS12" : KeyStore.getDefaultType(); + KeyStore ks = KeyStore.getInstance(type); + try (InputStream in = Files.newInputStream(p)) { + ks.load(in, password); + } + return ks; + } +} From ce4360f21069fe744d19c3029f3f81b2ff028551 Mon Sep 17 00:00:00 2001 From: Tim Claridge Date: Mon, 29 Jun 2026 22:02:52 +1200 Subject: [PATCH 2/4] TLS transport wiring: full-session TLS over the Presents NIO stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../com/threerings/nio/conman/Connection.java | 38 ++++++++++++ .../nio/conman/ConnectionManager.java | 35 +++++++++-- .../presents/client/BlockingCommunicator.java | 39 +++++++++++- .../threerings/presents/client/Client.java | 25 ++++++++ .../server/net/DatagramChannelReader.java | 11 ++++ .../server/net/PresentsConnection.java | 34 ++++++++++- .../server/net/PresentsConnectionManager.java | 59 ++++++++++++++++++- 7 files changed, 233 insertions(+), 8 deletions(-) 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(); From a942d5d3dc2c1616d2c2419bf0443a9da897bc4c Mon Sep 17 00:00:00 2001 From: Tim Claridge Date: Mon, 29 Jun 2026 22:16:57 +1200 Subject: [PATCH 3/4] TLS for inter-node peer connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the cluster's client port is TLS-enabled, peer connections (which connect to that same port) must also speak TLS. PeerManager gains setPeerClientContext/ getPeerClientContext, and PeerNode installs that context on its client before logon. Null (the default) leaves peering plaintext — a no-op, so non-TLS clusters are unchanged. --- .../presents/peer/server/PeerManager.java | 24 +++++++++++++++++++ .../presents/peer/server/PeerNode.java | 3 +++ 2 files changed, 27 insertions(+) diff --git a/core/src/main/java/com/threerings/presents/peer/server/PeerManager.java b/core/src/main/java/com/threerings/presents/peer/server/PeerManager.java index 5b6cc00ec..b8c99b63f 100644 --- a/core/src/main/java/com/threerings/presents/peer/server/PeerManager.java +++ b/core/src/main/java/com/threerings/presents/peer/server/PeerManager.java @@ -1319,6 +1319,27 @@ public abstract class PeerManager return PeerNode.class; } + /** + * Sets the {@link SSLContext} that {@link PeerNode}s use to wrap their connections to other + * nodes in TLS. When the cluster's client port is TLS-enabled, peer connections (which connect + * to that same port) must also speak TLS; this is the client-side (pinned-truststore) context + * the peer node installs on its client before logon. Null (the default) leaves peer connections + * plaintext. Set this before peers are discovered/connected. + */ + public void setPeerClientContext (javax.net.ssl.SSLContext ctx) + { + _peerClientContext = ctx; + } + + /** + * Returns the {@link SSLContext} peer nodes use to wrap connections to other nodes, or null if + * peer connections are plaintext. + */ + public javax.net.ssl.SSLContext getPeerClientContext () + { + return _peerClientContext; + } + /** * Creates credentials that a {@link PeerNode} can use to authenticate with another node. */ @@ -1812,6 +1833,9 @@ public abstract class PeerManager protected String _nodeName; protected String _sharedSecret; protected NodeRecord _self; + + /** TLS context peer nodes use when connecting to other nodes, or null for plaintext peering. */ + protected javax.net.ssl.SSLContext _peerClientContext; protected NodeObject _nodeobj; protected String _nodeNamespace; protected Map _peers = Maps.newHashMap(); diff --git a/core/src/main/java/com/threerings/presents/peer/server/PeerNode.java b/core/src/main/java/com/threerings/presents/peer/server/PeerNode.java index 118574891..d54bd236d 100644 --- a/core/src/main/java/com/threerings/presents/peer/server/PeerNode.java +++ b/core/src/main/java/com/threerings/presents/peer/server/PeerNode.java @@ -140,6 +140,9 @@ public class PeerNode // otherwise configure our client with the right bits and logon _client.setCredentials(_peermgr.createCreds()); + // if the cluster's client port is TLS-enabled, peer connections must speak TLS too; + // null (plaintext peering) is a no-op (see PeerManager.setPeerClientContext) + _client.setSSLContext(_peermgr.getPeerClientContext()); _client.setServer(hostName, new int[] { _record.port }); _client.logon(); _lastConnectStamp = System.currentTimeMillis(); From 3266a9c163ff0052fcaa8eb6f774a33652738985 Mon Sep 17 00:00:00 2001 From: Tim Claridge Date: Mon, 29 Jun 2026 22:47:16 +1200 Subject: [PATCH 4/4] Bump narya to 1.20 for the Bang TLS work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A clean minor-version bump (retiring the Epic 2 -bangN patched-fork suffix — that scheme is superseded; we now own these forks outright and version them normally). GitHub Packages won't let us re-publish 1.19, and 1.20 makes the TLS-capable build obvious in dependency listings. --- core/pom.xml | 2 +- pom.xml | 2 +- tools/pom.xml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/core/pom.xml b/core/pom.xml index fa0428cb0..ef588ddfc 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -4,7 +4,7 @@ com.threerings narya-parent - 1.19 + 1.20 narya diff --git a/pom.xml b/pom.xml index 71c123d62..a81c4cb73 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ com.threerings narya-parent pom - 1.19 + 1.20 Narya Parent Facilities for making networked multiplayer games. diff --git a/tools/pom.xml b/tools/pom.xml index 4bcdcd4ee..cf8b22459 100644 --- a/tools/pom.xml +++ b/tools/pom.xml @@ -4,7 +4,7 @@ com.threerings narya-parent - 1.19 + 1.20 narya-tools