feat(gateway): persist/reuse self-signed cert with hardened permissions
This commit is contained in:
@@ -68,4 +68,121 @@ public sealed class SelfSignedCertificateProvider
|
||||
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>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return GenerateAndPersist(path);
|
||||
}
|
||||
|
||||
private X509Certificate2 GenerateAndPersist(string path)
|
||||
{
|
||||
using X509Certificate2 generated = GenerateCertificate();
|
||||
byte[] pfx = generated.Export(X509ContentType.Pkcs12);
|
||||
|
||||
string? directory = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
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()
|
||||
=> 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 == "2.5.29.17")?
|
||||
.Format(false) ?? "(none)";
|
||||
_logger.LogInformation(
|
||||
"{Action} gateway self-signed certificate: thumbprint={Thumbprint}, notAfter={NotAfter:u}, sans={Sans}",
|
||||
action, cert.Thumbprint, cert.NotAfter.ToUniversalTime(), sans);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,75 @@ public sealed class SelfSignedCertificateProviderTests
|
||||
o => o.Value == "1.3.6.1.5.5.7.3.1"); // serverAuth
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadOrCreate_GeneratesPersistsAndReuses_SameThumbprint()
|
||||
{
|
||||
string dir = Directory.CreateTempSubdirectory().FullName;
|
||||
try
|
||||
{
|
||||
string path = Path.Combine(dir, "gw.pfx");
|
||||
FakeTimeProvider time = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
TlsOptions options = new() { SelfSignedCertPath = path };
|
||||
|
||||
using X509Certificate2 first = CreateProvider(options, time).LoadOrCreate();
|
||||
Assert.True(File.Exists(path));
|
||||
using X509Certificate2 second = CreateProvider(options, time).LoadOrCreate();
|
||||
|
||||
Assert.Equal(first.Thumbprint, second.Thumbprint); // reused, not regenerated
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadOrCreate_Regenerates_WhenPersistedCertExpired()
|
||||
{
|
||||
string dir = Directory.CreateTempSubdirectory().FullName;
|
||||
try
|
||||
{
|
||||
string path = Path.Combine(dir, "gw.pfx");
|
||||
FakeTimeProvider time = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
TlsOptions options = new() { SelfSignedCertPath = path, ValidityYears = 1 };
|
||||
|
||||
using X509Certificate2 first = CreateProvider(options, time).LoadOrCreate();
|
||||
time.Advance(TimeSpan.FromDays(800)); // past 1-year validity
|
||||
using X509Certificate2 second = CreateProvider(options, time).LoadOrCreate();
|
||||
|
||||
Assert.NotEqual(first.Thumbprint, second.Thumbprint);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadOrCreate_Regenerates_WhenPersistedFileCorrupt()
|
||||
{
|
||||
string dir = Directory.CreateTempSubdirectory().FullName;
|
||||
try
|
||||
{
|
||||
string path = Path.Combine(dir, "gw.pfx");
|
||||
File.WriteAllText(path, "not a pfx");
|
||||
TlsOptions options = new() { SelfSignedCertPath = path };
|
||||
using X509Certificate2 cert = CreateProvider(options, new FakeTimeProvider()).LoadOrCreate();
|
||||
Assert.True(cert.HasPrivateKey);
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadOrCreate_Throws_WhenExpiredAndRegenerateDisabled()
|
||||
{
|
||||
string dir = Directory.CreateTempSubdirectory().FullName;
|
||||
try
|
||||
{
|
||||
string path = Path.Combine(dir, "gw.pfx");
|
||||
FakeTimeProvider time = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
TlsOptions options = new() { SelfSignedCertPath = path, ValidityYears = 1, RegenerateIfExpired = false };
|
||||
using (CreateProvider(options, time).LoadOrCreate()) { }
|
||||
time.Advance(TimeSpan.FromDays(800));
|
||||
Assert.Throws<InvalidOperationException>(() => CreateProvider(options, time).LoadOrCreate());
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
|
||||
private static string ReadSubjectAltNames(X509Certificate2 cert)
|
||||
=> cert.Extensions
|
||||
.First(e => e.Oid?.Value == "2.5.29.17")
|
||||
|
||||
Reference in New Issue
Block a user