W tym poście chciałbym pokazać w jaki sposób wykonać serwer TCP TLS. Do komunikacji np. z urządzeniami embedded.
W celu nawiązania połączenia TLS muszą być wykonane następujące kroki:
- klient łączy się po TCP
- Hanshake TLS pomiędzy serwerem a klientem,
- walidacja certyfikatu,
Po poprawnym zestawieniu połączenie można już:
- odbierać dane,
- przetwarzanie odebranych ramek,
- wysłanie odpowiedzi.
W wiresharku nawiązania połączenia będzie wyglądało mniej więcej tak, dla wersji bez weryfikacji certyfikatu klienta:
Na samym początku potrzebuje odpowiedniego zestawu bibliotek:
- using System;
- using System.Net;
- using System.Net.Sockets;
- using System.Net.Security;
- using System.Security.Authentication;
- using System.Security.Cryptography.X509Certificates;
- using System.Text;
- using System.Threading;
- using System.Threading.Tasks;
- using System.Collections.Generic;
- using System.IO;
Dalej potrzebujemy certyfikatów oraz numer portu po którym nastapi połączenie :
- private int ServerPort = 5555;
- private readonly string ServerCertificateFile = @"C:\\przyklad\\Cert\\server-cert.pfx";
- private readonly string ServerCertificatePassword = "haslo_do_certyfikatu";
Definiujemy tzw. CancellationTokenSource. Pozwala na kontrolowane zatrzymanie serwera, który pracuje jako async.
- private CancellationTokenSource? _serverCts;
Klasa z danymi połączenia:
private sealed class ConnectionState : IDisposable
- {
- public string Id { get; }
- public TcpClient Client { get; }
- public SslStream Ssl { get; }
- public CancellationTokenSource ConnCts { get; }
- public ConnectionState(string id, TcpClient client, SslStream ssl, CancellationTokenSource connCts)
- {
- Id = id;
- Client = client;
- Ssl = ssl;
- ConnCts = connCts;
- }
- public void Dispose()
- {
- try { ConnCts.Cancel(); } catch { }
- try { Ssl.Dispose(); } catch { }
- try { Client.Dispose(); } catch { }
- }
- }
Przechowywane dane sa przypisane do jednego klienta.
Teraz funkcja odpowiedzialna za akceptacje klienta TLS na podstawie walidacji jego certyfikatu.
- public bool App_CertificateValidation(
- Object sender,
- X509Certificate certificate,
- X509Chain chain,
- SslPolicyErrors sslPolicyErrors)
- {
- Console.WriteLine($"{sslPolicyErrors}");
- if (sslPolicyErrors == SslPolicyErrors.None)
- return true;
- if (sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateNotAvailable))
- {
- Console.WriteLine("Klient nie przesłał certyfikatu — akceptuję.");
- return true;
- }
- if (sslPolicyErrors == SslPolicyErrors.RemoteCertificateChainErrors)
- {
- Console.WriteLine("Błąd łańcucha certyfikatu — akceptuję.");
- return true;
- }
- Console.WriteLine("Odrzucono certyfikat.");
- return false;
- }
Jeśli klient nie prześle certyfikatu to i tak akceptujemy połączenie. To ciągle jest TLS. Gdy obie strony podają certyfikat to połączenie jest definiowane jako mTLS (mutual TLS czyli obustronne uwierzytelnianie). Powyższa funkcja akceptuje takie błedy jak self-signed certyfikat, czy brak certyfikatu u klienta. Jeśli chcemy pełną to musimy zrobić tak:
- sslStream.AuthenticateAsServer(serverCertificate, true, SslProtocols.Tls12, true);
- public bool App_CertificateValidation(
- object sender,
- X509Certificate certificate,
- X509Chain chain,
- SslPolicyErrors sslPolicyErrors)
- {
- Console.WriteLine($"{sslPolicyErrors}");
- if (sslPolicyErrors == SslPolicyErrors.None)
- return true;
- Console.WriteLine("Odrzucono certyfikat");
- return false;
- }
Tutaj ustawiając parametr clientCertifiedRequired żadamy certyfikatu klienta i weryfikujemy go podczas handshake. Jeśli klient nie wyśle certyfikatu lub nie przejdzie on walidacji to handshake się nie powiedzie.
Przy takiej walidacji akceptowany jest tylko certyfikat obecny, z poprawnym łańcuchem zaufania, nie przeterminowany. Ogólnie w 100% poprawny certyfikat.
Często w testach bądź w systemach wbudowanych dysponujemy certyfikatem własnoręcznie podpisanym. Tutaj możemy albo akceptować takie certyfikaty lub zrobić weryfikację na podstawie tzw. thumbprinta.
- private readonly string AllowedClientThumbprint = "THUMPPRINT";
- public bool App_CertificateValidation(
- object sender,
- X509Certificate certificate,
- X509Chain chain,
- SslPolicyErrors sslPolicyErrors)
- {
- _log("[App_CertificateValidation] - start");
- _log($"[App_CertificateValidation] PolicyErrors: {sslPolicyErrors}");
- try
- {
- if (certificate == null)
- {
- //Jeśli ma przepuścić gdy nie ma certyfikatu
- //Zapisz_log("[App_CertificateValidation] Brak certyfikatu klienta.");
- //return true;
- return false;
- }
- var cert2 = new X509Certificate2(certificate);
- _log("[App_CertificateValidation] Subject: " + cert2.Subject);
- _log("[App_CertificateValidation] Issuer: " + cert2.Issuer);
- _log("[App_CertificateValidation] Thumbprint: " + cert2.Thumbprint);
- _log("[App_CertificateValidation] Serial: " + cert2.SerialNumber);
- _log("[App_CertificateValidation] NotBefore: " + cert2.NotBefore);
- _log("[App_CertificateValidation] NotAfter: " + cert2.NotAfter);
- _log("[App_CertificateValidation] HasPrivateKey: " + cert2.HasPrivateKey);
- bool isSelfSigned = string.Equals(cert2.Subject, cert2.Issuer, StringComparison.OrdinalIgnoreCase);
- _log("[App_CertificateValidation] IsSelfSigned: " + isSelfSigned);
- foreach (var ext in cert2.Extensions)
- {
- if (ext is X509EnhancedKeyUsageExtension eku)
- {
- foreach (var oid in eku.EnhancedKeyUsages)
- {
- _log("[App_CertificateValidation] EKU: " + oid.FriendlyName + " (" + oid.Value + ")");
- }
- }
- }
- var debugChain = new X509Chain();
- debugChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
- debugChain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
- bool chainValid = debugChain.Build(cert2);
- Zapisz_log("[App_CertificateValidation] Chain.Build(): " + chainValid);
- for (int i = 0; i < debugChain.ChainElements.Count; i++)
- {
- var element = debugChain.ChainElements[i];
- _log($"[App_CertificateValidation][{i}] Subject: {element.Certificate.Subject}");
- _log($"[App_CertificateValidation][{i}] Issuer: {element.Certificate.Issuer}");
- }
- foreach (var status in debugChain.ChainStatus)
- {
- _log($"[App_CertificateValidation] ERROR {status.Status} - {status.StatusInformation}");
- }
- if (sslPolicyErrors == SslPolicyErrors.None)
- {
- _log("[App_CertificateValidation] Certyfikat poprawny");
- return true;
- }
- if (sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateChainErrors))
- {
- string certThumbprint = (cert2.Thumbprint ?? "").Replace(" ", "").ToUpperInvariant();
- string allowedThumbprint = (AllowedClientThumbprint ?? "").Replace(" ", "").ToUpperInvariant();
- _log("[App_CertificateValidation] Compare thumbprint:");
- _log("[App_CertificateValidation] CERT: " + certThumbprint);
- _log("[App_CertificateValidation] ALLOW: " + allowedThumbprint);
- if (isSelfSigned && certThumbprint == allowedThumbprint)
- {
- Zapisz_log("[App_CertificateValidation] Zaakceptowano self-signed - thumbprint OK");
- return true;
- }
- _log("[App_CertificateValidation] Self-signed NIE pasuje do dozwolonego.");
- }
- _log("[App_CertificateValidation] Błąd certyfikatu: " + sslPolicyErrors);
- return false;
- }
- catch (Exception ex)
- {
- _log("[[App_CertificateValidation]] Exception: " + ex);
- return false;
- }
- finally
- {
- _log("[App_CertificateValidation] Koniec walidacji\n");
- }
- }
Do składania odebranych danych w gotowe bloki służy klasa CrLineParser:
- private sealed class CrLineParser
- {
- private readonly int _maxLineBytes;
- private readonly List<byte> _buf = new List<byte>(8192);
- public CrLineParser(int maxLineBytes) => _maxLineBytes = maxLineBytes;
- public void Append(byte[] data, int count)
- {
- for (int i = 0; i < count; i++)
- _buf.Add(data[i]);
- //czyść bufor jeśli błędne dane
- if (_buf.Count > _maxLineBytes * 2)
- {
- _buf.Clear();
- }
- }
- public bool TryGetLine(out string line)
- {
- line = "";
- int idxCr = _buf.IndexOf(13); //CR
- int idxLf = _buf.IndexOf(10); //LF
- int idx;
- if (idxCr >= 0 && idxLf >= 0)
- {
- idx = Math.Min(idxCr, idxLf);
- }
- else if (idxCr >= 0)
- {
- idx = idxCr;
- }
- else if (idxLf >= 0)
- {
- idx = idxLf;
- }
- else
- {
- return false;
- }
- var lineBytes = _buf.GetRange(0, idx).ToArray();
- int removeCount = idx + 1;
- while (removeCount < _buf.Count && (_buf[removeCount] == 13 || _buf[removeCount] == 10))
- {
- removeCount++;
- }
- _buf.RemoveRange(0, removeCount);
- line = Encoding.UTF8.GetString(lineBytes);
- if (lineBytes.Length > _maxLineBytes)
- {
- line = line.Substring(0, Math.Min(line.Length, 1024));
- }
- return true;
- }
- }
Bajty są zbierane. Następnie są z nich wyciągane linie zakończone na CR bądź LF.
No dobrze. Funkcje pomocnicze są już przygotowane to przejdźmy do nawiązywania połączenia:
- public async Task StartServerAsync()
- {
- var serverCertificate = new X509Certificate2(
- _serverCertificateFile,
- _serverCertificatePassword,
- X509KeyStorageFlags.DefaultKeySet);
- var listener = new TcpListener(IPAddress.Any, _serverPort);
- _serverCts = new CancellationTokenSource();
- CancellationToken token = _serverCts.Token;
- Console.WriteLine($"[StartServerAsync] - start server port - {_serverPort}");
- try
- {
- listener.Start();
- while (!token.IsCancellationRequested)
- {
- TcpClient client;
- try
- {
- client = await listener.AcceptTcpClientAsync().ConfigureAwait(false);
- }
- catch (ObjectDisposedException)
- {
- break;
- }
- catch (Exception ex)
- {
- Console.WriteLine($"[StartServerAsync] ERROR - {ex.Message}");
- await Task.Delay(300, token).ConfigureAwait(false);
- continue;
- }
- client.NoDelay = true;
- TryEnableKeepAlive(client);
- string clientId = client.Client.RemoteEndPoint?.ToString()
- ?? Guid.NewGuid().ToString("N");
- var sslStream = new SslStream(
- client.GetStream(),
- leaveInnerStreamOpen: false,
- userCertificateValidationCallback: AppCertificateValidation);
- var connectionCts = CancellationTokenSource.CreateLinkedTokenSource(token);
- var state = new ConnectionState(clientId, client, sslStream, connectionCts);
- _ = Task.Run(() => HandleClientAsync(state, serverCertificate), connectionCts.Token);
- }
- }
- finally
- {
- try { listener.Stop(); } catch { }
- Console.WriteLine("[StartServerAsync] - Listener stop");
- }
- }
Obsługa klientów:
- private async Task HandleClientAsync(ConnectionState state, X509Certificate2 serverCertificate)
- {
- try
- {
- await AuthenticateClientAsync(state, serverCertificate).ConfigureAwait(false);
- state.SslStream.ReadTimeout = 70000;
- state.SslStream.WriteTimeout = 70000;
- Console.WriteLine($"[HandleClientAsync] Połączenie TLS OK: {state.Id}");
- await ReadLoopAsync(state).ConfigureAwait(false);
- }
- catch (AuthenticationException ex)
- {
- Console.WriteLine($"[HandleClientAsync] [{state.Id}] TLS handshake failed: {ex.Message}");
- }
- catch (Exception ex)
- {
- Console.WriteLine($"[HandleClientAsync] [{state.Id}] Błąd obsługi klienta: {ex.GetType().Name} - {ex.Message}");
- }
- finally
- {
- state.Dispose();
- }
- }
Powyższa funckja obsługuje pojedynczego klienta po nawiązaniu połączenia TLS. Ustawianie timeotów, hanshake z klientem. Dalej uruchamiana jest pętla odbiorcza zbierająca dane od klienta i wysyłająca odpowiedzi na przesłane ramki danych.
Odczyt danych:
- private async Task ReadLoopAsync(ConnectionState state)
- {
- var parser = new CrLineParser(maxLineBytes: 64 * 1024);
- var buffer = new byte[4096];
- try
- {
- while (!state.ConnectionCts.IsCancellationRequested)
- {
- using var readCts = CancellationTokenSource.CreateLinkedTokenSource(state.ConnectionCts.Token);
- readCts.CancelAfter(TimeSpan.FromSeconds(70));
- int bytesRead;
- try
- {
- bytesRead = await state.SslStream
- .ReadAsync(buffer, 0, buffer.Length, readCts.Token)
- .ConfigureAwait(false);
- }
- catch (OperationCanceledException) when (!state.ConnectionCts.IsCancellationRequested)
- {
- Console.WriteLine($"[ReadLoopAsync] [{state.Id}] RX_TIMEOUT");
- return;
- }
- if (bytesRead == 0)
- {
- throw new EndOfStreamException("Klient zamknął połączenie.");
- }
- parser.Append(buffer, bytesRead);
- while (parser.TryGetLine(out string line))
- {
- HandleIncomingFrame(state, line);
- }
- }
- }
- catch (EndOfStreamException ex)
- {
- Console.WriteLine($"[ReadLoopAsync] [{state.Id}] EndOfStreamException {ex.Message}");
- }
- catch (IOException ex)
- {
- Console.WriteLine($"[ReadLoopAsync] [{state.Id}] ERROR IO: {ex.Message}");
- }
- catch (Exception ex)
- {
- Console.WriteLine($"[ReadLoopAsync] [{state.Id}] ERROR EX - {ex.GetType().Name} - {ex.Message}");
- }
- }
Obsługa odebranej ramki danych i przesłanie odpowiedzi:
- private void HandleIncomingFrame(ConnectionState state, string frame)
- {
- frame = (frame ?? string.Empty).TrimEnd('\r', '\n');
- Console.WriteLine($"[HandleIncomingFrame] [{state.Id}] <-- {frame}");
- if (frame.StartsWith("R2", StringComparison.Ordinal))
- {
- string reply = "ramka_R2";
- _ = SendFrameAsync(state, reply);
- } else if (frame.StartsWith("R1", StringComparison.Ordinal))
- {
- string reply = "ramka_R1";
- _ = SendFrameAsync(state, reply);
- }
- }
Przesłanie ramki danych:
- private async Task SendFrameAsync(ConnectionState state, string frameWithoutCr)
- {
- string message = frameWithoutCr.EndsWith("\r", StringComparison.Ordinal)
- ? frameWithoutCr
- : frameWithoutCr + "\r";
- byte[] bytes = Encoding.UTF8.GetBytes(message);
- await state.SslStream
- .WriteAsync(bytes, 0, bytes.Length, state.ConnectionCts.Token)
- .ConfigureAwait(false);
- await state.SslStream
- .FlushAsync(state.ConnectionCts.Token)
- .ConfigureAwait(false);
- Console.WriteLine($"[SendFrameAsync] [{state.Id}] --> {frameWithoutCr}");
- }
Powyżej mamy szkielet który można wykorzystać do budowy bardziej zaawansowanej aplikacji komunikacyjnej pomiędzy klientem TLS a serwerem.