Add live TLS certificate probing and improve restart error handling.

Configure CertificateCheckUrl per container for curl-like TLS checks, classify Sonic permission errors, and extend setup wizard for container management.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 13:16:21 +02:00
co-authored by Cursor
parent d20e0df618
commit 58b7826162
1430 changed files with 358394 additions and 71 deletions
@@ -34,7 +34,20 @@ public sealed class CertificateProbeService
+ string.Join(", ", SupportedExtensions));
}
if (!File.Exists(normalized))
PathReachability reachability = AssessPathReachability(normalized);
if (reachability == PathReachability.AccessDenied)
{
return CertificateProbeResult.ForAccessDenied(normalized);
}
if (reachability == PathReachability.Unreachable)
{
return CertificateProbeResult.Failed(
"Pfad nicht erreichbar.");
}
if (reachability == PathReachability.FileMissing)
{
return CertificateProbeResult.Missing(
normalized,
@@ -72,6 +85,14 @@ public sealed class CertificateProbeService
"Das Kennwort ist falsch oder die Datei ist beschädigt.");
}
}
catch (UnauthorizedAccessException)
{
return CertificateProbeResult.ForAccessDenied(normalized);
}
catch (IOException ex) when (IsAccessOrLogonFailure(ex))
{
return CertificateProbeResult.ForAccessDenied(normalized);
}
catch (Exception ex)
{
return CertificateProbeResult.Failed(
@@ -151,6 +172,154 @@ public sealed class CertificateProbeService
|| extension.Equals(".p12", StringComparison.OrdinalIgnoreCase);
}
private enum PathReachability
{
FileExists,
FileMissing,
AccessDenied,
Unreachable
}
/// <summary>
/// Unterscheidet „Datei fehlt“ von „Share/Pfad ohne Admin-Konto nicht erreichbar“.
/// File.Exists liefert bei fehlenden Credentials oft nur false.
/// </summary>
private static PathReachability AssessPathReachability(string filePath)
{
try
{
if (File.Exists(filePath))
{
return PathReachability.FileExists;
}
}
catch (UnauthorizedAccessException)
{
return PathReachability.AccessDenied;
}
catch (IOException ex) when (IsAccessOrLogonFailure(ex))
{
return PathReachability.AccessDenied;
}
catch (IOException)
{
return PathReachability.Unreachable;
}
string? directory = Path.GetDirectoryName(filePath);
if (string.IsNullOrWhiteSpace(directory))
{
return PathReachability.Unreachable;
}
return AssessDirectoryReachability(directory);
}
private static PathReachability AssessDirectoryReachability(string directory)
{
string current = directory.TrimEnd('\\', '/');
while (!string.IsNullOrWhiteSpace(current))
{
try
{
if (Directory.Exists(current))
{
// Exists kann bei UNC ohne Rechte „lügen“ Auflisten erzwingen.
_ = Directory.EnumerateFileSystemEntries(current)
.Any();
return PathReachability.FileMissing;
}
if (current.StartsWith(@"\\", StringComparison.Ordinal))
{
// Exists=false: echter Zugriffsfehler oder Ordner fehlt.
_ = Directory.GetFileSystemEntries(current);
return PathReachability.FileMissing;
}
// Lokaler Ordner fehlt → für Erst-Deployment als „Datei fehlt“ werten.
return PathReachability.FileMissing;
}
catch (UnauthorizedAccessException)
{
return PathReachability.AccessDenied;
}
catch (IOException ex) when (IsAccessOrLogonFailure(ex))
{
return PathReachability.AccessDenied;
}
catch (DirectoryNotFoundException)
{
string? parent = GetParentPath(current);
if (parent is null || parent == current)
{
return current.StartsWith(@"\\", StringComparison.Ordinal)
? PathReachability.Unreachable
: PathReachability.FileMissing;
}
current = parent;
continue;
}
catch (IOException)
{
return PathReachability.Unreachable;
}
}
return PathReachability.Unreachable;
}
private static string? GetParentPath(string path)
{
string trimmed = path.TrimEnd('\\', '/');
if (trimmed.StartsWith(@"\\", StringComparison.Ordinal))
{
// \\server\share → Stop; darunter weiter nach oben.
string withoutPrefix = trimmed[2..];
int slash = withoutPrefix.IndexOfAny(['\\', '/']);
if (slash < 0)
{
return null;
}
int second = withoutPrefix.IndexOfAny(['\\', '/'], slash + 1);
if (second < 0)
{
// Bereits Share-Root \\server\share
return null;
}
}
string? parent = Path.GetDirectoryName(trimmed);
return string.IsNullOrWhiteSpace(parent) ? null : parent;
}
private static bool IsAccessOrLogonFailure(IOException ex)
{
// HRESULT-Lower-Word = Win32-Fehlercode
int win32 = ex.HResult & 0xFFFF;
return win32 is
5 or // ERROR_ACCESS_DENIED
53 or // ERROR_BAD_NETPATH
67 or // ERROR_BAD_NET_NAME
86 or // ERROR_INVALID_PASSWORD
1326 or // ERROR_LOGON_FAILURE
59 or // ERROR_UNEXP_NET_ERR
64 or // ERROR_NETNAME_DELETED
1219 or // ERROR_SESSION_CREDENTIAL_CONFLICT
1240 or // ERROR_LOGIN_WKSTA_RESTRICTION
1245 or // ERROR_ACCOUNT_RESTRICTION
1396; // ERROR_WRONG_TARGET_NAME
}
private static string? NormalizePath(string? path)
{
if (string.IsNullOrWhiteSpace(path))
@@ -233,10 +402,14 @@ public sealed class CertificateProbeService
public sealed class CertificateProbeResult
{
public const string AccessDeniedShortText = "Admin nötig";
public bool Success { get; init; }
public bool FileExists { get; init; }
public bool AccessDenied { get; init; }
public string Path { get; init; } = string.Empty;
public string Message { get; init; } = string.Empty;
@@ -289,6 +462,19 @@ public sealed class CertificateProbeResult
};
}
public static CertificateProbeResult ForAccessDenied(string path)
{
return new CertificateProbeResult
{
Success = false,
FileExists = false,
AccessDenied = true,
Path = path,
Message =
"Kein Zugriff auf den Pfad Admin-Konto nötig."
};
}
public static CertificateProbeResult PasswordRequired(string path)
{
return new CertificateProbeResult
@@ -43,6 +43,7 @@ public sealed class DeploymentTargetRepository
container.[SonicContainerId],
container.[ContainerName],
container.[ContainerDisplayName],
container.[CertificateCheckUrl],
container.[RestartTimeoutSeconds],
connection.[ConnectionName],
@@ -119,6 +120,9 @@ public sealed class DeploymentTargetRepository
int containerDisplayNameOrdinal =
reader.GetOrdinal("ContainerDisplayName");
int certificateCheckUrlOrdinal =
reader.GetOrdinal("CertificateCheckUrl");
int restartTimeoutOrdinal =
reader.GetOrdinal("RestartTimeoutSeconds");
@@ -144,6 +148,11 @@ public sealed class DeploymentTargetRepository
reader,
containerDisplayNameOrdinal);
string? certificateCheckUrl =
ReadNullableString(
reader,
certificateCheckUrlOrdinal);
string companyCode =
reader.GetString(companyCodeOrdinal);
@@ -152,6 +161,22 @@ public sealed class DeploymentTargetRepository
? containerName
: containerDisplayName;
string tlsHost = string.Empty;
int? tlsPort = null;
string tlsServerName = string.Empty;
if (TlsEndpointProbeService.TryParseEndpoint(
certificateCheckUrl,
out string host,
out int port,
out string serverName,
out _))
{
tlsHost = host;
tlsPort = port;
tlsServerName = serverName;
}
targets.Add(new DeploymentTarget
{
Id = reader.GetInt32(targetIdOrdinal),
@@ -188,11 +213,11 @@ public sealed class DeploymentTargetRepository
RestartTimeoutSeconds =
reader.GetInt32(restartTimeoutOrdinal),
TlsHost = string.Empty,
TlsHost = tlsHost,
TlsPort = null,
TlsPort = tlsPort,
TlsServerName = string.Empty,
TlsServerName = tlsServerName,
ExpectedFingerprint = null,
@@ -0,0 +1,75 @@
namespace ZA.CoreService.ESBCertificateManager.Services;
/// <summary>
/// Erkennt typische Sonic-/MfApi-Fehlermeldungen und liefert
/// verständliche Hinweise für die UI.
/// </summary>
public static class SonicErrorClassifier
{
private static readonly string[] PermissionMarkers =
[
"ManagePermissionDenied",
"ConfigurePermissionDenied",
"ManagementPermissionDenied",
"PermissionDenied",
"MFSecurityException",
"SecurityException",
"Access is denied",
"Access denied",
"not authorized",
"nicht autorisiert",
"permission denied",
"keine Berechtigung",
"Insufficient privileges",
"Unauthorized"
];
public static bool LooksLikeMissingPermission(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return false;
}
foreach (string marker in PermissionMarkers)
{
if (text.Contains(marker, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
public static string PermissionDeniedUserMessage =>
"Keine Rechte für den Neustart.\n\n"
+ "Der hinterlegte Sonic-Benutzer darf diesen Container "
+ "nicht neu starten (Manage-/Restart-Recht fehlt).\n\n"
+ "Bitte in der Sonic Management Console die Rechte prüfen "
+ "oder ein Profil mit ausreichenden Rechten verwenden.";
public static string FormatRestartFailure(
string? status,
string? detail)
{
string combined = string.Join(
Environment.NewLine,
new[] { status, detail }
.Where(value => !string.IsNullOrWhiteSpace(value)));
if (LooksLikeMissingPermission(combined))
{
return PermissionDeniedUserMessage
+ Environment.NewLine
+ Environment.NewLine
+ "--- Technische Details ---"
+ Environment.NewLine
+ combined;
}
return string.IsNullOrWhiteSpace(combined)
? "Neustart fehlgeschlagen (keine Details)."
: combined;
}
}
@@ -34,9 +34,21 @@ public sealed class SonicManagementClient : IDisposable
$"Domain={_connection.DomainName}; URL={_connection.ConnectionUrl}\n{output}");
}
return (false, "Neustart fehlgeschlagen (MfApi)",
$"Container '{containerName}', Domain '{_connection.DomainName}', URL {_connection.ConnectionUrl}\n" +
$"Fehler: {error}\n\n{output}");
string technical =
$"Container '{containerName}', Domain '{_connection.DomainName}', URL {_connection.ConnectionUrl}\n"
+ $"Fehler: {error}\n\n{output}";
if (SonicErrorClassifier.LooksLikeMissingPermission(technical))
{
return (
false,
"Keine Rechte für den Neustart",
SonicErrorClassifier.FormatRestartFailure(
"Keine Rechte für den Neustart",
technical));
}
return (false, "Neustart fehlgeschlagen (MfApi)", technical);
}
public void Dispose()
@@ -380,6 +380,228 @@ public sealed class SonicSetupRepository
}
}
public async Task UpdateCredentialAsync(
int sonicCredentialId,
int sonicConnectionId,
string credentialName,
string userName,
string secret,
bool isDefault,
CancellationToken cancellationToken = default)
{
if (sonicCredentialId <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(sonicCredentialId));
}
if (sonicConnectionId <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(sonicConnectionId));
}
if (string.IsNullOrWhiteSpace(credentialName))
{
throw new ArgumentException(
"Der Profilname fehlt.",
nameof(credentialName));
}
if (string.IsNullOrWhiteSpace(userName))
{
throw new ArgumentException(
"Der Sonic-Benutzername fehlt.",
nameof(userName));
}
if (string.IsNullOrEmpty(secret))
{
throw new ArgumentException(
"Das Sonic-Kennwort fehlt.",
nameof(secret));
}
string normalizedCredentialName =
credentialName.Trim();
string normalizedUserName =
userName.Trim();
if (normalizedCredentialName.Length > 150)
{
throw new ArgumentException(
"Der Profilname darf maximal 150 Zeichen enthalten.",
nameof(credentialName));
}
if (normalizedUserName.Length > 256)
{
throw new ArgumentException(
"Der Benutzername darf maximal 256 Zeichen enthalten.",
nameof(userName));
}
if (secret.Length > 512)
{
throw new ArgumentException(
"Das Kennwort darf maximal 512 Zeichen enthalten.",
nameof(secret));
}
await using SqlConnection connection =
new(_connectionString);
await connection.OpenAsync(cancellationToken);
await using SqlTransaction transaction =
(SqlTransaction)
await connection.BeginTransactionAsync(
cancellationToken);
try
{
if (isDefault)
{
const string clearDefaultSql = """
UPDATE [dbo].[SonicCredential]
SET
[IsDefault] = 0,
[ModifiedDateTime] = SYSUTCDATETIME(),
[ModifiedBy] = SUSER_SNAME()
WHERE [SonicConnectionId] =
@SonicConnectionId
AND [IsDefault] = 1
AND [SonicCredentialId] <>
@SonicCredentialId;
""";
await using SqlCommand clearDefaultCommand =
new(
clearDefaultSql,
connection,
transaction);
clearDefaultCommand.Parameters.Add(
new SqlParameter(
"@SonicConnectionId",
SqlDbType.Int)
{
Value = sonicConnectionId
});
clearDefaultCommand.Parameters.Add(
new SqlParameter(
"@SonicCredentialId",
SqlDbType.Int)
{
Value = sonicCredentialId
});
await clearDefaultCommand.ExecuteNonQueryAsync(
cancellationToken);
}
const string updateSql = """
UPDATE [dbo].[SonicCredential]
SET
[CredentialName] = @CredentialName,
[CredentialUserName] = @CredentialUserName,
[CredentialSecret] = @CredentialSecret,
[IsDefault] = @IsDefault,
[ModifiedDateTime] = SYSUTCDATETIME(),
[ModifiedBy] = SUSER_SNAME()
WHERE [SonicCredentialId] = @SonicCredentialId
AND [SonicConnectionId] = @SonicConnectionId
AND [IsActive] = 1;
""";
await using SqlCommand updateCommand =
new(
updateSql,
connection,
transaction);
updateCommand.Parameters.Add(
new SqlParameter(
"@SonicCredentialId",
SqlDbType.Int)
{
Value = sonicCredentialId
});
updateCommand.Parameters.Add(
new SqlParameter(
"@SonicConnectionId",
SqlDbType.Int)
{
Value = sonicConnectionId
});
updateCommand.Parameters.Add(
new SqlParameter(
"@CredentialName",
SqlDbType.NVarChar,
150)
{
Value = normalizedCredentialName
});
updateCommand.Parameters.Add(
new SqlParameter(
"@CredentialUserName",
SqlDbType.NVarChar,
256)
{
Value = normalizedUserName
});
updateCommand.Parameters.Add(
new SqlParameter(
"@CredentialSecret",
SqlDbType.NVarChar,
512)
{
Value = secret
});
updateCommand.Parameters.Add(
new SqlParameter(
"@IsDefault",
SqlDbType.Bit)
{
Value = isDefault
});
int affected =
await updateCommand.ExecuteNonQueryAsync(
cancellationToken);
if (affected == 0)
{
throw new InvalidOperationException(
$"Benutzerprofil Id {sonicCredentialId} "
+ "wurde nicht gefunden oder ist inaktiv.");
}
await transaction.CommitAsync(cancellationToken);
}
catch
{
try
{
await transaction.RollbackAsync(
CancellationToken.None);
}
catch
{
// Die ursprüngliche Exception soll erhalten bleiben.
}
throw;
}
}
public async Task<IReadOnlyList<SonicCredentialProfile>>
GetCredentialsAsync(
int sonicConnectionId,
@@ -663,6 +885,7 @@ public sealed class SonicSetupRepository
container.[CompanyId],
container.[ContainerName],
container.[ContainerDisplayName],
container.[CertificateCheckUrl],
container.[RestartTimeoutSeconds],
company.[CompanyCode],
connection.[ConnectionName]
@@ -729,6 +952,9 @@ public sealed class SonicSetupRepository
int displayOrdinal =
reader.GetOrdinal("ContainerDisplayName");
int checkUrlOrdinal =
reader.GetOrdinal("CertificateCheckUrl");
containers.Add(
new ContainerOption
{
@@ -753,6 +979,11 @@ public sealed class SonicSetupRepository
? null
: reader.GetString(displayOrdinal),
CertificateCheckUrl =
reader.IsDBNull(checkUrlOrdinal)
? null
: reader.GetString(checkUrlOrdinal),
RestartTimeoutSeconds =
reader.GetInt32(
reader.GetOrdinal("RestartTimeoutSeconds")),
@@ -1057,6 +1288,7 @@ public sealed class SonicSetupRepository
string containerName,
string? containerDisplayName,
int restartTimeoutSeconds,
string? certificateCheckUrl = null,
CancellationToken cancellationToken = default)
{
if (companyId <= 0)
@@ -1093,6 +1325,9 @@ public sealed class SonicSetupRepository
"Timeout muss zwischen 10 und 3600 Sekunden liegen.");
}
string? checkUrl =
TlsEndpointProbeService.NormalizeCheckUrl(certificateCheckUrl);
const string sql = """
INSERT INTO [dbo].[SonicContainer]
(
@@ -1100,6 +1335,7 @@ public sealed class SonicSetupRepository
[SonicConnectionId],
[ContainerName],
[ContainerDisplayName],
[CertificateCheckUrl],
[RestartTimeoutSeconds],
[IsActive],
[CreationDateTime],
@@ -1112,6 +1348,7 @@ public sealed class SonicSetupRepository
@SonicConnectionId,
@ContainerName,
@ContainerDisplayName,
@CertificateCheckUrl,
@RestartTimeoutSeconds,
1,
SYSUTCDATETIME(),
@@ -1159,6 +1396,17 @@ public sealed class SonicSetupRepository
: display
});
command.Parameters.Add(
new SqlParameter(
"@CertificateCheckUrl",
SqlDbType.NVarChar,
500)
{
Value = checkUrl is null
? DBNull.Value
: checkUrl
});
command.Parameters.Add(
new SqlParameter(
"@RestartTimeoutSeconds",
@@ -1457,6 +1705,7 @@ public sealed class SonicSetupRepository
string containerName,
string? containerDisplayName,
int restartTimeoutSeconds,
string? certificateCheckUrl = null,
CancellationToken cancellationToken = default)
{
if (sonicContainerId <= 0)
@@ -1499,6 +1748,9 @@ public sealed class SonicSetupRepository
"Timeout muss zwischen 10 und 3600 Sekunden liegen.");
}
string? checkUrl =
TlsEndpointProbeService.NormalizeCheckUrl(certificateCheckUrl);
const string sql = """
UPDATE [dbo].[SonicContainer]
SET
@@ -1506,6 +1758,7 @@ public sealed class SonicSetupRepository
[SonicConnectionId] = @SonicConnectionId,
[ContainerName] = @ContainerName,
[ContainerDisplayName] = @ContainerDisplayName,
[CertificateCheckUrl] = @CertificateCheckUrl,
[RestartTimeoutSeconds] = @RestartTimeoutSeconds,
[ModifiedDateTime] = SYSUTCDATETIME(),
[ModifiedBy] = SUSER_SNAME()
@@ -1559,6 +1812,17 @@ public sealed class SonicSetupRepository
: display
});
command.Parameters.Add(
new SqlParameter(
"@CertificateCheckUrl",
SqlDbType.NVarChar,
500)
{
Value = checkUrl is null
? DBNull.Value
: checkUrl
});
command.Parameters.Add(
new SqlParameter(
"@RestartTimeoutSeconds",
@@ -0,0 +1,235 @@
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text.RegularExpressions;
using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Services;
/// <summary>
/// Liest das präsentierte TLS-Zertifikat eines Endpoints
/// (entspricht grob <c>curl -v https://host:port</c>).
/// </summary>
public sealed class TlsEndpointProbeService
{
private static readonly Regex HostPortRegex = new(
@"^(?<host>[^:/]+)(:(?<port>\d{1,5}))?$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
public CertificateProbeResult Probe(
string? checkUrl,
int timeoutMilliseconds = 8000)
{
if (!TryParseEndpoint(
checkUrl,
out string host,
out int port,
out string serverName,
out string? parseError))
{
return CertificateProbeResult.Failed(
parseError ?? "Ungültige Prüf-URL.");
}
try
{
using CancellationTokenSource cts = new(timeoutMilliseconds);
using TcpClient client = new();
using (cts.Token.Register(
() =>
{
try
{
client.Close();
}
catch
{
// Timeout beendet die Verbindung.
}
}))
{
client.Connect(host, port);
}
using SslStream ssl = new(
client.GetStream(),
leaveInnerStreamOpen: false,
userCertificateValidationCallback:
static (_, _, _, _) => true);
ssl.AuthenticateAsClient(serverName);
if (ssl.RemoteCertificate is null)
{
return CertificateProbeResult.Failed(
$"Kein Zertifikat von {host}:{port} erhalten.");
}
using X509Certificate2 certificate = new(ssl.RemoteCertificate);
CertificateInfo info = ToCertificateInfo(certificate);
return CertificateProbeResult.Found(
$"{host}:{port}",
info);
}
catch (Exception ex)
{
return CertificateProbeResult.Failed(
$"TLS-Prüfung {host}:{port} fehlgeschlagen: {ex.Message}");
}
}
public static bool TryParseEndpoint(
string? value,
out string host,
out int port,
out string serverName,
out string? error)
{
host = string.Empty;
port = 443;
serverName = string.Empty;
error = null;
if (string.IsNullOrWhiteSpace(value))
{
error = "Keine Prüf-URL hinterlegt.";
return false;
}
string trimmed = value.Trim();
if (Uri.TryCreate(trimmed, UriKind.Absolute, out Uri? uri)
&& (uri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase)
|| uri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase)
|| uri.Scheme.Equals("ssl", StringComparison.OrdinalIgnoreCase)
|| uri.Scheme.Equals("tcp", StringComparison.OrdinalIgnoreCase)))
{
if (string.IsNullOrWhiteSpace(uri.Host))
{
error = "Host in der Prüf-URL fehlt.";
return false;
}
host = uri.Host;
serverName = uri.Host;
port = uri.IsDefaultPort
? (uri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase)
? 80
: 443)
: uri.Port;
if (port is < 1 or > 65535)
{
error = "Port muss zwischen 1 und 65535 liegen.";
return false;
}
return true;
}
Match match = HostPortRegex.Match(trimmed);
if (!match.Success)
{
error =
"Prüf-URL ungültig. Beispiele:\n"
+ "https://server:8443\n"
+ "server:8443\n"
+ "server";
return false;
}
host = match.Groups["host"].Value;
serverName = host;
if (match.Groups["port"].Success)
{
if (!int.TryParse(match.Groups["port"].Value, out port)
|| port is < 1 or > 65535)
{
error = "Port muss zwischen 1 und 65535 liegen.";
return false;
}
}
return !string.IsNullOrWhiteSpace(host);
}
public static string? NormalizeCheckUrl(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
string trimmed = value.Trim();
if (!TryParseEndpoint(
trimmed,
out _,
out _,
out _,
out string? error))
{
throw new ArgumentException(
error ?? "Ungültige Prüf-URL.",
nameof(value));
}
if (trimmed.Length > 500)
{
throw new ArgumentException(
"Die Prüf-URL darf maximal 500 Zeichen enthalten.",
nameof(value));
}
return trimmed;
}
private static CertificateInfo ToCertificateInfo(
X509Certificate2 certificate)
{
string subject = certificate.GetNameInfo(
X509NameType.SimpleName,
forIssuer: false);
string issuer = certificate.GetNameInfo(
X509NameType.SimpleName,
forIssuer: true);
if (string.IsNullOrWhiteSpace(subject))
{
subject = certificate.Subject;
}
if (string.IsNullOrWhiteSpace(issuer))
{
issuer = certificate.Issuer;
}
string fingerprint = certificate.GetCertHashString(
HashAlgorithmName.SHA256);
fingerprint = string.Join(
":",
Enumerable.Range(0, fingerprint.Length / 2)
.Select(index => fingerprint.Substring(index * 2, 2)));
DateTimeOffset validFrom = new(certificate.NotBefore);
DateTimeOffset validUntil = new(certificate.NotAfter);
DateTimeOffset now = DateTimeOffset.Now;
return new CertificateInfo
{
Subject = subject,
Issuer = issuer,
FingerprintSha256 = fingerprint,
ValidFrom = validFrom,
ValidUntil = validUntil,
IsCurrentlyValid = now >= validFrom && now <= validUntil
};
}
}