Add project files.

alphons <alphons@heijden.com> 17 Jul 2026, 08:17
e70d6076a4c1f31f898646b3278dd730551251a0
6 files changed
  • CheckSmtpCert.slnx
  • CheckSmtpCert/CheckSmtpCert.csproj
  • CheckSmtpCert/Program.cs
  • CheckSmtpCert/Properties/launchSettings.json
  • CheckSmtpCert/SslCheckResult.cs
  • CheckSmtpCert/SslChecker.cs
diff --git a/CheckSmtpCert.slnx b/CheckSmtpCert.slnx
new file mode 100644
index 0000000..8ca1b66
--- /dev/null
+++ b/CheckSmtpCert.slnx
@@ -0,0 +1,3 @@
+<Solution>
+ <Project Path="CheckSmtpCert/CheckSmtpCert.csproj" />
+</Solution>
diff --git a/CheckSmtpCert/CheckSmtpCert.csproj b/CheckSmtpCert/CheckSmtpCert.csproj
new file mode 100644
index 0000000..5f23857
--- /dev/null
+++ b/CheckSmtpCert/CheckSmtpCert.csproj
@@ -0,0 +1,8 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <OutputType>Exe</OutputType>
+ <TargetFramework>net10.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+ </PropertyGroup>
+</Project>
diff --git a/CheckSmtpCert/Program.cs b/CheckSmtpCert/Program.cs
new file mode 100644
index 0000000..21a6e31
--- /dev/null
+++ b/CheckSmtpCert/Program.cs
@@ -0,0 +1,40 @@
+using CheckSmtpCert;
+
+// Example entry point for SslChecker: dotnet run <mailHost> <mailPort>
+if (args.Length < 2)
+{
+ Console.WriteLine("Usage: MailDiagnostics <mailHost> <mailPort>");
+ return 1;
+}
+
+string mailHost = args[0];
+int mailPort = int.Parse(args[1]);
+
+try
+{
+ // SslChecker returns a result object
+ SslCheckResult result = await SslChecker.InvokeSslCheckAsync(mailHost, mailPort);
+
+ // Show the certificate chain straight from the result object, independent of the log lines.
+ Console.WriteLine();
+ Console.WriteLine($"Certificaat voor {result.MailHost}:{result.MailPort}");
+
+ Console.WriteLine($" Geldig tot (leaf) : {result.NotAfter:yyyy-MM-dd HH:mm:ss} UTC");
+ Console.WriteLine($" Protocol : {result.Protocol}");
+ Console.WriteLine($" Cipher : {result.Cipher}");
+ Console.WriteLine($" Verificatie : {result.VerificationMessage}");
+ Console.WriteLine();
+ foreach (SslChainEntry entry in result.ChainEntries)
+ {
+ Console.WriteLine($" [{entry.Index}] {entry.Label,-8}: {entry.Subject}");
+ }
+
+ return result.IsValid ? 0 : 1;
+
+}
+catch (Exception exception)
+{
+ Console.WriteLine($"FOUT: {exception.Message}");
+ return 1;
+}
+
diff --git a/CheckSmtpCert/Properties/launchSettings.json b/CheckSmtpCert/Properties/launchSettings.json
new file mode 100644
index 0000000..1ae23c1
--- /dev/null
+++ b/CheckSmtpCert/Properties/launchSettings.json
@@ -0,0 +1,9 @@
+{
+ "profiles": {
+ "CheckSmtpCert": {
+ "commandName": "Project",
+ "commandLineArgs": "192.168.51.225 25 c:\\temp",
+ "workingDirectory": "c:\\temp"
+ }
+ }
+}
\ No newline at end of file
diff --git a/CheckSmtpCert/SslCheckResult.cs b/CheckSmtpCert/SslCheckResult.cs
new file mode 100644
index 0000000..a04e0c0
--- /dev/null
+++ b/CheckSmtpCert/SslCheckResult.cs
@@ -0,0 +1,31 @@
+namespace CheckSmtpCert;
+
+/// <summary>
+/// Structured outcome of an SSL/TLS STARTTLS check, so callers can consume the
+/// result programmatically (protocol, cipher, chain, validity) instead of
+/// depending on parsed console output.
+/// </summary>
+public sealed record SslCheckResult
+{
+ public required string MailHost { get; init; }
+
+ public required int MailPort { get; init; }
+
+ public required bool IsValid { get; init; }
+
+ public required string Protocol { get; init; }
+
+ public required string Cipher { get; init; }
+
+ public required DateTime NotAfter { get; init; }
+
+ public required IReadOnlyList<SslChainEntry> ChainEntries { get; init; }
+
+ public required string VerificationMessage { get; init; }
+
+}
+
+/// <summary>
+/// One certificate found in the validated chain (e.g. leaf, intermediate CA, or root).
+/// </summary>
+public sealed record SslChainEntry(int Index, string Label, string Subject);
diff --git a/CheckSmtpCert/SslChecker.cs b/CheckSmtpCert/SslChecker.cs
new file mode 100644
index 0000000..4021e3c
--- /dev/null
+++ b/CheckSmtpCert/SslChecker.cs
@@ -0,0 +1,190 @@
+using System.Globalization;
+using System.Net.Security;
+using System.Net.Sockets;
+using System.Security.Authentication;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+
+namespace CheckSmtpCert;
+
+/// <summary>
+/// Native .NET port of the PowerShell Invoke-SslCheck function.
+/// Instead of shelling out to openssl.exe, it performs the SMTP STARTTLS
+/// handshake and certificate chain validation directly using SslStream/X509Chain.
+/// </summary>
+public static class SslChecker
+{
+ private static readonly string[] ChainLabels = ["Leaf", "CA", "Root"];
+
+ public static async Task<SslCheckResult> InvokeSslCheckAsync(string mailHost, int mailPort)
+ {
+ using TcpClient tcpClient = new();
+ await tcpClient.ConnectAsync(mailHost, mailPort);
+ await using NetworkStream networkStream = tcpClient.GetStream();
+
+ // Upgrade the plaintext SMTP session to TLS before touching SslStream
+ await PerformStartTlsHandshakeAsync(networkStream);
+
+ X509Certificate2? leafCertificate = null;
+ List<X509Certificate2> presentedCertificates = [];
+
+ await using SslStream sslStream = new(
+ networkStream,
+ leaveInnerStreamOpen: false,
+ userCertificateValidationCallback: (sender, certificate, chain, sslPolicyErrors) =>
+ {
+ // Capture the leaf certificate plus everything the OS collected while building
+ // its own chain (leaf + intermediates the server sent, and possibly the root).
+ // Validation against the CA bundle happens afterwards, offline.
+ leafCertificate = certificate is null ? null : new X509Certificate2(certificate);
+ if (chain is not null)
+ {
+ foreach (X509ChainElement element in chain.ChainElements)
+ {
+ presentedCertificates.Add(new X509Certificate2(element.Certificate));
+ }
+ }
+
+ return true;
+ });
+
+ SslClientAuthenticationOptions authOptions = new()
+ {
+ TargetHost = mailHost,
+ EnabledSslProtocols = SslProtocols.None // let the OS negotiate the strongest common protocol
+ };
+
+ await sslStream.AuthenticateAsClientAsync(authOptions);
+
+ if (leafCertificate is null)
+ {
+ throw new InvalidOperationException("Server did not present a certificate.");
+ }
+
+ using HttpClient httpClient = new();
+
+ string isrgrootx1 = await httpClient.GetStringAsync("https://letsencrypt.org/certs/isrgrootx1.pem");
+ string isrgrootx2 = await httpClient.GetStringAsync("https://letsencrypt.org/certs/isrg-root-x2.pem");
+
+ string caPem = $"{isrgrootx1}{Environment.NewLine}{isrgrootx2}";
+
+ string protocol = sslStream.SslProtocol.ToString();
+ string cipher = sslStream.NegotiatedCipherSuite.ToString();
+
+ using X509Chain evaluatedChain = BuildChain(leafCertificate, caPem, presentedCertificates);
+ bool isValid = evaluatedChain.ChainStatus.Length == 0;
+ string verificationMessage = isValid
+ ? "OK"
+ : $"FOUT: {string.Join("; ", evaluatedChain.ChainStatus.Select(status => $"{status.Status}: {status.StatusInformation.Trim()}"))}";
+
+ List<SslChainEntry> chainEntries = [];
+ for (int i = 0; i < evaluatedChain.ChainElements.Count; i++)
+ {
+ string label = i < ChainLabels.Length ? ChainLabels[i] : "Onbekend";
+ chainEntries.Add(new SslChainEntry(i, label, evaluatedChain.ChainElements[i].Certificate.Subject));
+ }
+
+ DateTime notAfter = leafCertificate.NotAfter.ToUniversalTime();
+
+ return new SslCheckResult
+ {
+ MailHost = mailHost,
+ MailPort = mailPort,
+ IsValid = isValid,
+ Protocol = protocol,
+ Cipher = cipher,
+ NotAfter = notAfter,
+ ChainEntries = chainEntries,
+ VerificationMessage = verificationMessage
+ };
+ }
+
+ private static async Task PerformStartTlsHandshakeAsync(NetworkStream networkStream)
+ {
+ // Read the server greeting, e.g. "220 mail.example.com ESMTP"
+ await ReadSmtpResponseAsync(networkStream);
+
+ // Announce ourselves and read the capability list
+ await WriteCommandAsync(networkStream, "EHLO ssl-checker.local");
+ await ReadSmtpResponseAsync(networkStream);
+
+ // Request an upgrade to TLS
+ await WriteCommandAsync(networkStream, "STARTTLS");
+ string startTlsResponse = await ReadSmtpResponseAsync(networkStream);
+
+ if (!startTlsResponse.StartsWith("220", StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException($"Server refused STARTTLS: {startTlsResponse}");
+ }
+ }
+
+ private static async Task WriteCommandAsync(NetworkStream networkStream, string command)
+ {
+ byte[] bytes = Encoding.ASCII.GetBytes(command + "\r\n");
+ await networkStream.WriteAsync(bytes);
+ }
+
+ private static async Task<string> ReadSmtpResponseAsync(NetworkStream networkStream)
+ {
+ string lastLine;
+ string line;
+ do
+ {
+ line = await ReadLineAsync(networkStream);
+ lastLine = line;
+ }
+ while (line.Length > 3 && line[3] == '-'); // multi-line responses use "250-" until the final "250 "
+
+ return lastLine;
+ }
+
+ private static async Task<string> ReadLineAsync(NetworkStream networkStream)
+ {
+ // Read one byte at a time straight off the socket (no buffered reader) so that
+ // nothing belonging to the upcoming TLS handshake is ever consumed prematurely.
+ StringBuilder lineBuilder = new();
+ byte[] singleByte = new byte[1];
+ int previousByte = -1;
+
+ while (true)
+ {
+ int bytesRead = await networkStream.ReadAsync(singleByte);
+ if (bytesRead == 0)
+ {
+ throw new IOException("Connection closed while reading SMTP response.");
+ }
+
+ int currentByte = singleByte[0];
+ if (previousByte == '\r' && currentByte == '\n')
+ {
+ lineBuilder.Length--; // drop the trailing '\r' that was already appended
+ break;
+ }
+
+ lineBuilder.Append((char)currentByte);
+ previousByte = currentByte;
+ }
+
+ return lineBuilder.ToString();
+ }
+
+ private static X509Chain BuildChain(
+ X509Certificate2 leafCertificate, string caPem,
+ IReadOnlyCollection<X509Certificate2> presentedCertificates)
+ {
+ X509Certificate2Collection customRoots = [];
+ customRoots.ImportFromPem(caPem);
+
+ X509Chain chain = new();
+ chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
+ chain.ChainPolicy.CustomTrustStore.AddRange(customRoots);
+ chain.ChainPolicy.ExtraStore.AddRange(new X509Certificate2Collection(presentedCertificates.ToArray()));
+ chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
+ chain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
+
+ chain.Build(leafCertificate);
+ return chain;
+ }
+
+
+}