środa, 25 marca 2026

C# - Serwer TLS V2

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:


oraz z weryfkacją (mTLS):


Na samym początku potrzebuje odpowiedniego zestawu bibliotek:

  1. using System;
  2. using System.Net;
  3. using System.Net.Sockets;
  4. using System.Net.Security;
  5. using System.Security.Authentication;
  6. using System.Security.Cryptography.X509Certificates;
  7. using System.Text;
  8. using System.Threading;
  9. using System.Threading.Tasks;
  10. using System.Collections.Generic;
  11. using System.IO;

Dalej potrzebujemy certyfikatów oraz numer portu po którym nastapi połączenie :

  1.         private int ServerPort = 5555;
  2.         private readonly string ServerCertificateFile = @"C:\\przyklad\\Cert\\server-cert.pfx";
  3.         private readonly string ServerCertificatePassword = "haslo_do_certyfikatu";

Definiujemy tzw. CancellationTokenSource. Pozwala na kontrolowane zatrzymanie serwera, który pracuje jako async. 

  1.         private CancellationTokenSource? _serverCts;

Klasa z danymi połączenia:

  1.  private sealed class ConnectionState : IDisposable
  2.         {
  3.             public string Id { get; }
  4.             public TcpClient Client { get; }
  5.             public SslStream Ssl { get; }
  6.             public CancellationTokenSource ConnCts { get; }
  7.  
  8.             public ConnectionState(string id, TcpClient client, SslStream ssl, CancellationTokenSource connCts)
  9.             {
  10.                 Id = id;
  11.                 Client = client;
  12.                 Ssl = ssl;
  13.                 ConnCts = connCts;
  14.             }
  15.  
  16.             public void Dispose()
  17.             {
  18.                 try { ConnCts.Cancel(); } catch { }
  19.                 try { Ssl.Dispose(); } catch { }
  20.                 try { Client.Dispose(); } catch { }
  21.             }
  22.         }

Przechowywane dane sa przypisane do jednego klienta. 

