();
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/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;
+ }
+}
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();
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();
diff --git a/pom.xml b/pom.xml
index b905b4c4d..a81c4cb73 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,7 +5,7 @@
com.threerings
narya-parent
pom
- 1.20-SNAPSHOT
+ 1.20
Narya Parent
Facilities for making networked multiplayer games.
@@ -34,7 +34,7 @@
scm:git:git://github.com/threerings/narya.git
scm:git:git@github.com:threerings/narya.git
https://github.com/threerings/narya/
- HEAD
+ narya-1.19
diff --git a/tools/pom.xml b/tools/pom.xml
index 3e3c7980c..cf8b22459 100644
--- a/tools/pom.xml
+++ b/tools/pom.xml
@@ -4,7 +4,7 @@
com.threerings
narya-parent
- 1.20-SNAPSHOT
+ 1.20
narya-tools
@@ -21,7 +21,7 @@
com.samskivert
jmustache
- 1.17-SNAPSHOT
+ 1.4
org.javassist