11a716a07f
Full-host tests passed in isolation but failed 2-4/run under parallel execution on
Windows with 'the process cannot access the file ... because it is being used by
another process' (macOS never sees it — Unix deletes open files). Two shared-state
parallel collisions, discovered while verifying TST-08:
1. Self-signed cert generation. GatewayTlsBootstrapTests sets process-global
Kestrel/TLS env vars that GatewayApplication.Build reads; a parallel host-building
test inherits them mid-run and both generate a cert at the same path, racing on the
fixed-name '<path>.tmp'. Fixes:
- SelfSignedCertificateProvider: stage the PFX in a unique '<path>.<guid>.tmp' instead
of a fixed name, so concurrent/interrupted writers never collide on the temp file
(real robustness: two instances or a restart-during-write no longer clash). The
final atomic Move (last-writer-wins) still yields an equivalent cert.
- TestHostEnvironmentInitializer: default MxGateway__Tls__SelfSignedCertPath to a
per-process temp path so the suite never writes the shared ProgramData default or
fights the deployed service; first host-building test generates, the rest load it.
- GatewayTlsBootstrapTests: [Collection] with DisableParallelization so its global
env-var mutation cannot bleed into parallel tests (new GlobalEnvironmentCollection).
2. AuthStoreHealthCheckTests opened a real SQLite file; Microsoft.Data.Sqlite's
connection pool keeps the .db handle open after 'await using', so the finally
File.Delete threw. Reuse the existing TempDatabaseDirectory helper, which calls
SqliteConnection.ClearAllPools() before deleting.
Server build clean; affected classes 17/17 on macOS. Windows full-suite verified separately.
254 lines
11 KiB
C#
254 lines
11 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 options controlling certificate generation and persistence.</param>
|
|
/// <param name="logger">Logger for certificate generation and load diagnostics.</param>
|
|
/// <param name="timeProvider">Time provider used for certificate validity and expiration checks.</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>The generated self-signed certificate.</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 and persisted certificate.</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.
|
|
//
|
|
// The temp name carries a unique suffix rather than a fixed "<path>.tmp": two
|
|
// processes (or two parallel callers) generating to the same target must not
|
|
// collide on one temp file. On Windows a fixed name makes the second writer's
|
|
// File.Create/Move fail with "the process cannot access the file ... because it
|
|
// is being used by another process"; a unique name lets each generation stage
|
|
// independently, and the final atomic Move (last-writer-wins) still yields a
|
|
// valid, equivalent certificate at the shared path.
|
|
string temp = $"{path}.{Guid.NewGuid():N}.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);
|
|
}
|
|
}
|