Teraz funkcja odpowiedzialna za akceptacje klienta TLS na podstawie walidacji jego certyfikatu. 

  1.         public bool App_CertificateValidation(
  2.             Object sender,
  3.             X509Certificate certificate,
  4.             X509Chain chain,
  5.             SslPolicyErrors sslPolicyErrors)
  6.         {
  7.             Console.WriteLine($"{sslPolicyErrors}");
  8.  
  9.             if (sslPolicyErrors == SslPolicyErrors.None)
  10.                 return true;
  11.  
  12.             if (sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateNotAvailable))
  13.             {
  14.                 Console.WriteLine("Klient nie przesłał certyfikatu — akceptuję.");
  15.                 return true;
  16.             }
  17.  
  18.             if (sslPolicyErrors == SslPolicyErrors.RemoteCertificateChainErrors)
  19.             {
  20.                 Console.WriteLine("Błąd łańcucha certyfikatu — akceptuję.");
  21.                 return true;
  22.             }
  23.  
  24.             Console.WriteLine("Odrzucono certyfikat.");
  25.             return false;
  26.         }

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:

  1. sslStream.AuthenticateAsServer(serverCertificate, true, SslProtocols.Tls12, true);
  2.  
  3. public bool App_CertificateValidation(
  4.     object sender,
  5.     X509Certificate certificate,
  6.     X509Chain chain,
  7.     SslPolicyErrors sslPolicyErrors)
  8. {
  9.     Console.WriteLine($"{sslPolicyErrors}");
  10.  
  11.     if (sslPolicyErrors == SslPolicyErrors.None)
  12.         return true;
  13.  
  14.     Console.WriteLine("Odrzucono certyfikat");
  15.     return false;
  16. }

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. 

  1.         private readonly string AllowedClientThumbprint = "THUMPPRINT";        
  2.  
  3.         public bool App_CertificateValidation(
  4.                     object sender,
  5.                     X509Certificate certificate,
  6.                     X509Chain chain,
  7.                     SslPolicyErrors sslPolicyErrors)
  8.         {
  9.             _log("[App_CertificateValidation] - start");
  10.             _log($"[App_CertificateValidation] PolicyErrors: {sslPolicyErrors}");
  11.  
  12.             try
  13.             {
  14.                 if (certificate == null)
  15.                 {
  16.                     //Jeśli ma przepuścić gdy nie ma certyfikatu
  17.  
  18.                     //Zapisz_log("[App_CertificateValidation] Brak certyfikatu klienta.");
  19.                     //return true;
  20.  
  21.                     return false;
  22.                 }
  23.  
  24.                 var cert2 = new X509Certificate2(certificate);
  25.  
  26.                 _log("[App_CertificateValidation] Subject: " + cert2.Subject);
  27.                 _log("[App_CertificateValidation] Issuer: " + cert2.Issuer);
  28.                 _log("[App_CertificateValidation] Thumbprint: " + cert2.Thumbprint);
  29.                 _log("[App_CertificateValidation] Serial: " + cert2.SerialNumber);
  30.                 _log("[App_CertificateValidation] NotBefore: " + cert2.NotBefore);
  31.                 _log("[App_CertificateValidation] NotAfter: " + cert2.NotAfter);
  32.                 _log("[App_CertificateValidation] HasPrivateKey: " + cert2.HasPrivateKey);
  33.  
  34.                 bool isSelfSigned = string.Equals(cert2.Subject, cert2.Issuer, StringComparison.OrdinalIgnoreCase);
  35.                 _log("[App_CertificateValidation] IsSelfSigned: " + isSelfSigned);
  36.  
  37.                 foreach (var ext in cert2.Extensions)
  38.                 {
  39.                     if (ext is X509EnhancedKeyUsageExtension eku)
  40.                     {
  41.                         foreach (var oid in eku.EnhancedKeyUsages)
  42.                         {
  43.                             _log("[App_CertificateValidation] EKU: " + oid.FriendlyName + " (" + oid.Value + ")");
  44.                         }
  45.                     }
  46.                 }
  47.  
  48.                 var debugChain = new X509Chain();
  49.                 debugChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
  50.                 debugChain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
  51.  
  52.                 bool chainValid = debugChain.Build(cert2);
  53.  
  54.                 Zapisz_log("[App_CertificateValidation] Chain.Build(): " + chainValid);
  55.  
  56.                 for (int i = 0; i < debugChain.ChainElements.Count; i++)
  57.                 {
  58.                     var element = debugChain.ChainElements[i];
  59.                     _log($"[App_CertificateValidation][{i}] Subject: {element.Certificate.Subject}");
  60.                     _log($"[App_CertificateValidation][{i}] Issuer: {element.Certificate.Issuer}");
  61.                 }
  62.  
  63.                 foreach (var status in debugChain.ChainStatus)
  64.                 {
  65.                     _log($"[App_CertificateValidation] ERROR {status.Status} - {status.StatusInformation}");
  66.                 }
  67.  
  68.                 if (sslPolicyErrors == SslPolicyErrors.None)
  69.                 {
  70.                     _log("[App_CertificateValidation] Certyfikat poprawny");
  71.                     return true;
  72.                 }
  73.  
  74.                 if (sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateChainErrors))
  75.                 {
  76.                     string certThumbprint = (cert2.Thumbprint ?? "").Replace(" ", "").ToUpperInvariant();
  77.                     string allowedThumbprint = (AllowedClientThumbprint ?? "").Replace(" ", "").ToUpperInvariant();
  78.  
  79.                     _log("[App_CertificateValidation] Compare thumbprint:");
  80.                     _log("[App_CertificateValidation] CERT: " + certThumbprint);
  81.                     _log("[App_CertificateValidation] ALLOW: " + allowedThumbprint);
  82.  
  83.                     if (isSelfSigned && certThumbprint == allowedThumbprint)
  84.                     {
  85.                         Zapisz_log("[App_CertificateValidation] Zaakceptowano self-signed - thumbprint OK");
  86.                         return true;
  87.                     }
  88.  
  89.                     _log("[App_CertificateValidation] Self-signed NIE pasuje do dozwolonego.");
  90.                 }
  91.  
  92.                 _log("[App_CertificateValidation] Błąd certyfikatu: " + sslPolicyErrors);
  93.  
  94.                 return false;
  95.             }
  96.             catch (Exception ex)
  97.             {
  98.                 _log("[[App_CertificateValidation]] Exception: " + ex);
  99.                 return false;
  100.             }
  101.             finally
  102.             {
  103.                 _log("[App_CertificateValidation] Koniec walidacji\n");
  104.             }
  105.         }

