From 1b22b8688dbd85e1fc5d06805f224c5ae30d1f9f Mon Sep 17 00:00:00 2001 From: Maksym Sadovnychyy Date: Mon, 13 Apr 2026 19:07:58 +0200 Subject: [PATCH] (bugfix): ACME rate limits, cooldown persistence, and LetsEncrypt hardening --- CHANGELOG.md | 22 ++ assets/badges/coverage-lines.svg | 8 +- assets/badges/coverage-methods.svg | 8 +- src/LetsEncrypt.Tests/AcmeProblemKindTests.cs | 28 ++ .../AcmeRetryAfterParserTests.cs | 72 ++++ ...tionCacheAcmeCooldownSerializationTests.cs | 30 ++ src/LetsEncrypt/AcmeProblemKind.cs | 52 +++ src/LetsEncrypt/AcmeRetryAfterParser.cs | 66 ++++ .../Entities/LetsEncrypt/RegistrationCache.cs | 37 ++ src/LetsEncrypt/Entities/LetsEncrypt/State.cs | 13 +- .../Exceptions/LetsEncrytException.cs | 30 +- .../Extensions/ServiceCollectionExtensions.cs | 1 + src/LetsEncrypt/LetsEncrypt.csproj | 4 + .../Models/Responses/AcmeDirectory.cs | 20 +- .../Responses/AuthorizationChallengeError.cs | 4 +- src/LetsEncrypt/Models/Responses/Problem.cs | 6 +- src/LetsEncrypt/Services/AcmeSessionStore.cs | 21 ++ .../Services/LetsEncryptService.Helpers.cs | 235 ++++++++++++ .../Services/LetsEncryptService.cs | 356 ++++++------------ .../BackgroundServices/AutoRenewal.cs | 26 +- src/MaksIT.Webapi/MaksIT.Webapi.csproj | 2 +- .../Services/CertsFlowService.cs | 30 +- 22 files changed, 791 insertions(+), 280 deletions(-) create mode 100644 src/LetsEncrypt.Tests/AcmeProblemKindTests.cs create mode 100644 src/LetsEncrypt.Tests/AcmeRetryAfterParserTests.cs create mode 100644 src/LetsEncrypt.Tests/RegistrationCacheAcmeCooldownSerializationTests.cs create mode 100644 src/LetsEncrypt/AcmeProblemKind.cs create mode 100644 src/LetsEncrypt/AcmeRetryAfterParser.cs create mode 100644 src/LetsEncrypt/Services/AcmeSessionStore.cs create mode 100644 src/LetsEncrypt/Services/LetsEncryptService.Helpers.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 59d5732..43c4a3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,28 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.3.6] - 2026-04-13 + +### Added + +- **LetsEncrypt:** Per-host ACME rate-limit cooldown on `RegistrationCache` (`AcmeRenewalNotBeforeUtcByHostname`), with HTTP `Retry-After` and problem-detail parsing (`AcmeRetryAfterParser`), structured logging, and `Result.TooManyRequests` when the CA returns `rateLimited`. +- **LetsEncrypt:** `AcmeProblemKind` as an `Enumeration` (RFC 8555 problem `type` URIs) instead of ad hoc strings; `LetsEncrytException` exposes `ProblemKind`, `RetryAfterUtc`, and optional rate-limit hostname. +- **LetsEncrypt:** `AcmeSessionStore` for per-session `State` in memory; `LetsEncryptService` split into partial files (`LetsEncryptService.Helpers.cs`) for HTTP/JWS/error helpers. +- **LetsEncrypt:** `State.TryGetAccountKey` for a single place to validate account key material after `Init`. +- **LetsEncrypt.Tests:** Unit tests for retry parsing, problem-kind resolution, and cooldown JSON round-trip. + +### Changed + +- **AutoRenewal:** Skips hostnames that are still in an ACME cooldown window (with debug logs for skipped hosts). +- **Certs flow:** Persists registration cache after failed full certificate flows when a session exists so cooldown metadata is saved. +- **LetsEncrypt:** Broader nullable reference annotations on ACME DTOs (`Problem`, `AcmeDirectory`, `AuthorizationChallengeError`, etc.) and explicit null guards in `LetsEncryptService`. + +### Fixed + +- **LetsEncrypt:** Certificate PEM loading uses `X509Certificate2.CreateFromPem` instead of the obsolete `X509Certificate2(byte[])` constructor (SYSLIB0057). +- **LetsEncrypt:** `RevokeCertificate` now fails correctly on non-success responses (missing `return`), uses the same problem-document handling as other ACME calls, and disposes the HTTP response on successful revoke. +- **LetsEncrypt:** `NewOrder` authorization error log line now logs the authorization status, not the order status. + ## [3.3.5] - 2026-04-12 ### Changed diff --git a/assets/badges/coverage-lines.svg b/assets/badges/coverage-lines.svg index 7f47a71..7d6f1c0 100644 --- a/assets/badges/coverage-lines.svg +++ b/assets/badges/coverage-lines.svg @@ -1,5 +1,5 @@ - - Line Coverage: 21.7% + + Line Coverage: 21.6% @@ -15,7 +15,7 @@ Line Coverage - - 21.7% + + 21.6% diff --git a/assets/badges/coverage-methods.svg b/assets/badges/coverage-methods.svg index 548fa01..b11a61b 100644 --- a/assets/badges/coverage-methods.svg +++ b/assets/badges/coverage-methods.svg @@ -1,5 +1,5 @@ - - Method Coverage: 29.7% + + Method Coverage: 31.5% @@ -15,7 +15,7 @@ Method Coverage - - 29.7% + + 31.5% diff --git a/src/LetsEncrypt.Tests/AcmeProblemKindTests.cs b/src/LetsEncrypt.Tests/AcmeProblemKindTests.cs new file mode 100644 index 0000000..ce70f45 --- /dev/null +++ b/src/LetsEncrypt.Tests/AcmeProblemKindTests.cs @@ -0,0 +1,28 @@ +using MaksIT.LetsEncrypt; +using Xunit; + +namespace MaksIT.LetsEncrypt.Tests; + +public class AcmeProblemKindTests { + [Theory] + [InlineData("urn:ietf:params:acme:error:rateLimited")] + [InlineData("urn:ietf:params:acme:error:badNonce")] + [InlineData("urn:ietf:params:acme:error:malformed")] + [InlineData("urn:ietf:params:acme:error:serverInternal")] + public void FromTypeUri_maps_rfc8555_uris(string uri) { + var kind = AcmeProblemKind.FromTypeUri(uri); + Assert.Equal(uri, kind.Name); + } + + [Fact] + public void FromTypeUri_unknown_or_empty_returns_unknown() { + Assert.Same(AcmeProblemKind.Unknown, AcmeProblemKind.FromTypeUri(null)); + Assert.Same(AcmeProblemKind.Unknown, AcmeProblemKind.FromTypeUri("")); + Assert.Same(AcmeProblemKind.Unknown, AcmeProblemKind.FromTypeUri("urn:ietf:params:acme:error:customVendorThing")); + } + + [Fact] + public void FromTypeUri_is_case_sensitive_for_full_uri() { + Assert.Same(AcmeProblemKind.Unknown, AcmeProblemKind.FromTypeUri("urn:ietf:params:acme:error:RATELIMITED")); + } +} diff --git a/src/LetsEncrypt.Tests/AcmeRetryAfterParserTests.cs b/src/LetsEncrypt.Tests/AcmeRetryAfterParserTests.cs new file mode 100644 index 0000000..4bf9a4d --- /dev/null +++ b/src/LetsEncrypt.Tests/AcmeRetryAfterParserTests.cs @@ -0,0 +1,72 @@ +using System.Net.Http.Headers; +using MaksIT.LetsEncrypt.Models.Responses; +using Xunit; + +namespace MaksIT.LetsEncrypt.Tests; + +public class AcmeRetryAfterParserTests { + [Fact] + public void TryParseRetryAfterHttpHeader_reads_delta_seconds() { + using var response = new HttpResponseMessage(); + response.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromSeconds(90)); + + var parsed = AcmeRetryAfterParser.TryParseRetryAfterHttpHeader(response); + + Assert.NotNull(parsed); + var skew = (parsed.Value - DateTimeOffset.UtcNow.AddSeconds(90)).TotalSeconds; + Assert.InRange(skew, -2, 2); + } + + [Fact] + public void TryParseRetryAfterHttpHeader_reads_absolute_date() { + using var response = new HttpResponseMessage(); + var when = new DateTimeOffset(2026, 4, 12, 17, 49, 19, TimeSpan.Zero); + response.Headers.RetryAfter = new RetryConditionHeaderValue(when); + + var parsed = AcmeRetryAfterParser.TryParseRetryAfterHttpHeader(response); + + Assert.Equal(when, parsed); + } + + [Fact] + public void TryParseRetryAfterFromDetail_parses_lets_encrypt_sample() { + const string detail = + "urn:ietf:params:acme:error:rateLimited: too many failed authorizations (5) for \"cloud.maks-it.com\" in the last 1h0m0s, retry after 2026-04-12 17:49:19 UTC: see https://letsencrypt.org/docs/rate-limits/"; + + var parsed = AcmeRetryAfterParser.TryParseRetryAfterFromDetail(detail); + + Assert.NotNull(parsed); + Assert.Equal(2026, parsed.Value.Year); + Assert.Equal(4, parsed.Value.Month); + Assert.Equal(12, parsed.Value.Day); + Assert.Equal(17, parsed.Value.Hour); + Assert.Equal(49, parsed.Value.Minute); + } + + [Fact] + public void TryParseRateLimitedHostname_extracts_identifier() { + const string detail = + "too many failed authorizations (5) for \"cloud.maks-it.com\" in the last 1h0m0s, retry after 2026-04-12 17:49:19 UTC"; + + var host = AcmeRetryAfterParser.TryParseRateLimitedHostname(detail); + + Assert.Equal("cloud.maks-it.com", host); + } + + [Fact] + public void TryCombineRetryAfterUtc_takes_later_of_header_and_detail() { + using var response = new HttpResponseMessage(); + response.Headers.RetryAfter = new RetryConditionHeaderValue(new DateTimeOffset(2026, 4, 12, 12, 0, 0, TimeSpan.Zero)); + + var problem = new Problem { + Type = "urn:ietf:params:acme:error:rateLimited", + Detail = "retry after 2026-04-12 18:00:00 UTC", + RawJson = "" + }; + + var combined = AcmeRetryAfterParser.TryCombineRetryAfterUtc(response, problem); + + Assert.NotNull(combined); + Assert.Equal(18, combined.Value.Hour); + } +} diff --git a/src/LetsEncrypt.Tests/RegistrationCacheAcmeCooldownSerializationTests.cs b/src/LetsEncrypt.Tests/RegistrationCacheAcmeCooldownSerializationTests.cs new file mode 100644 index 0000000..19a316d --- /dev/null +++ b/src/LetsEncrypt.Tests/RegistrationCacheAcmeCooldownSerializationTests.cs @@ -0,0 +1,30 @@ +using MaksIT.Core.Extensions; +using MaksIT.LetsEncrypt.Entities; +using Xunit; + +namespace MaksIT.LetsEncrypt.Tests; + +public class RegistrationCacheAcmeCooldownSerializationTests { + [Fact] + public void AcmeRenewalNotBeforeUtcByHostname_round_trips_json() { + var until = new DateTimeOffset(2026, 4, 12, 17, 49, 19, TimeSpan.Zero); + var cache = new RegistrationCache { + AccountId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), + Description = "test", + Contacts = ["ops@example.com"], + IsStaging = true, + ChallengeType = "http-01", + AcmeRenewalNotBeforeUtcByHostname = new Dictionary(StringComparer.OrdinalIgnoreCase) { + ["cloud.example.com"] = until + } + }; + + var json = cache.ToJson(); + var restored = json.ToObject(); + + Assert.NotNull(restored); + Assert.NotNull(restored!.AcmeRenewalNotBeforeUtcByHostname); + Assert.True(restored.AcmeRenewalNotBeforeUtcByHostname!.TryGetValue("cloud.example.com", out var v)); + Assert.Equal(until, v); + } +} diff --git a/src/LetsEncrypt/AcmeProblemKind.cs b/src/LetsEncrypt/AcmeProblemKind.cs new file mode 100644 index 0000000..130a6bf --- /dev/null +++ b/src/LetsEncrypt/AcmeProblemKind.cs @@ -0,0 +1,52 @@ +using MaksIT.Core.Abstractions; + +namespace MaksIT.LetsEncrypt; + +/// +/// ACME problem type URIs (RFC 8555 §6.7). covers missing or unrecognized URIs. +/// +public sealed class AcmeProblemKind : Enumeration { + + public static readonly AcmeProblemKind Unknown = new(0, ""); + + public static readonly AcmeProblemKind BadCsr = new(1, "urn:ietf:params:acme:error:badCSR"); + public static readonly AcmeProblemKind BadNonce = new(2, "urn:ietf:params:acme:error:badNonce"); + public static readonly AcmeProblemKind BadPublicKey = new(3, "urn:ietf:params:acme:error:badPublicKey"); + public static readonly AcmeProblemKind BadRevocationReason = new(4, "urn:ietf:params:acme:error:badRevocationReason"); + public static readonly AcmeProblemKind BadSignatureAlgorithm = new(5, "urn:ietf:params:acme:error:badSignatureAlgorithm"); + public static readonly AcmeProblemKind Caa = new(6, "urn:ietf:params:acme:error:caa"); + public static readonly AcmeProblemKind Compound = new(7, "urn:ietf:params:acme:error:compound"); + public static readonly AcmeProblemKind Connection = new(8, "urn:ietf:params:acme:error:connection"); + public static readonly AcmeProblemKind Dns = new(9, "urn:ietf:params:acme:error:dns"); + public static readonly AcmeProblemKind ExternalAccountRequired = new(10, "urn:ietf:params:acme:error:externalAccountRequired"); + public static readonly AcmeProblemKind IncorrectResponse = new(11, "urn:ietf:params:acme:error:incorrectResponse"); + public static readonly AcmeProblemKind InvalidContact = new(12, "urn:ietf:params:acme:error:invalidContact"); + public static readonly AcmeProblemKind Malformed = new(13, "urn:ietf:params:acme:error:malformed"); + public static readonly AcmeProblemKind OrderNotReady = new(14, "urn:ietf:params:acme:error:orderNotReady"); + public static readonly AcmeProblemKind RateLimited = new(15, "urn:ietf:params:acme:error:rateLimited"); + public static readonly AcmeProblemKind RejectedIdentifier = new(16, "urn:ietf:params:acme:error:rejectedIdentifier"); + public static readonly AcmeProblemKind ServerInternal = new(17, "urn:ietf:params:acme:error:serverInternal"); + public static readonly AcmeProblemKind Tls = new(18, "urn:ietf:params:acme:error:tls"); + public static readonly AcmeProblemKind UnsupportedContact = new(19, "urn:ietf:params:acme:error:unsupportedContact"); + public static readonly AcmeProblemKind UnsupportedIdentifier = new(20, "urn:ietf:params:acme:error:unsupportedIdentifier"); + public static readonly AcmeProblemKind UserActionRequired = new(21, "urn:ietf:params:acme:error:userActionRequired"); + + private AcmeProblemKind(int id, string name) : base(id, name) { } + + /// + /// Resolves a problem type URI from the CA. Comparison is ordinal (RFC 3986). + /// + public static AcmeProblemKind FromTypeUri(string? typeUri) { + if (string.IsNullOrEmpty(typeUri)) + return Unknown; + + foreach (var kind in GetAll()) { + if (kind == Unknown) + continue; + if (kind.Name == typeUri) + return kind; + } + + return Unknown; + } +} diff --git a/src/LetsEncrypt/AcmeRetryAfterParser.cs b/src/LetsEncrypt/AcmeRetryAfterParser.cs new file mode 100644 index 0000000..6c46567 --- /dev/null +++ b/src/LetsEncrypt/AcmeRetryAfterParser.cs @@ -0,0 +1,66 @@ +using System.Globalization; +using System.Net.Http; +using System.Text.RegularExpressions; +using MaksIT.LetsEncrypt.Models.Responses; + +namespace MaksIT.LetsEncrypt; + +internal static class AcmeRetryAfterParser { + private static readonly Regex RetryAfterDetailRegex = new( + @"retry\s+after\s+(?\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+UTC", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + private static readonly Regex RateLimitedHostRegex = new( + @"for\s+""(?[^""]+)""", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + internal static DateTimeOffset? TryParseRetryAfterHttpHeader(HttpResponseMessage? response) { + if (response?.Headers.RetryAfter is not { } ra) + return null; + + if (ra.Date is { } absolute) + return new DateTimeOffset(absolute.UtcDateTime, TimeSpan.Zero); + + if (ra.Delta is { } delta) + return DateTimeOffset.UtcNow + delta; + + return null; + } + + internal static DateTimeOffset? TryParseRetryAfterFromDetail(string? detail) { + if (string.IsNullOrEmpty(detail)) + return null; + + var m = RetryAfterDetailRegex.Match(detail); + if (!m.Success) + return null; + + var ts = m.Groups["ts"].Value; + if (DateTimeOffset.TryParseExact(ts, "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var dto)) + return dto; + + return null; + } + + /// + /// Latest of header and detail-derived times (stricter / later wins). Null if neither present. + /// + internal static DateTimeOffset? TryCombineRetryAfterUtc(HttpResponseMessage? response, Problem? problem) { + var fromHeader = TryParseRetryAfterHttpHeader(response); + var fromDetail = TryParseRetryAfterFromDetail(problem?.Detail); + + if (fromHeader.HasValue && fromDetail.HasValue) + return fromHeader.Value > fromDetail.Value ? fromHeader.Value : fromDetail.Value; + + return fromHeader ?? fromDetail; + } + + internal static string? TryParseRateLimitedHostname(string? detail) { + if (string.IsNullOrEmpty(detail)) + return null; + + var m = RateLimitedHostRegex.Match(detail); + return m.Success ? m.Groups["host"].Value : null; + } +} diff --git a/src/LetsEncrypt/Entities/LetsEncrypt/RegistrationCache.cs b/src/LetsEncrypt/Entities/LetsEncrypt/RegistrationCache.cs index c958993..da9da16 100644 --- a/src/LetsEncrypt/Entities/LetsEncrypt/RegistrationCache.cs +++ b/src/LetsEncrypt/Entities/LetsEncrypt/RegistrationCache.cs @@ -23,6 +23,13 @@ public class RegistrationCache { public Dictionary? CachedCerts { get; set; } + + /// + /// Earliest UTC instant when renewal may be attempted again for each hostname (e.g. after ACME rate limit). + /// Keys use the same DNS names as . + /// + public Dictionary? AcmeRenewalNotBeforeUtcByHostname { get; set; } + public byte[]? AccountKey { get; set; } public string? Id { get; set; } public Jwk? Key { get; set; } @@ -77,6 +84,36 @@ public class RegistrationCache { return hosts.ToArray(); } + /// + /// True if the hostname must not be renewed yet due to a stored ACME cooldown. + /// + public bool IsHostnameInAcmeCooldown(string hostname, out DateTimeOffset notBeforeUtc) { + notBeforeUtc = default; + if (AcmeRenewalNotBeforeUtcByHostname == null) + return false; + + foreach (var kvp in AcmeRenewalNotBeforeUtcByHostname) { + if (!string.Equals(kvp.Key, hostname, StringComparison.OrdinalIgnoreCase)) + continue; + if (kvp.Value <= DateTimeOffset.UtcNow) + return false; + notBeforeUtc = kvp.Value; + return true; + } + + return false; + } + + public void ClearAcmeCooldownForHostname(string hostname) { + if (AcmeRenewalNotBeforeUtcByHostname == null) + return; + + var key = AcmeRenewalNotBeforeUtcByHostname.Keys + .FirstOrDefault(k => string.Equals(k, hostname, StringComparison.OrdinalIgnoreCase)); + if (key != null) + AcmeRenewalNotBeforeUtcByHostname.Remove(key); + } + /// /// Returns cached certificate. Certs older than 30 days are not returned /// diff --git a/src/LetsEncrypt/Entities/LetsEncrypt/State.cs b/src/LetsEncrypt/Entities/LetsEncrypt/State.cs index 53996cc..02091ea 100644 --- a/src/LetsEncrypt/Entities/LetsEncrypt/State.cs +++ b/src/LetsEncrypt/Entities/LetsEncrypt/State.cs @@ -1,7 +1,7 @@ -using MaksIT.Core.Security.JWK; -using MaksIT.LetsEncrypt.Models.Responses; +using System.Diagnostics.CodeAnalysis; using System.Security.Cryptography; - +using MaksIT.Core.Security.JWK; +using MaksIT.LetsEncrypt.Models.Responses; namespace MaksIT.LetsEncrypt.Entities.LetsEncrypt; @@ -13,4 +13,11 @@ public class State { public RegistrationCache? Cache { get; set; } public Jwk? Jwk { get; set; } public RSA? Rsa { get; set; } + + /// Returns the session account key pair when both RSA and JWK are present (after Init). + public bool TryGetAccountKey([NotNullWhen(true)] out RSA? rsa, [NotNullWhen(true)] out Jwk? jwk) { + rsa = Rsa; + jwk = Jwk; + return rsa is not null && jwk is not null; + } } diff --git a/src/LetsEncrypt/Exceptions/LetsEncrytException.cs b/src/LetsEncrypt/Exceptions/LetsEncrytException.cs index f91c967..afbc1d0 100644 --- a/src/LetsEncrypt/Exceptions/LetsEncrytException.cs +++ b/src/LetsEncrypt/Exceptions/LetsEncrytException.cs @@ -1,20 +1,44 @@ -using MaksIT.LetsEncrypt.Models.Responses; - +using MaksIT.LetsEncrypt; +using MaksIT.LetsEncrypt.Models.Responses; namespace MaksIT.LetsEncrypt.Exceptions; +/// Thrown when the ACME server returns a problem document or challenge error. +/// +/// implements . disposes it +/// when mapping this exception to a ; any other handler must dispose it to avoid holding connections. +/// public class LetsEncrytException : Exception { public Problem? Problem { get; } + /// HTTP response that carried the problem (must be disposed if this exception is not handled by ). public HttpResponseMessage Response { get; } + /// Classified type from the ACME problem document (RFC 8555 §6.7). + public AcmeProblemKind ProblemKind { get; } + + /// Combined Retry-After from HTTP header and problem detail, when present. + public DateTimeOffset? RetryAfterUtc { get; } + + /// Hostname from Let's Encrypt rate-limit detail, when parseable. + public string? RateLimitedIdentifier { get; } + + public bool IsRateLimited => ProblemKind == AcmeProblemKind.RateLimited; + public LetsEncrytException( Problem? problem, HttpResponseMessage response - ) : base(problem != null ? $"{problem.Type}: {problem.Detail}" : "") { + ) : base(problem != null + ? $"{problem.Type}: {problem.Detail}" + : $"HTTP {(int)(response ?? throw new ArgumentNullException(nameof(response))).StatusCode}") { + ArgumentNullException.ThrowIfNull(response); Problem = problem; Response = response; + ProblemKind = AcmeProblemKind.FromTypeUri(problem?.Type); + RetryAfterUtc = AcmeRetryAfterParser.TryCombineRetryAfterUtc(response, problem); + if (ProblemKind == AcmeProblemKind.RateLimited) + RateLimitedIdentifier = AcmeRetryAfterParser.TryParseRateLimitedHostname(problem?.Detail); } } diff --git a/src/LetsEncrypt/Extensions/ServiceCollectionExtensions.cs b/src/LetsEncrypt/Extensions/ServiceCollectionExtensions.cs index 6f26caf..684bbb1 100644 --- a/src/LetsEncrypt/Extensions/ServiceCollectionExtensions.cs +++ b/src/LetsEncrypt/Extensions/ServiceCollectionExtensions.cs @@ -14,6 +14,7 @@ public static class ServiceCollectionExtensions { }; services.AddSingleton(config); + services.AddSingleton(); services.AddHttpClient(); } } diff --git a/src/LetsEncrypt/LetsEncrypt.csproj b/src/LetsEncrypt/LetsEncrypt.csproj index d60ce70..6fd5f62 100644 --- a/src/LetsEncrypt/LetsEncrypt.csproj +++ b/src/LetsEncrypt/LetsEncrypt.csproj @@ -7,6 +7,10 @@ MaksIT.$(MSBuildProjectName.Replace(" ", "_")) + + + + diff --git a/src/LetsEncrypt/Models/Responses/AcmeDirectory.cs b/src/LetsEncrypt/Models/Responses/AcmeDirectory.cs index 8391f25..6a306be 100644 --- a/src/LetsEncrypt/Models/Responses/AcmeDirectory.cs +++ b/src/LetsEncrypt/Models/Responses/AcmeDirectory.cs @@ -1,17 +1,17 @@ namespace MaksIT.LetsEncrypt.Models.Responses; public class AcmeDirectory { - public Uri KeyChange { get; set; } - public AcmeDirectoryMeta Meta { get; set; } - public Uri NewAccount { get; set; } - public Uri NewNonce { get; set; } - public Uri NewOrder { get; set; } - public Uri RenewalInfo { get; set; } - public Uri RevokeCert { get; set; } + public Uri? KeyChange { get; set; } + public AcmeDirectoryMeta? Meta { get; set; } + public Uri? NewAccount { get; set; } + public Uri? NewNonce { get; set; } + public Uri? NewOrder { get; set; } + public Uri? RenewalInfo { get; set; } + public Uri? RevokeCert { get; set; } } public class AcmeDirectoryMeta { - public string[] CaaIdentities { get; set; } - public string TermsOfService { get; set; } - public string Website { get; set; } + public string[]? CaaIdentities { get; set; } + public string? TermsOfService { get; set; } + public string? Website { get; set; } } \ No newline at end of file diff --git a/src/LetsEncrypt/Models/Responses/AuthorizationChallengeError.cs b/src/LetsEncrypt/Models/Responses/AuthorizationChallengeError.cs index b581c59..ba43bab 100644 --- a/src/LetsEncrypt/Models/Responses/AuthorizationChallengeError.cs +++ b/src/LetsEncrypt/Models/Responses/AuthorizationChallengeError.cs @@ -6,7 +6,7 @@ using System.Threading.Tasks; namespace MaksIT.LetsEncrypt.Models.Responses; public class AuthorizationChallengeError { - public string Type { get; set; } + public string? Type { get; set; } - public string Detail { get; set; } + public string? Detail { get; set; } } diff --git a/src/LetsEncrypt/Models/Responses/Problem.cs b/src/LetsEncrypt/Models/Responses/Problem.cs index fa7811b..04547a5 100644 --- a/src/LetsEncrypt/Models/Responses/Problem.cs +++ b/src/LetsEncrypt/Models/Responses/Problem.cs @@ -6,10 +6,10 @@ using System.Threading.Tasks; namespace MaksIT.LetsEncrypt.Models.Responses { public class Problem { - public string Type { get; set; } + public string? Type { get; set; } - public string Detail { get; set; } + public string? Detail { get; set; } - public string RawJson { get; set; } + public string? RawJson { get; set; } } } diff --git a/src/LetsEncrypt/Services/AcmeSessionStore.cs b/src/LetsEncrypt/Services/AcmeSessionStore.cs new file mode 100644 index 0000000..8f3f92f --- /dev/null +++ b/src/LetsEncrypt/Services/AcmeSessionStore.cs @@ -0,0 +1,21 @@ +using MaksIT.LetsEncrypt.Entities.LetsEncrypt; +using Microsoft.Extensions.Caching.Memory; + +namespace MaksIT.LetsEncrypt.Services; + +/// Caches per-session for ACME flows (directory, account, current order, challenges). +public sealed class AcmeSessionStore { + private static readonly TimeSpan SessionTtl = TimeSpan.FromHours(1); + + private readonly IMemoryCache _cache; + + public AcmeSessionStore(IMemoryCache cache) => _cache = cache; + + public State GetOrCreate(Guid sessionId) { + if (!_cache.TryGetValue(sessionId, out State? state) || state is null) { + state = new State(); + _cache.Set(sessionId, state, SessionTtl); + } + return state; + } +} diff --git a/src/LetsEncrypt/Services/LetsEncryptService.Helpers.cs b/src/LetsEncrypt/Services/LetsEncryptService.Helpers.cs new file mode 100644 index 0000000..37e3ccb --- /dev/null +++ b/src/LetsEncrypt/Services/LetsEncryptService.Helpers.cs @@ -0,0 +1,235 @@ +using System.Net.Http.Headers; +using Microsoft.Extensions.Logging; +using MaksIT.Core.Extensions; +using MaksIT.Core.Security.JWS; +using MaksIT.LetsEncrypt.Entities; +using MaksIT.LetsEncrypt.Entities.Jws; +using MaksIT.LetsEncrypt.Entities.LetsEncrypt; +using MaksIT.LetsEncrypt.Exceptions; +using MaksIT.LetsEncrypt.Models.Interfaces; +using MaksIT.LetsEncrypt.Models.Responses; +using MaksIT.Results; + + +namespace MaksIT.LetsEncrypt.Services; + +public partial class LetsEncryptService { + + #region Internal helpers + + private State GetOrCreateState(Guid sessionId) => _sessions.GetOrCreate(sessionId); + + private async Task> GetNonceAsync(Guid sessionId, Uri uri) { + if (uri == null) + return Result.InternalServerError(null, "URI is null"); + + try { + var state = GetOrCreateState(sessionId); + + _logger.LogInformation($"Executing {nameof(GetNonceAsync)}..."); + + if (state.Directory is not { NewNonce: { } newNonceUri }) + return Result.InternalServerError(null, "Directory or NewNonce URL is null."); + + var result = await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, newNonceUri)); + + var nonce = result.Headers.GetValues(ReplayNonceHeader).FirstOrDefault(); + + if (nonce == null) + return Result.InternalServerError(null, "Nonce is null"); + + return Result.Ok(nonce); + } + catch (Exception ex) { + return HandleUnhandledException(ex); + } + } + + private async Task?>> SendAcmeRequest(HttpRequestMessage request, State state, HttpMethod method) { + try { + var response = await _httpClient.SendAsync(request); + + var responseText = await response.Content.ReadAsStringAsync(); + + HandleProblemResponseAsync(response, responseText); + + var sendResult = ProcessResponseContent(response, responseText); + + return Result?>.Ok(sendResult); + } + + catch (LetsEncrytException ex) { + return MapLetsEncryptException?>(state, ex, null); + } + catch (Exception ex) { + return HandleUnhandledException?>(ex); + } + } + + private Result EncodeMessage(Guid sessionId, bool isPostAsGet, object? requestModel, ACMEJwsHeader protectedHeader) { + var state = GetOrCreateState(sessionId); + + if (!state.TryGetAccountKey(out var rsa, out var jwk)) + return Result.InternalServerError(AccountKeyMissingMessage); + + if (isPostAsGet) { + if (!JwsGenerator.TryEncode(rsa, jwk, protectedHeader, out var jwsPostAsGet, out var errPostAsGet)) + return Result.InternalServerError(errPostAsGet); + + return Result.Ok(jwsPostAsGet.ToJson()); + } + + if (!JwsGenerator.TryEncode(rsa, jwk, protectedHeader, requestModel, out var jwsWithPayload, out var errWithPayload)) + return Result.InternalServerError(errWithPayload); + + return Result.Ok(jwsWithPayload.ToJson()); + } + + private static string GetContentType(ContentType type) => type.GetDisplayName(); + + private void PrepareRequestContent(HttpRequestMessage request, string json, HttpMethod method) { + request.Content = new StringContent(json ?? string.Empty); + var contentType = method == HttpMethod.Post + ? GetContentType(ContentType.JoseJson) + : GetContentType(ContentType.Json); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType); + } + + private async Task PollChallengeStatus(Guid sessionId, AuthorizationChallengeChallenge challenge) { + if (challenge?.Url == null) + return Result.InternalServerError("Challenge URL is null"); + + var start = DateTime.UtcNow; + + while (true) { + var pollRequest = new HttpRequestMessage(HttpMethod.Post, challenge.Url); + + var nonceResult = await GetNonceAsync(sessionId, challenge.Url); + if (!nonceResult.IsSuccess || nonceResult.Value == null) + return nonceResult; + + var nonce = nonceResult.Value; + + var pollJsonResult = EncodeMessage(sessionId, true, null, new ACMEJwsHeader { + Url = challenge.Url.ToString(), + Nonce = nonce + }); + + if (!pollJsonResult.IsSuccess || pollJsonResult.Value == null) + return pollJsonResult; + + var pollJson = pollJsonResult.Value; + + PrepareRequestContent(pollRequest, pollJson, HttpMethod.Post); + + var pollResponse = await _httpClient.SendAsync(pollRequest); + + var pollResponseText = await pollResponse.Content.ReadAsStringAsync(); + + HandleProblemResponseAsync(pollResponse, pollResponseText); + + var authChallenge = ProcessResponseContent(pollResponse, pollResponseText); + + if (authChallenge.Result?.Status != "pending") + return authChallenge.Result?.Status == "valid" ? Result.Ok() : Result.InternalServerError(); + + if ((DateTime.UtcNow - start).Seconds > 120) + return Result.InternalServerError("Timeout"); + + await Task.Delay(1000); + } + } + + private void HandleProblemResponseAsync(HttpResponseMessage response, string responseText) { + if (response.Content.Headers.ContentType?.MediaType == GetContentType(ContentType.ProblemJson)) { + var problem = responseText.ToObject(); + + throw new LetsEncrytException(problem, response); + } + + if (response.Content.Headers.ContentType?.MediaType == GetContentType(ContentType.Json)) { + var authorizationChallengeChallenge = responseText.ToObject(); + + if (authorizationChallengeChallenge?.Status == "invalid") { + throw new LetsEncrytException(new Problem { + Type = authorizationChallengeChallenge.Error?.Type, + Detail = authorizationChallengeChallenge.Error?.Detail, + RawJson = responseText + }, response); + } + } + } + + private SendResult ProcessResponseContent(HttpResponseMessage response, string responseText) { + if (response.Content.Headers.ContentType?.MediaType == GetContentType(ContentType.PemCertificateChain) && typeof(TResult) == typeof(string)) { + return new SendResult { + Result = (TResult)(object)responseText + }; + } + var responseContent = responseText.ToObject(); + if (responseContent is IHasLocation ihl && response.Headers.Location != null) { + ihl.Location = response.Headers.Location; + } + return new SendResult { + Result = responseContent, + ResponseText = responseText + }; + } + + private static bool StatusEquals(string? status, OrderStatus expected) => status == expected.GetDisplayName(); + + private Result MapLetsEncryptException(State state, LetsEncrytException ex) => + MapLetsEncryptExceptionCore(state, ex, m => Result.TooManyRequests(m), m => Result.InternalServerError(m)); + + private Result MapLetsEncryptException(State state, LetsEncrytException ex, T? defaultValue) => + MapLetsEncryptExceptionCore(state, ex, m => Result.TooManyRequests(defaultValue, m), m => Result.InternalServerError(defaultValue, m)); + + private TResult MapLetsEncryptExceptionCore( + State state, + LetsEncrytException ex, + Func tooManyRequests, + Func internalError) { + try { + if (ex.ProblemKind == AcmeProblemKind.RateLimited) { + var when = ex.RetryAfterUtc ?? DateTimeOffset.UtcNow.AddHours(1); + var id = ex.RateLimitedIdentifier; + if (state.Cache != null && !string.IsNullOrEmpty(id)) { + var key = id.ToLowerInvariant(); + state.Cache.AcmeRenewalNotBeforeUtcByHostname ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + if (state.Cache.AcmeRenewalNotBeforeUtcByHostname.TryGetValue(key, out var existing)) + when = when > existing ? when : existing; + state.Cache.AcmeRenewalNotBeforeUtcByHostname[key] = when; + } + + _logger.LogWarning( + "ACME rate limited: Kind {AcmeProblemKind}, Type {AcmeProblemType}, Hostname {Hostname}, RetryNotBefore {RetryNotBeforeUtc:o}. Detail: {Detail}", + ex.ProblemKind, ex.Problem?.Type, id, when, ex.Problem?.Detail); + + var msg = string.IsNullOrEmpty(id) + ? $"Let's Encrypt rate limit. Do not retry certificate operations before {when:u} UTC." + : $"Let's Encrypt rate limit for hostname '{id}'. Do not retry before {when:u} UTC."; + return tooManyRequests(msg); + } + + _logger.LogWarning(ex, + "Let's Encrypt ACME problem: Kind {AcmeProblemKind}, Type {AcmeProblemType}. {Detail}", + ex.ProblemKind, ex.Problem?.Type, ex.Problem?.Detail); + var fallback = string.IsNullOrEmpty(ex.Message) ? "Let's Encrypt request failed." : ex.Message; + return internalError(fallback); + } + finally { + ex.Response?.Dispose(); + } + } + + private Result HandleUnhandledException(Exception ex, string defaultMessage = "Let's Encrypt client unhandled exception") { + _logger.LogError(ex, defaultMessage); + return Result.InternalServerError([defaultMessage, .. ex.ExtractMessages()]); + } + + private Result HandleUnhandledException(Exception ex, T? defaultValue = default, string defaultMessage = "Let's Encrypt client unhandled exception") { + _logger.LogError(ex, defaultMessage); + return Result.InternalServerError(defaultValue, [.. ex.ExtractMessages()]); + } + #endregion +} diff --git a/src/LetsEncrypt/Services/LetsEncryptService.cs b/src/LetsEncrypt/Services/LetsEncryptService.cs index 2c542d0..732622f 100644 --- a/src/LetsEncrypt/Services/LetsEncryptService.cs +++ b/src/LetsEncrypt/Services/LetsEncryptService.cs @@ -15,7 +15,6 @@ using MaksIT.LetsEncrypt.Models.Interfaces; using MaksIT.LetsEncrypt.Models.Requests; using MaksIT.LetsEncrypt.Models.Responses; using MaksIT.Results; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using System.Net.Http.Headers; using System.Security.Cryptography; @@ -28,7 +27,7 @@ namespace MaksIT.LetsEncrypt.Services; public interface ILetsEncryptService { Result GetRegistrationCache(Guid sessionId); Task ConfigureClient(Guid sessionId, bool isStaging); - Task Init(Guid sessionId,Guid accountId, string description, string[] contacts, RegistrationCache? registrationCache); + Task Init(Guid sessionId, Guid accountId, string description, string[] contacts, RegistrationCache? registrationCache); Result GetTermsOfServiceUri(Guid sessionId); Task?>> NewOrder(Guid sessionId, string[] hostnames, string challengeType); Task CompleteChallenges(Guid sessionId); @@ -37,32 +36,33 @@ public interface ILetsEncryptService { Task RevokeCertificate(Guid sessionId, string subject, RevokeReason reason); } -public class LetsEncryptService : ILetsEncryptService { +public partial class LetsEncryptService : ILetsEncryptService { private const string DnsType = "dns"; private const string DirectoryEndpoint = "directory"; private const string ReplayNonceHeader = "Replay-Nonce"; + private const string AccountKeyMissingMessage = "Account key is not loaded; complete Init before this operation."; private readonly ILogger _logger; private readonly LetsEncryptConfiguration _appSettings; private readonly HttpClient _httpClient; - private readonly IMemoryCache _memoryCache; + private readonly AcmeSessionStore _sessions; public LetsEncryptService( ILogger logger, LetsEncryptConfiguration appSettings, HttpClient httpClient, - IMemoryCache cache + AcmeSessionStore sessions ) { _logger = logger; _appSettings = appSettings; _httpClient = httpClient; - _memoryCache = cache; + _sessions = sessions; } public Result GetRegistrationCache(Guid sessionId) { var state = GetOrCreateState(sessionId); - if (state?.Cache == null) + if (state.Cache == null) return Result.InternalServerError(null); return Result.Ok(state.Cache); @@ -84,7 +84,7 @@ public class LetsEncryptService : ILetsEncryptService { if (!requestResult.IsSuccess || requestResult.Value == null) return requestResult; - var directory = requestResult.Value; + var directory = requestResult.Value; state.Directory = directory.Result ?? throw new InvalidOperationException("Directory response is null"); } @@ -92,7 +92,8 @@ public class LetsEncryptService : ILetsEncryptService { return Result.Ok("Client configured successfully."); } catch (LetsEncrytException ex) { - return HandleUnhandledException(ex, "Let's Encrypt client encountered a problem"); + var state = GetOrCreateState(sessionId); + return MapLetsEncryptException(state, ex); } catch (Exception ex) { return HandleUnhandledException(ex); @@ -149,21 +150,24 @@ public class LetsEncryptService : ILetsEncryptService { state.Rsa = accountKey; state.Jwk = jwk; + if (state.Directory.NewAccount is not { } newAccountUri) + return Result.InternalServerError("Directory is missing NewAccount URL."); + var letsEncryptOrder = new Account { TermsOfServiceAgreed = true, Contacts = [.. contacts.Select(contact => $"mailto:{contact}")] }; - var request = new HttpRequestMessage(HttpMethod.Post, state.Directory.NewAccount); + var request = new HttpRequestMessage(HttpMethod.Post, newAccountUri); - var nonceResult = await GetNonceAsync(sessionId, state.Directory.NewAccount); + var nonceResult = await GetNonceAsync(sessionId, newAccountUri); if (!nonceResult.IsSuccess || nonceResult.Value == null) return nonceResult; var nonce = nonceResult.Value; var jsonResult = EncodeMessage(sessionId, false, letsEncryptOrder, new ACMEJwsHeader { - Url = state.Directory.NewAccount.ToString(), + Url = newAccountUri.ToString(), Nonce = nonce }); @@ -183,9 +187,9 @@ public class LetsEncryptService : ILetsEncryptService { state.Jwk.KeyId = result.Result?.Location?.ToString() ?? string.Empty; if (result.Result?.Status != "valid") { - errorMessage = $"Account status is not valid, was: {result.Result?.Status} \r\n {result.ResponseText}"; - _logger.LogError(errorMessage); - return Result.InternalServerError(errorMessage); + var accountStatusMessage = $"Account status is not valid, was: {result.Result?.Status} \r\n {result.ResponseText}"; + _logger.LogError(accountStatusMessage); + return Result.InternalServerError(accountStatusMessage); } state.Cache = new RegistrationCache { @@ -204,7 +208,7 @@ public class LetsEncryptService : ILetsEncryptService { return Result.Ok("Initialization successful."); } catch (LetsEncrytException ex) { - return HandleUnhandledException(ex, "Let's Encrypt client encountered a problem"); + return MapLetsEncryptException(state, ex); } catch (Exception ex) { return HandleUnhandledException(ex); @@ -248,24 +252,24 @@ public class LetsEncryptService : ILetsEncryptService { }).ToArray() ?? [] }; - if (state.Directory == null || state.Directory.NewOrder == null) + if (state.Directory?.NewOrder is not { } newOrderUri) return Result?>.InternalServerError(null); - var request = new HttpRequestMessage(HttpMethod.Post, state.Directory.NewOrder); + var request = new HttpRequestMessage(HttpMethod.Post, newOrderUri); - var nonceResult = await GetNonceAsync(sessionId, state.Directory.NewOrder); + var nonceResult = await GetNonceAsync(sessionId, newOrderUri); if (!nonceResult.IsSuccess || nonceResult.Value == null) return nonceResult.ToResultOfType?>(_ => null); var nonce = nonceResult.Value; var jsonResult = EncodeMessage(sessionId, false, letsEncryptOrder, new ACMEJwsHeader { - Url = state.Directory.NewOrder.ToString(), + Url = newOrderUri.ToString(), Nonce = nonce }); if (!jsonResult.IsSuccess || jsonResult.Value == null) - return jsonResult.ToResultOfType ?>(_ => null); + return jsonResult.ToResultOfType?>(_ => null); var json = jsonResult.Value; @@ -323,7 +327,7 @@ public class LetsEncryptService : ILetsEncryptService { continue; if (!StatusEquals(challengeResponse.Result?.Status, OrderStatus.Pending)) { - _logger.LogError($"Expected authorization status '{OrderStatus.Pending.GetDisplayName()}', but got: {state.CurrentOrder?.Status} \r\n {challengeResponse.ResponseText}"); + _logger.LogError($"Expected authorization status '{OrderStatus.Pending.GetDisplayName()}', but got: {challengeResponse.Result?.Status} \r\n {challengeResponse.ResponseText}"); return Result?>.InternalServerError(null); } @@ -340,6 +344,9 @@ public class LetsEncryptService : ILetsEncryptService { if (state.Cache != null) state.Cache.ChallengeType = challengeType; + if (state.Jwk is null) + return Result?>.InternalServerError(null, AccountKeyMissingMessage); + if (!JwkThumbprintUtility.TryGetKeyAuthorization(state.Jwk, challenge.Token, out var keyToken, out var errorMessage)) return Result?>.InternalServerError(null, errorMessage); @@ -381,7 +388,12 @@ public class LetsEncryptService : ILetsEncryptService { for (var index = 0; index < state.Challenges.Count; index++) { var challenge = state.Challenges[index]; - if (challenge?.Url == null) { + if (challenge is null) { + _logger.LogError("Challenge entry is null"); + return Result.InternalServerError("Challenge entry is null"); + } + + if (challenge.Url is null) { _logger.LogError("Challenge URL is null"); return Result.InternalServerError("Challenge URL is null"); } @@ -406,7 +418,7 @@ public class LetsEncryptService : ILetsEncryptService { PrepareRequestContent(request, json, HttpMethod.Post); - var authChallenge = await SendAcmeRequest(request, state, HttpMethod.Post); + _ = await SendAcmeRequest(request, state, HttpMethod.Post); var result = await PollChallengeStatus(sessionId, challenge); @@ -415,6 +427,9 @@ public class LetsEncryptService : ILetsEncryptService { } return Result.Ok(); } + catch (LetsEncrytException ex) { + return MapLetsEncryptException(GetOrCreateState(sessionId), ex); + } catch (Exception ex) { return HandleUnhandledException(ex); } @@ -428,24 +443,27 @@ public class LetsEncryptService : ILetsEncryptService { var state = GetOrCreateState(sessionId); + if (state.Directory?.NewOrder is not { } newOrderUri) + return Result.InternalServerError("Directory is not configured. Run ConfigureClient first."); + var letsEncryptOrder = new Order { Expires = DateTime.UtcNow.AddDays(2), Identifiers = hostnames?.Where(h => h != null).Select(hostname => new OrderIdentifier { - Type = "dns", + Type = DnsType, Value = hostname! }).ToArray() ?? [] }; - var request = new HttpRequestMessage(HttpMethod.Post, state.Directory!.NewOrder); + var request = new HttpRequestMessage(HttpMethod.Post, newOrderUri); - var nonceResult = await GetNonceAsync(sessionId, state.Directory.NewOrder); + var nonceResult = await GetNonceAsync(sessionId, newOrderUri); if (!nonceResult.IsSuccess || nonceResult.Value == null) return nonceResult; var nonce = nonceResult.Value; var jsonResult = EncodeMessage(sessionId, false, letsEncryptOrder, new ACMEJwsHeader { - Url = state.Directory.NewOrder.ToString(), + Url = newOrderUri.ToString(), Nonce = nonce }); @@ -479,15 +497,14 @@ public class LetsEncryptService : ILetsEncryptService { _logger.LogInformation($"Executing {nameof(GetCertificate)}..."); - if (state.CurrentOrder?.Identifiers == null) { + if (state.CurrentOrder?.Identifiers is not { } initialIdentifiers) return Result.InternalServerError(); - } var key = new RSACryptoServiceProvider(4096); var csr = new CertificateRequest("CN=" + subject, key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); var san = new SubjectAlternativeNameBuilder(); - foreach (var host in state.CurrentOrder.Identifiers) { + foreach (var host in initialIdentifiers) { if (host?.Value != null) san.AddDnsName(host.Value); } @@ -503,23 +520,34 @@ public class LetsEncryptService : ILetsEncryptService { var start = DateTime.UtcNow; while (certificateUrl == null) { - var hostnames = state.CurrentOrder?.Identifiers?.Select(x => x?.Value).Where(x => x != null).Cast().ToArray() ?? []; + var activeOrder = state.CurrentOrder; + if (activeOrder?.Identifiers is not { } idents) + return Result.InternalServerError("Current order identifiers are not available."); + + var hostnames = idents.Select(x => x?.Value).Where(x => x != null).Cast().ToArray(); await GetOrder(sessionId, hostnames); - var status = state.CurrentOrder?.Status; + activeOrder = state.CurrentOrder; + if (activeOrder is null) + return Result.InternalServerError("Current order is no longer available."); + + var status = activeOrder.Status; if (StatusEquals(status, OrderStatus.Ready)) { - var request = new HttpRequestMessage(HttpMethod.Post, state.CurrentOrder.Finalize); + if (activeOrder.Finalize is not { } finalizeUri) + return Result.InternalServerError("Order finalize URL is missing."); - var nonceResult = await GetNonceAsync(sessionId, state.CurrentOrder.Finalize); + var request = new HttpRequestMessage(HttpMethod.Post, finalizeUri); + + var nonceResult = await GetNonceAsync(sessionId, finalizeUri); if (!nonceResult.IsSuccess || nonceResult.Value == null) return nonceResult; var nonce = nonceResult.Value; var jsonResult = EncodeMessage(sessionId, false, letsEncryptOrder, new ACMEJwsHeader { - Url = state.CurrentOrder.Finalize.ToString(), + Url = finalizeUri.ToString(), Nonce = nonce }); @@ -537,16 +565,20 @@ public class LetsEncryptService : ILetsEncryptService { var order = orderResult.Value; if (StatusEquals(order.Result?.Status, OrderStatus.Processing)) { - request = new HttpRequestMessage(HttpMethod.Post, state.CurrentOrder.Location!); + activeOrder = state.CurrentOrder; + if (activeOrder?.Location is not { } orderLocation) + return Result.InternalServerError("Order location URL is missing."); - nonceResult = await GetNonceAsync(sessionId, state.CurrentOrder.Location); + request = new HttpRequestMessage(HttpMethod.Post, orderLocation); + + nonceResult = await GetNonceAsync(sessionId, orderLocation); if (!nonceResult.IsSuccess || nonceResult.Value == null) return nonceResult; nonce = nonceResult.Value; jsonResult = EncodeMessage(sessionId, true, null, new ACMEJwsHeader { - Url = state.CurrentOrder.Location.ToString(), + Url = orderLocation.ToString(), Nonce = nonce }); @@ -565,11 +597,15 @@ public class LetsEncryptService : ILetsEncryptService { } if (StatusEquals(order.Result?.Status, OrderStatus.Valid)) { - certificateUrl = order.Result.Certificate; + certificateUrl = order.Result?.Certificate; + if (certificateUrl is null) + return Result.InternalServerError("Certificate URL was not returned by the CA."); } } else if (StatusEquals(status, OrderStatus.Valid)) { - certificateUrl = state.CurrentOrder.Certificate; + if (activeOrder.Certificate is not { } certUri) + return Result.InternalServerError("Certificate URL is missing on the order."); + certificateUrl = certUri; break; } @@ -579,7 +615,10 @@ public class LetsEncryptService : ILetsEncryptService { await Task.Delay(1000); } - var finalRequest = new HttpRequestMessage(HttpMethod.Post, certificateUrl!); + if (certificateUrl is null) + return Result.InternalServerError("Certificate URL could not be determined."); + + var finalRequest = new HttpRequestMessage(HttpMethod.Post, certificateUrl); var finalNonceResult = await GetNonceAsync(sessionId, certificateUrl); if (!finalNonceResult.IsSuccess || finalNonceResult.Value == null) @@ -618,14 +657,13 @@ public class LetsEncryptService : ILetsEncryptService { PrivatePem = key.ExportRSAPrivateKeyPem() }; - var certPem = pem.Result ?? string.Empty; - - if (!string.IsNullOrEmpty(certPem)) { - var cert = new X509Certificate2(Encoding.UTF8.GetBytes(certPem)); - } + state.Cache.ClearAcmeCooldownForHostname(subject); return Result.Ok(); } + catch (LetsEncrytException ex) { + return MapLetsEncryptException(GetOrCreateState(sessionId), ex); + } catch (Exception ex) { return HandleUnhandledException(ex); } @@ -657,7 +695,7 @@ public class LetsEncryptService : ILetsEncryptService { return Result.InternalServerError("Certificate PEM is null or empty"); } - var certificate = new X509Certificate2(Encoding.UTF8.GetBytes(certPem)); + var certificate = X509Certificate2.CreateFromPem(certPem); var derEncodedCert = certificate.Export(X509ContentType.Cert); @@ -668,20 +706,26 @@ public class LetsEncryptService : ILetsEncryptService { Reason = (int)reason }; - var request = new HttpRequestMessage(HttpMethod.Post, state.Directory!.RevokeCert); + if (state.Directory?.RevokeCert is not { } revokeUri) + return Result.InternalServerError("Directory is not configured or RevokeCert URL is missing."); - var nonceResult = await GetNonceAsync(sessionId, state.Directory.RevokeCert); + if (!state.TryGetAccountKey(out var rsa, out var jwk)) + return Result.InternalServerError(AccountKeyMissingMessage); + + var request = new HttpRequestMessage(HttpMethod.Post, revokeUri); + + var nonceResult = await GetNonceAsync(sessionId, revokeUri); if (!nonceResult.IsSuccess || nonceResult.Value == null) return nonceResult; var nonce = nonceResult.Value; var jwsHeader = new ACMEJwsHeader { - Url = state.Directory.RevokeCert.ToString(), + Url = revokeUri.ToString(), Nonce = nonce }; - if (!JwsGenerator.TryEncode(state.Rsa, state.Jwk, jwsHeader, revokeRequest, out var jwsMessage, out var errorMessage)) { + if (!JwsGenerator.TryEncode(rsa, jwk, jwsHeader, revokeRequest, out var jwsMessage, out var errorMessage)) { return Result.InternalServerError(errorMessage); } @@ -695,203 +739,29 @@ public class LetsEncryptService : ILetsEncryptService { var responseText = await response.Content.ReadAsStringAsync(); - if (response.Content.Headers.ContentType?.MediaType == GetContentType(ContentType.ProblemJson)) { - var erroObj = responseText.ToObject(); + HandleProblemResponseAsync(response, responseText); + + try { + if (!response.IsSuccessStatusCode) + return Result.InternalServerError(responseText); + + state.Cache.CachedCerts.Remove(subject); + _logger.LogInformation("Certificate revoked successfully"); + + return Result.Ok(); + } + finally { + response.Dispose(); } - if (!response.IsSuccessStatusCode) - Result.InternalServerError(responseText); - - state.Cache.CachedCerts.Remove(subject); - _logger.LogInformation("Certificate revoked successfully"); - - return Result.Ok(); - + } + catch (LetsEncrytException ex) { + var state = GetOrCreateState(sessionId); + return MapLetsEncryptException(state, ex); } catch (Exception ex) { return HandleUnhandledException(ex); } } #endregion - - #region Internal helpers - private State GetOrCreateState(Guid sessionId) { - if (!_memoryCache.TryGetValue(sessionId, out State? state) || state == null) { - state = new State(); - _memoryCache.Set(sessionId, state, TimeSpan.FromHours(1)); - } - return state; - } - - private async Task> GetNonceAsync(Guid sessionId, Uri uri) { - if (uri == null) - return Result.InternalServerError(null, "URI is null"); - - try { - var state = GetOrCreateState(sessionId); - - _logger.LogInformation($"Executing {nameof(GetNonceAsync)}..."); - - if (state.Directory?.NewNonce == null) - return Result.InternalServerError(null, $"{nameof(state.Directory.NewNonce)} is null"); - - var result = await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, state.Directory.NewNonce)); - - var nonce = result.Headers.GetValues("Replay-Nonce").FirstOrDefault(); - - if (nonce == null) - return Result.InternalServerError(null, "Nonce is null"); - - return Result.Ok(nonce); - } - catch (Exception ex) { - return HandleUnhandledException(ex); - } - } - - // Helper: Send ACME request and process response - private async Task?>> SendAcmeRequest(HttpRequestMessage request, State state, HttpMethod method) { - try { - var response = await _httpClient.SendAsync(request); - - var responseText = await response.Content.ReadAsStringAsync(); - - HandleProblemResponseAsync(response, responseText); - - var sendResult = ProcessResponseContent(response, responseText); - - return Result?>.Ok(sendResult); - } - - catch (Exception ex) { - return HandleUnhandledException?>(ex); - } - } - - private Result EncodeMessage(Guid sessionId, bool isPostAsGet, object? requestModel, ACMEJwsHeader protectedHeader) { - var state = GetOrCreateState(sessionId); - - JwsMessage jwsMessage; - string errorMessage; - - if (isPostAsGet) { - if (!JwsGenerator.TryEncode(state.Rsa, state.Jwk, protectedHeader, out jwsMessage, out errorMessage)) - return Result.InternalServerError(errorMessage); - - return Result.Ok(jwsMessage.ToJson()); - } - else { - if (!JwsGenerator.TryEncode(state.Rsa, state.Jwk, protectedHeader, requestModel, out jwsMessage, out errorMessage)) - return Result.InternalServerError(errorMessage); - - return Result.Ok(jwsMessage.ToJson()); - } - - } - - private static string GetContentType(ContentType type) => type.GetDisplayName(); - - private void PrepareRequestContent(HttpRequestMessage request, string json, HttpMethod method) { - request.Content = new StringContent(json ?? string.Empty); - var contentType = method == HttpMethod.Post - ? GetContentType(ContentType.JoseJson) - : GetContentType(ContentType.Json); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType); - } - - // Helper: Poll challenge status until valid or timeout - private async Task PollChallengeStatus(Guid sessionId, AuthorizationChallengeChallenge challenge) { - if (challenge?.Url == null) - return Result.InternalServerError("Challenge URL is null"); - - var start = DateTime.UtcNow; - - while (true) { - var pollRequest = new HttpRequestMessage(HttpMethod.Post, challenge.Url); - - var nonceResult = await GetNonceAsync(sessionId, challenge.Url); - if (!nonceResult.IsSuccess || nonceResult.Value == null) - return nonceResult; - - var nonce = nonceResult.Value; - - var pollJsonResult = EncodeMessage(sessionId, true, null, new ACMEJwsHeader { - Url = challenge.Url.ToString(), - Nonce = nonce - }); - - if (!pollJsonResult.IsSuccess || pollJsonResult.Value == null) - return pollJsonResult; - - var pollJson = pollJsonResult.Value; - - PrepareRequestContent(pollRequest, pollJson, HttpMethod.Post); - - var pollResponse = await _httpClient.SendAsync(pollRequest); - - var pollResponseText = await pollResponse.Content.ReadAsStringAsync(); - - HandleProblemResponseAsync(pollResponse, pollResponseText); - - var authChallenge = ProcessResponseContent(pollResponse, pollResponseText); - - if (authChallenge.Result?.Status != "pending") - return authChallenge.Result?.Status == "valid" ? Result.Ok() : Result.InternalServerError(); - - if ((DateTime.UtcNow - start).Seconds > 120) - return Result.InternalServerError("Timeout"); - - await Task.Delay(1000); - } - } - - private void HandleProblemResponseAsync(HttpResponseMessage response, string responseText) { - if (response.Content.Headers.ContentType?.MediaType == GetContentType(ContentType.ProblemJson)) { - var problem = responseText.ToObject(); - - throw new LetsEncrytException(problem, response); - } - - if (response.Content.Headers.ContentType?.MediaType == GetContentType(ContentType.Json)) { - var authorizationChallengeChallenge = responseText.ToObject(); - - if (authorizationChallengeChallenge?.Status == "invalid") { - throw new LetsEncrytException(new Problem { - Type = authorizationChallengeChallenge.Error.Type, - Detail = authorizationChallengeChallenge.Error.Detail, - RawJson = responseText - }, response); - } - } - } - - private SendResult ProcessResponseContent(HttpResponseMessage response, string responseText) { - if (response.Content.Headers.ContentType?.MediaType == GetContentType(ContentType.PemCertificateChain) && typeof(TResult) == typeof(string)) { - return new SendResult { - Result = (TResult)(object)responseText - }; - } - var responseContent = responseText.ToObject(); - if (responseContent is IHasLocation ihl && response.Headers.Location != null) { - ihl.Location = response.Headers.Location; - } - return new SendResult { - Result = responseContent, - ResponseText = responseText - }; - } - - // Helper for status comparison - private static bool StatusEquals(string? status, OrderStatus expected) => status == expected.GetDisplayName(); - - private Result HandleUnhandledException(Exception ex, string defaultMessage = "Let's Encrypt client unhandled exception") { - _logger.LogError(ex, defaultMessage); - return Result.InternalServerError([defaultMessage, .. ex.ExtractMessages()]); - } - - private Result HandleUnhandledException(Exception ex, T? defaultValue = default, string defaultMessage = "Let's Encrypt client unhandled exception") { - _logger.LogError(ex, defaultMessage); - return Result.InternalServerError(defaultValue, [.. ex.ExtractMessages()]); - } - #endregion } diff --git a/src/MaksIT.Webapi/BackgroundServices/AutoRenewal.cs b/src/MaksIT.Webapi/BackgroundServices/AutoRenewal.cs index a5bc6de..a919441 100644 --- a/src/MaksIT.Webapi/BackgroundServices/AutoRenewal.cs +++ b/src/MaksIT.Webapi/BackgroundServices/AutoRenewal.cs @@ -71,14 +71,36 @@ namespace MaksIT.Webapi.BackgroundServices { return Result.Ok(); } + var cooldownSkipped = new List<(string Hostname, DateTimeOffset NotBeforeUtc)>(); + var eligible = new List(); + foreach (var hostname in toRenew) { + if (cache.IsHostnameInAcmeCooldown(hostname, out var notBefore)) { + cooldownSkipped.Add((hostname, notBefore)); + continue; + } + eligible.Add(hostname); + } + + if (cooldownSkipped.Count > 0) { + var sample = cooldownSkipped[0]; + _logger.LogInformation( + "Skipping {SkippedCount} hostname(s) in ACME cooldown for account {AccountId} (e.g. {ExampleHost} until {NotBeforeUtc:u} UTC).", + cooldownSkipped.Count, cache.AccountId, sample.Hostname, sample.NotBeforeUtc); + } + + if (!eligible.Any()) { + _logger.LogInformation("All due certificates for account {AccountId} are in ACME cooldown; no renewal attempted.", cache.AccountId); + return Result.Ok(); + } + var fullFlowResult = await _certsFlowService.FullFlow( - cache.IsStaging, cache.AccountId, cache.Description, cache.Contacts, cache.ChallengeType, toRenew.ToArray() + cache.IsStaging, cache.AccountId, cache.Description, cache.Contacts, cache.ChallengeType, eligible.ToArray() ); if (!fullFlowResult.IsSuccess) return fullFlowResult; - _logger.LogInformation($"Certificates renewed for account {cache.AccountId}: {string.Join(", ", toRenew)}"); + _logger.LogInformation("Certificates renewed for account {AccountId}: {Hostnames}", cache.AccountId, string.Join(", ", eligible)); return Result.Ok(); } diff --git a/src/MaksIT.Webapi/MaksIT.Webapi.csproj b/src/MaksIT.Webapi/MaksIT.Webapi.csproj index 3d4f96f..2337622 100644 --- a/src/MaksIT.Webapi/MaksIT.Webapi.csproj +++ b/src/MaksIT.Webapi/MaksIT.Webapi.csproj @@ -1,7 +1,7 @@ - 3.3.5 + 3.3.6 net10.0 enable enable diff --git a/src/MaksIT.Webapi/Services/CertsFlowService.cs b/src/MaksIT.Webapi/Services/CertsFlowService.cs index cc62017..a4e5a43 100644 --- a/src/MaksIT.Webapi/Services/CertsFlowService.cs +++ b/src/MaksIT.Webapi/Services/CertsFlowService.cs @@ -187,27 +187,37 @@ public class CertsFlowService( var challengesResult = await NewOrderAsync(sessionId, hostnames, challengeType); - if (!challengesResult.IsSuccess) + if (!challengesResult.IsSuccess) { + await TryPersistRegistrationCacheFromSessionAsync(sessionId); return challengesResult.ToResultOfType(_ => null); + } if (challengesResult.Value?.Count > 0) { var challengeResult = await CompleteChallengesAsync(sessionId); - if (!challengeResult.IsSuccess) + if (!challengeResult.IsSuccess) { + await TryPersistRegistrationCacheFromSessionAsync(sessionId); return challengeResult.ToResultOfType(default); + } } var getOrderResult = await GetOrderAsync(sessionId, hostnames); - if (!getOrderResult.IsSuccess) + if (!getOrderResult.IsSuccess) { + await TryPersistRegistrationCacheFromSessionAsync(sessionId); return getOrderResult.ToResultOfType(default); + } var certsResult = await GetCertificatesAsync(sessionId, hostnames); - if (!certsResult.IsSuccess) + if (!certsResult.IsSuccess) { + await TryPersistRegistrationCacheFromSessionAsync(sessionId); return certsResult.ToResultOfType(default); + } if (!isStaging) { var applyCertsResult = await ApplyCertificatesAsync(accountId.Value); - if (!applyCertsResult.IsSuccess) + if (!applyCertsResult.IsSuccess) { + await TryPersistRegistrationCacheFromSessionAsync(sessionId); return applyCertsResult.ToResultOfType(_ => null); + } } return Result.Ok(initResult.Value); @@ -236,6 +246,16 @@ public class CertsFlowService( return Result.Ok(fileContent); } + private async Task TryPersistRegistrationCacheFromSessionAsync(Guid sessionId) { + var cacheResult = letsEncryptService.GetRegistrationCache(sessionId); + if (!cacheResult.IsSuccess || cacheResult.Value == null) + return; + + var saveResult = await cacheService.SaveToCacheAsync(cacheResult.Value.AccountId, cacheResult.Value); + if (!saveResult.IsSuccess) + logger.LogWarning("Could not persist registration cache after ACME flow step for account {AccountId}.", cacheResult.Value.AccountId); + } + private void DeleteExporedChallenges() { var currentDate = DateTime.Now; foreach (var file in Directory.GetFiles(_acmePath)) {