(bugfix): ACME rate limits, cooldown persistence, and LetsEncrypt hardening

This commit is contained in:
Maksym Sadovnychyy 2026-04-13 19:07:58 +02:00
parent 685a174806
commit 1b22b8688d
22 changed files with 791 additions and 280 deletions

View File

@ -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

View File

@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 21.7%">
<title>Line Coverage: 21.7%</title>
<svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 21.6%">
<title>Line Coverage: 21.6%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="47.25" y="15" fill="#010101" fill-opacity=".3">Line Coverage</text>
<text x="47.25" y="14" fill="#fff">Line Coverage</text>
<text aria-hidden="true" x="115.75" y="15" fill="#010101" fill-opacity=".3">21.7%</text>
<text x="115.75" y="14" fill="#fff">21.7%</text>
<text aria-hidden="true" x="115.75" y="15" fill="#010101" fill-opacity=".3">21.6%</text>
<text x="115.75" y="14" fill="#fff">21.6%</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 29.7%">
<title>Method Coverage: 29.7%</title>
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 31.5%">
<title>Method Coverage: 31.5%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Method Coverage</text>
<text x="53.75" y="14" fill="#fff">Method Coverage</text>
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">29.7%</text>
<text x="128.75" y="14" fill="#fff">29.7%</text>
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">31.5%</text>
<text x="128.75" y="14" fill="#fff">31.5%</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -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"));
}
}

View File

@ -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);
}
}

View File

@ -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<string, DateTimeOffset>(StringComparer.OrdinalIgnoreCase) {
["cloud.example.com"] = until
}
};
var json = cache.ToJson();
var restored = json.ToObject<RegistrationCache>();
Assert.NotNull(restored);
Assert.NotNull(restored!.AcmeRenewalNotBeforeUtcByHostname);
Assert.True(restored.AcmeRenewalNotBeforeUtcByHostname!.TryGetValue("cloud.example.com", out var v));
Assert.Equal(until, v);
}
}

View File

@ -0,0 +1,52 @@
using MaksIT.Core.Abstractions;
namespace MaksIT.LetsEncrypt;
/// <summary>
/// ACME problem <c>type</c> URIs (RFC 8555 §6.7). <see cref="Unknown"/> covers missing or unrecognized URIs.
/// </summary>
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) { }
/// <summary>
/// Resolves a problem <c>type</c> URI from the CA. Comparison is ordinal (RFC 3986).
/// </summary>
public static AcmeProblemKind FromTypeUri(string? typeUri) {
if (string.IsNullOrEmpty(typeUri))
return Unknown;
foreach (var kind in GetAll<AcmeProblemKind>()) {
if (kind == Unknown)
continue;
if (kind.Name == typeUri)
return kind;
}
return Unknown;
}
}

View File

