diff --git a/clientserver/src/main/java/net/rptools/clientserver/simple/connection/SocketConnection.java b/clientserver/src/main/java/net/rptools/clientserver/simple/connection/SocketConnection.java index 42124691c5..0d0439bfc3 100644 --- a/clientserver/src/main/java/net/rptools/clientserver/simple/connection/SocketConnection.java +++ b/clientserver/src/main/java/net/rptools/clientserver/simple/connection/SocketConnection.java @@ -17,6 +17,8 @@ import java.io.*; import java.net.Socket; import java.net.SocketTimeoutException; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -24,35 +26,39 @@ * @author drice */ public class SocketConnection extends AbstractConnection implements Connection { + + @FunctionalInterface + public interface SocketSupplier { + public T get() throws IOException; + } + /** Instance used for log messages. */ private static final Logger log = LogManager.getLogger(SocketConnection.class); - private final String id; - private SendThread send; - private ReceiveThread receive; - private Socket socket; - private String hostName; - private int port; + @Nonnull private final String id; + @Nullable private SendThread send; + @Nullable private ReceiveThread receive; + @Nullable private Socket socket; + @Nullable private SocketSupplier socketSupplier; - public SocketConnection(String id, String hostName, int port) { + public SocketConnection(@Nonnull String id, @Nonnull SocketSupplier socketSupplier) { this.id = id; - this.hostName = hostName; - this.port = port; + this.socketSupplier = socketSupplier; } - public SocketConnection(String id, Socket socket) { + public SocketConnection(@Nonnull String id, @Nonnull Socket socket) { this.id = id; - this.socket = socket; initialize(socket); } @Override + @Nonnull public String getId() { return id; } - private void initialize(Socket socket) { + private void initialize(@Nonnull Socket socket) { this.socket = socket; this.send = new SendThread(socket); this.receive = new ReceiveThread(socket); @@ -63,7 +69,10 @@ private void initialize(Socket socket) { @Override public void open() throws IOException { - initialize(new Socket(hostName, port)); + if (socketSupplier == null) { + throw new AssertionError("open should not be called when created with a Socket"); + } + initialize(socketSupplier.get()); } @Override diff --git a/doc/SSL.md b/doc/SSL.md new file mode 100644 index 0000000000..9bef405ace --- /dev/null +++ b/doc/SSL.md @@ -0,0 +1,190 @@ +# Securing MapTool connections with SSL + +## 0. A brief explainer of the data model + +SSL requires the server to provide a certificate +signed by a Certification Authority that is trusted by the client. + +A GM with expirence administering web services could sort this out themselves +but even GMs with this experience will find this to be a barrier to entry +that discourages securing MapTool servers with SSL so we're going to assume +the average GM wants MapTool to manage this itself. + +Since MapTool isn't like a web server, we generally don't use domain names +and the certificates have a validity period much longer than the average game +we can't request a Let's Encrypt certificate +so we have to rely on self-signed certificates. + +Because certificates must include the addresses they're valid for +and MapTool must support dynamic IP address allocation +we must issue a new certificate every session. + +Issuing a new self-signed certificate every session would require out-of-band +verification every session which is going to become tiresome. + +The solution is that every MapTool server works as a Certification Authority +and this CA's root certificate can be verified once +and the server can issue a new certificate signed by the CA root certificate +for every session. + +Since MapTool already includes RSA keys for client authentication +we can build on top of this to make a CA for server security. + +## 1. Creating a Certification Authority + +**NOTE**: These manual steps are intended to be automated later. + +First, define some directories to organise things and some CA-specific files. + +``` +mkdir -p ~/.maptool-rptools/config/ca/{certs,crl,newcerts,private,csr} +echo 1000 > ~/.maptool-rptools/config/ca/serial +echo 0100 > ~/.maptool-rptools/config/ca/crlnumber +touch ~/.maptool-rptools/config/ca/index.txt +``` + +The private key should have its access restricted. + +``` +chmod 400 ~/.maptool-rptools/config/private.key +``` + +Then, we must generate the root certificate + +``` +openssl req -new -x509 -days 3650 -key ~/.maptool-rptools/config/private.key -out ~/.maptool-rptools/config/ca/certs/ca.crt -subj "/CN=MapTool $(cat ~/.maptool-rptools/client-id) CA Root" +``` + +`-subj "/CN=..."` is a unique identifier for the certificate. +It doesn't need to be globally unique but it must not be duplicated +within a certificate validation chain. +Using the MapTool client-id and giving it an appropriate prefix and suffix +should ensure sufficient uniqueness. + +## 2. Creating a new certificate + +**NOTE**: These manual steps are intended to be automated later. + +First we must determine the set of addresses the certificate will be valid for. +This shell snippet collects it using `miniupnpc`'s `external-ip` and parsing `ip`. + +``` +ips=() +localips=() +subjectAltName=() +if extip="$(external-ip)"; then + ips+=("$extip") + subjectAltName+=("IP:$extip") +fi +for localaddr in $(ip -json addr show up | jq -r '.[].addr_info[]|select(.scope == "global").local'); do + ips+=("$localladdr") + localips+=("$localladdr") + subjectAltName+=("IP:$localaddr") +done +``` + +Now create a Certificate Signing Request. +This is convoluted because the entity requesting a certificate isn't usually +the same as the CA. Usually `-key` is a different key from the CA but isn't required to be. + +If the addresses haven't been collected by the script above, +then the significant part is that `-subj "CN/="` should be the most public IP +but "subjectAltName" permits a certificate to be used for multiple addresses +and is specified as every address having the prefix `IP:` and separated by `,` +e.g. `-addext subjectAltName=IP:203.0.113.46,IP:192.168.1.10,IP:2001:db8::4d`. + + +``` +openssl req -new -key ~/.maptool-rptools/config/private.key -out ~/.maptool-rptools/config/ca/csr/server-1.csr -subj "/CN=${ips[0]}" -addext "subjectAltName=$(IFS=","; echo "${subjectAltName[*]}")" +``` + +Now create the certificate by signing the CSR. + +Note this is issued for 1 day because that is the minimum +and `-copy_extensions copy` is used to copy subjectAltName into the certificate. + +``` +openssl x509 -req -in ~/.maptool-rptools/config/ca/csr/server-1.csr -copy_extensions copy -CA ~/.maptool-rptools/config/ca/certs/ca.crt -CAkey ~/.maptool-rptools/config/private.key -CAcreateserial -days 1 -out ~/.maptool-rptools/config/ca/certs/server-1.crt +``` + +## 3. Starting a SSL enabled MapTool server + +**NOTE**: These manual steps are intended to be automated later. + +Without native SSL support, MapTool must use an SSL tunnel. + +`socat` requires a certificate bundled with the private key. +We can create this with the following command: + +``` +openssl rsa -in ~/.maptool-rptools/config/private.key | cat - ~/.maptool-rptools/config/ca/certs/server-1.crt >~/.maptool-rptools/config/ca/certs/server-1.pem +``` + +We can now create the server tunnel. + +``` +socat OPENSSL-LISTEN:51232,cert="$HOME/.maptool-rptools/config/ca/certs/server-1.pem",verify=0 TCP:127.0.0.1:51234 +``` + +where 51234 is the port the server is listening on and 51232 is a free port. + +While this command is running an SSL connection to 51232 will connect +to a MapTool server running on port 51234. + +## 4. Connecting to a SSL enabled MapTool server using a tunnel + +Without native SSL support, MapTool must use an SSL tunnel. + +### Creating a tunnel using the certificate store + +The command to create an SSL tunnel that a client can connect to in order to create an ssl connection is: + +``` +socat TCP-LISTEN:51231 OPENSSL:"${localips[0]}":51232,cafile="$HOME/.maptool-rptools/config/ca/certs/ca.crt",snihost"=${localips[0]}" +``` + +While this command is running MapTool can connect to port 51231 +to make a SSL connection via the first local IP address. +`snihost=` must match one of the addresses in `subjectAltName`. + +### Installing the certificate in the OS certificate store + +The need to provide `cafile` on the TCP-LISTEN command-line can be removed +by installing the certificate into the OS certificate store. + +``` +sudo install -D -m644 ~/.maptool-rptools/config/ca/certs/ca.crt /usr/local/share/ca-certificates/extra/maptool-$(cat ~/.maptool-rptools/client-id)-root-ca.crt +sudo update-ca-certificates +``` + +The command to create the tunnel then becomes: + +``` +socat TCP-LISTEN:51231 OPENSSL:"${localips[0]}":51232,snihost"=${localips[0]}" +``` + +### Connecting with MapTool through the tunnel + +Using the URI support in develop's version of MapTool we can instruct MapTool to connect through the proxy. + +``` +/opt/maptool/bin/MapTool-Develop rptools-maptool+tcp://${localips[0]}:51231 +``` +or while running from git +``` +./gradlew run --args=rptools-maptool+tcp://${localips[0]}:51231 +``` + +## 5. Connecting to a SSL enabled MapTool server directly + +With the certificate installed in the system certificate store +the second `TCP-LISTEN` socat tunnel can be omitted +and the new `rptools-maptool+tcps://` scheme used +to connect from the command-line to port 51232. + +``` +./gradlew run --args=rptools-maptool+tcps://${localips[0]}:51232 +``` + +Alternatively the "Use SSL" checkbox in the "Direct" tab of the connect dialog +can be checked to specify to connect with SSL. diff --git a/package/linux/launcher.desktop b/package/linux/launcher.desktop index 251509cf9b..4263a6ccc8 100644 --- a/package/linux/launcher.desktop +++ b/package/linux/launcher.desktop @@ -6,5 +6,5 @@ Icon=/opt/maptool/lib/${projectName}${developerRelease}.png Terminal=false Type=Application Categories=Game -MimeType=application/maptool;x-scheme-handler/rptools-maptool+registry;x-scheme-handler/rptools-maptool+lan;x-scheme-handler/rptools-maptool+tcp +MimeType=application/maptool;x-scheme-handler/rptools-maptool+registry;x-scheme-handler/rptools-maptool+lan;x-scheme-handler/rptools-maptool+tcp;x-scheme-handler/rptools-maptool+tcps StartupWMClass=net-rptools-maptool-client-LaunchInstructions \ No newline at end of file diff --git a/package/windows/main.wxs b/package/windows/main.wxs index e0dc3d5cfe..9c9307c0ea 100644 --- a/package/windows/main.wxs +++ b/package/windows/main.wxs @@ -173,6 +173,19 @@ + + + + + + + + + + diff --git a/src/main/java/net/rptools/clientserver/ConnectionFactory.java b/src/main/java/net/rptools/clientserver/ConnectionFactory.java index a3c4c9fee8..2d6fc20d02 100644 --- a/src/main/java/net/rptools/clientserver/ConnectionFactory.java +++ b/src/main/java/net/rptools/clientserver/ConnectionFactory.java @@ -15,8 +15,10 @@ package net.rptools.clientserver; import java.awt.EventQueue; +import java.net.Socket; import javax.annotation.Nonnull; import javax.annotation.Nullable; +import javax.net.ssl.SSLSocketFactory; import net.rptools.clientserver.simple.connection.Connection; import net.rptools.clientserver.simple.connection.SocketConnection; import net.rptools.clientserver.simple.connection.WebRTCConnection; @@ -38,8 +40,12 @@ public static ConnectionFactory getInstance() { @Nonnull public Connection createConnection(@Nonnull String id, @Nonnull RemoteServerConfig config) { return switch (config) { + case RemoteServerConfig.SSLSocket(String hostName, int port) -> + // TODO: Create socket with custom SSL context + new SocketConnection( + id, () -> SSLSocketFactory.getDefault().createSocket(hostName, port)); case RemoteServerConfig.Socket(String hostName, int port) -> - new SocketConnection(id, hostName, port); + new SocketConnection(id, () -> new Socket(hostName, port)); case RemoteServerConfig.WebRTC(String serverName) -> new WebRTCConnection( id, diff --git a/src/main/java/net/rptools/maptool/client/RemoteServerConfig.java b/src/main/java/net/rptools/maptool/client/RemoteServerConfig.java index 1581541736..a13212fdf2 100644 --- a/src/main/java/net/rptools/maptool/client/RemoteServerConfig.java +++ b/src/main/java/net/rptools/maptool/client/RemoteServerConfig.java @@ -17,6 +17,8 @@ import javax.annotation.Nonnull; public sealed interface RemoteServerConfig { + record SSLSocket(@Nonnull String hostName, int port) implements RemoteServerConfig {} + record Socket(@Nonnull String hostName, int port) implements RemoteServerConfig {} record WebRTC(@Nonnull String serverName) implements RemoteServerConfig {} diff --git a/src/main/java/net/rptools/maptool/client/ServerAddress.java b/src/main/java/net/rptools/maptool/client/ServerAddress.java index b78a1626d5..e6d7ea807a 100644 --- a/src/main/java/net/rptools/maptool/client/ServerAddress.java +++ b/src/main/java/net/rptools/maptool/client/ServerAddress.java @@ -103,19 +103,28 @@ public URI toUri() { } } - record Tcp(@Nonnull String address, int port) implements ServerAddress { + record Tcp(@Nonnull String address, int port, boolean useSSL) implements ServerAddress { @Override @Nonnull public RemoteServerConfig findServer() { - return new RemoteServerConfig.Socket( - address(), port() == -1 ? ServerConfig.DEFAULT_PORT : port()); + var port = port(); + if (port == -1) { + port = ServerConfig.DEFAULT_PORT; + } + + if (useSSL) { + return new RemoteServerConfig.SSLSocket(address(), port); + } + + return new RemoteServerConfig.Socket(address(), port); } @Override @Nonnull public URI toUri() { try { - return new URI("rptools-maptool+tcp", null, address(), port(), "/", null, null); + var scheme = useSSL() ? "rptools-maptool+tcps" : "rptools-maptool+tcp"; + return new URI(scheme, null, address(), port(), "/", null, null); } catch (URISyntaxException e) { throw new AssertionError( "Scheme and path are given and the path is absolute and IP address authorities are all valid so this should be infallible", @@ -173,14 +182,14 @@ static ServerAddress parse(@Nonnull String s) return new ServerAddress.Lan(serviceIdentifier); case "rptools-maptool+tcp": + case "rptools-maptool+tcps": if (host == null) { - throw new IllegalArgumentException("rptools-maptool+tcp URIs must have a host"); + throw new IllegalArgumentException(scheme + " URIs must have a host"); } if (path != null && !path.isEmpty() && !path.equals("/")) { - throw new IllegalArgumentException( - "rptools-maptool+tcp URIs must have no path or just /"); + throw new IllegalArgumentException(scheme + " URIs must have no path or just /"); } - return new ServerAddress.Tcp(host, port); + return new ServerAddress.Tcp(host, port, scheme.endsWith("s")); case null: default: diff --git a/src/main/java/net/rptools/maptool/client/ui/connectioninfodialog/ConnectionInfoDialog.java b/src/main/java/net/rptools/maptool/client/ui/connectioninfodialog/ConnectionInfoDialog.java index 1496952221..2da7476880 100644 --- a/src/main/java/net/rptools/maptool/client/ui/connectioninfodialog/ConnectionInfoDialog.java +++ b/src/main/java/net/rptools/maptool/client/ui/connectioninfodialog/ConnectionInfoDialog.java @@ -107,7 +107,9 @@ public ConnectionInfoDialog(MapToolServer server) { return null; } return new ServerAddress.Tcp( - NetUtil.formatAddress(localAddresses.ipv4().get(0)), server.getPort()); + NetUtil.formatAddress(localAddresses.ipv4().get(0)), + server.getPort(), + false); }); Supplier> getLocalV6 = () -> @@ -119,7 +121,9 @@ public ConnectionInfoDialog(MapToolServer server) { return null; } return new ServerAddress.Tcp( - NetUtil.formatAddress(localAddresses.ipv6().get(0)), server.getPort()); + NetUtil.formatAddress(localAddresses.ipv6().get(0)), + server.getPort(), + false); }); Supplier> getExternal = () -> @@ -131,7 +135,7 @@ public ConnectionInfoDialog(MapToolServer server) { return null; } return new ServerAddress.Tcp( - NetUtil.formatAddress(address), server.getPort()); + NetUtil.formatAddress(address), server.getPort(), false); }); registerCopyButton(panel, "registryUriCopyButton", getServerName, ServerAddress::toUri); registerCopyButton(panel, "registryHttpUrlCopyButton", getServerName, ServerAddress::toHttpUrl); diff --git a/src/main/java/net/rptools/maptool/client/ui/connecttoserverdialog/ConnectToServerDialog.java b/src/main/java/net/rptools/maptool/client/ui/connecttoserverdialog/ConnectToServerDialog.java index eeba2650af..28b9bea366 100644 --- a/src/main/java/net/rptools/maptool/client/ui/connecttoserverdialog/ConnectToServerDialog.java +++ b/src/main/java/net/rptools/maptool/client/ui/connecttoserverdialog/ConnectToServerDialog.java @@ -260,6 +260,15 @@ public JCheckBox getUsePublicKeyCheckBox() { return (JCheckBox) getComponent("@usePublicKey"); } + @Nonnull + public JCheckBox getUseSSLCheckBox() { + if (getComponent("@useSSL") instanceof JCheckBox checkBox) { + return checkBox; + } else { + throw new AssertionError("Connect to server dialog should have a JCheckBox named @useSSL"); + } + } + private void handleOK() { String username = getUsernameTextField().getText().trim(); if (username.length() == 0) { @@ -311,8 +320,14 @@ private void handleOK() { } getHostTextField().setText(host); + boolean useSSL = getUseSSLCheckBox().isSelected(); + // OK - connectionDetails = new RemoteServerConfig.Socket(host, portTemp); + if (useSSL) { + connectionDetails = new RemoteServerConfig.SSLSocket(host, portTemp); + } else { + connectionDetails = new RemoteServerConfig.Socket(host, portTemp); + } } else if (SwingUtil.hasComponent(selectedPanel, "rptoolsPanel")) { String serverName = getServerNameTextField().getText().trim(); if (serverName.length() == 0) { diff --git a/src/main/java/net/rptools/maptool/client/ui/connecttoserverdialog/ConnectToServerDialogPreferences.java b/src/main/java/net/rptools/maptool/client/ui/connecttoserverdialog/ConnectToServerDialogPreferences.java index 2ee18db341..21d6b1fd4e 100644 --- a/src/main/java/net/rptools/maptool/client/ui/connecttoserverdialog/ConnectToServerDialogPreferences.java +++ b/src/main/java/net/rptools/maptool/client/ui/connecttoserverdialog/ConnectToServerDialogPreferences.java @@ -31,6 +31,7 @@ public class ConnectToServerDialogPreferences { private static final String KEY_TAB = "tab"; private static final String KEY_SERVER_NAME = "serverName"; private static final String USE_PUBLIC_KEY = "usePublicKey"; + private static final String USE_SSL = "useSSL"; private static final String USE_WEB_RTC = "useWebRTC"; @Nonnull @@ -100,4 +101,12 @@ public boolean getUseWebRTC() { public void setUseWebRTC(boolean useWebRTC) { prefs.putBoolean(USE_WEB_RTC, useWebRTC); } + + public boolean getUseSSL() { + return prefs.getBoolean(USE_SSL, false); + } + + public void setUseSSL(boolean useSSL) { + prefs.putBoolean(USE_SSL, useSSL); + } } diff --git a/src/main/java/net/rptools/maptool/client/ui/connecttoserverdialog/ConnectToServerDialogView.form b/src/main/java/net/rptools/maptool/client/ui/connecttoserverdialog/ConnectToServerDialogView.form index e80a70480e..1338b15232 100644 --- a/src/main/java/net/rptools/maptool/client/ui/connecttoserverdialog/ConnectToServerDialogView.form +++ b/src/main/java/net/rptools/maptool/client/ui/connecttoserverdialog/ConnectToServerDialogView.form @@ -227,6 +227,23 @@ + + + + + + + + + + + + + + + + + diff --git a/src/main/java/net/rptools/maptool/client/ui/connecttoserverdialog/ConnectToServerDialogView.java b/src/main/java/net/rptools/maptool/client/ui/connecttoserverdialog/ConnectToServerDialogView.java index 41c249f5f5..c6e47598d5 100644 --- a/src/main/java/net/rptools/maptool/client/ui/connecttoserverdialog/ConnectToServerDialogView.java +++ b/src/main/java/net/rptools/maptool/client/ui/connecttoserverdialog/ConnectToServerDialogView.java @@ -14,11 +14,11 @@ */ package net.rptools.maptool.client.ui.connecttoserverdialog; -import java.awt.*; import javax.swing.*; public class ConnectToServerDialogView { private JPanel mainPanel; + private JCheckBox directUseSSLCheckbox; public JComponent getRootComponent() { return mainPanel;