Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs
T
Joseph Doherty a1156960b9 docs: add missing XML doc comments across gateway, worker, and .NET client
Resolves 1113 documentation-completeness gaps flagged by CommentChecker
(MissingReturns, MissingInheritDoc, InheritDocMisused, MissingDoc,
MissingParam, RedundantInheritDoc) so the API surface is fully documented
and the analyzer scan is clean. Doc comments only; no code changes.
2026-06-03 12:33:53 -04:00

246 lines
10 KiB
C#

using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Security.Tls;
/// <summary>
/// Generates and persists a long-lived self-signed certificate used as the
/// Kestrel HTTPS default when no operator certificate is configured.
/// </summary>
public sealed class SelfSignedCertificateProvider
{
private const string ServerAuthOid = "1.3.6.1.5.5.7.3.1";
private const string SubjectAltNameOid = "2.5.29.17";
private readonly TlsOptions _options;
private readonly ILogger<SelfSignedCertificateProvider> _logger;
private readonly TimeProvider _timeProvider;
/// <summary>Initializes a new instance of the <see cref="SelfSignedCertificateProvider"/> class.</summary>
/// <param name="options">TLS configuration options controlling certificate subject, validity, and storage.</param>
/// <param name="logger">Logger for certificate lifecycle events.</param>
/// <param name="timeProvider">Time provider used to compute certificate validity windows.</param>
public SelfSignedCertificateProvider(
TlsOptions options,
ILogger<SelfSignedCertificateProvider> logger,
TimeProvider timeProvider)
{
_options = options;
_logger = logger;
_timeProvider = timeProvider;
}
/// <summary>Creates a fresh in-memory ECDSA P-256 self-signed certificate.</summary>
/// <returns>A new self-signed <see cref="X509Certificate2"/> with the configured SANs and validity period.</returns>
public X509Certificate2 GenerateCertificate()
{
using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
CertificateRequest request = new(
new X500DistinguishedName("CN=MxAccessGateway Self-Signed"),
key,
HashAlgorithmName.SHA256);
// End-entity (non-CA) certificate: RFC 5280 marks BasicConstraints critical only for CA certs.
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
request.CertificateExtensions.Add(new X509KeyUsageExtension(
X509KeyUsageFlags.DigitalSignature,
critical: true));
request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(
[new Oid(ServerAuthOid, "Server Authentication")],
critical: false));
SubjectAlternativeNameBuilder san = new();
san.AddDnsName("localhost");
string machine = Environment.MachineName;
if (!string.IsNullOrWhiteSpace(machine))
{
san.AddDnsName(machine);
}
// Best-effort: add the machine FQDN when it differs from the short name and "localhost".
// GetHostEntry may fail if DNS is unavailable; skip silently in that case.
try
{
string fqdn = Dns.GetHostEntry(machine).HostName;
if (!string.IsNullOrWhiteSpace(fqdn)
&& !fqdn.Equals("localhost", StringComparison.OrdinalIgnoreCase)
&& !fqdn.Equals(machine, StringComparison.OrdinalIgnoreCase))
{
san.AddDnsName(fqdn);
}
}
catch (SocketException) { /* DNS not resolvable — FQDN SAN is optional */ }
catch (ArgumentException) { /* invalid host name — skip */ }
foreach (string extra in _options.AdditionalDnsNames)
{
if (!string.IsNullOrWhiteSpace(extra))
{
san.AddDnsName(extra);
}
}
san.AddIpAddress(IPAddress.Loopback);
san.AddIpAddress(IPAddress.IPv6Loopback);
request.CertificateExtensions.Add(san.Build());
DateTimeOffset now = _timeProvider.GetUtcNow();
return request.CreateSelfSigned(now.AddDays(-1), now.AddYears(_options.ValidityYears));
}
/// <summary>Loads the persisted certificate, regenerating when missing,
/// expired (and allowed), or unreadable.</summary>
/// <returns>The loaded or newly generated <see cref="X509Certificate2"/>.</returns>
public X509Certificate2 LoadOrCreate()
{
string path = _options.SelfSignedCertPath;
if (string.IsNullOrWhiteSpace(path))
{
throw new InvalidOperationException(
"MxGateway:Tls:SelfSignedCertPath must be set when an HTTPS endpoint has no certificate.");
}
if (File.Exists(path))
{
try
{
X509Certificate2 existing = X509CertificateLoader.LoadPkcs12FromFile(path, password: null, KeyStorageFlags());
if (existing.NotAfter.ToUniversalTime() > _timeProvider.GetUtcNow().UtcDateTime)
{
Log("Loaded", existing);
return existing;
}
if (!_options.RegenerateIfExpired)
{
string notAfter = existing.NotAfter.ToUniversalTime().ToString("u");
existing.Dispose();
throw new InvalidOperationException(
$"Persisted gateway certificate at '{path}' expired on {notAfter} " +
"and MxGateway:Tls:RegenerateIfExpired is false.");
}
_logger.LogWarning(
"Persisted gateway certificate at {Path} expired on {NotAfter:u}; regenerating.",
path, existing.NotAfter.ToUniversalTime());
existing.Dispose();
}
catch (CryptographicException ex)
{
_logger.LogWarning(ex,
"Persisted gateway certificate at {Path} is unreadable; regenerating.", path);
}
catch (Exception ex) when (ex is UnauthorizedAccessException or IOException)
{
// A permission/IO error must fail fast: do not regenerate (which would
// overwrite the operator's file), surface the cause instead.
throw new InvalidOperationException(
$"Persisted gateway certificate at '{path}' could not be read; the gateway " +
"process lacks read access or the file is otherwise inaccessible.", ex);
}
}
return GenerateAndPersist(path);
}
private X509Certificate2 GenerateAndPersist(string path)
{
using X509Certificate2 generated = GenerateCertificate();
byte[] pfx = generated.Export(X509ContentType.Pkcs12);
try
{
string? directory = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
// The private-key bytes must never touch a world-readable file. Create the
// temp file empty, harden its permissions, and only then write the PFX into
// the already-protected file. The temp path is in the same directory as the
// target so the Move is atomic and preserves the hardened DACL/mode.
string temp = path + ".tmp";
using (File.Create(temp)) { }
HardenPermissions(temp);
// Writing into an existing file truncates content but preserves its ACL/mode.
// If the write or move fails the hardened temp file (which may contain private-key
// material) must not be left on disk; delete it best-effort before rethrowing.
try
{
File.WriteAllBytes(temp, pfx);
File.Move(temp, path, overwrite: true);
HardenPermissions(path);
}
catch (Exception)
{
try { File.Delete(temp); } catch { /* best effort */ }
throw;
}
X509Certificate2 loaded = X509CertificateLoader.LoadPkcs12FromFile(path, password: null, KeyStorageFlags());
Log("Generated", loaded);
return loaded;
}
finally
{
// pfx holds raw private-key material; clear it as soon as it is no longer needed.
Array.Clear(pfx, 0, pfx.Length);
}
}
private static X509KeyStorageFlags KeyStorageFlags()
=> OperatingSystem.IsWindows()
? X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable
: X509KeyStorageFlags.Exportable;
private static void HardenPermissions(string path)
{
if (OperatingSystem.IsWindows())
{
HardenWindowsAcl(path);
}
else
{
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
}
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
private static void HardenWindowsAcl(string path)
{
FileInfo file = new(path);
System.Security.AccessControl.FileSecurity security = new();
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
foreach (System.Security.Principal.WellKnownSidType sid in new[]
{
System.Security.Principal.WellKnownSidType.LocalSystemSid,
System.Security.Principal.WellKnownSidType.BuiltinAdministratorsSid,
})
{
System.Security.Principal.SecurityIdentifier identifier = new(sid, null);
security.AddAccessRule(new System.Security.AccessControl.FileSystemAccessRule(
identifier,
System.Security.AccessControl.FileSystemRights.FullControl,
System.Security.AccessControl.AccessControlType.Allow));
}
file.SetAccessControl(security);
}
private void Log(string action, X509Certificate2 cert)
{
string sans = cert.Extensions
.FirstOrDefault(e => e.Oid?.Value == SubjectAltNameOid)?
.Format(false) ?? "(none)";
_logger.LogInformation(
"{Action} gateway self-signed certificate: thumbprint={Thumbprint}, notAfter={NotAfter:u}, sans={Sans}",
action, cert.Thumbprint, cert.NotAfter.ToUniversalTime(), sans);
}
}