@ -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+(?<ts>\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+""(?<host>[^""]+)""",
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;
}
/// <summary>
/// Latest of header and detail-derived times (stricter / later wins). Null if neither present.
/// </summary>
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;
}
}

View File

@ -23,6 +23,13 @@ public class RegistrationCache {
public Dictionary<string, CertificateCache>? CachedCerts { get; set; }
/// <summary>
/// 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 <see cref="CachedCerts"/>.
/// </summary>
public Dictionary<string, DateTimeOffset>? 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();
}
/// <summary>
/// True if the hostname must not be renewed yet due to a stored ACME cooldown.
/// </summary>
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);
}
/// <summary>
/// Returns cached certificate. Certs older than 30 days are not returned
/// </summary>

View File

@ -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; }
/// <summary>Returns the session account key pair when both RSA and JWK are present (after <c>Init</c>).</summary>
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;
}
}

View File

@ -1,20 +1,44 @@
using MaksIT.LetsEncrypt.Models.Responses;
using MaksIT.LetsEncrypt;
using MaksIT.LetsEncrypt.Models.Responses;
namespace MaksIT.LetsEncrypt.Exceptions;
/// <summary>Thrown when the ACME server returns a problem document or challenge error.</summary>
/// <remarks>
/// <see cref="Response"/> implements <see cref="IDisposable"/>. <see cref="Services.LetsEncryptService"/> disposes it
/// when mapping this exception to a <see cref="MaksIT.Core.Results.Result"/>; any other handler must dispose it to avoid holding connections.
/// </remarks>
public class LetsEncrytException : Exception {
public Problem? Problem { get; }
/// <summary>HTTP response that carried the problem (must be disposed if this exception is not handled by <see cref="Services.LetsEncryptService"/>).</summary>
public HttpResponseMessage Response { get; }
/// <summary>Classified <c>type</c> from the ACME problem document (RFC 8555 §6.7).</summary>
public AcmeProblemKind ProblemKind { get; }
/// <summary>Combined Retry-After from HTTP header and problem detail, when present.</summary>
public DateTimeOffset? RetryAfterUtc { get; }
/// <summary>Hostname from Let's Encrypt rate-limit <c>detail</c>, when parseable.</summary>
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);
}
}

View File

@ -14,6 +14,7 @@ public static class ServiceCollectionExtensions {
};
services.AddSingleton(config);
services.AddSingleton<AcmeSessionStore>();
services.AddHttpClient<ILetsEncryptService, LetsEncryptService>();
}
}

View File

@ -7,6 +7,10 @@
<RootNamespace>MaksIT.$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="LetsEncrypt.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MaksIT.Core" Version="1.6.5" />
<PackageReference Include="MaksIT.Results" Version="2.0.1" />

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -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; }
}
}

View File

@ -0,0 +1,21 @@
using MaksIT.LetsEncrypt.Entities.LetsEncrypt;
using Microsoft.Extensions.Caching.Memory;
namespace MaksIT.LetsEncrypt.Services;
/// <summary>Caches per-session <see cref="State"/> for ACME flows (directory, account, current order, challenges).</summary>
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;
}
}

View File

@ -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<Result<string?>> GetNonceAsync(Guid sessionId, Uri uri) {
if (uri == null)
return Result<string?>.InternalServerError(null, "URI is null");
try {
var state = GetOrCreateState(sessionId);
_logger.LogInformation($"Executing {nameof(GetNonceAsync)}...");
if (state.Directory is not { NewNonce: { } newNonceUri })
return Result<string?>.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<string?>.InternalServerError(null, "Nonce is null");
return Result<string?>.Ok(nonce);
}
catch (Exception ex) {
return HandleUnhandledException<string?>(ex);
}
}
private async Task<Result<SendResult<T>?>> SendAcmeRequest<T>(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<T>(response, responseText);
return Result<SendResult<T>?>.Ok(sendResult);
}
catch (LetsEncrytException ex) {
return MapLetsEncryptException<SendResult<T>?>(state, ex, null);
}
catch (Exception ex) {
return HandleUnhandledException<SendResult<T>?>(ex);
}
}
private Result<string?> EncodeMessage(Guid sessionId, bool isPostAsGet, object? requestModel, ACMEJwsHeader protectedHeader) {
var state = GetOrCreateState(sessionId);
if (!state.TryGetAccountKey(out var rsa, out var jwk))
return Result<string?>.InternalServerError(AccountKeyMissingMessage);
if (isPostAsGet) {
if (!JwsGenerator.TryEncode(rsa, jwk, protectedHeader, out var jwsPostAsGet, out var errPostAsGet))
return Result<string?>.InternalServerError(errPostAsGet);
return Result<string?>.Ok(jwsPostAsGet.ToJson());
}
if (!JwsGenerator.TryEncode(rsa, jwk, protectedHeader, requestModel, out var jwsWithPayload, out var errWithPayload))
return Result<string?>.InternalServerError(errWithPayload);
return Result<string?>.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<Result> 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<AuthorizationChallengeResponse>(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<Problem>();
throw new LetsEncrytException(problem, response);
}
if (response.Content.Headers.ContentType?.MediaType == GetContentType(ContentType.Json)) {
var authorizationChallengeChallenge = responseText.ToObject<AuthorizationChallengeChallenge>();
if (authorizationChallengeChallenge?.Status == "invalid") {
throw new LetsEncrytException(new Problem {
Type = authorizationChallengeChallenge.Error?.Type,
Detail = authorizationChallengeChallenge.Error?.Detail,
RawJson = responseText
}, response);
}
}
}
private SendResult<TResult> ProcessResponseContent<TResult>(HttpResponseMessage response, string responseText) {
if (response.Content.Headers.ContentType?.MediaType == GetContentType(ContentType.PemCertificateChain) && typeof(TResult) == typeof(string)) {
return new SendResult<TResult> {
Result = (TResult)(object)responseText
};
}
var responseContent = responseText.ToObject<TResult>();
if (responseContent is IHasLocation ihl && response.Headers.Location != null) {
ihl.Location = response.Headers.Location;
}
return new SendResult<TResult> {
Result = responseContent,
ResponseText = responseText
};
}
private static bool StatusEquals(string? status, OrderStatus expected) => status == expected.GetDisplayName();
private Result MapLetsEncryptException(State state, LetsEncrytException ex) =>
MapLetsEncryptExceptionCore<Result>(state, ex, m => Result.TooManyRequests(m), m => Result.InternalServerError(m));
private Result<T?> MapLetsEncryptException<T>(State state, LetsEncrytException ex, T? defaultValue) =>
MapLetsEncryptExceptionCore(state, ex, m => Result<T?>.TooManyRequests(defaultValue, m), m => Result<T?>.InternalServerError(defaultValue, m));
private TResult MapLetsEncryptExceptionCore<TResult>(
State state,
LetsEncrytException ex,
Func<string, TResult> tooManyRequests,
Func<string, TResult> 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<string, DateTimeOffset>(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<T?> HandleUnhandledException<T>(Exception ex, T? defaultValue = default, string defaultMessage = "Let's Encrypt client unhandled exception") {
_logger.LogError(ex, defaultMessage);
return Result<T?>.InternalServerError(defaultValue, [.. ex.ExtractMessages()]);
}
#endregion
}

View File

@ -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<RegistrationCache?> GetRegistrationCache(Guid sessionId);
Task<Result> ConfigureClient(Guid sessionId, bool isStaging);
Task<Result> Init(Guid sessionId,Guid accountId, string description, string[] contacts, RegistrationCache? registrationCache);
Task<Result> Init(Guid sessionId, Guid accountId, string description, string[] contacts, RegistrationCache? registrationCache);
Result<string?> GetTermsOfServiceUri(Guid sessionId);
Task<Result<Dictionary<string, string>?>> NewOrder(Guid sessionId, string[] hostnames, string challengeType);
Task<Result> CompleteChallenges(Guid sessionId);
@ -37,32 +36,33 @@ public interface ILetsEncryptService {
Task<Result> 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<LetsEncryptService> _logger;
private readonly LetsEncryptConfiguration _appSettings;
private readonly HttpClient _httpClient;
private readonly IMemoryCache _memoryCache;
private readonly AcmeSessionStore _sessions;
public LetsEncryptService(
ILogger<LetsEncryptService> logger,
LetsEncryptConfiguration appSettings,
HttpClient httpClient,
IMemoryCache cache
AcmeSessionStore sessions
) {
_logger = logger;
_appSettings = appSettings;
_httpClient = httpClient;
_memoryCache = cache;
_sessions = sessions;
}
public Result<RegistrationCache?> GetRegistrationCache(Guid sessionId) {
var state = GetOrCreateState(sessionId);
if (state?.Cache == null)
if (state.Cache == null)
return Result<RegistrationCache?>.InternalServerError(null);
return Result<RegistrationCache?>.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<Dictionary<string, string>?>.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<Dictionary<string, string>?>(_ => 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 <Dictionary<string, string>?>(_ => null);
return jsonResult.ToResultOfType<Dictionary<string, string>?>(_ => 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<Dictionary<string, string>?>.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<Dictionary<string, string>?>.InternalServerError(null, AccountKeyMissingMessage);
if (!JwkThumbprintUtility.TryGetKeyAuthorization(state.Jwk, challenge.Token, out var keyToken, out var errorMessage))
return Result<Dictionary<string, string>?>.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<AuthorizationChallengeResponse>(request, state, HttpMethod.Post);
_ = await SendAcmeRequest<AuthorizationChallengeResponse>(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<string>().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<string>().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<Problem>();
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<Result<string?>> GetNonceAsync(Guid sessionId, Uri uri) {
if (uri == null)
return Result<string?>.InternalServerError(null, "URI is null");
try {
var state = GetOrCreateState(sessionId);
_logger.LogInformation($"Executing {nameof(GetNonceAsync)}...");
if (state.Directory?.NewNonce == null)
return Result<string?>.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<string?>.InternalServerError(null, "Nonce is null");
return Result<string?>.Ok(nonce);
}
catch (Exception ex) {
return HandleUnhandledException<string?>(ex);
}
}
// Helper: Send ACME request and process response
private async Task<Result<SendResult<T>?>> SendAcmeRequest<T>(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<T>(response, responseText);
return Result<SendResult<T>?>.Ok(sendResult);
}
catch (Exception ex) {
return HandleUnhandledException<SendResult<T>?>(ex);
}
}
private Result<string?> 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<string?>.InternalServerError(errorMessage);
return Result<string?>.Ok(jwsMessage.ToJson());
}
else {
if (!JwsGenerator.TryEncode(state.Rsa, state.Jwk, protectedHeader, requestModel, out jwsMessage, out errorMessage))
return Result<string?>.InternalServerError(errorMessage);
return Result<string?>.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<Result> 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<AuthorizationChallengeResponse>(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<Problem>();
throw new LetsEncrytException(problem, response);
}
if (response.Content.Headers.ContentType?.MediaType == GetContentType(ContentType.Json)) {
var authorizationChallengeChallenge = responseText.ToObject<AuthorizationChallengeChallenge>();
if (authorizationChallengeChallenge?.Status == "invalid") {
throw new LetsEncrytException(new Problem {
Type = authorizationChallengeChallenge.Error.Type,
Detail = authorizationChallengeChallenge.Error.Detail,
RawJson = responseText
}, response);
}
}
}
private SendResult<TResult> ProcessResponseContent<TResult>(HttpResponseMessage response, string responseText) {
if (response.Content.Headers.ContentType?.MediaType == GetContentType(ContentType.PemCertificateChain) && typeof(TResult) == typeof(string)) {
return new SendResult<TResult> {
Result = (TResult)(object)responseText
};
}
var responseContent = responseText.ToObject<TResult>();
if (responseContent is IHasLocation ihl && response.Headers.Location != null) {
ihl.Location = response.Headers.Location;
}
return new SendResult<TResult> {
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<T?> HandleUnhandledException<T>(Exception ex, T? defaultValue = default, string defaultMessage = "Let's Encrypt client unhandled exception") {
_logger.LogError(ex, defaultMessage);
return Result<T?>.InternalServerError(defaultValue, [.. ex.ExtractMessages()]);
}
#endregion
}

View File

@ -71,14 +71,36 @@ namespace MaksIT.Webapi.BackgroundServices {
return Result.Ok();
}
var cooldownSkipped = new List<(string Hostname, DateTimeOffset NotBeforeUtc)>();
var eligible = new List<string>();
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();
}

View File

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<Version>3.3.5</Version>
<Version>3.3.6</Version>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>

View File

@ -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<Guid?>(_ => null);
}
if (challengesResult.Value?.Count > 0) {
var challengeResult = await CompleteChallengesAsync(sessionId);
if (!challengeResult.IsSuccess)
if (!challengeResult.IsSuccess) {
await TryPersistRegistrationCacheFromSessionAsync(sessionId);
return challengeResult.ToResultOfType<Guid?>(default);
}
}
var getOrderResult = await GetOrderAsync(sessionId, hostnames);
if (!getOrderResult.IsSuccess)
if (!getOrderResult.IsSuccess) {
await TryPersistRegistrationCacheFromSessionAsync(sessionId);
return getOrderResult.ToResultOfType<Guid?>(default);
}
var certsResult = await GetCertificatesAsync(sessionId, hostnames);
if (!certsResult.IsSuccess)
if (!certsResult.IsSuccess) {
await TryPersistRegistrationCacheFromSessionAsync(sessionId);
return certsResult.ToResultOfType<Guid?>(default);
}
if (!isStaging) {
var applyCertsResult = await ApplyCertificatesAsync(accountId.Value);
if (!applyCertsResult.IsSuccess)
if (!applyCertsResult.IsSuccess) {
await TryPersistRegistrationCacheFromSessionAsync(sessionId);
return applyCertsResult.ToResultOfType<Guid?>(_ => null);
}
}
return Result<Guid?>.Ok(initResult.Value);
@ -236,6 +246,16 @@ public class CertsFlowService(
return Result<string?>.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)) {