diff --git a/.github/workflows/tls-lifecycle.yml b/.github/workflows/tls-lifecycle.yml new file mode 100644 index 0000000..714162a --- /dev/null +++ b/.github/workflows/tls-lifecycle.yml @@ -0,0 +1,61 @@ +name: TLS lifecycle tests + +on: + pull_request: + paths: + - "TruthGate-Web/TruthGate-Web/Configuration/**" + - "TruthGate-Web/TruthGate-Web/Services/ConfigWatchAndIssueService.cs" + - "TruthGate-Web/TruthGate-Web.Tests/**" + - ".github/workflows/tls-lifecycle.yml" + push: + branches: + - master + paths: + - "TruthGate-Web/TruthGate-Web/Configuration/**" + - "TruthGate-Web/TruthGate-Web/Services/ConfigWatchAndIssueService.cs" + - "TruthGate-Web/TruthGate-Web.Tests/**" + - ".github/workflows/tls-lifecycle.yml" + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "9.0.x" + + - name: Restore + run: dotnet restore TruthGate-Web/TruthGate-Web.Tests/TruthGate-Web.Tests.csproj + + - name: Test + id: test + shell: bash + run: | + set +e + dotnet test \ + TruthGate-Web/TruthGate-Web.Tests/TruthGate-Web.Tests.csproj \ + --no-restore \ + --configuration Release \ + --logger "console;verbosity=normal" \ + > tls-test-output.log 2>&1 + status=$? + echo "status=$status" >> "$GITHUB_OUTPUT" + exit 0 + + - name: Upload test diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: tls-test-output + path: tls-test-output.log + if-no-files-found: error + + - name: Report failure + if: steps.test.outputs.status != '0' + shell: bash + run: | + echo "TLS lifecycle tests failed. Download the tls-test-output artifact for the complete compiler/test diagnostics." + exit 1 diff --git a/TruthGate-Web/TruthGate-Web.Tests/CertificateLifecycleTests.cs b/TruthGate-Web/TruthGate-Web.Tests/CertificateLifecycleTests.cs new file mode 100644 index 0000000..c286baa --- /dev/null +++ b/TruthGate-Web/TruthGate-Web.Tests/CertificateLifecycleTests.cs @@ -0,0 +1,397 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Logging.Abstractions; +using TruthGate_Web.Configuration; +using TruthGate_Web.Models; +using TruthGate_Web.Services; +using Xunit; + +namespace TruthGate_Web.Tests; + +public sealed class CertificateLifecycleTests : IDisposable +{ + private readonly string _tempDirectory = + Path.Combine(Path.GetTempPath(), $"truthgate-tls-tests-{Guid.NewGuid():N}"); + + public CertificateLifecycleTests() + { + Directory.CreateDirectory(_tempDirectory); + } + + [Fact] + public async Task RenewalDueCertificate_RemainsServedWhileRenewalRuns() + { + const string host = "truthgate.io"; + using var existing = CreateCertificate( + host, + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(10)); + + var store = new MemoryCertificateStore(); + store.Set(host, existing); + + var issuerEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var releaseIssuer = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + var issuer = new DelegateIssuer(async (_, ct) => + { + issuerEntered.TrySetResult(true); + await releaseIssuer.Task.WaitAsync(ct); + return CreateCertificate( + host, + DateTimeOffset.UtcNow.AddMinutes(-5), + DateTimeOffset.UtcNow.AddDays(90)); + }); + + using var fallback = CreateCertificate( + "localhost", + DateTimeOffset.UtcNow.AddMinutes(-5), + DateTimeOffset.UtcNow.AddDays(365)); + + var provider = CreateProvider(host, store, issuer, fallback); + var reconciliation = provider.ReconcileAsync(host); + + await issuerEntered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Same(existing, provider.TryLoadIssued(host)); + Assert.Equal(1, issuer.CallCount); + + releaseIssuer.TrySetResult(true); + await reconciliation; + } + + [Fact] + public async Task ExpiredCertificate_IsAutomaticallyReplaced() + { + const string host = "truthgate.io"; + using var expired = CreateCertificate( + host, + DateTimeOffset.UtcNow.AddDays(-90), + DateTimeOffset.UtcNow.AddDays(-1)); + + var store = new MemoryCertificateStore(); + store.Set(host, expired); + + var issuer = new DelegateIssuer((_, _) => + Task.FromResult( + CreateCertificate( + host, + DateTimeOffset.UtcNow.AddMinutes(-5), + DateTimeOffset.UtcNow.AddDays(90)))); + + using var fallback = CreateCertificate( + "localhost", + DateTimeOffset.UtcNow.AddMinutes(-5), + DateTimeOffset.UtcNow.AddDays(365)); + + var provider = CreateProvider(host, store, issuer, fallback); + + await provider.ReconcileAsync(host); + + var served = provider.TryLoadIssued(host); + Assert.NotNull(served); + Assert.True(served.NotAfter.ToUniversalTime() > DateTime.UtcNow.AddDays(80)); + Assert.Equal(1, issuer.CallCount); + } + + [Fact] + public async Task ConcurrentReconciliation_ProducesOneAcmeOrder() + { + const string host = "truthgate.io"; + var store = new MemoryCertificateStore(); + + var issuerEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var releaseIssuer = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + var issuer = new DelegateIssuer(async (_, ct) => + { + issuerEntered.TrySetResult(true); + await releaseIssuer.Task.WaitAsync(ct); + return CreateCertificate( + host, + DateTimeOffset.UtcNow.AddMinutes(-5), + DateTimeOffset.UtcNow.AddDays(90)); + }); + + using var fallback = CreateCertificate( + "localhost", + DateTimeOffset.UtcNow.AddMinutes(-5), + DateTimeOffset.UtcNow.AddDays(365)); + + var provider = CreateProvider(host, store, issuer, fallback); + + var calls = Enumerable.Range(0, 100) + .Select(_ => provider.ReconcileAsync(host)) + .ToArray(); + + await issuerEntered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(1, issuer.CallCount); + + releaseIssuer.TrySetResult(true); + await Task.WhenAll(calls); + + Assert.Equal(1, issuer.CallCount); + } + + [Fact] + public async Task CorruptStoredCertificate_IsQuarantinedAndRepaired() + { + const string host = "truthgate.io"; + var store = new InitiallyCorruptCertificateStore(); + + var issuer = new DelegateIssuer((_, _) => + Task.FromResult( + CreateCertificate( + host, + DateTimeOffset.UtcNow.AddMinutes(-5), + DateTimeOffset.UtcNow.AddDays(90)))); + + using var fallback = CreateCertificate( + "localhost", + DateTimeOffset.UtcNow.AddMinutes(-5), + DateTimeOffset.UtcNow.AddDays(365)); + + var provider = CreateProvider(host, store, issuer, fallback); + + await provider.ReconcileAsync(host); + + Assert.True(store.Quarantined); + Assert.NotNull(provider.TryLoadIssued(host)); + Assert.Equal(1, issuer.CallCount); + } + + [Fact] + public void WrongHostnameCertificate_IsRejected() + { + using var certificate = CreateCertificate( + "other.example", + DateTimeOffset.UtcNow.AddMinutes(-5), + DateTimeOffset.UtcNow.AddDays(90)); + + var inspection = CertificateInspector.Inspect( + certificate, + "truthgate.io", + DateTimeOffset.UtcNow, + TimeSpan.FromDays(30)); + + Assert.Equal(CertificateState.WrongHostname, inspection.State); + Assert.False(inspection.CanServe); + Assert.True(inspection.NeedsIssuance); + } + + [Fact] + public async Task FileStore_PersistsReloadableCertificateAtomically() + { + const string host = "truthgate.io"; + using var certificate = CreateCertificate( + host, + DateTimeOffset.UtcNow.AddMinutes(-5), + DateTimeOffset.UtcNow.AddDays(90)); + + var store = new FileCertStore(_tempDirectory); + await store.SaveAsync(host, certificate, CancellationToken.None); + + using var loaded = await store.LoadAsync(host, CancellationToken.None); + + Assert.NotNull(loaded); + Assert.True(loaded.HasPrivateKey); + Assert.True(loaded.MatchesHostname( + host, + allowWildcards: true, + allowCommonName: false)); + + var temporaryFiles = Directory + .EnumerateFiles(_tempDirectory, "*.tmp", SearchOption.TopDirectoryOnly) + .ToArray(); + + Assert.Empty(temporaryFiles); + } + + private LiveCertProvider CreateProvider( + string host, + ICertificateStore store, + IAcmeIssuer issuer, + X509Certificate2 fallback) + { + var fallbackPath = Path.Combine( + _tempDirectory, + $"{Guid.NewGuid():N}-fallback.pfx"); + + var fallbackCache = new SelfSignedCertCache(fallback, fallbackPath); + + return new LiveCertProvider( + fallbackCache, + store, + issuer, + new TestConfigService(host), + NullLogger.Instance); + } + + private static X509Certificate2 CreateCertificate( + string host, + DateTimeOffset notBefore, + DateTimeOffset notAfter) + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest( + $"CN={host}", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + var san = new SubjectAlternativeNameBuilder(); + san.AddDnsName(host); + request.CertificateExtensions.Add(san.Build()); + request.CertificateExtensions.Add( + new X509BasicConstraintsExtension(false, false, 0, false)); + request.CertificateExtensions.Add( + new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature | + X509KeyUsageFlags.KeyEncipherment, + critical: false)); + + var eku = new OidCollection + { + new("1.3.6.1.5.5.7.3.1") + }; + request.CertificateExtensions.Add( + new X509EnhancedKeyUsageExtension(eku, critical: false)); + + using var generated = request.CreateSelfSigned(notBefore, notAfter); + var pfx = generated.Export(X509ContentType.Pkcs12); + return X509CertificateLoader.LoadPkcs12( + pfx, + ReadOnlySpan.Empty); + } + + public void Dispose() + { + try + { + Directory.Delete(_tempDirectory, recursive: true); + } + catch + { + } + } + + private sealed class TestConfigService : IConfigService + { + private readonly Config _config; + + public TestConfigService(string host) + { + _config = new Config + { + Domains = + [ + new EdgeDomain + { + Domain = host, + UseSSL = "true" + } + ] + }; + } + + public Config Get() => _config; + public Task SaveAsync(Config newConfig, CancellationToken ct = default) + => Task.CompletedTask; + public Task UpdateAsync(Action mutator, CancellationToken ct = default) + => Task.CompletedTask; + public string ConfigPath => string.Empty; + } + + private sealed class DelegateIssuer : IAcmeIssuer + { + private readonly Func> _handler; + private int _callCount; + + public DelegateIssuer( + Func> handler) + { + _handler = handler; + } + + public int CallCount => Volatile.Read(ref _callCount); + + public Task IssueOrRenewAsync( + string host, + CancellationToken ct = default) + { + Interlocked.Increment(ref _callCount); + return _handler(host, ct); + } + } + + private sealed class MemoryCertificateStore : ICertificateStore + { + private readonly ConcurrentDictionary _certificates = + new(StringComparer.OrdinalIgnoreCase); + + public void Set(string host, X509Certificate2 certificate) + => _certificates[host] = certificate; + + public Task LoadAsync(string host, CancellationToken ct) + { + _certificates.TryGetValue(host, out var certificate); + return Task.FromResult(certificate); + } + + public Task SaveAsync( + string host, + X509Certificate2 certificate, + CancellationToken ct) + { + _certificates[host] = certificate; + return Task.CompletedTask; + } + } + + private sealed class InitiallyCorruptCertificateStore : + ICertificateStore, + ICertificateStoreMaintenance + { + private X509Certificate2? _certificate; + private bool _throwOnLoad = true; + + public bool Quarantined { get; private set; } + + public Task LoadAsync(string host, CancellationToken ct) + { + if (_throwOnLoad) + throw new CryptographicException("The PFX is corrupt."); + + return Task.FromResult(_certificate); + } + + public Task SaveAsync( + string host, + X509Certificate2 certificate, + CancellationToken ct) + { + _certificate = certificate; + _throwOnLoad = false; + return Task.CompletedTask; + } + + public Task QuarantineAsync( + string host, + string reason, + CancellationToken ct) + { + Quarantined = true; + _throwOnLoad = false; + return Task.CompletedTask; + } + + public void HardenExistingPermissions() + { + } + } +} diff --git a/TruthGate-Web/TruthGate-Web.Tests/TruthGate-Web.Tests.csproj b/TruthGate-Web/TruthGate-Web.Tests/TruthGate-Web.Tests.csproj new file mode 100644 index 0000000..69e4f2d --- /dev/null +++ b/TruthGate-Web/TruthGate-Web.Tests/TruthGate-Web.Tests.csproj @@ -0,0 +1,24 @@ + + + + net9.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/TruthGate-Web/TruthGate-Web/Configuration/CertesAcmeIssuer.cs b/TruthGate-Web/TruthGate-Web/Configuration/CertesAcmeIssuer.cs index afb5886..981af5f 100644 --- a/TruthGate-Web/TruthGate-Web/Configuration/CertesAcmeIssuer.cs +++ b/TruthGate-Web/TruthGate-Web/Configuration/CertesAcmeIssuer.cs @@ -1,11 +1,10 @@ -using System.Reflection; using System.Security.Cryptography; +using System.Security.Cryptography.Pkcs; using System.Security.Cryptography.X509Certificates; using System.Text; using Certes; using Certes.Acme; using Certes.Acme.Resource; -using System.Security.Cryptography.Pkcs; namespace TruthGate_Web.Configuration { @@ -29,214 +28,438 @@ public CertesAcmeIssuer( _accountPemPath = accountPemPath; _logger = logger; _isStaging = useStaging; - _dirUri = useStaging ? WellKnownServers.LetsEncryptStagingV2 : WellKnownServers.LetsEncryptV2; + _dirUri = useStaging + ? WellKnownServers.LetsEncryptStagingV2 + : WellKnownServers.LetsEncryptV2; } - public async Task IssueOrRenewAsync(string host, CancellationToken ct = default) + public async Task IssueOrRenewAsync( + string host, + CancellationToken ct = default) { try { _logger.LogInformation("ACME[{Dir}] start {Host}", Label, host); - // 1) ACME context + account - var accountKey = await LoadOrCreateAccountKeyAsync(ct); + var accountKey = await LoadOrCreateAccountKeyAsync(ct).ConfigureAwait(false); var acme = new AcmeContext(_dirUri, accountKey); - try { await acme.NewAccount(Array.Empty(), true); } - catch { _logger.LogDebug("ACME[{Dir}] account exists", Label); } - // 2) Create order & validate HTTP-01 - var order = await acme.NewOrder(new[] { host }); + try + { + await acme.NewAccount(Array.Empty(), true).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogDebug( + ex, + "ACME[{Dir}] existing account registration will be reused", + Label); + } + + var order = await acme.NewOrder(new[] { host }).ConfigureAwait(false); _logger.LogInformation("ACME[{Dir}] order created for {Host}", Label, host); - var authzs = await order.Authorizations(); - foreach (var authz in authzs) + var authorizations = await order.Authorizations().ConfigureAwait(false); + foreach (var authorization in authorizations) { - var http = await authz.Http(); + var http = await authorization.Http().ConfigureAwait(false); var token = http.Token; - var keyAuthz = http.KeyAuthz; + var keyAuthorization = http.KeyAuthz; var url = $"http://{host}/.well-known/acme-challenge/{token}"; - // Preflight (best-effort) + // Publish challenge content before either our preflight or the CA can request it. + _challengeStore.Put(token, keyAuthorization, TimeSpan.FromMinutes(10)); try { - using var hc = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }); - hc.Timeout = TimeSpan.FromSeconds(5); - var resp = await hc.GetAsync(url, ct); - var body = await resp.Content.ReadAsStringAsync(ct); - _logger.LogInformation("Preflight GET {Url} -> {Status} len={Len}", url, (int)resp.StatusCode, body.Length); - if (resp.StatusCode == System.Net.HttpStatusCode.OK && !string.Equals(body, keyAuthz, StringComparison.Ordinal)) - _logger.LogWarning("Preflight mismatch: body != keyAuthz (first 60) body='{Body}'", body.Length > 60 ? body[..60] : body); - } - catch (Exception ex) { _logger.LogWarning(ex, "Preflight GET failed"); } + await RunPreflightAsync(url, keyAuthorization, ct).ConfigureAwait(false); + await http.Validate().ConfigureAwait(false); - _challengeStore.Put(token, keyAuthz, TimeSpan.FromMinutes(10)); - await http.Validate(); + var challengeDeadline = + DateTimeOffset.UtcNow + TimeSpan.FromMinutes(2); - var chDeadline = DateTimeOffset.UtcNow + TimeSpan.FromMinutes(2); - while (true) - { - ct.ThrowIfCancellationRequested(); - var chRes = await http.Resource(); - if (chRes.Status == ChallengeStatus.Valid) + while (true) { - _logger.LogInformation("ACME[{Dir}] challenge VALID for {Host}", Label, host); - break; + ct.ThrowIfCancellationRequested(); + + var challenge = await http.Resource().ConfigureAwait(false); + if (challenge.Status == ChallengeStatus.Valid) + { + _logger.LogInformation( + "ACME[{Dir}] challenge VALID for {Host}", + Label, + host); + break; + } + + if (challenge.Status == ChallengeStatus.Invalid) + { + throw new InvalidOperationException( + $"ACME authorization failed for {host}: " + + $"{challenge.Error?.Type} {challenge.Error?.Detail}"); + } + + if (DateTimeOffset.UtcNow > challengeDeadline) + { + throw new TimeoutException( + $"ACME challenge timed out for {host}"); + } + + await Task.Delay(1000, ct).ConfigureAwait(false); } - if (chRes.Status == ChallengeStatus.Invalid) - throw new InvalidOperationException($"ACME authorization failed for {host}: {chRes.Error?.Type} {chRes.Error?.Detail}"); - if (DateTimeOffset.UtcNow > chDeadline) - throw new TimeoutException($"ACME challenge timed out for {host}"); - await Task.Delay(1000, ct); } - - _challengeStore.Remove(token); + finally + { + _challengeStore.Remove(token); + } } - // 3) Finalize order (poll to READY to finalize to poll to VALID) - var acctKey = KeyFactory.NewKey(KeyAlgorithm.ES256); + var certificateKey = KeyFactory.NewKey(KeyAlgorithm.ES256); var csrInfo = new CsrInfo { CommonName = host }; var deadline = DateTimeOffset.UtcNow + TimeSpan.FromMinutes(2); - var oRes = await order.Resource(); - while (oRes.Status is OrderStatus.Pending or OrderStatus.Processing) + var orderResource = await order.Resource().ConfigureAwait(false); + + while (orderResource.Status is OrderStatus.Pending or OrderStatus.Processing) { + ct.ThrowIfCancellationRequested(); + if (DateTimeOffset.UtcNow > deadline) - throw new TimeoutException($"ACME order not ready for {host} (status={oRes.Status})."); - await Task.Delay(1000, ct); - oRes = await order.Resource(); - } + { + throw new TimeoutException( + $"ACME order not ready for {host} " + + $"(status={orderResource.Status})."); + } - if (oRes.Status != OrderStatus.Valid) - { - await order.Finalize(csrInfo, acctKey); + await Task.Delay(1000, ct).ConfigureAwait(false); + orderResource = await order.Resource().ConfigureAwait(false); } + if (orderResource.Status != OrderStatus.Valid) + await order.Finalize(csrInfo, certificateKey).ConfigureAwait(false); + deadline = DateTimeOffset.UtcNow + TimeSpan.FromMinutes(2); - oRes = await order.Resource(); - while (oRes.Status is OrderStatus.Processing or OrderStatus.Pending or OrderStatus.Ready) + orderResource = await order.Resource().ConfigureAwait(false); + + while (orderResource.Status is + OrderStatus.Processing or + OrderStatus.Pending or + OrderStatus.Ready) { + ct.ThrowIfCancellationRequested(); + if (DateTimeOffset.UtcNow > deadline) - throw new TimeoutException($"ACME finalize timed out for {host} (status={oRes.Status})."); - await Task.Delay(1000, ct); - oRes = await order.Resource(); + { + throw new TimeoutException( + $"ACME finalize timed out for {host} " + + $"(status={orderResource.Status})."); + } + + await Task.Delay(1000, ct).ConfigureAwait(false); + orderResource = await order.Resource().ConfigureAwait(false); } - if (oRes.Status != OrderStatus.Valid) - throw new InvalidOperationException($"ACME order did not become valid for {host} (status={oRes.Status})."); - // 4) Download chain and build PFX WITHOUT using key-bound ToPem - // 4) Download the chain - var chain = await order.Download(); + if (orderResource.Status != OrderStatus.Valid) + { + throw new InvalidOperationException( + $"ACME order did not become valid for {host} " + + $"(status={orderResource.Status})."); + } - // === Build PFX without any ToPem/ToPfx usage === + var chain = await order.Download().ConfigureAwait(false); var (leafDer, issuersDer) = ExtractDerFromChain(chain); - // Load leaf (public) - var leafPublic = X509CertificateLoader.LoadCertificate(leafDer); - - // Import ES256 private key from Certes and bind to leaf + using var leafPublic = X509CertificateLoader.LoadCertificate(leafDer); using var ecdsa = ECDsa.Create(); - ecdsa.ImportPkcs8PrivateKey(acctKey.ToDer(), out _); - var leafWithKey = leafPublic.CopyWithPrivateKey(ecdsa); + ecdsa.ImportPkcs8PrivateKey(certificateKey.ToDer(), out _); + using var leafWithKey = leafPublic.CopyWithPrivateKey(ecdsa); - // Assemble PKCS#12 var pfxBuilder = new Pkcs12Builder(); - // Bag A: leaf + shrouded private key var leafBag = new Pkcs12SafeContents(); leafBag.AddCertificate(leafWithKey); leafBag.AddShroudedKey( ecdsa, - password: "", // empty is fine for your server-side storage - new PbeParameters(PbeEncryptionAlgorithm.Aes256Cbc, HashAlgorithmName.SHA256, 100_000) - ); + password: string.Empty, + new PbeParameters( + PbeEncryptionAlgorithm.Aes256Cbc, + HashAlgorithmName.SHA256, + 100_000)); pfxBuilder.AddSafeContentsUnencrypted(leafBag); - // Bag B: intermediates if (issuersDer.Count > 0) { - var interBag = new Pkcs12SafeContents(); + var issuerBag = new Pkcs12SafeContents(); foreach (var der in issuersDer) { - var ic = X509CertificateLoader.LoadCertificate(der); - interBag.AddCertificate(ic); + using var issuer = X509CertificateLoader.LoadCertificate(der); + issuerBag.AddCertificate(issuer); } - pfxBuilder.AddSafeContentsUnencrypted(interBag); + + pfxBuilder.AddSafeContentsUnencrypted(issuerBag); } - // Seal & emit - pfxBuilder.SealWithMac("", HashAlgorithmName.SHA256, 100_000); + pfxBuilder.SealWithMac( + string.Empty, + HashAlgorithmName.SHA256, + 100_000); + var pfxBytes = pfxBuilder.Encode(); - _logger.LogInformation("ACME[{Dir}] issued PFX for {Host} (len={Len})", Label, host, pfxBytes.Length); - return X509CertificateLoader.LoadPkcs12(pfxBytes, ReadOnlySpan.Empty); + _logger.LogInformation( + "ACME[{Dir}] issued PFX for {Host} (len={Len})", + Label, + host, + pfxBytes.Length); + return X509CertificateLoader.LoadPkcs12( + pfxBytes, + ReadOnlySpan.Empty); } catch (Exception ex) { - _logger.LogError(ex, "ACME[{Dir}] issuance FAILED for {Host}", Label, host); + _logger.LogError( + ex, + "ACME[{Dir}] issuance FAILED for {Host}", + Label, + host); throw; } } + private async Task RunPreflightAsync( + string url, + string keyAuthorization, + CancellationToken ct) + { + try + { + using var client = new HttpClient( + new HttpClientHandler { AllowAutoRedirect = false }) + { + Timeout = TimeSpan.FromSeconds(5) + }; + + using var response = await client.GetAsync(url, ct).ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + + _logger.LogInformation( + "Preflight GET {Url} -> {Status} len={Len}", + url, + (int)response.StatusCode, + body.Length); + + if (response.StatusCode == System.Net.HttpStatusCode.OK && + !string.Equals(body, keyAuthorization, StringComparison.Ordinal)) + { + _logger.LogWarning( + "Preflight mismatch: body != keyAuthz (first 60) body='{Body}'", + body.Length > 60 ? body[..60] : body); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Preflight GET failed"); + } + } + private async Task LoadOrCreateAccountKeyAsync(CancellationToken ct) { - var dir = Path.GetDirectoryName(_accountPemPath)!; - System.IO.Directory.CreateDirectory(dir); + var directory = Path.GetDirectoryName(_accountPemPath)!; + EnsureDirectory(directory); if (File.Exists(_accountPemPath)) { - _logger.LogInformation("ACME account key: using existing {Path}", _accountPemPath); - var pem = await File.ReadAllTextAsync(_accountPemPath, ct); + HardenFile(_accountPemPath); + _logger.LogInformation( + "ACME account key: using existing {Path}", + _accountPemPath); + + var pem = await File.ReadAllTextAsync(_accountPemPath, ct) + .ConfigureAwait(false); return KeyFactory.FromPem(pem); } - _logger.LogInformation("ACME account key: creating {Path}", _accountPemPath); + _logger.LogInformation( + "ACME account key: creating {Path}", + _accountPemPath); + var key = KeyFactory.NewKey(KeyAlgorithm.ES256); - await File.WriteAllTextAsync(_accountPemPath, key.ToPem(), ct); + await WriteTextAtomicallyAsync( + _accountPemPath, + key.ToPem(), + ct) + .ConfigureAwait(false); + return key; } - // Pulls leaf + issuer DER certs out of Certes' CertificateChain without calling ToPem(). - private static (byte[] leafDer, List issuersDer) ExtractDerFromChain(object certificateChain) + private static async Task WriteTextAtomicallyAsync( + string path, + string content, + CancellationToken ct) { - var t = certificateChain.GetType(); + var directory = Path.GetDirectoryName(path)!; + EnsureDirectory(directory); - // Leaf: property is often "Certificate" or "Leaf" - var leafObj = - t.GetProperty("Certificate")?.GetValue(certificateChain) - ?? t.GetProperty("Leaf")?.GetValue(certificateChain) - ?? throw new InvalidOperationException("CertificateChain leaf not found."); + var tempPath = Path.Combine( + directory, + $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp"); - var toDer = leafObj.GetType().GetMethod("ToDer") - ?? throw new InvalidOperationException("Leaf.ToDer() not found."); - var leafDer = (byte[])toDer.Invoke(leafObj, Array.Empty())!; + try + { + var bytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false) + .GetBytes(content); + + await using (var stream = new FileStream( + tempPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 4096, + FileOptions.Asynchronous | FileOptions.WriteThrough)) + { + await stream.WriteAsync(bytes, ct).ConfigureAwait(false); + await stream.FlushAsync(ct).ConfigureAwait(false); + stream.Flush(flushToDisk: true); + } + + HardenFile(tempPath); + ReplaceAtomically(tempPath, path); + HardenFile(path); + } + finally + { + try + { + if (File.Exists(tempPath)) + File.Delete(tempPath); + } + catch + { + } + } + } + + private static void ReplaceAtomically(string tempPath, string destinationPath) + { + if (!File.Exists(destinationPath)) + { + File.Move(tempPath, destinationPath); + return; + } + + try + { + File.Replace( + tempPath, + destinationPath, + destinationBackupFileName: null, + ignoreMetadataErrors: true); + } + catch (PlatformNotSupportedException) + { + File.Move(tempPath, destinationPath, overwrite: true); + } + catch (IOException) + { + File.Move(tempPath, destinationPath, overwrite: true); + } + } + + private static void EnsureDirectory(string directory) + { + System.IO.Directory.CreateDirectory(directory); + + if (OperatingSystem.IsWindows()) + return; + + try + { + File.SetUnixFileMode( + directory, + UnixFileMode.UserRead | + UnixFileMode.UserWrite | + UnixFileMode.UserExecute); + } + catch + { + } + } + + private static void HardenFile(string path) + { + if (OperatingSystem.IsWindows() || !File.Exists(path)) + return; + + try + { + File.SetUnixFileMode( + path, + UnixFileMode.UserRead | + UnixFileMode.UserWrite); + } + catch + { + } + } + + private static (byte[] LeafDer, List IssuersDer) + ExtractDerFromChain(object certificateChain) + { + var type = certificateChain.GetType(); + + var leafObject = + type.GetProperty("Certificate")?.GetValue(certificateChain) ?? + type.GetProperty("Leaf")?.GetValue(certificateChain) ?? + throw new InvalidOperationException("CertificateChain leaf not found."); + + var toDer = leafObject.GetType().GetMethod("ToDer") ?? + throw new InvalidOperationException("Leaf.ToDer() not found."); + + var leafDer = (byte[])toDer.Invoke( + leafObject, + Array.Empty())!; + + var issuerPropertyNames = new[] + { + "Chain", + "IssuerChain", + "IssuerCertificates", + "Certificates" + }; - // Issuers: property names vary across Certes versions - var issuerPropNames = new[] { "Chain", "IssuerChain", "IssuerCertificates", "Certificates" }; var issuersDer = new List(); - foreach (var name in issuerPropNames) + foreach (var propertyName in issuerPropertyNames) { - var p = t.GetProperty(name); - if (p == null) continue; + var property = type.GetProperty(propertyName); + if (property?.GetValue(certificateChain) is not + System.Collections.IEnumerable collection) + { + continue; + } - if (p.GetValue(certificateChain) is System.Collections.IEnumerable coll) + foreach (var item in collection) { - foreach (var item in coll) - { - var m = item.GetType().GetMethod("ToDer"); - if (m == null) continue; - var der = (byte[])m.Invoke(item, Array.Empty())!; - // Some versions include the leaf again in the list — filter it out - if (!der.AsSpan().SequenceEqual(leafDer)) - issuersDer.Add(der); - } - break; // we found a valid property + if (item is null) + continue; + + var method = item.GetType().GetMethod("ToDer"); + if (method is null) + continue; + + var der = (byte[])method.Invoke( + item, + Array.Empty())!; + + if (!der.AsSpan().SequenceEqual(leafDer)) + issuersDer.Add(der); } + + break; } return (leafDer, issuersDer); } - } } diff --git a/TruthGate-Web/TruthGate-Web/Configuration/CertificateInspector.cs b/TruthGate-Web/TruthGate-Web/Configuration/CertificateInspector.cs new file mode 100644 index 0000000..d0c1ef3 --- /dev/null +++ b/TruthGate-Web/TruthGate-Web/Configuration/CertificateInspector.cs @@ -0,0 +1,114 @@ +using System.Globalization; +using System.Security.Cryptography.X509Certificates; + +namespace TruthGate_Web.Configuration +{ + public enum CertificateState + { + Fresh, + RenewalDue, + Expired, + Missing, + Corrupt, + WrongHostname, + MissingPrivateKey, + NotYetValid + } + + public readonly record struct CertificateInspection( + CertificateState State, + DateTimeOffset? NotBefore, + DateTimeOffset? NotAfter, + string? Detail = null) + { + public bool CanServe => State is CertificateState.Fresh or CertificateState.RenewalDue; + public bool NeedsIssuance => State != CertificateState.Fresh; + } + + public static class CertificateInspector + { + public static CertificateInspection Inspect( + X509Certificate2? certificate, + string host, + DateTimeOffset now, + TimeSpan renewalWindow) + { + if (certificate is null) + return new CertificateInspection(CertificateState.Missing, null, null, "No certificate is stored."); + + var notBefore = new DateTimeOffset(certificate.NotBefore.ToUniversalTime()); + var notAfter = new DateTimeOffset(certificate.NotAfter.ToUniversalTime()); + + if (!certificate.HasPrivateKey) + return new CertificateInspection( + CertificateState.MissingPrivateKey, + notBefore, + notAfter, + "The certificate does not contain a private key."); + + var normalizedHost = NormalizeHost(host); + bool matches; + try + { + matches = certificate.MatchesHostname( + normalizedHost, + allowWildcards: true, + allowCommonName: false); + } + catch (Exception ex) + { + return new CertificateInspection( + CertificateState.Corrupt, + notBefore, + notAfter, + $"Hostname validation failed: {ex.Message}"); + } + + if (!matches) + return new CertificateInspection( + CertificateState.WrongHostname, + notBefore, + notAfter, + $"The certificate SAN does not match '{normalizedHost}'."); + + if (now < notBefore) + return new CertificateInspection( + CertificateState.NotYetValid, + notBefore, + notAfter, + "The certificate is not valid yet."); + + if (now >= notAfter) + return new CertificateInspection( + CertificateState.Expired, + notBefore, + notAfter, + "The certificate has expired."); + + if (notAfter - now <= renewalWindow) + return new CertificateInspection( + CertificateState.RenewalDue, + notBefore, + notAfter, + "The certificate is valid and should be renewed in the background."); + + return new CertificateInspection(CertificateState.Fresh, notBefore, notAfter); + } + + public static string NormalizeHost(string host) + { + var normalized = (host ?? string.Empty).Trim().TrimEnd('.').ToLowerInvariant(); + if (normalized.Length == 0) + return normalized; + + try + { + return new IdnMapping().GetAscii(normalized); + } + catch + { + return normalized; + } + } + } +} diff --git a/TruthGate-Web/TruthGate-Web/Configuration/EagerIssueAtStartup.cs b/TruthGate-Web/TruthGate-Web/Configuration/EagerIssueAtStartup.cs index 1c05ebe..fdead88 100644 --- a/TruthGate-Web/TruthGate-Web/Configuration/EagerIssueAtStartup.cs +++ b/TruthGate-Web/TruthGate-Web/Configuration/EagerIssueAtStartup.cs @@ -1,4 +1,4 @@ -using TruthGate_Web.Services; +using TruthGate_Web.Services; namespace TruthGate_Web.Configuration { @@ -6,34 +6,47 @@ public sealed class EagerIssueAtStartup : IHostedService { private readonly IConfigService _config; private readonly LiveCertProvider _live; + private readonly ILogger _logger; - public EagerIssueAtStartup(IConfigService cfg, LiveCertProvider live) + public EagerIssueAtStartup( + IConfigService config, + LiveCertProvider live, + ILogger logger) { - _config = cfg; _live = live; + _config = config; + _live = live; + _logger = logger; } - public Task StartAsync(CancellationToken ct) + public async Task StartAsync(CancellationToken ct) { - var cfg = _config.Get(); - - // 1) Explicit domains - var hosts = cfg.Domains - .Where(d => bool.TryParse(d.UseSSL, out var ok) && ok) - .Select(d => (d.Domain ?? "").Trim().ToLowerInvariant()) - .Where(h => !string.IsNullOrWhiteSpace(h)) - .Distinct(); - - foreach (var h in hosts) - _live.TryQueueIssueIfMissing(h); - - // 2) Star-ish ipns authorized subdomains - foreach (var h in _live.EnumerateAuthorizedIpnsHosts()) - _live.TryQueueIssueIfMissing(h); - - return Task.CompletedTask; + var config = _config.Get(); + + var hosts = config.Domains + .Where(domain => bool.TryParse(domain.UseSSL, out var enabled) && enabled) + .Select(domain => CertificateInspector.NormalizeHost(domain.Domain ?? string.Empty)) + .Where(host => host.Length != 0) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var host in _live.EnumerateAuthorizedIpnsHosts()) + hosts.Add(host); + + try + { + await Task.WhenAll( + hosts.Select(host => _live.WarmCacheAsync(host, ct))) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "[TLS] Failed to hydrate the certificate cache at startup"); + } } public Task StopAsync(CancellationToken ct) => Task.CompletedTask; } - } diff --git a/TruthGate-Web/TruthGate-Web/Configuration/FileCertStore.cs b/TruthGate-Web/TruthGate-Web/Configuration/FileCertStore.cs index 6a7ac85..4fec5eb 100644 --- a/TruthGate-Web/TruthGate-Web/Configuration/FileCertStore.cs +++ b/TruthGate-Web/TruthGate-Web/Configuration/FileCertStore.cs @@ -1,4 +1,4 @@ -using System.Security.Cryptography.X509Certificates; +using System.Security.Cryptography.X509Certificates; namespace TruthGate_Web.Configuration { @@ -8,7 +8,13 @@ public interface ICertificateStore Task SaveAsync(string host, X509Certificate2 cert, CancellationToken ct); } - public sealed class FileCertStore : ICertificateStore + public interface ICertificateStoreMaintenance + { + Task QuarantineAsync(string host, string reason, CancellationToken ct); + void HardenExistingPermissions(); + } + + public sealed class FileCertStore : ICertificateStore, ICertificateStoreMaintenance { private readonly string _dir; private readonly bool _staging; @@ -17,17 +23,18 @@ public FileCertStore(string dir, bool staging = false) { _dir = dir; _staging = staging; + HardenExistingPermissions(); } private static string SafeFileNameForKey(string key) { if (string.IsNullOrWhiteSpace(key)) key = "unknown"; - // Normalize host-ish things. If someone ever passes "*.example.com", make it readable. + var safe = key.Trim() - .Replace("*.", "_wildcard_.") - .Replace(":", "_") - .Replace("/", "_") - .Replace("\\", "_"); + .Replace("*.", "_wildcard_.") + .Replace(":", "_") + .Replace("/", "_") + .Replace("\\", "_"); foreach (var c in Path.GetInvalidFileNameChars()) safe = safe.Replace(c, '_'); @@ -43,34 +50,179 @@ private string PathFor(string hostOrKey) public async Task LoadAsync(string host, CancellationToken ct) { - Directory.CreateDirectory(_dir); + EnsureDirectory(); var path = PathFor(host); if (!File.Exists(path)) { if (_staging) + return null; + + var legacy = Path.Combine(_dir, $"{SafeFileNameForKey(host)}.pfx"); + if (!File.Exists(legacy)) + return null; + + path = legacy; + } + + HardenFile(path); + var bytes = await File.ReadAllBytesAsync(path, ct).ConfigureAwait(false); + return X509CertificateLoader.LoadPkcs12(bytes, ReadOnlySpan.Empty); + } + + public async Task SaveAsync(string host, X509Certificate2 cert, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(cert); + if (!cert.HasPrivateKey) + throw new InvalidOperationException($"Refusing to save certificate for '{host}' because it has no private key."); + + EnsureDirectory(); + + var path = PathFor(host); + var tempPath = Path.Combine( + _dir, + $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp"); + + try + { + var bytes = cert.Export(X509ContentType.Pkcs12); + + await using (var stream = new FileStream( + tempPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 16 * 1024, + FileOptions.Asynchronous | FileOptions.WriteThrough)) { - return null; // no prod fallback in staging + await stream.WriteAsync(bytes, ct).ConfigureAwait(false); + await stream.FlushAsync(ct).ConfigureAwait(false); + stream.Flush(flushToDisk: true); } - else + + HardenFile(tempPath); + + var persistedBytes = await File.ReadAllBytesAsync(tempPath, ct).ConfigureAwait(false); + using (var validation = X509CertificateLoader.LoadPkcs12( + persistedBytes, + ReadOnlySpan.Empty)) { - // legacy prod fallback (unsuffixed) - var legacy = Path.Combine(_dir, $"{SafeFileNameForKey(host)}.pfx"); - if (!File.Exists(legacy)) return null; - path = legacy; + if (!validation.HasPrivateKey) + throw new InvalidOperationException( + $"Persisted certificate for '{host}' lost its private key."); } + + ReplaceAtomically(tempPath, path); + HardenFile(path); + } + finally + { + TryDelete(tempPath); } + } - var bytes = await File.ReadAllBytesAsync(path, ct); - return X509CertificateLoader.LoadPkcs12(bytes, ReadOnlySpan.Empty); + public Task QuarantineAsync(string host, string reason, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + EnsureDirectory(); + + var path = PathFor(host); + if (!File.Exists(path)) + return Task.CompletedTask; + + var stamp = DateTimeOffset.UtcNow.ToString("yyyyMMddHHmmssfff"); + var quarantinePath = $"{path}.corrupt.{stamp}"; + var suffix = 0; + + while (File.Exists(quarantinePath)) + quarantinePath = $"{path}.corrupt.{stamp}.{++suffix}"; + + File.Move(path, quarantinePath); + HardenFile(quarantinePath); + + return Task.CompletedTask; } - public async Task SaveAsync(string host, X509Certificate2 cert, CancellationToken ct) + public void HardenExistingPermissions() + { + EnsureDirectory(); + + foreach (var path in Directory.EnumerateFiles(_dir, "*.pfx", SearchOption.TopDirectoryOnly)) + HardenFile(path); + + foreach (var path in Directory.EnumerateFiles(_dir, "account*.pem", SearchOption.TopDirectoryOnly)) + HardenFile(path); + } + + private void EnsureDirectory() { Directory.CreateDirectory(_dir); - var path = PathFor(host); - var bytes = cert.Export(X509ContentType.Pkcs12); - await File.WriteAllBytesAsync(path, bytes, ct); + + if (OperatingSystem.IsWindows()) + return; + + try + { + File.SetUnixFileMode( + _dir, + UnixFileMode.UserRead | + UnixFileMode.UserWrite | + UnixFileMode.UserExecute); + } + catch + { + } + } + + private static void HardenFile(string path) + { + if (OperatingSystem.IsWindows() || !File.Exists(path)) + return; + + try + { + File.SetUnixFileMode( + path, + UnixFileMode.UserRead | + UnixFileMode.UserWrite); + } + catch + { + } + } + + private static void ReplaceAtomically(string tempPath, string destinationPath) + { + if (!File.Exists(destinationPath)) + { + File.Move(tempPath, destinationPath); + return; + } + + try + { + File.Replace(tempPath, destinationPath, destinationBackupFileName: null, ignoreMetadataErrors: true); + } + catch (PlatformNotSupportedException) + { + File.Move(tempPath, destinationPath, overwrite: true); + } + catch (IOException) + { + File.Move(tempPath, destinationPath, overwrite: true); + } + } + + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + File.Delete(path); + } + catch + { + } } } } diff --git a/TruthGate-Web/TruthGate-Web/Configuration/LiveCertProvider.cs b/TruthGate-Web/TruthGate-Web/Configuration/LiveCertProvider.cs index a7cfc87..26a992a 100644 --- a/TruthGate-Web/TruthGate-Web/Configuration/LiveCertProvider.cs +++ b/TruthGate-Web/TruthGate-Web/Configuration/LiveCertProvider.cs @@ -1,5 +1,4 @@ -using System.Collections.Concurrent; -using System.Net; +using System.Collections.Concurrent; using System.Security.Cryptography.X509Certificates; using TruthGate_Web.Services; @@ -19,12 +18,30 @@ public interface IAcmeIssuerLabel public readonly record struct SslDecision(SslDecisionKind Kind); + public sealed record CertificateLifecycleStatus( + string Host, + CertificateState State, + DateTimeOffset? NotBefore, + DateTimeOffset? NotAfter, + bool CurrentlyServable, + bool IssuanceInFlight, + int FailureCount, + DateTimeOffset? NextRetry, + DateTimeOffset? LastSuccessfulIssuance, + string? LastError); + public sealed class LiveCertProvider { - private static readonly SemaphoreSlim _throttle = new(2); - private readonly ConcurrentDictionary _inflight = + private static readonly TimeSpan RenewalWindow = TimeSpan.FromDays(30); + + private readonly SemaphoreSlim _throttle = new(2); + private readonly ConcurrentDictionary> _inflight = + new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _cooldown = new(StringComparer.OrdinalIgnoreCase); - private readonly ConcurrentDictionary _cooldown = + private readonly ConcurrentDictionary _issuedCache = + new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _status = new(StringComparer.OrdinalIgnoreCase); private readonly SelfSignedCertCache _self; @@ -33,282 +50,585 @@ public sealed class LiveCertProvider private readonly IConfigService _config; private readonly ILogger? _log; private readonly string _acmeLabel; + private readonly TimeProvider _timeProvider; + + private readonly record struct RetryState( + DateTimeOffset Until, + int Failures, + string LastError); public LiveCertProvider( SelfSignedCertCache self, ICertificateStore store, IAcmeIssuer acme, IConfigService config, - ILogger? log = null) + ILogger? log = null, + TimeProvider? timeProvider = null) { _self = self; _store = store; _acme = acme; _config = config; _log = log; + _timeProvider = timeProvider ?? TimeProvider.System; _acmeLabel = (acme as IAcmeIssuerLabel)?.Label ?? "unknown"; } - // ---------- IPNS wildcard base helpers ---------- + public X509Certificate2 GetSelfSigned() => _self.Get(); - private (string? baseHost, bool enabled) GetIpnsWildcardBase() + public X509Certificate2? TryLoadIssued(string exactHostKey) { - var w = _config.Get().IpnsWildCardSubDomain; - if (w is null) return (null, false); + var key = CertificateInspector.NormalizeHost(exactHostKey); + if (key.Length == 0) + return null; - var baseHost = (w.WildCardSubDomain ?? "").Trim().ToLowerInvariant(); - var useSsl = bool.TryParse(w.UseSSL, out var ok) && ok; + if (!_issuedCache.TryGetValue(key, out var certificate)) + return null; - if (string.IsNullOrWhiteSpace(baseHost) || !useSsl) return (null, false); - return (baseHost, true); + var inspection = CertificateInspector.Inspect( + certificate, + key, + _timeProvider.GetUtcNow(), + RenewalWindow); + + if (inspection.CanServe) + { + UpdateStatus(key, inspection, currentlyServable: true, lastError: null); + return certificate; + } + + _issuedCache.TryRemove(key, out _); + UpdateStatus(key, inspection, currentlyServable: false, lastError: inspection.Detail); + return null; } - private static string? LeftLabel(string host) + public async Task WarmCacheAsync(string exactHostKey, CancellationToken ct = default) { - host = (host ?? "").Trim().ToLowerInvariant(); - var ix = host.IndexOf('.'); - if (ix <= 0) return null; - return host[..ix]; + var key = CertificateInspector.NormalizeHost(exactHostKey); + if (key.Length == 0) + return; + + var (_, inspection) = await LoadAndInspectAsync(key, quarantineCorrupt: true, ct) + .ConfigureAwait(false); + + UpdateStatus( + key, + inspection, + currentlyServable: inspection.CanServe, + lastError: inspection.CanServe ? null : inspection.Detail); } - private bool IsAuthorizedIpnsStarishHost(string host) + public Task ReconcileAsync(string exactHostKey, CancellationToken ct = default) { - host = (host ?? "").Trim().ToLowerInvariant(); - var (baseHost, enabled) = GetIpnsWildcardBase(); - if (!enabled || baseHost is null) return false; + var (task, _) = GetOrStartReconciliation(exactHostKey, ct); + return task; + } - if (!(host == baseHost || host.EndsWith("." + baseHost, StringComparison.OrdinalIgnoreCase))) - return false; + public bool TryQueueIssueIfMissing(string exactHostKey) + { + var (_, started) = GetOrStartReconciliation(exactHostKey, CancellationToken.None); + return started; + } - var left = LeftLabel(host); - if (left is null) return false; + public void QueueIssueIfMissing(string host) => TryQueueIssueIfMissing(host); - var cfg = _config.Get(); - var authorized = cfg.Domains - .Select(d => new { d.IpnsPeerId, d.IpnsKeyName }) - .Where(x => !string.IsNullOrWhiteSpace(x.IpnsPeerId) - || !string.IsNullOrWhiteSpace(x.IpnsKeyName)); + public bool IsInFlight(string host) + { + var key = CertificateInspector.NormalizeHost(host); + return key.Length != 0 && _inflight.ContainsKey(key); + } - foreach (var a in authorized) - { - if (!string.IsNullOrWhiteSpace(a.IpnsPeerId) && - string.Equals(left, a.IpnsPeerId.Trim(), StringComparison.OrdinalIgnoreCase)) - return true; + public IReadOnlyCollection GetStatusSnapshot() + => _status.Values + .OrderBy(status => status.Host, StringComparer.OrdinalIgnoreCase) + .ToArray(); - if (!string.IsNullOrWhiteSpace(a.IpnsKeyName) && - string.Equals(left, a.IpnsKeyName.Trim(), StringComparison.OrdinalIgnoreCase)) - return true; + public object GetCooldownSnapshot(string host) + { + var key = CertificateInspector.NormalizeHost(host); + if (_cooldown.TryGetValue(key, out var retry)) + { + return new + { + host = key, + coolingDown = _timeProvider.GetUtcNow() < retry.Until, + until = retry.Until, + failures = retry.Failures, + lastError = retry.LastError + }; } - return false; + return new { host = key, coolingDown = false, failures = 0 }; } - - - /// - /// Main decision including star-ish ipns hosts. - /// - public SslDecision DecideForHostIncludingStarish(string host) + private (Task Task, bool Started) GetOrStartReconciliation( + string exactHostKey, + CancellationToken ct) { - host = (host ?? "").Trim().ToLowerInvariant(); + var key = CertificateInspector.NormalizeHost(exactHostKey); + if (key.Length == 0) + return (Task.CompletedTask, false); - // 1) Exact configured domain - var cfg = _config.Get(); - var match = cfg.Domains.FirstOrDefault(d => - string.Equals(d.Domain?.Trim(), host, StringComparison.OrdinalIgnoreCase)); + var decision = DecideForHostIncludingStarish(key); + if (decision.Kind != SslDecisionKind.RealIfPresent) + return (Task.CompletedTask, false); - if (match is not null) + if (_cooldown.TryGetValue(key, out var retry) && + _timeProvider.GetUtcNow() < retry.Until) { - var useSsl = bool.TryParse(match.UseSSL, out var ok) && ok; - return useSsl ? new SslDecision(SslDecisionKind.RealIfPresent) - : new SslDecision(SslDecisionKind.NoneFailTls); + _log?.LogWarning( + "[TLS] {Host} is in cooldown until {Until} after {Failures} failure(s); reconciliation skipped", + key, + retry.Until, + retry.Failures); + + RefreshInFlightStatus(key, false); + return (Task.CompletedTask, false); } - // 2) Authorized ipns star-ish subdomain - if (IsAuthorizedIpnsStarishHost(host)) - return new SslDecision(SslDecisionKind.RealIfPresent); + var candidate = new Lazy( + () => ReconcileCoreAsync(key, ct), + LazyThreadSafetyMode.ExecutionAndPublication); - // 3) Fallback - return new SslDecision(SslDecisionKind.SelfSigned); - } + var winner = _inflight.GetOrAdd(key, candidate); + var started = ReferenceEquals(candidate, winner); + var task = winner.Value; - // ---------- Load / Queue using EXACT host as the cert key ---------- + if (started) + _ = CleanupFlightAsync(key, candidate, task); - // in LiveCertProvider fields - private readonly ConcurrentDictionary _issuedCache - = new(StringComparer.OrdinalIgnoreCase); - - private static bool IsExpiringSoon(DateTimeOffset notAfter) - => (notAfter - DateTimeOffset.UtcNow) <= TimeSpan.FromDays(30); - - // util - private static DateTimeOffset? TryGetNotAfter(X509Certificate2 c) - => DateTimeOffset.TryParse(c.GetExpirationDateString(), out var d) ? d : null; + RefreshInFlightStatus(key, true); + return (task, started); + } - // replace TryLoadIssued(...) with: - public X509Certificate2? TryLoadIssued(string exactHostKey) + private async Task CleanupFlightAsync(string key, Lazy owner, Task task) { - // 1) cache - if (_issuedCache.TryGetValue(exactHostKey, out var entry)) + try { - if (!IsExpiringSoon(entry.notAfter)) - return entry.cert; - // drop expiring - _issuedCache.TryRemove(exactHostKey, out _); + await task.ConfigureAwait(false); } + finally + { + if (_inflight.TryGetValue(key, out var current) && + ReferenceEquals(current, owner)) + { + _inflight.TryRemove(key, out _); + } - // 2) disk - var cert = _store.LoadAsync(exactHostKey, CancellationToken.None).GetAwaiter().GetResult(); - if (cert is null) return null; - - var notAfter = TryGetNotAfter(cert); - if (notAfter is null || IsExpiringSoon(notAfter.Value)) - return null; - - _issuedCache[exactHostKey] = (cert, notAfter.Value); - return cert; + RefreshInFlightStatus(key, false); + } } - - public bool TryQueueIssueIfMissing(string exactHostKey) + private async Task ReconcileCoreAsync(string key, CancellationToken ct) { - var decision = DecideForHostIncludingStarish(exactHostKey); - if (decision.Kind != SslDecisionKind.RealIfPresent) - return false; + X509Certificate2? lastKnownServable = null; - var key = (exactHostKey ?? "").Trim().ToLowerInvariant(); - if (string.IsNullOrWhiteSpace(key)) return false; - - // Cooldown guard - if (_cooldown.TryGetValue(key, out var cd) && DateTimeOffset.UtcNow < cd.until) + try { - _log?.LogWarning("[TLS] {Key} in cooldown until {Until} (failures={Fails}), skip queue", - key, cd.until, cd.failures); - return false; - } + var (existing, inspection) = await LoadAndInspectAsync( + key, + quarantineCorrupt: true, + ct).ConfigureAwait(false); - // De-dupe - if (_inflight.ContainsKey(key)) - { - _log?.LogInformation("[TLS] {Key} already issuing on {Label}, skip re-queue", key, _acmeLabel); - return false; - } + if (inspection.CanServe) + lastKnownServable = existing; - _inflight[key] = Task.Run(async () => - { + UpdateStatus( + key, + inspection, + currentlyServable: inspection.CanServe, + lastError: inspection.CanServe ? null : inspection.Detail); + + if (!inspection.NeedsIssuance) + { + _cooldown.TryRemove(key, out _); + return; + } + + await _throttle.WaitAsync(ct).ConfigureAwait(false); try { - var existing = await _store.LoadAsync(key, CancellationToken.None); - var need = existing is null || IsCloseToExpiry(existing); - if (!need) + var (latest, latestInspection) = await LoadAndInspectAsync( + key, + quarantineCorrupt: true, + ct).ConfigureAwait(false); + + if (latestInspection.CanServe) + lastKnownServable = latest; + + UpdateStatus( + key, + latestInspection, + currentlyServable: latestInspection.CanServe, + lastError: latestInspection.CanServe ? null : latestInspection.Detail); + + if (!latestInspection.NeedsIssuance) { - _log?.LogInformation("[TLS] {Key} has fresh PFX; no issuance needed", key); + _cooldown.TryRemove(key, out _); return; } - await _throttle.WaitAsync(); - try + _log?.LogInformation( + "[TLS] [{Label}] {State} certificate for {Host}; issuing/renewing", + _acmeLabel, + latestInspection.State, + key); + + var issued = await _acme.IssueOrRenewAsync(key, ct).ConfigureAwait(false); + if (issued is null) + throw new InvalidOperationException( + $"ACME issuer returned no certificate for '{key}'."); + + var issuedInspection = CertificateInspector.Inspect( + issued, + key, + _timeProvider.GetUtcNow(), + RenewalWindow); + + if (!issuedInspection.CanServe) + { + throw new InvalidOperationException( + $"ACME returned an unusable certificate for '{key}': " + + $"{issuedInspection.State}. {issuedInspection.Detail}"); + } + + await _store.SaveAsync(key, issued, ct).ConfigureAwait(false); + + var (persisted, persistedInspection) = await LoadAndInspectAsync( + key, + quarantineCorrupt: false, + ct).ConfigureAwait(false); + + if (persisted is null || !persistedInspection.CanServe) { - _log?.LogInformation("[TLS] [{Label}] issuing/renewing {Key}...", _acmeLabel, key); - var issued = await _acme.IssueOrRenewAsync(key, CancellationToken.None); - if (issued is not null) - { - await _store.SaveAsync(key, issued, CancellationToken.None); - _cooldown.TryRemove(key, out _); - _log?.LogInformation("[TLS] [{Label}] saved new PFX for {Key}", _acmeLabel, key); - } - else - { - RegisterFailure(key); - } + throw new InvalidOperationException( + $"The persisted replacement certificate for '{key}' failed validation: " + + $"{persistedInspection.State}. {persistedInspection.Detail}"); } - finally { _throttle.Release(); } + + _issuedCache[key] = persisted; + _cooldown.TryRemove(key, out _); + + var now = _timeProvider.GetUtcNow(); + _status[key] = new CertificateLifecycleStatus( + key, + persistedInspection.State, + persistedInspection.NotBefore, + persistedInspection.NotAfter, + CurrentlyServable: true, + IssuanceInFlight: true, + FailureCount: 0, + NextRetry: null, + LastSuccessfulIssuance: now, + LastError: null); + + _log?.LogInformation( + "[TLS] [{Label}] installed certificate for {Host}, valid until {NotAfter}", + _acmeLabel, + key, + persistedInspection.NotAfter); } - catch (Exception ex) + finally { - _log?.LogError(ex, "[TLS] [{Label}] issuance failed for {Key}", _acmeLabel, key); - RegisterFailure(key); + _throttle.Release(); } - finally + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + if (lastKnownServable is not null) + _issuedCache[key] = lastKnownServable; + else + _issuedCache.TryRemove(key, out _); + + _log?.LogError( + ex, + "[TLS] [{Label}] reconciliation failed for {Host}", + _acmeLabel, + key); + + RegisterFailure(key, ex.Message); + } + } + + private async Task<(X509Certificate2? Certificate, CertificateInspection Inspection)> + LoadAndInspectAsync( + string key, + bool quarantineCorrupt, + CancellationToken ct) + { + try + { + var certificate = await _store.LoadAsync(key, ct).ConfigureAwait(false); + var inspection = CertificateInspector.Inspect( + certificate, + key, + _timeProvider.GetUtcNow(), + RenewalWindow); + + if (inspection.CanServe && certificate is not null) + _issuedCache[key] = certificate; + else + _issuedCache.TryRemove(key, out _); + + return (certificate, inspection); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _issuedCache.TryRemove(key, out _); + + _log?.LogWarning( + ex, + "[TLS] Stored certificate for {Host} could not be loaded and will be repaired", + key); + + if (quarantineCorrupt && _store is ICertificateStoreMaintenance maintenance) { - _inflight.TryRemove(key, out _); + try + { + await maintenance.QuarantineAsync(key, ex.Message, ct) + .ConfigureAwait(false); + } + catch (Exception quarantineException) + { + _log?.LogWarning( + quarantineException, + "[TLS] Could not quarantine the broken certificate for {Host}", + key); + } } - }); - return true; + return ( + null, + new CertificateInspection( + CertificateState.Corrupt, + null, + null, + ex.Message)); + } } - public void QueueIssueIfMissing(string host) => TryQueueIssueIfMissing(host); + private void RegisterFailure(string key, string error) + { + var now = _timeProvider.GetUtcNow(); + var retry = _cooldown.AddOrUpdate( + key, + _ => new RetryState(now.AddMinutes(1), 1, error), + (_, previous) => + { + var failures = Math.Min(previous.Failures + 1, 5); + var delay = failures switch + { + 1 => TimeSpan.FromMinutes(1), + 2 => TimeSpan.FromMinutes(5), + 3 => TimeSpan.FromMinutes(15), + 4 => TimeSpan.FromMinutes(30), + _ => TimeSpan.FromHours(1) + }; + + return new RetryState(now + delay, failures, error); + }); - // ---------- Other helpers ---------- + var existing = _status.TryGetValue(key, out var status) + ? status + : new CertificateLifecycleStatus( + key, + CertificateState.Missing, + null, + null, + CurrentlyServable: false, + IssuanceInFlight: true, + FailureCount: 0, + NextRetry: null, + LastSuccessfulIssuance: null, + LastError: null); + + _status[key] = existing with + { + IssuanceInFlight = true, + FailureCount = retry.Failures, + NextRetry = retry.Until, + LastError = error + }; + + _log?.LogWarning( + "[TLS] {Host} retry scheduled for {Until} after {Failures} failure(s)", + key, + retry.Until, + retry.Failures); + } - public X509Certificate2 GetSelfSigned() => _self.Get(); + private void UpdateStatus( + string key, + CertificateInspection inspection, + bool currentlyServable, + string? lastError) + { + var retry = _cooldown.TryGetValue(key, out var existingRetry) + ? existingRetry + : (RetryState?)null; + + var previous = _status.TryGetValue(key, out var existing) + ? existing + : null; + + _status[key] = new CertificateLifecycleStatus( + key, + inspection.State, + inspection.NotBefore, + inspection.NotAfter, + currentlyServable, + IsInFlight(key), + retry?.Failures ?? 0, + retry?.Until, + previous?.LastSuccessfulIssuance, + lastError ?? retry?.LastError); + } - // Legacy exact-match decision (kept if other code calls it) - public SslDecision DecideForHost(string host) + private void RefreshInFlightStatus(string key, bool inFlight) { - var cfg = _config.Get(); - var match = cfg.Domains.FirstOrDefault(d => - string.Equals(d.Domain?.Trim(), host, StringComparison.OrdinalIgnoreCase)); - if (match is null) return new SslDecision(SslDecisionKind.SelfSigned); - var useSsl = bool.TryParse(match.UseSSL, out var ok) && ok; - return useSsl ? new SslDecision(SslDecisionKind.RealIfPresent) - : new SslDecision(SslDecisionKind.NoneFailTls); + if (!_status.TryGetValue(key, out var existing)) + return; + + _status[key] = existing with { IssuanceInFlight = inFlight }; } - public bool IsInFlight(string host) => _inflight.ContainsKey((host ?? "").Trim().ToLowerInvariant()); + private (string? BaseHost, bool Enabled) GetIpnsWildcardBase() + { + var wildcard = _config.Get().IpnsWildCardSubDomain; + if (wildcard is null) + return (null, false); - private void RegisterFailure(string key) + var baseHost = CertificateInspector.NormalizeHost(wildcard.WildCardSubDomain ?? string.Empty); + var useSsl = bool.TryParse(wildcard.UseSSL, out var enabled) && enabled; + + if (baseHost.Length == 0 || !useSsl) + return (null, false); + + return (baseHost, true); + } + + private static string? LeftLabel(string host) + { + var normalized = CertificateInspector.NormalizeHost(host); + var index = normalized.IndexOf('.'); + return index <= 0 ? null : normalized[..index]; + } + + private bool IsAuthorizedIpnsStarishHost(string host) { - var next = _cooldown.AddOrUpdate(key, - _ => (DateTimeOffset.UtcNow.AddMinutes(1), 1), - (_, prev) => + host = CertificateInspector.NormalizeHost(host); + var (baseHost, enabled) = GetIpnsWildcardBase(); + if (!enabled || baseHost is null) + return false; + + if (!(host == baseHost || + host.EndsWith("." + baseHost, StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + var left = LeftLabel(host); + if (left is null) + return false; + + var authorized = _config.Get().Domains + .Select(domain => new { domain.IpnsPeerId, domain.IpnsKeyName }) + .Where(value => + !string.IsNullOrWhiteSpace(value.IpnsPeerId) || + !string.IsNullOrWhiteSpace(value.IpnsKeyName)); + + foreach (var value in authorized) + { + if (!string.IsNullOrWhiteSpace(value.IpnsPeerId) && + string.Equals( + left, + value.IpnsPeerId.Trim(), + StringComparison.OrdinalIgnoreCase)) { - var failures = Math.Min(prev.failures + 1, 5); - var minutes = failures switch { 1 => 1, 2 => 5, 3 => 15, 4 => 30, _ => 60 }; - return (DateTimeOffset.UtcNow.AddMinutes(minutes), failures); - }); + return true; + } + + if (!string.IsNullOrWhiteSpace(value.IpnsKeyName) && + string.Equals( + left, + value.IpnsKeyName.Trim(), + StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } - _log?.LogWarning("[TLS] {Key} cooldown until {Until} (failures={Fails})", - key, next.until, next.failures); + return false; } - private static bool IsCloseToExpiry(X509Certificate2 cert) + public SslDecision DecideForHostIncludingStarish(string host) { - if (!DateTimeOffset.TryParse(cert.GetExpirationDateString(), out var notAfter)) return false; - return (notAfter - DateTimeOffset.UtcNow) <= TimeSpan.FromDays(30); + host = CertificateInspector.NormalizeHost(host); + + var match = _config.Get().Domains.FirstOrDefault(domain => + string.Equals( + CertificateInspector.NormalizeHost(domain.Domain ?? string.Empty), + host, + StringComparison.OrdinalIgnoreCase)); + + if (match is not null) + { + var useSsl = bool.TryParse(match.UseSSL, out var enabled) && enabled; + return useSsl + ? new SslDecision(SslDecisionKind.RealIfPresent) + : new SslDecision(SslDecisionKind.NoneFailTls); + } + + if (IsAuthorizedIpnsStarishHost(host)) + return new SslDecision(SslDecisionKind.RealIfPresent); + + return new SslDecision(SslDecisionKind.SelfSigned); } - // Debug - public object GetCooldownSnapshot(string host) + public SslDecision DecideForHost(string host) { - host = (host ?? "").Trim().ToLowerInvariant(); - if (_cooldown.TryGetValue(host, out var cd)) - return new { host, coolingDown = DateTimeOffset.UtcNow < cd.until, until = cd.until, failures = cd.failures }; - return new { host, coolingDown = false, failures = 0 }; + host = CertificateInspector.NormalizeHost(host); + + var match = _config.Get().Domains.FirstOrDefault(domain => + string.Equals( + CertificateInspector.NormalizeHost(domain.Domain ?? string.Empty), + host, + StringComparison.OrdinalIgnoreCase)); + + if (match is null) + return new SslDecision(SslDecisionKind.SelfSigned); + + var useSsl = bool.TryParse(match.UseSSL, out var enabled) && enabled; + return useSsl + ? new SslDecision(SslDecisionKind.RealIfPresent) + : new SslDecision(SslDecisionKind.NoneFailTls); } - // Enumerate star-ish hosts to pre-issue at startup / renew in watcher public IEnumerable EnumerateAuthorizedIpnsHosts() { var (baseHost, enabled) = GetIpnsWildcardBase(); - if (!enabled || baseHost is null) yield break; + if (!enabled || baseHost is null) + yield break; - var cfg = _config.Get(); - foreach (var d in cfg.Domains.Where(d => bool.TryParse(d.UseSSL, out var ok) && ok)) + var config = _config.Get(); + foreach (var domain in config.Domains.Where(domain => + bool.TryParse(domain.UseSSL, out var useSsl) && useSsl)) { - var ids = new[] - { - d.IpnsPeerId?.Trim(), - d.IpnsKeyName?.Trim() - } - .Where(s => !string.IsNullOrWhiteSpace(s)) - .Distinct(StringComparer.OrdinalIgnoreCase); + var identifiers = new[] + { + domain.IpnsPeerId?.Trim(), + domain.IpnsKeyName?.Trim() + } + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.OrdinalIgnoreCase); - foreach (var left in ids) - yield return $"{left!.ToLowerInvariant()}.{baseHost}"; + foreach (var identifier in identifiers) + yield return $"{identifier!.ToLowerInvariant()}.{baseHost}"; } } - } } diff --git a/TruthGate-Web/TruthGate-Web/Configuration/SelfSignedCertCache.cs b/TruthGate-Web/TruthGate-Web/Configuration/SelfSignedCertCache.cs index 9ed99f3..cc6e2b0 100644 --- a/TruthGate-Web/TruthGate-Web/Configuration/SelfSignedCertCache.cs +++ b/TruthGate-Web/TruthGate-Web/Configuration/SelfSignedCertCache.cs @@ -1,12 +1,278 @@ -using System.Net; +using System.Net; using System.Security.Cryptography.X509Certificates; +using TruthGate_Web.Utils; namespace TruthGate_Web.Configuration { public sealed class SelfSignedCertCache { - private readonly X509Certificate2 _cached; - public SelfSignedCertCache(X509Certificate2 cert) => _cached = cert; - public X509Certificate2 Get() => _cached; + private static readonly TimeSpan RotationWindow = TimeSpan.FromDays(30); + + private readonly SemaphoreSlim _rotationLock = new(1, 1); + private readonly TimeProvider _timeProvider; + private readonly string _path; + private X509Certificate2 _cached; + + public SelfSignedCertCache(X509Certificate2 initialCertificate) + : this( + initialCertificate, + GetDefaultPersistencePath(), + TimeProvider.System) + { + } + + public SelfSignedCertCache( + X509Certificate2 initialCertificate, + string persistencePath, + TimeProvider? timeProvider = null) + { + ArgumentNullException.ThrowIfNull(initialCertificate); + ArgumentException.ThrowIfNullOrWhiteSpace(persistencePath); + + _timeProvider = timeProvider ?? TimeProvider.System; + _path = Path.GetFullPath(persistencePath); + + EnsureDirectory(Path.GetDirectoryName(_path)!); + + var persisted = TryLoadPersisted(); + if (persisted is not null && IsCurrentlyUsable(persisted)) + { + _cached = persisted; + } + else + { + _cached = initialCertificate; + PersistAtomicAsync(initialCertificate, CancellationToken.None) + .GetAwaiter() + .GetResult(); + } + } + + private static string GetDefaultPersistencePath() + { + var certDir = Environment.GetEnvironmentVariable("TRUTHGATE_CERT_PATH"); + if (string.IsNullOrWhiteSpace(certDir)) + certDir = "/opt/truthgate/certs"; + + return Path.Combine( + Path.GetFullPath(certDir), + "fallback-selfsigned.pfx"); + } + + public X509Certificate2 Get() => Volatile.Read(ref _cached); + + public async Task EnsureFreshAsync(CancellationToken ct = default) + { + var now = _timeProvider.GetUtcNow(); + var current = Get(); + + if (IsCurrentlyUsable(current) && + new DateTimeOffset(current.NotAfter.ToUniversalTime()) - now > RotationWindow) + { + return; + } + + await _rotationLock.WaitAsync(ct).ConfigureAwait(false); + try + { + now = _timeProvider.GetUtcNow(); + current = Get(); + + if (IsCurrentlyUsable(current) && + new DateTimeOffset(current.NotAfter.ToUniversalTime()) - now > RotationWindow) + { + return; + } + + var replacement = CreateReplacement(); + await PersistAtomicAsync(replacement, ct).ConfigureAwait(false); + Volatile.Write(ref _cached, replacement); + } + finally + { + _rotationLock.Release(); + } + } + + private bool IsCurrentlyUsable(X509Certificate2 certificate) + { + var now = _timeProvider.GetUtcNow(); + var notBefore = new DateTimeOffset(certificate.NotBefore.ToUniversalTime()); + var notAfter = new DateTimeOffset(certificate.NotAfter.ToUniversalTime()); + + return certificate.HasPrivateKey && now >= notBefore && now < notAfter; + } + + private X509Certificate2? TryLoadPersisted() + { + if (!File.Exists(_path)) + return null; + + try + { + HardenFile(_path); + var bytes = File.ReadAllBytes(_path); + return X509CertificateLoader.LoadPkcs12(bytes, ReadOnlySpan.Empty); + } + catch + { + try + { + var quarantine = $"{_path}.corrupt.{DateTimeOffset.UtcNow:yyyyMMddHHmmssfff}"; + File.Move(_path, quarantine); + HardenFile(quarantine); + } + catch + { + } + + return null; + } + } + + private static X509Certificate2 CreateReplacement() + { + IReadOnlyList ips; + var overrideValue = Environment.GetEnvironmentVariable("TRUTHGATE_CERT_IPS"); + + if (!string.IsNullOrWhiteSpace(overrideValue)) + { + ips = overrideValue + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(value => IPAddress.TryParse(value, out var ip) ? ip : null) + .Where(ip => ip is not null) + .Cast() + .Distinct() + .ToList(); + } + else + { + try + { + ips = IPHelper.GetPublicInterfaceIPs() + .Distinct() + .ToList(); + } + catch + { + ips = Array.Empty(); + } + } + + if (ips.Count == 0) + ips = new[] { IPAddress.Loopback, IPAddress.IPv6Loopback }; + + return KestrelExtensions.CreateSelfSignedServerCert( + dnsNames: Array.Empty(), + ipAddresses: ips); + } + + private async Task PersistAtomicAsync(X509Certificate2 certificate, CancellationToken ct) + { + var directory = Path.GetDirectoryName(_path)!; + EnsureDirectory(directory); + + var tempPath = Path.Combine( + directory, + $".{Path.GetFileName(_path)}.{Guid.NewGuid():N}.tmp"); + + try + { + var bytes = certificate.Export(X509ContentType.Pkcs12); + + await using (var stream = new FileStream( + tempPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 16 * 1024, + FileOptions.Asynchronous | FileOptions.WriteThrough)) + { + await stream.WriteAsync(bytes, ct).ConfigureAwait(false); + await stream.FlushAsync(ct).ConfigureAwait(false); + stream.Flush(flushToDisk: true); + } + + HardenFile(tempPath); + + var validationBytes = await File.ReadAllBytesAsync(tempPath, ct).ConfigureAwait(false); + using var validation = X509CertificateLoader.LoadPkcs12( + validationBytes, + ReadOnlySpan.Empty); + + if (!validation.HasPrivateKey) + throw new InvalidOperationException("The persisted fallback certificate has no private key."); + + if (File.Exists(_path)) + { + try + { + File.Replace(tempPath, _path, destinationBackupFileName: null, ignoreMetadataErrors: true); + } + catch (PlatformNotSupportedException) + { + File.Move(tempPath, _path, overwrite: true); + } + catch (IOException) + { + File.Move(tempPath, _path, overwrite: true); + } + } + else + { + File.Move(tempPath, _path); + } + + HardenFile(_path); + } + finally + { + try + { + if (File.Exists(tempPath)) + File.Delete(tempPath); + } + catch + { + } + } + } + + private static void EnsureDirectory(string directory) + { + Directory.CreateDirectory(directory); + + if (OperatingSystem.IsWindows()) + return; + + try + { + File.SetUnixFileMode( + directory, + UnixFileMode.UserRead | + UnixFileMode.UserWrite | + UnixFileMode.UserExecute); + } + catch + { + } + } + + private static void HardenFile(string path) + { + if (OperatingSystem.IsWindows() || !File.Exists(path)) + return; + + try + { + File.SetUnixFileMode( + path, + UnixFileMode.UserRead | + UnixFileMode.UserWrite); + } + catch + { + } + } } } diff --git a/TruthGate-Web/TruthGate-Web/Services/ConfigWatchAndIssueService.cs b/TruthGate-Web/TruthGate-Web/Services/ConfigWatchAndIssueService.cs index 7b0a52f..4d06d60 100644 --- a/TruthGate-Web/TruthGate-Web/Services/ConfigWatchAndIssueService.cs +++ b/TruthGate-Web/TruthGate-Web/Services/ConfigWatchAndIssueService.cs @@ -1,55 +1,97 @@ -using System.Security.Cryptography.X509Certificates; using TruthGate_Web.Configuration; namespace TruthGate_Web.Services { public sealed class ConfigWatchAndIssueService : BackgroundService { + private static readonly TimeSpan ReconcileInterval = TimeSpan.FromMinutes(2); + private readonly IConfigService _config; private readonly LiveCertProvider _live; + private readonly SelfSignedCertCache _fallback; + private readonly IHostApplicationLifetime _lifetime; + private readonly ILogger _logger; - public ConfigWatchAndIssueService(IConfigService config, LiveCertProvider live) + public ConfigWatchAndIssueService( + IConfigService config, + LiveCertProvider live, + SelfSignedCertCache fallback, + IHostApplicationLifetime lifetime, + ILogger logger) { _config = config; _live = live; + _fallback = fallback; + _lifetime = lifetime; + _logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - var lastSnapshot = new HashSet(StringComparer.OrdinalIgnoreCase); + await WaitForApplicationStartedAsync(stoppingToken).ConfigureAwait(false); while (!stoppingToken.IsCancellationRequested) { try { - var cfg = _config.Get(); - - var want = cfg.Domains - .Where(d => bool.TryParse(d.UseSSL, out var ok) && ok) - .Select(d => (d.Domain ?? "").Trim().ToLowerInvariant()) - .Where(h => !string.IsNullOrWhiteSpace(h)) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - - // Add authorized star-ish ipns names - foreach (var h in _live.EnumerateAuthorizedIpnsHosts()) - want.Add(h); - - foreach (var host in want.Except(lastSnapshot)) - _live.TryQueueIssueIfMissing(host); + await _fallback.EnsureFreshAsync(stoppingToken).ConfigureAwait(false); - foreach (var host in want) - _live.TryQueueIssueIfMissing(host); - - lastSnapshot = want; + var hosts = GetDesiredHosts(); + await Task.WhenAll( + hosts.Select(host => _live.ReconcileAsync(host, stoppingToken))) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; } - catch + catch (Exception ex) { - // optional: log + _logger.LogError(ex, "[TLS] Certificate reconciliation cycle failed"); } - await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken); + try + { + await Task.Delay(ReconcileInterval, stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } } } - } + private HashSet GetDesiredHosts() + { + var config = _config.Get(); + + var hosts = config.Domains + .Where(domain => bool.TryParse(domain.UseSSL, out var enabled) && enabled) + .Select(domain => CertificateInspector.NormalizeHost(domain.Domain ?? string.Empty)) + .Where(host => host.Length != 0) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var host in _live.EnumerateAuthorizedIpnsHosts()) + hosts.Add(host); + + return hosts; + } + + private async Task WaitForApplicationStartedAsync(CancellationToken stoppingToken) + { + if (_lifetime.ApplicationStarted.IsCancellationRequested) + return; + + var started = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + using var startedRegistration = _lifetime.ApplicationStarted.Register( + () => started.TrySetResult(true)); + + using var stoppingRegistration = stoppingToken.Register( + () => started.TrySetCanceled(stoppingToken)); + + await started.Task.ConfigureAwait(false); + } + } }