diff --git a/OpenPolytopia.Common/Network/ClientConnection.cs b/OpenPolytopia.Common/Network/ClientConnection.cs
index c64cc26..4d1587b 100644
--- a/OpenPolytopia.Common/Network/ClientConnection.cs
+++ b/OpenPolytopia.Common/Network/ClientConnection.cs
@@ -1,7 +1,10 @@
namespace OpenPolytopia.Common.Network;
using System.Collections.Concurrent;
+using System.Net;
+using System.Net.Security;
using System.Net.Sockets;
+using System.Security.Authentication;
using Packets;
///
@@ -10,15 +13,30 @@ namespace OpenPolytopia.Common.Network;
///
/// Received packets are queued in to let the consumer
/// process them on its own thread; gets answered automatically
-/// and the connection gets closed if the server doesn't send anything for longer than
+/// and the connection gets closed if the server doesn't send anything for longer than .
+/// Every connection that leaves the machine goes through TLS, validated against the certificate store
+/// of the system; there is no plaintext fallback, a server with a bad certificate is a server we don't talk to
///
-public class ClientConnection(string address, int port) : IDisposable {
+/// the host name or ip address of the server
+/// the port of the server
+///
+/// true to wrap the connection in TLS, false for plaintext;
+/// null to decide from , meaning TLS for everything but loopback
+///
+public class ClientConnection(string address, int port, bool? useTls = null) : IDisposable {
private static readonly TimeSpan TIMEOUT = TimeSpan.FromSeconds(30);
private static readonly TimeSpan TIMEOUT_CHECK_INTERVAL = TimeSpan.FromSeconds(5);
+ private static readonly TimeSpan TLS_HANDSHAKE_TIMEOUT = TimeSpan.FromSeconds(10);
private readonly TcpClient _client = new();
private readonly CancellationTokenSource _cts = new();
private NetworkConnection? _connection;
+ private int _disposed;
+
+ ///
+ /// true when the connection to the server is wrapped in TLS
+ ///
+ public bool UsesTls { get; } = useTls ?? !IsLoopback(address);
///
/// Packets received from the server, waiting to be processed
@@ -38,11 +56,16 @@ public class ClientConnection(string address, int port) : IDisposable {
///
/// Connects to the server and starts reading packets in background
///
+ ///
+ /// if the server doesn't present a certificate this machine trusts for
+ ///
public async Task ConnectAsync() {
PacketRegistrar.RegisterAllPackets();
await _client.ConnectAsync(address, port, _cts.Token);
- _connection = new NetworkConnection(0, _client);
+ var stream = UsesTls ? await AuthenticateAsync() : null;
+
+ _connection = new NetworkConnection(0, _client, stream);
_connection.OnPacketReceived += PacketReceivedAsync;
_connection.OnDisconnected += _ => OnDisconnected?.Invoke();
@@ -71,6 +94,45 @@ public void Disconnect() {
_connection?.Close();
}
+ ///
+ /// Checks if an address points to this same machine
+ ///
+ ///
+ /// Loopback traffic never leaves the machine, so it's the only case where plaintext is acceptable;
+ /// anything that can't be recognized as loopback gets treated as remote and encrypted
+ ///
+ /// the host name or ip address to check
+ private static bool IsLoopback(string address) =>
+ string.Equals(address, "localhost", StringComparison.OrdinalIgnoreCase) ||
+ (IPAddress.TryParse(address, out var ip) && IPAddress.IsLoopback(ip));
+
+ ///
+ /// Wraps the connection in TLS
+ ///
+ ///
+ /// The certificate gets validated the standard way, against the trust store of the operating system;
+ /// a failure throws and leaves the connection unusable, it never falls back to plaintext
+ ///
+ /// the authenticated stream
+ private async Task AuthenticateAsync() {
+ var ssl = new SslStream(_client.GetStream(), false);
+
+ try {
+ using var handshake = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token);
+ handshake.CancelAfter(TLS_HANDSHAKE_TIMEOUT);
+ await ssl.AuthenticateAsClientAsync(
+ new SslClientAuthenticationOptions {
+ TargetHost = address, EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13
+ }, handshake.Token);
+ }
+ catch {
+ await ssl.DisposeAsync();
+ throw;
+ }
+
+ return ssl;
+ }
+
private static async Task TimeoutLoopAsync(NetworkConnection connection, CancellationToken ct) {
using var timer = new PeriodicTimer(TIMEOUT_CHECK_INTERVAL);
@@ -99,6 +161,7 @@ private async Task PacketReceivedAsync(NetworkConnection connection, IPacket pac
}
public void Dispose() {
+ if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
Disconnect();
_cts.Dispose();
_connection?.Dispose();
diff --git a/OpenPolytopia.Common/Network/NetworkConnection.cs b/OpenPolytopia.Common/Network/NetworkConnection.cs
index d506aeb..58ba5d1 100644
--- a/OpenPolytopia.Common/Network/NetworkConnection.cs
+++ b/OpenPolytopia.Common/Network/NetworkConnection.cs
@@ -11,8 +11,14 @@ namespace OpenPolytopia.Common.Network;
/// Used by the client for its connection to the server
/// and by the server for every connected client
///
-public class NetworkConnection(uint id, TcpClient client) : IDisposable {
- private readonly NetworkStream _stream = client.GetStream();
+/// the id of this connection
+/// the connected socket
+///
+/// the stream to read and write packets on; null to use the plaintext stream of .
+/// Pass an already authenticated here to talk over TLS
+///
+public class NetworkConnection(uint id, TcpClient client, Stream? stream = null) : IDisposable {
+ private readonly Stream _stream = stream ?? client.GetStream();
private readonly SemaphoreSlim _writeLock = new(1, 1);
private int _closed;
@@ -116,6 +122,15 @@ public void Close() {
return;
}
+ // disposing the stream unblocks a pending read and, on TLS, sends the close notify;
+ // it can fail if the remote endpoint is already gone, which is not a problem here
+ try {
+ _stream.Dispose();
+ }
+ catch (Exception e) when (e is IOException or ObjectDisposedException or SocketException) {
+ // remote endpoint already gone
+ }
+
client.Close();
OnDisconnected?.Invoke(this);
}
diff --git a/OpenPolytopia.Common/Network/ServerConnection.cs b/OpenPolytopia.Common/Network/ServerConnection.cs
index b11cf65..bd84abc 100644
--- a/OpenPolytopia.Common/Network/ServerConnection.cs
+++ b/OpenPolytopia.Common/Network/ServerConnection.cs
@@ -1,7 +1,10 @@
namespace OpenPolytopia.Common.Network;
using System.Collections.Concurrent;
+using System.Net.Security;
using System.Net.Sockets;
+using System.Security.Authentication;
+using System.Security.Cryptography.X509Certificates;
using System.Threading.Channels;
using Packets;
@@ -14,17 +17,29 @@ namespace OpenPolytopia.Common.Network;
/// nor grow the memory of the server by never draining his queue.
/// Every it sends a to every client
/// and disconnects the ones that didn't send anything back for longer than ;
-/// clients that don't complete a handshake within get disconnected too
+/// clients that don't complete a handshake within get disconnected too.
+/// When a certificate is given every connection is wrapped in TLS before any packet is read;
+/// the TLS handshake runs in background so a slow or hostile client can't stall the accept loop
///
/// the port to listen on
/// the ip address to bind to; null to listen on every interface
-public class ServerConnection(int port, string? bindAddress = null) : IDisposable {
+///
+/// the certificate to serve TLS with; null to accept plaintext connections, which is only
+/// acceptable on loopback
+///
+public class ServerConnection(int port, string? bindAddress = null, X509Certificate2? certificate = null)
+ : IDisposable {
private static readonly TimeSpan KEEP_ALIVE_INTERVAL = TimeSpan.FromSeconds(10);
private static readonly TimeSpan TIMEOUT = TimeSpan.FromSeconds(30);
private static readonly TimeSpan HANDSHAKE_TIMEOUT = TimeSpan.FromSeconds(10);
private static readonly TimeSpan SEND_TIMEOUT = TimeSpan.FromSeconds(10);
private static readonly TimeSpan ACCEPT_RETRY_DELAY = TimeSpan.FromSeconds(1);
+ ///
+ /// How long a client has to complete the TLS handshake before its socket gets dropped
+ ///
+ private static readonly TimeSpan TLS_HANDSHAKE_TIMEOUT = TimeSpan.FromSeconds(10);
+
///
/// Max frames queued for a single client; way more than lobby traffic ever needs
///
@@ -37,6 +52,11 @@ public class ServerConnection(int port, string? bindAddress = null) : IDisposabl
private readonly CancellationTokenSource _cts = new();
private uint _nextId;
+ ///
+ /// true when connections get wrapped in TLS
+ ///
+ public bool TlsEnabled => certificate != null;
+
///
/// Fired when a new client connects
///
@@ -83,20 +103,9 @@ public async Task RunAsync() {
continue;
}
- var id = Interlocked.Increment(ref _nextId);
-
- var connection = new NetworkConnection(id, tcpClient);
- connection.OnPacketReceived += ClientPacketReceivedAsync;
- connection.OnDisconnected += ClientDisconnected;
-
- var client = new Client(connection);
- _clients[id] = client;
-
- OnClientConnected?.Invoke(connection);
-
- // manage the client in background
- _ = connection.RunAsync(_cts.Token);
- _ = SenderLoopAsync(client, _cts.Token);
+ // the TLS handshake needs a round trip with the client, so it can't run here
+ // or one slow client would keep everybody else from connecting
+ _ = SetupClientAsync(tcpClient);
}
}
catch (OperationCanceledException) {
@@ -116,6 +125,84 @@ public async Task RunAsync() {
///
public void Stop() => _cts.Cancel();
+ ///
+ /// Completes the TLS handshake, if enabled, and registers the client
+ ///
+ ///
+ /// Runs in background, one task per accepted socket; a socket that fails or takes longer than
+ /// to negotiate TLS gets dropped without ever becoming a client
+ ///
+ /// the freshly accepted socket
+ private async Task SetupClientAsync(TcpClient tcpClient) {
+ var stream = await AuthenticateAsync(tcpClient);
+
+ // the handshake failed, the socket is already gone
+ if (certificate != null && stream == null) {
+ return;
+ }
+
+ if (_cts.IsCancellationRequested) {
+ stream?.Dispose();
+ tcpClient.Dispose();
+ return;
+ }
+
+ var id = Interlocked.Increment(ref _nextId);
+
+ var connection = new NetworkConnection(id, tcpClient, stream);
+ connection.OnPacketReceived += ClientPacketReceivedAsync;
+ connection.OnDisconnected += ClientDisconnected;
+
+ var client = new Client(connection);
+ _clients[id] = client;
+
+ OnClientConnected?.Invoke(connection);
+
+ // manage the client in background
+ _ = connection.RunAsync(_cts.Token);
+ _ = SenderLoopAsync(client, _cts.Token);
+ }
+
+ ///
+ /// Wraps a socket in TLS
+ ///
+ /// the socket to wrap
+ ///
+ /// the authenticated stream, null when TLS is disabled (plaintext) or when the handshake failed;
+ /// on failure the socket gets disposed
+ ///
+ private async Task AuthenticateAsync(TcpClient tcpClient) {
+ if (certificate == null) {
+ return null;
+ }
+
+ SslStream? ssl = null;
+ try {
+ ssl = new SslStream(tcpClient.GetStream(), false);
+
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token);
+ cts.CancelAfter(TLS_HANDSHAKE_TIMEOUT);
+
+ await ssl.AuthenticateAsServerAsync(
+ new SslServerAuthenticationOptions {
+ ServerCertificate = certificate,
+ ClientCertificateRequired = false,
+ EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13
+ }, cts.Token);
+
+ return ssl;
+ }
+ catch (Exception e) when (e is AuthenticationException or IOException or OperationCanceledException
+ or ObjectDisposedException or SocketException) {
+ Console.Error.WriteLine($"TLS handshake failed: {e.Message}");
+
+ // nothing was ever handed out for this socket, drop it here
+ ssl?.Dispose();
+ tcpClient.Dispose();
+ return null;
+ }
+ }
+
///
/// Marks a client as having completed the handshake
///
diff --git a/OpenPolytopia.Server/GameServer.cs b/OpenPolytopia.Server/GameServer.cs
index 191419f..2cc2481 100644
--- a/OpenPolytopia.Server/GameServer.cs
+++ b/OpenPolytopia.Server/GameServer.cs
@@ -26,7 +26,8 @@ public class GameServer(int port, string? bindAddress = null) : IDisposable {
///
private const int MAX_LOBBIES = 100;
- private readonly ServerConnection _server = new(port, bindAddress);
+ private readonly System.Security.Cryptography.X509Certificates.X509Certificate2? _certificate = ServerTls.LoadAndValidate(bindAddress);
+ private ServerConnection _server = null!;
private readonly LobbyManager _lobbyManager = new();
private readonly GameManager _gameManager = new();
private readonly Dictionary _playerNames = new();
@@ -45,6 +46,7 @@ public class GameServer(int port, string? bindAddress = null) : IDisposable {
/// Runs the server until gets called
///
public async Task RunAsync() {
+ _server = new ServerConnection(port, bindAddress, _certificate);
RegisterHandlers();
_server.OnPacketReceived += ManagePacketAsync;
@@ -62,7 +64,7 @@ public async Task RunAsync() {
///
public void Stop() {
_cts.Cancel();
- _server.Stop();
+ _server?.Stop();
}
private async Task ManagePacketAsync(NetworkConnection connection, IPacket packet) {
@@ -646,7 +648,8 @@ private async Task ManageEndTurnAsync(NetworkConnection connection, EndTurnPacke
public void Dispose() {
Stop();
_cts.Dispose();
- _server.Dispose();
+ _server?.Dispose();
+ _certificate?.Dispose();
_stateLock.Dispose();
GC.SuppressFinalize(this);
}
diff --git a/OpenPolytopia.Server/ServerTls.cs b/OpenPolytopia.Server/ServerTls.cs
new file mode 100644
index 0000000..d19b8e9
--- /dev/null
+++ b/OpenPolytopia.Server/ServerTls.cs
@@ -0,0 +1,109 @@
+namespace OpenPolytopia.Server;
+
+using System.Net;
+using System.Security.Cryptography.X509Certificates;
+
+///
+/// Loads and validates the TLS configuration of the server
+///
+///
+/// Passwords and tokens travel on this connection, so a server reachable from outside this machine
+/// has to serve TLS; the certificate comes from a PKCS#12 file pointed at by
+/// , unlocked with
+///
+public static class ServerTls {
+ ///
+ /// Environment variable holding the path of the PKCS#12 (.pfx) file with the certificate and its private key
+ ///
+ public const string CERTIFICATE_PATH_ENV = "OPENPOLYTOPIA_TLS_CERTIFICATE";
+
+ ///
+ /// Environment variable holding the password of the PKCS#12 file; unset means no password
+ ///
+ public const string CERTIFICATE_PASSWORD_ENV = "OPENPOLYTOPIA_TLS_PASSWORD";
+
+ ///
+ /// Loads the certificate configured through the environment
+ ///
+ /// the certificate or null if isn't set
+ /// if the configured file doesn't exist
+ /// if the file can't be read as a certificate with a private key
+ public static X509Certificate2? LoadCertificate() {
+ var path = Environment.GetEnvironmentVariable(CERTIFICATE_PATH_ENV);
+ if (string.IsNullOrWhiteSpace(path)) {
+ return null;
+ }
+
+ if (!File.Exists(path)) {
+ throw new FileNotFoundException($"{CERTIFICATE_PATH_ENV} points to a file that doesn't exist", path);
+ }
+
+ var password = Environment.GetEnvironmentVariable(CERTIFICATE_PASSWORD_ENV);
+
+ X509Certificate2 certificate;
+ try {
+ certificate = X509CertificateLoader.LoadPkcs12FromFile(path, password);
+ }
+ catch (Exception e) {
+ throw new InvalidOperationException($"Failed to load the TLS certificate from '{path}': {e.Message}", e);
+ }
+
+ // without the private key we can't answer a single handshake, better to fail at startup
+ if (!certificate.HasPrivateKey) {
+ certificate.Dispose();
+ throw new InvalidOperationException($"The TLS certificate at '{path}' has no private key");
+ }
+
+ return certificate;
+ }
+
+ ///
+ /// Checks that the given bind address is allowed to run without TLS
+ ///
+ ///
+ /// Only a server bound to loopback, so tests and local development, may run plaintext;
+ /// anything reachable from the network needs a certificate
+ ///
+ /// the address the server binds to; null means every interface
+ /// the loaded certificate, or null if there is none
+ /// if a non loopback server has no certificate
+ public static void Validate(string? bindAddress, X509Certificate2? certificate) {
+ if (certificate != null || IsLoopback(bindAddress)) {
+ return;
+ }
+
+ throw new InvalidOperationException(
+ $"Refusing to listen on '{bindAddress ?? "*"}' without TLS: accounts send passwords and tokens over " +
+ $"this connection. Set {CERTIFICATE_PATH_ENV} to a .pfx file (and {CERTIFICATE_PASSWORD_ENV} to its " +
+ "password) or bind to 127.0.0.1 for local development.");
+ }
+
+ ///
+ /// Loads the configured certificate and checks it against the bind address
+ ///
+ /// the address the server binds to; null means every interface
+ /// the certificate to serve TLS with, or null when running plaintext on loopback
+ /// if a non loopback server has no certificate
+ public static X509Certificate2? LoadAndValidate(string? bindAddress) {
+ var certificate = LoadCertificate();
+
+ try {
+ Validate(bindAddress, certificate);
+ }
+ catch {
+ certificate?.Dispose();
+ throw;
+ }
+
+ return certificate;
+ }
+
+ ///
+ /// Checks if a bind address only accepts connections from this same machine
+ ///
+ /// the address the server binds to; null means every interface
+ private static bool IsLoopback(string? bindAddress) =>
+ !string.IsNullOrWhiteSpace(bindAddress) &&
+ IPAddress.TryParse(bindAddress, out var ip) &&
+ IPAddress.IsLoopback(ip);
+}
diff --git a/OpenPolytopia.UnitTest/TransportSecurityTest.cs b/OpenPolytopia.UnitTest/TransportSecurityTest.cs
new file mode 100644
index 0000000..a5081f0
--- /dev/null
+++ b/OpenPolytopia.UnitTest/TransportSecurityTest.cs
@@ -0,0 +1,304 @@
+namespace OpenPolytopia;
+
+using System;
+using System.IO;
+using System.Net;
+using System.Net.Security;
+using System.Net.Sockets;
+using System.Security.Authentication;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using System.Threading;
+using System.Threading.Tasks;
+using Common.Network;
+using Common.Network.Packets;
+using Server;
+using Shouldly;
+
+///
+/// Covers the transport of the connection: plaintext on loopback, TLS everywhere else
+///
+[Collection(nameof(TransportSecurityTest))]
+public class TransportSecurityTest {
+ [Fact]
+ public void TestRepeatedClientDisposalIsSafe() {
+ using var client = new ClientConnection("localhost", 1);
+ client.Dispose();
+ client.Dispose();
+ }
+
+ [Fact]
+ public void TestGameServerRejectsPublicBindWithoutCertificate() {
+ var previous = Environment.GetEnvironmentVariable(ServerTls.CERTIFICATE_PATH_ENV);
+ try {
+ Environment.SetEnvironmentVariable(ServerTls.CERTIFICATE_PATH_ENV, null);
+ Should.Throw(() => new GameServer(0, "0.0.0.0"));
+ using var local = new GameServer(0, "127.0.0.1");
+ }
+ finally { Environment.SetEnvironmentVariable(ServerTls.CERTIFICATE_PATH_ENV, previous); }
+ }
+
+ private static readonly TimeSpan _timeout = TimeSpan.FromSeconds(10);
+
+ private const string CERTIFICATE_PASSWORD = "test-password";
+
+ [Fact]
+ public void TestTlsIsOffForLoopbackAndOnForEverythingElse() {
+ new ClientConnection("127.0.0.1", 1).UsesTls.ShouldBeFalse();
+ new ClientConnection("localhost", 1).UsesTls.ShouldBeFalse();
+ new ClientConnection("LOCALHOST", 1).UsesTls.ShouldBeFalse();
+ new ClientConnection("::1", 1).UsesTls.ShouldBeFalse();
+
+ new ClientConnection("example.com", 1).UsesTls.ShouldBeTrue();
+ new ClientConnection("203.0.113.7", 1).UsesTls.ShouldBeTrue();
+
+ // an explicit choice always wins over the default
+ new ClientConnection("127.0.0.1", 1, true).UsesTls.ShouldBeTrue();
+ new ClientConnection("example.com", 1, false).UsesTls.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task TestPlaintextLoopbackConnectionDeliversPackets() {
+ var port = FreePort();
+ using var server = new ServerConnection(port, "127.0.0.1");
+ server.TlsEnabled.ShouldBeFalse();
+
+ var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ server.OnPacketReceived += (_, packet) => {
+ received.TrySetResult(packet);
+ return Task.CompletedTask;
+ };
+
+ _ = server.RunAsync();
+ await WaitForListenerAsync(port);
+
+ using var client = new ClientConnection("127.0.0.1", port);
+ await client.ConnectAsync();
+ await client.SendPacketAsync(new SetNamePacket { Name = "plaintext" });
+
+ var packet = await received.Task.WaitAsync(_timeout);
+ packet.ShouldBeOfType().Name.ShouldBe("plaintext");
+ }
+
+ [Fact]
+ public async Task TestTlsClientTimesOutWhenServerNeverCompletesHandshake() {
+ using var listener = new TcpListenerHolder();
+ using var client = new ClientConnection("127.0.0.1", listener.Port, true);
+ var connecting = client.ConnectAsync();
+ using var peer = await listener.Listener.AcceptTcpClientAsync().WaitAsync(_timeout);
+
+ // Keep TCP open without answering TLS: the client's own deadline must end the attempt.
+ await Should.ThrowAsync(connecting.WaitAsync(TimeSpan.FromSeconds(20)));
+ client.Connected.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task TestTlsConnectionRejectsAnUntrustedCertificate() {
+ var port = FreePort();
+ using var certificate = CreateSelfSignedCertificate();
+ using var server = new ServerConnection(port, "127.0.0.1", certificate);
+ server.TlsEnabled.ShouldBeTrue();
+
+ _ = server.RunAsync();
+ await WaitForListenerAsync(port);
+
+ // the certificate is self signed, so no trust store on earth accepts it:
+ // the client must refuse instead of falling back to plaintext
+ using var client = new ClientConnection("localhost", port, true);
+ await Should.ThrowAsync(client.ConnectAsync().WaitAsync(_timeout));
+ client.Connected.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task TestTlsServerDropsPlaintextClientsWithoutBlockingTheOthers() {
+ var port = FreePort();
+ using var certificate = CreateSelfSignedCertificate();
+ using var server = new ServerConnection(port, "127.0.0.1", certificate);
+
+ var connected = 0;
+ server.OnClientConnected += _ => Interlocked.Increment(ref connected);
+
+ _ = server.RunAsync();
+ await WaitForListenerAsync(port);
+
+ // a client speaking plaintext to a TLS server never becomes a connection
+ using var plaintext = new ClientConnection("127.0.0.1", port, false);
+ await plaintext.ConnectAsync();
+ await plaintext.SendPacketAsync(new SetNamePacket { Name = "clear" });
+
+ // the accept loop keeps working while that socket is being dropped
+ await WaitForListenerAsync(port);
+
+ connected.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task TestPacketsSurviveTheRoundTripOverAnAuthenticatedStream() {
+ PacketRegistrar.RegisterAllPackets();
+
+ using var certificate = CreateSelfSignedCertificate();
+ using var listener = new TcpListenerHolder();
+ var acceptTask = listener.Listener.AcceptTcpClientAsync();
+
+ using var clientTcp = new TcpClient();
+ await clientTcp.ConnectAsync(IPAddress.Loopback, listener.Port);
+ using var serverTcp = await acceptTask.WaitAsync(_timeout);
+
+ await using var serverSsl = new SslStream(serverTcp.GetStream(), false);
+
+ // the test pins this exact certificate instead of weakening the validation:
+ // production code never gets a callback at all
+ await using var clientSsl = new SslStream(clientTcp.GetStream(), false,
+ (_, remote, _, _) => remote != null && remote.GetCertHashString() == certificate.GetCertHashString());
+
+ await Task.WhenAll(
+ serverSsl.AuthenticateAsServerAsync(certificate),
+ clientSsl.AuthenticateAsClientAsync("localhost")).WaitAsync(_timeout);
+
+ clientSsl.IsEncrypted.ShouldBeTrue();
+
+ var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ using var serverConnection = new NetworkConnection(1, serverTcp, serverSsl);
+ serverConnection.OnPacketReceived += (_, packet) => {
+ received.TrySetResult(packet);
+ return Task.CompletedTask;
+ };
+ _ = serverConnection.RunAsync();
+
+ using var clientConnection = new NetworkConnection(0, clientTcp, clientSsl);
+ await clientConnection.SendPacketAsync(new SetNamePacket { Name = "encrypted" });
+
+ var packet = await received.Task.WaitAsync(_timeout);
+ packet.ShouldBeOfType().Name.ShouldBe("encrypted");
+ }
+
+ [Fact]
+ public void TestPlaintextIsOnlyAllowedOnLoopback() {
+ // every interface, so reachable from the network
+ Should.Throw(() => ServerTls.Validate(null, null));
+ Should.Throw(() => ServerTls.Validate("0.0.0.0", null));
+ Should.Throw(() => ServerTls.Validate("203.0.113.7", null));
+
+ // not an ip address, so we can't prove it's loopback
+ Should.Throw(() => ServerTls.Validate("example.com", null));
+
+ Should.NotThrow(() => ServerTls.Validate("127.0.0.1", null));
+ Should.NotThrow(() => ServerTls.Validate("::1", null));
+ }
+
+ [Fact]
+ public void TestACertificateAllowsAnyBindAddress() {
+ using var certificate = CreateSelfSignedCertificate();
+
+ Should.NotThrow(() => ServerTls.Validate(null, certificate));
+ Should.NotThrow(() => ServerTls.Validate("0.0.0.0", certificate));
+ }
+
+ [Fact]
+ public void TestTheCertificateGetsLoadedFromTheEnvironment() {
+ var path = Path.Combine(Path.GetTempPath(), $"openpolytopia-tls-{Guid.NewGuid():N}.pfx");
+ using var source = CreateSelfSignedCertificate();
+ File.WriteAllBytes(path, source.Export(X509ContentType.Pfx, CERTIFICATE_PASSWORD));
+
+ var oldPath = Environment.GetEnvironmentVariable(ServerTls.CERTIFICATE_PATH_ENV);
+ var oldPassword = Environment.GetEnvironmentVariable(ServerTls.CERTIFICATE_PASSWORD_ENV);
+
+ try {
+ Environment.SetEnvironmentVariable(ServerTls.CERTIFICATE_PATH_ENV, null);
+ ServerTls.LoadCertificate().ShouldBeNull();
+
+ Environment.SetEnvironmentVariable(ServerTls.CERTIFICATE_PATH_ENV, path);
+ Environment.SetEnvironmentVariable(ServerTls.CERTIFICATE_PASSWORD_ENV, CERTIFICATE_PASSWORD);
+
+ using var loaded = ServerTls.LoadCertificate();
+ loaded.ShouldNotBeNull();
+ loaded.Subject.ShouldBe(source.Subject);
+ loaded.HasPrivateKey.ShouldBeTrue();
+
+ // a wrong password must fail loudly at startup instead of silently running plaintext
+ Environment.SetEnvironmentVariable(ServerTls.CERTIFICATE_PASSWORD_ENV, "wrong");
+ Should.Throw(() => ServerTls.LoadCertificate());
+
+ Environment.SetEnvironmentVariable(ServerTls.CERTIFICATE_PATH_ENV, $"{path}.missing");
+ Should.Throw(() => ServerTls.LoadCertificate());
+ }
+ finally {
+ Environment.SetEnvironmentVariable(ServerTls.CERTIFICATE_PATH_ENV, oldPath);
+ Environment.SetEnvironmentVariable(ServerTls.CERTIFICATE_PASSWORD_ENV, oldPassword);
+ File.Delete(path);
+ }
+ }
+
+ ///
+ /// Creates a self signed certificate for localhost, only good enough for a test
+ ///
+ private static X509Certificate2 CreateSelfSignedCertificate() {
+ using var rsa = RSA.Create(2048);
+ var request = new CertificateRequest("CN=localhost", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+
+ var alternativeNames = new SubjectAlternativeNameBuilder();
+ alternativeNames.AddDnsName("localhost");
+ alternativeNames.AddIpAddress(IPAddress.Loopback);
+ request.CertificateExtensions.Add(alternativeNames.Build());
+ request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true));
+
+ // server authentication
+ request.CertificateExtensions.Add(
+ new X509EnhancedKeyUsageExtension([new Oid("1.3.6.1.5.5.7.3.1")], false));
+
+ using var certificate =
+ request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1));
+
+ // round trip through a pfx so the private key is usable by SslStream on every platform
+ return X509CertificateLoader.LoadPkcs12(
+ certificate.Export(X509ContentType.Pfx, CERTIFICATE_PASSWORD), CERTIFICATE_PASSWORD,
+ X509KeyStorageFlags.Exportable);
+ }
+
+ ///
+ /// Grabs a free loopback port
+ ///
+ private static int FreePort() {
+ using var holder = new TcpListenerHolder();
+ return holder.Port;
+ }
+
+ ///
+ /// Waits until something is accepting connections on the port
+ ///
+ private static async Task WaitForListenerAsync(int port) {
+ var deadline = DateTime.UtcNow + _timeout;
+
+ while (DateTime.UtcNow < deadline) {
+ try {
+ using var probe = new TcpClient();
+ await probe.ConnectAsync(IPAddress.Loopback, port);
+ return;
+ }
+ catch (SocketException) {
+ await Task.Delay(20);
+ }
+ }
+
+ throw new TimeoutException($"Nothing started listening on port {port}");
+ }
+
+ ///
+ /// A started loopback listener on an OS assigned port
+ ///
+ private sealed class TcpListenerHolder : IDisposable {
+ public TcpListener Listener { get; }
+ public int Port { get; }
+
+ public TcpListenerHolder() {
+ Listener = new TcpListener(IPAddress.Loopback, 0);
+ Listener.Start();
+ Port = ((IPEndPoint)Listener.LocalEndpoint).Port;
+ }
+
+ public void Dispose() => Listener.Dispose();
+ }
+}
+
+[CollectionDefinition(nameof(TransportSecurityTest), DisableParallelization = true)]
+public class TransportEnvironmentCollection { }