Do składania odebranych danych w gotowe bloki służy klasa CrLineParser:

  1.        private sealed class CrLineParser
  2.        {
  3.            private readonly int _maxLineBytes;
  4.            private readonly List<byte> _buf = new List<byte>(8192);
  5.  
  6.            public CrLineParser(int maxLineBytes) => _maxLineBytes = maxLineBytes;
  7.  
  8.            public void Append(byte[] data, int count)
  9.            {
  10.                for (int i = 0; i < count; i++)
  11.                    _buf.Add(data[i]);
  12.  
  13.                //czyść bufor jeśli błędne dane
  14.                if (_buf.Count > _maxLineBytes * 2)
  15.                {
  16.                    _buf.Clear();
  17.                }      
  18.            }
  19.  
  20.            public bool TryGetLine(out string line)
  21.            {
  22.                line = "";
  23.  
  24.                int idxCr = _buf.IndexOf(13); //CR
  25.                int idxLf = _buf.IndexOf(10); //LF
  26.  
  27.                int idx;
  28.                if (idxCr >= 0 && idxLf >= 0)
  29.                {
  30.                    idx = Math.Min(idxCr, idxLf);
  31.                }
  32.                else if (idxCr >= 0)
  33.                {
  34.                    idx = idxCr;
  35.                }
  36.                else if (idxLf >= 0)
  37.                {
  38.                    idx = idxLf;
  39.                }
  40.                else  
  41.                {
  42.                    return false;
  43.                }
  44.  
  45.                var lineBytes = _buf.GetRange(0, idx).ToArray();
  46.  
  47.                int removeCount = idx + 1;
  48.                while (removeCount < _buf.Count && (_buf[removeCount] == 13 || _buf[removeCount] == 10))
  49.                {
  50.                    removeCount++;
  51.                }
  52.                  
  53.                _buf.RemoveRange(0, removeCount);
  54.  
  55.                line = Encoding.UTF8.GetString(lineBytes);
  56.                if (lineBytes.Length > _maxLineBytes)
  57.                {
  58.                    line = line.Substring(0, Math.Min(line.Length, 1024));
  59.                }
  60.  
  61.                return true;
  62.            }
  63.        }

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:

  1.         public async Task StartServerAsync()
  2.         {
  3.             var serverCertificate = new X509Certificate2(
  4.                 _serverCertificateFile,
  5.                 _serverCertificatePassword,
  6.                 X509KeyStorageFlags.DefaultKeySet);
  7.  
  8.             var listener = new TcpListener(IPAddress.Any, _serverPort);
  9.  
  10.             _serverCts = new CancellationTokenSource();
  11.             CancellationToken token = _serverCts.Token;
  12.  
  13.             Console.WriteLine($"[StartServerAsync] - start server port - {_serverPort}");
  14.  
  15.             try
  16.             {
  17.                 listener.Start();
  18.  
  19.                 while (!token.IsCancellationRequested)
  20.                 {
  21.                     TcpClient client;
  22.  
  23.                     try
  24.                     {
  25.                         client = await listener.AcceptTcpClientAsync().ConfigureAwait(false);
  26.                     }
  27.                     catch (ObjectDisposedException)
  28.                     {
  29.                         break;
  30.                     }
  31.                     catch (Exception ex)
  32.                     {
  33.                         Console.WriteLine($"[StartServerAsync] ERROR - {ex.Message}");
  34.                         await Task.Delay(300, token).ConfigureAwait(false);
  35.                         continue;
  36.                     }
  37.  
  38.                     client.NoDelay = true;
  39.                     TryEnableKeepAlive(client);
  40.  
  41.                     string clientId = client.Client.RemoteEndPoint?.ToString()
  42.                                       ?? Guid.NewGuid().ToString("N");
  43.  
  44.                     var sslStream = new SslStream(
  45.                         client.GetStream(),
  46.                         leaveInnerStreamOpen: false,
  47.                         userCertificateValidationCallback: AppCertificateValidation);
  48.  
  49.                     var connectionCts = CancellationTokenSource.CreateLinkedTokenSource(token);
  50.                     var state = new ConnectionState(clientId, client, sslStream, connectionCts);
  51.  
  52.                     _ = Task.Run(() => HandleClientAsync(state, serverCertificate), connectionCts.Token);
  53.                 }
  54.             }
  55.             finally
  56.             {
  57.                 try { listener.Stop(); } catch { }
  58.                 Console.WriteLine("[StartServerAsync] - Listener stop");
  59.             }
  60.         }

