fix(gateway): harden self-signed cert persistence and config validation
This commit is contained in:
@@ -18,6 +18,13 @@ public static class KestrelTlsInspector
|
||||
/// </summary>
|
||||
public static bool RequiresGeneratedCertificate(IConfiguration configuration)
|
||||
{
|
||||
// A Kestrel default certificate applies to every endpoint that lacks its own.
|
||||
// If the operator configured one, the gateway must not override it.
|
||||
if (HasConfiguredCertificate(configuration.GetSection("Kestrel:Certificates:Default")))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
IConfigurationSection endpoints = configuration.GetSection("Kestrel:Endpoints");
|
||||
foreach (IConfigurationSection endpoint in endpoints.GetChildren())
|
||||
{
|
||||
@@ -28,13 +35,7 @@ public static class KestrelTlsInspector
|
||||
continue;
|
||||
}
|
||||
|
||||
IConfigurationSection certificate = endpoint.GetSection("Certificate");
|
||||
bool hasOwnCertificate =
|
||||
!string.IsNullOrWhiteSpace(certificate["Path"]) ||
|
||||
!string.IsNullOrWhiteSpace(certificate["Subject"]) ||
|
||||
!string.IsNullOrWhiteSpace(certificate["Thumbprint"]);
|
||||
|
||||
if (!hasOwnCertificate)
|
||||
if (!HasConfiguredCertificate(endpoint.GetSection("Certificate")))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -42,4 +43,9 @@ public static class KestrelTlsInspector
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HasConfiguredCertificate(IConfigurationSection certificate)
|
||||
=> !string.IsNullOrWhiteSpace(certificate["Path"]) ||
|
||||
!string.IsNullOrWhiteSpace(certificate["Subject"]) ||
|
||||
!string.IsNullOrWhiteSpace(certificate["Thumbprint"]);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Tls;
|
||||
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;
|
||||
@@ -37,7 +38,8 @@ public sealed class SelfSignedCertificateProvider
|
||||
key,
|
||||
HashAlgorithmName.SHA256);
|
||||
|
||||
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true));
|
||||
// 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));
|
||||
@@ -110,6 +112,14 @@ public sealed class SelfSignedCertificateProvider
|
||||
_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);
|
||||
@@ -119,21 +129,36 @@ public sealed class SelfSignedCertificateProvider
|
||||
{
|
||||
using X509Certificate2 generated = GenerateCertificate();
|
||||
byte[] pfx = generated.Export(X509ContentType.Pkcs12);
|
||||
|
||||
string? directory = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
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.
|
||||
File.WriteAllBytes(temp, pfx);
|
||||
File.Move(temp, path, overwrite: true);
|
||||
HardenPermissions(path);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
string temp = path + ".tmp";
|
||||
File.WriteAllBytes(temp, pfx);
|
||||
File.Move(temp, path, overwrite: true);
|
||||
HardenPermissions(path);
|
||||
|
||||
X509Certificate2 loaded = X509CertificateLoader.LoadPkcs12FromFile(path, password: null, KeyStorageFlags());
|
||||
Log("Generated", loaded);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
private static X509KeyStorageFlags KeyStorageFlags()
|
||||
@@ -179,7 +204,7 @@ public sealed class SelfSignedCertificateProvider
|
||||
private void Log(string action, X509Certificate2 cert)
|
||||
{
|
||||
string sans = cert.Extensions
|
||||
.FirstOrDefault(e => e.Oid?.Value == "2.5.29.17")?
|
||||
.FirstOrDefault(e => e.Oid?.Value == SubjectAltNameOid)?
|
||||
.Format(false) ?? "(none)";
|
||||
_logger.LogInformation(
|
||||
"{Action} gateway self-signed certificate: thumbprint={Thumbprint}, notAfter={NotAfter:u}, sans={Sans}",
|
||||
|
||||
Reference in New Issue
Block a user