Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,42 +17,48 @@
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;

/**
* @author drice
*/
public class SocketConnection extends AbstractConnection implements Connection {

@FunctionalInterface
public interface SocketSupplier<T> {
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<Socket> socketSupplier;

public SocketConnection(String id, String hostName, int port) {
public SocketConnection(@Nonnull String id, @Nonnull SocketSupplier<Socket> 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);
Expand All @@ -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
Expand Down
190 changes: 190 additions & 0 deletions doc/SSL.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion package/linux/launcher.desktop
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 13 additions & 0 deletions package/windows/main.wxs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,19 @@
<RegistryValue Type="string" Value="&quot;[INSTALLDIR]$(var.JpAppName).exe&quot; &quot;%1&quot;" />
</RegistryKey>
</RegistryKey>
<RegistryKey Root="HKCR"
Key="rptools-maptool+tcps"
ForceCreateOnInstall="yes"
ForceDeleteOnUninstall="yes">
<RegistryValue Type="string" Name="URL Protocol" Value=""/>
<RegistryValue Type="string" Value="URL:MapTool SSL connect"/>
<RegistryKey Key="DefaultIcon">
<RegistryValue Type="string" Value="[INSTALLDIR]$(var.JpAppName).exe" />
</RegistryKey>
<RegistryKey Key="shell\open\command">
<RegistryValue Type="string" Value="&quot;[INSTALLDIR]$(var.JpAppName).exe&quot; &quot;%1&quot;" />
</RegistryKey>
</RegistryKey>
<?endif?>
</Component>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down
25 changes: 17 additions & 8 deletions src/main/java/net/rptools/maptool/client/ServerAddress.java
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand Down
Loading