Obsługa klientów:

  1.         private async Task HandleClientAsync(ConnectionState state, X509Certificate2 serverCertificate)
  2.         {
  3.             try
  4.             {
  5.                 await AuthenticateClientAsync(state, serverCertificate).ConfigureAwait(false);
  6.  
  7.                 state.SslStream.ReadTimeout = 70000;
  8.                 state.SslStream.WriteTimeout = 70000;
  9.  
  10.                 Console.WriteLine($"[HandleClientAsync] Połączenie TLS OK: {state.Id}");
  11.  
  12.                 await ReadLoopAsync(state).ConfigureAwait(false);
  13.             }
  14.             catch (AuthenticationException ex)
  15.             {
  16.                 Console.WriteLine($"[HandleClientAsync] [{state.Id}] TLS handshake failed: {ex.Message}");
  17.             }
  18.             catch (Exception ex)
  19.             {
  20.                 Console.WriteLine($"[HandleClientAsync] [{state.Id}] Błąd obsługi klienta: {ex.GetType().Name} - {ex.Message}");
  21.             }
  22.             finally
  23.             {
  24.                 state.Dispose();
  25.             }
  26.         }

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:

  1.         private async Task ReadLoopAsync(ConnectionState state)
  2.         {
  3.             var parser = new CrLineParser(maxLineBytes: 64 * 1024);
  4.             var buffer = new byte[4096];
  5.  
  6.             try
  7.             {
  8.                 while (!state.ConnectionCts.IsCancellationRequested)
  9.                 {
  10.                     using var readCts = CancellationTokenSource.CreateLinkedTokenSource(state.ConnectionCts.Token);
  11.                     readCts.CancelAfter(TimeSpan.FromSeconds(70));
  12.  
  13.                     int bytesRead;
  14.  
  15.                     try
  16.                     {
  17.                         bytesRead = await state.SslStream
  18.                             .ReadAsync(buffer, 0, buffer.Length, readCts.Token)
  19.                             .ConfigureAwait(false);
  20.                     }
  21.                     catch (OperationCanceledException) when (!state.ConnectionCts.IsCancellationRequested)
  22.                     {
  23.                         Console.WriteLine($"[ReadLoopAsync] [{state.Id}] RX_TIMEOUT");
  24.                         return;
  25.                     }
  26.  
  27.                     if (bytesRead == 0)
  28.                     {
  29.                         throw new EndOfStreamException("Klient zamknął połączenie.");
  30.                     }
  31.  
  32.                     parser.Append(buffer, bytesRead);
  33.  
  34.                     while (parser.TryGetLine(out string line))
  35.                     {
  36.                         HandleIncomingFrame(state, line);
  37.                     }
  38.                 }
  39.             }
  40.             catch (EndOfStreamException ex)
  41.             {
  42.                 Console.WriteLine($"[ReadLoopAsync] [{state.Id}] EndOfStreamException {ex.Message}");
  43.             }
  44.             catch (IOException ex)
  45.             {
  46.                 Console.WriteLine($"[ReadLoopAsync] [{state.Id}] ERROR IO: {ex.Message}");
  47.             }
  48.             catch (Exception ex)
  49.             {
  50.                 Console.WriteLine($"[ReadLoopAsync]  [{state.Id}] ERROR EX - {ex.GetType().Name} - {ex.Message}");
  51.             }
  52.         }

Obsługa odebranej ramki danych i przesłanie odpowiedzi:

  1.         private void HandleIncomingFrame(ConnectionState state, string frame)
  2.         {
  3.             frame = (frame ?? string.Empty).TrimEnd('\r', '\n');
  4.  
  5.             Console.WriteLine($"[HandleIncomingFrame] [{state.Id}] <-- {frame}");

  6.             if (frame.StartsWith("R2", StringComparison.Ordinal))
  7.             {
  8.                 string reply = "ramka_R2";
  9.  
  10.                 _ = SendFrameAsync(state, reply);
  11.             } else if (frame.StartsWith("R1", StringComparison.Ordinal))
  12.             {
  13.                 string reply = "ramka_R1";
  14.                 _ = SendFrameAsync(state, reply);
  15.             }
  16.         }

Przesłanie ramki danych:

  1.         private async Task SendFrameAsync(ConnectionState state, string frameWithoutCr)
  2.         {
  3.             string message = frameWithoutCr.EndsWith("\r", StringComparison.Ordinal)
  4.                 ? frameWithoutCr
  5.                 : frameWithoutCr + "\r";
  6.  
  7.             byte[] bytes = Encoding.UTF8.GetBytes(message);
  8.  
  9.             await state.SslStream
  10.                 .WriteAsync(bytes, 0, bytes.Length, state.ConnectionCts.Token)
  11.                 .ConfigureAwait(false);
  12.  
  13.             await state.SslStream
  14.                 .FlushAsync(state.ConnectionCts.Token)
  15.                 .ConfigureAwait(false);
  16.  
  17.             Console.WriteLine($"[SendFrameAsync] [{state.Id}] --> {frameWithoutCr}");
  18.         }

Powyżej mamy szkielet który można wykorzystać do budowy bardziej zaawansowanej aplikacji komunikacyjnej pomiędzy klientem TLS a serwerem.