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 { }