diff --git a/.github/workflows/keyfactor-bootstrap-workflow.yml b/.github/workflows/keyfactor-bootstrap-workflow.yml new file mode 100644 index 0000000..46f6fc9 --- /dev/null +++ b/.github/workflows/keyfactor-bootstrap-workflow.yml @@ -0,0 +1,19 @@ +name: Keyfactor Bootstrap Workflow + +on: + workflow_dispatch: + pull_request: + types: [opened, closed, synchronize, edited, reopened] + push: + create: + branches: + - 'release-*.*' + +jobs: + call-starter-workflow: + uses: keyfactor/actions/.github/workflows/starter.yml@v3 + secrets: + token: ${{ secrets.V2BUILDTOKEN}} + APPROVE_README_PUSH: ${{ secrets.APPROVE_README_PUSH}} + gpg_key: ${{ secrets.KF_GPG_PRIVATE_KEY }} + gpg_pass: ${{ secrets.KF_GPG_PASSPHRASE }} diff --git a/.gitignore b/.gitignore index 9491a2f..3e759b7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,6 @@ ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore # User-specific files -*.rsuser *.suo *.user *.userosscache @@ -13,9 +12,6 @@ # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs -# Mono auto generated files -mono_crash.* - # Build results [Dd]ebug/ [Dd]ebugPublic/ @@ -23,15 +19,10 @@ mono_crash.* [Rr]eleases/ x64/ x86/ -[Ww][Ii][Nn]32/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ bld/ [Bb]in/ [Oo]bj/ -[Oo]ut/ [Ll]og/ -[Ll]ogs/ # Visual Studio 2015/2017 cache/options directory .vs/ @@ -45,10 +36,9 @@ Generated\ Files/ [Tt]est[Rr]esult*/ [Bb]uild[Ll]og.* -# NUnit +# NUNIT *.VisualState.xml TestResult.xml -nunit-*.xml # Build Results of an ATL Project [Dd]ebugPS/ @@ -62,9 +52,7 @@ BenchmarkDotNet.Artifacts/ project.lock.json project.fragment.lock.json artifacts/ - -# ASP.NET Scaffolding -ScaffoldingReadMe.txt +**/Properties/launchSettings.json # StyleCop StyleCopReport.xml @@ -72,7 +60,7 @@ StyleCopReport.xml # Files built by Visual Studio *_i.c *_p.c -*_h.h +*_i.h *.ilk *.meta *.obj @@ -89,7 +77,6 @@ StyleCopReport.xml *.tlh *.tmp *.tmp_proj -*_wpftmp.csproj *.log *.vspscc *.vssscc @@ -132,6 +119,9 @@ _ReSharper*/ *.[Rr]e[Ss]harper *.DotSettings.user +# JustCode is a .NET coding add-in +.JustCode + # TeamCity is a build add-in _TeamCity* @@ -142,11 +132,6 @@ _TeamCity* .axoCover/* !.axoCover/settings.json -# Coverlet is a free, cross platform Code Coverage Tool -coverage*.json -coverage*.xml -coverage*.info - # Visual Studio code coverage results *.coverage *.coveragexml @@ -194,8 +179,6 @@ PublishScripts/ # NuGet Packages *.nupkg -# NuGet Symbol Packages -*.snupkg # The packages folder can be ignored because of Package Restore **/[Pp]ackages/* # except build/, which is used as an MSBuild target. @@ -220,14 +203,12 @@ BundleArtifacts/ Package.StoreAssociation.xml _pkginfo.txt *.appx -*.appxbundle -*.appxupload # Visual Studio cache files # files ending in .cache can be ignored *.[Cc]ache # but keep track of directories ending in .cache -!?*.[Cc]ache/ +!*.[Cc]ache/ # Others ClientBin/ @@ -240,7 +221,7 @@ ClientBin/ *.publishsettings orleans.codegen.cs -# Including strong name files can present a security risk +# Including strong name files can present a security risk # (https://github.com/github/gitignore/pull/2483#issue-259490424) #*.snk @@ -271,9 +252,6 @@ ServiceFabricBackup/ *.bim.layout *.bim_*.settings *.rptproj.rsuser -*- [Bb]ackup.rdl -*- [Bb]ackup ([0-9]).rdl -*- [Bb]ackup ([0-9][0-9]).rdl # Microsoft Fakes FakesAssemblies/ @@ -309,8 +287,12 @@ paket-files/ # FAKE - F# Make .fake/ -# CodeRush personal settings -.cr/personal +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ # Python Tools for Visual Studio (PTVS) __pycache__/ @@ -335,7 +317,7 @@ __pycache__/ # OpenCover UI analysis results OpenCover/ -# Azure Stream Analytics local run output +# Azure Stream Analytics local run output ASALocalRun/ # MSBuild Binary and Structured Log @@ -344,20 +326,5 @@ ASALocalRun/ # NVidia Nsight GPU debugger configuration file *.nvuser -# MFractors (Xamarin productivity tool) working folder +# MFractors (Xamarin productivity tool) working folder .mfractor/ - -# Local History for Visual Studio -.localhistory/ - -# BeatPulse healthcheck temp database -healthchecksdb - -# Backup folder for Package Reference Convert tool in Visual Studio 2017 -MigrationBackup/ - -# Ionide (cross platform F# VS Code tools) working folder -.ionide/ - -# Fody - auto-generated XML schema -FodyWeavers.xsd \ No newline at end of file diff --git a/AcmeCaPlugin.sln b/AcmeCaPlugin.sln new file mode 100644 index 0000000..9e2f30d --- /dev/null +++ b/AcmeCaPlugin.sln @@ -0,0 +1,36 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.10.35027.167 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AcmeCaPlugin", "AcmeCaPlugin\AcmeCaPlugin.csproj", "{011DC646-BEF9-4D3B-9D20-CA444A26B355}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestProgram", "TestProgram\TestProgram.csproj", "{F45D27E5-26B8-435B-AC49-5A119094BFD3}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Prerelease|Any CPU = Prerelease|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {011DC646-BEF9-4D3B-9D20-CA444A26B355}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {011DC646-BEF9-4D3B-9D20-CA444A26B355}.Debug|Any CPU.Build.0 = Debug|Any CPU + {011DC646-BEF9-4D3B-9D20-CA444A26B355}.Prerelease|Any CPU.ActiveCfg = Release|Any CPU + {011DC646-BEF9-4D3B-9D20-CA444A26B355}.Prerelease|Any CPU.Build.0 = Release|Any CPU + {011DC646-BEF9-4D3B-9D20-CA444A26B355}.Release|Any CPU.ActiveCfg = Release|Any CPU + {011DC646-BEF9-4D3B-9D20-CA444A26B355}.Release|Any CPU.Build.0 = Release|Any CPU + {F45D27E5-26B8-435B-AC49-5A119094BFD3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F45D27E5-26B8-435B-AC49-5A119094BFD3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F45D27E5-26B8-435B-AC49-5A119094BFD3}.Prerelease|Any CPU.ActiveCfg = Debug|Any CPU + {F45D27E5-26B8-435B-AC49-5A119094BFD3}.Prerelease|Any CPU.Build.0 = Debug|Any CPU + {F45D27E5-26B8-435B-AC49-5A119094BFD3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F45D27E5-26B8-435B-AC49-5A119094BFD3}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {0E1B1C00-FE0C-4138-85D7-51AA99D8745F} + EndGlobalSection +EndGlobal diff --git a/AcmeCaPlugin/AcmeCaPlugin.cs b/AcmeCaPlugin/AcmeCaPlugin.cs new file mode 100644 index 0000000..b1f8553 --- /dev/null +++ b/AcmeCaPlugin/AcmeCaPlugin.cs @@ -0,0 +1,515 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Logging; +using Keyfactor.PKI.Enums.EJBCA; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using System.Linq; +using System.Net.Http; +using ACMESharp.Authorizations; +using Keyfactor.Extensions.CAPlugin.Acme.Clients.Acme; +using System.Threading; +using ACMESharp.Protocol.Resources; +using ACMESharp.Protocol; +using System.Text; +using Keyfactor.Extensions.CAPlugin.Acme.Clients.DNS; +using System.Text.RegularExpressions; + +namespace Keyfactor.Extensions.CAPlugin.Acme +{ + /// + /// HTTP message handler that logs all requests and responses for debugging ACME communication + /// + public class LoggingHandler : DelegatingHandler + { + public LoggingHandler(HttpMessageHandler innerHandler) : base(innerHandler) { } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + // Add consistent user agent for all ACME requests + request.Headers.UserAgent.TryParseAdd("KeyfactorAcmePlugin/1.0"); + + // Log request details for debugging (consider removing in production for security) + var body = request.Content != null ? await request.Content.ReadAsStringAsync() : ""; + Console.WriteLine($"REQUEST: {request.Method} {request.RequestUri}"); + Console.WriteLine($"HEADERS: {request.Headers}"); + Console.WriteLine($"BODY: {body}"); + + var response = await base.SendAsync(request, cancellationToken); + + // Log response details for debugging + Console.WriteLine($"RESPONSE: {response.StatusCode}"); + var respContent = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"RESPONSE BODY: {respContent}"); + + return response; + } + } + + /// + /// Keyfactor CA Plugin implementation for ACME (Automatic Certificate Management Environment) protocol + /// Handles certificate enrollment via ACME-compliant Certificate Authorities like Let's Encrypt + /// + public class AcmeCaPlugin : IAnyCAPlugin + { + private static readonly ILogger _logger = LogHandler.GetClassLogger(); + private IAnyCAPluginConfigProvider Config { get; set; } + + // Constants for better maintainability + private const string DEFAULT_PRODUCT_ID = "default"; + private const string DNS_CHALLENGE_TYPE = "dns-01"; + private const int DNS_PROPAGATION_DELAY_SECONDS = 30; + private const string USER_AGENT = "KeyfactorAcmePlugin/1.0"; + + /// + /// Initialize the plugin with configuration and certificate data reader + /// + public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDataReader certificateDataReader) + { + _logger.MethodEntry(); + Config = configProvider ?? throw new ArgumentNullException(nameof(configProvider)); + _logger.MethodExit(); + } + + /// + /// Health check method - currently no-op for ACME + /// + /// + /// Health check method - pings the ACME directory endpoint to verify connectivity + /// + public async Task Ping() + { + _logger.MethodEntry(); + + HttpClient httpClient = null; + try + { + var config = GetConfig(); + + // Create HTTP client for ping operation + var handler = new HttpClientHandler(); + httpClient = new HttpClient(handler) + { + Timeout = TimeSpan.FromSeconds(30) // Set reasonable timeout for ping + }; + httpClient.DefaultRequestHeaders.UserAgent.TryParseAdd(USER_AGENT); + + _logger.LogInformation("Pinging ACME directory at: {DirectoryUrl}", config.DirectoryUrl); + + // Attempt to fetch the ACME directory + var response = await httpClient.GetAsync(config.DirectoryUrl); + + if (response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(); + + // Verify it's a valid ACME directory by checking for required endpoints + if (content.Contains("newAccount") && content.Contains("newOrder")) + { + _logger.LogInformation("ACME directory ping successful - valid directory response received"); + } + else + { + _logger.LogWarning("ACME directory responded but may not be a valid ACME directory"); + throw new InvalidOperationException("Directory response does not contain required ACME endpoints"); + } + } + else + { + var errorContent = await response.Content.ReadAsStringAsync(); + _logger.LogError("ACME directory ping failed with status: {StatusCode}, Content: {Content}", + response.StatusCode, errorContent); + throw new HttpRequestException($"Directory ping failed: {response.StatusCode} - {response.ReasonPhrase}"); + } + } + catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) + { + _logger.LogError("ACME directory ping timed out"); + throw new TimeoutException("ACME directory ping timed out", ex); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "HTTP error during ACME directory ping"); + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error during ACME directory ping"); + throw; + } + finally + { + httpClient?.Dispose(); + _logger.MethodExit(); + } + } + + /// + /// Validates required connection information for ACME CA + /// + public Task ValidateCAConnectionInfo(Dictionary connectionInfo) + { + _logger.MethodEntry(); + + if (connectionInfo == null) + throw new ArgumentNullException(nameof(connectionInfo)); + + var rawData = JsonConvert.SerializeObject(connectionInfo); + var config = JsonConvert.DeserializeObject(rawData); + + // Validate required configuration fields + var missingFields = new List(); + if (string.IsNullOrWhiteSpace(config?.DirectoryUrl)) + missingFields.Add(nameof(AcmeClientConfig.DirectoryUrl)); + if (string.IsNullOrWhiteSpace(config?.Email)) + missingFields.Add(nameof(AcmeClientConfig.Email)); + + if (missingFields.Count > 0) + throw new ArgumentException($"Missing required fields: {string.Join(", ", missingFields)}"); + + _logger.MethodExit(); + return Task.CompletedTask; + } + + /// + /// Validates product information - currently no validation needed for ACME + /// + public Task ValidateProductInfo(EnrollmentProductInfo productInfo, Dictionary connectionInfo) + { + _logger.MethodEntry(); + _logger.MethodExit(); + return Task.CompletedTask; + } + + /// + /// Returns available product IDs - ACME typically has one default product + /// + public List GetProductIds() + { + _logger.MethodEntry(); + _logger.MethodExit(); + return new List { DEFAULT_PRODUCT_ID }; + } + + /// + /// Synchronization not supported by ACME protocol as certificates are managed externally + /// + public Task Synchronize( + System.Collections.Concurrent.BlockingCollection blockingBuffer, + DateTime? lastSync, + bool fullSync, + CancellationToken cancelToken) + { + _logger.MethodEntry(); + _logger.MethodEntry(); + _logger.LogWarning("Certificate sync is not supported by standard ACME protocol"); + _logger.MethodExit(); + return Task.CompletedTask; + } + + + + /// + /// Main certificate enrollment method using ACME protocol + /// + public async Task Enroll( + string csr, + string subject, + Dictionary san, + EnrollmentProductInfo productInfo, + RequestFormat requestFormat, + EnrollmentType enrollmentType) + { + _logger.MethodEntry(); + + if (string.IsNullOrWhiteSpace(csr)) + throw new ArgumentException("CSR cannot be null or empty", nameof(csr)); + if (string.IsNullOrWhiteSpace(subject)) + throw new ArgumentException("Subject cannot be null or empty", nameof(subject)); + + csr = FormatCsrToSingleLine(csr); + + HttpClient httpClient = null; + + try + { + var config = GetConfig(); + var handler = new LoggingHandler(new HttpClientHandler()); + httpClient = new HttpClient(handler); + httpClient.DefaultRequestHeaders.UserAgent.TryParseAdd(USER_AGENT); + + // Init ACME client + var clientManager = new AcmeClientManager(_logger, config, httpClient); + var (protocolClient, accountDetails, signer) = await clientManager.CreateClientAsync(); + var acmeClient = new AcmeClient(_logger, config, httpClient, protocolClient.Directory, + new Clients.Acme.Account(accountDetails, signer)); + + // Extract domain + var cleanDomain = ExtractDomainFromSubject(subject); + var identifiers = new List + { + new Identifier { Type = "dns", Value = cleanDomain } + }; + + // Create order + var order = await acmeClient.CreateOrderAsync(identifiers, null); + + // Store pending order immediately + var accountId = accountDetails.Kid.Split('/').Last(); + + // Process challenges + await ProcessAuthorizations(acmeClient, order, config); + + // Finalize + var csrBytes = Convert.FromBase64String(csr); + order = await acmeClient.FinalizeOrderAsync(order, csrBytes); + + // If order is valid immediately, download cert + if (order.Payload?.Status == "valid" && !string.IsNullOrEmpty(order.Payload.Certificate)) + { + var certBytes = await acmeClient.GetCertificateAsync(order); + var certPem = EncodeToPem(certBytes, "CERTIFICATE"); + + return new EnrollmentResult + { + CARequestID = order.Payload.Finalize, + Certificate = certPem, + Status = (int)EndEntityStatus.GENERATED + }; + } + else + { + _logger.LogInformation("⏳ Order not valid yet — will be synced later. Status: {Status}", order.Payload?.Status); + // Order stays saved for next sync + return new EnrollmentResult + { + CARequestID = order.Payload.Finalize, + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "Could not retrieve order in allowed time." + }; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "❌ Enrollment failed for subject: {Subject}", subject); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = ex.Message + }; + } + finally + { + httpClient?.Dispose(); + _logger.MethodExit(); + } + } + + + + /// + /// Extracts the domain name from X.509 subject string + /// + /// Subject string in format "CN=domain.com" or similar + /// Clean domain name + private static string ExtractDomainFromSubject(string subject) + { + if (string.IsNullOrWhiteSpace(subject)) + throw new ArgumentException("Subject cannot be null or empty", nameof(subject)); + + return subject + .Replace("CN=", "", StringComparison.OrdinalIgnoreCase) + .Replace("cn=", "", StringComparison.OrdinalIgnoreCase) + .Trim(); + } + + /// + /// Processes ACME authorizations for domain validation + /// Currently hardcoded to use DNS-01 challenge with Google DNS provider + /// + /// + /// Processes ACME authorizations with DNS verification before challenge submission + /// + private async Task ProcessAuthorizations(AcmeClient acmeClient, OrderDetails order, AcmeClientConfig config) + { + if (order?.Payload is not Order payload || payload.Authorizations == null) + { + throw new InvalidOperationException("Missing or invalid authorization list in order payload."); + } + + var dnsVerifier = new DnsVerificationHelper(_logger); + var pendingChallenges = new List<(Authorization authz, Challenge challenge, Dns01ChallengeValidationDetails validation)>(); + + // First pass: Create all DNS records + foreach (var authzUrl in payload.Authorizations) + { + var authz = await acmeClient.GetAuthorizationAsync(authzUrl); + + // Skip if authorization is already valid (cached) + if (authz.Status == "valid") + { + _logger.LogInformation("Using cached authorization for {Domain}", authz.Identifier.Value); + continue; + } + + // Find DNS-01 challenge + var challenge = authz.Challenges.FirstOrDefault(c => c.Type == DNS_CHALLENGE_TYPE); + if (challenge == null) + throw new InvalidOperationException($"{DNS_CHALLENGE_TYPE} challenge not available"); + + // Decode challenge validation details + var validation = acmeClient.DecodeChallengeValidation(authz, challenge) as Dns01ChallengeValidationDetails; + if (validation == null) + throw new InvalidOperationException($"Failed to decode {DNS_CHALLENGE_TYPE} challenge validation details"); + + // Create DNS record + var dnsProvider = DnsProviderFactory.Create(config, _logger); + await dnsProvider.CreateRecordAsync(validation.DnsRecordName, validation.DnsRecordValue); + + _logger.LogInformation("Created DNS record {RecordName} for domain {Domain}", + validation.DnsRecordName, authz.Identifier.Value); + + pendingChallenges.Add((authz, challenge, validation)); + } + + // Second pass: Wait for DNS propagation and submit challenges + foreach (var (authz, challenge, validation) in pendingChallenges) + { + _logger.LogInformation("Waiting for DNS propagation for {Domain}...", authz.Identifier.Value); + + // Wait for DNS propagation with verification + var propagated = await dnsVerifier.WaitForDnsPropagationAsync( + validation.DnsRecordName, + validation.DnsRecordValue, + minimumServers: 3 // Require at least 3 DNS servers to confirm + ); + + if (!propagated) + { + _logger.LogWarning("DNS record may not have fully propagated for {Domain}. Proceeding anyway...", + authz.Identifier.Value); + + // Optional: Add a final delay as fallback + await Task.Delay(TimeSpan.FromSeconds(30)); + } + + // Submit challenge response + _logger.LogInformation("Submitting challenge for {Domain}", authz.Identifier.Value); + await acmeClient.AnswerChallengeAsync(challenge); + } + } + + /// + /// Encodes binary data to PEM format with specified label + /// + /// Binary data to encode + /// PEM label (e.g., "CERTIFICATE") + /// PEM formatted string + private static string EncodeToPem(byte[] data, string label) + { + if (data == null || data.Length == 0) + throw new ArgumentException("Data cannot be null or empty", nameof(data)); + if (string.IsNullOrWhiteSpace(label)) + throw new ArgumentException("Label cannot be null or empty", nameof(label)); + + var builder = new StringBuilder(); + builder.AppendLine($"-----BEGIN {label}-----"); + builder.AppendLine(Convert.ToBase64String(data, Base64FormattingOptions.InsertLineBreaks)); + builder.AppendLine($"-----END {label}-----"); + return builder.ToString(); + } + + /// + /// Certificate revocation not supported by standard ACME protocol + /// + public Task Revoke(string caRequestID, string hexSerialNumber, uint revocationReason) + { + _logger.MethodEntry(); + _logger.LogWarning("Certificate revocation is not supported by standard ACME protocol"); + _logger.MethodExit(); + return Task.FromResult((int)EndEntityStatus.FAILED); + } + + /// + /// Individual certificate record retrieval not supported by standard ACME protocol + /// + public Task GetSingleRecord(string caRequestID) + { + _logger.MethodEntry(); + _logger.LogWarning("Individual certificate record retrieval is not supported by standard ACME protocol"); + _logger.MethodExit(); + return Task.FromResult(new AnyCAPluginCertificate + { + CARequestID = caRequestID, + Status = (int)EndEntityStatus.FAILED + }); + } + + /// + /// Returns CA connector configuration annotations + /// + public Dictionary GetCAConnectorAnnotations() + { + _logger.MethodEntry(); + var annotations = AcmeCaPluginConfig.GetPluginAnnotations(); + _logger.MethodExit(); + return annotations; + } + + /// + /// Returns template parameter annotations - none needed for ACME + /// + public Dictionary GetTemplateParameterAnnotations() + { + _logger.MethodEntry(); + _logger.MethodExit(); + return new Dictionary(); + } + + /// + /// Converts a PEM CSR with headers to a compact single-line Base64 CSR. + /// + /// PEM formatted CSR including BEGIN and END lines. + /// Single-line Base64 CSR string. + public static string FormatCsrToSingleLine(string pemCsr) + { + if (string.IsNullOrWhiteSpace(pemCsr)) + throw new ArgumentException("CSR input is null or empty.", nameof(pemCsr)); + + // Remove header/footer and all line breaks + var cleaned = Regex.Replace(pemCsr, + "-----BEGIN CERTIFICATE REQUEST-----|-----END CERTIFICATE REQUEST-----|\\s+", + string.Empty); + + // Decode to binary to validate it's valid base64 + byte[] derBytes = Convert.FromBase64String(cleaned); + + // Re-encode as single-line Base64 (optional: strip padding or line length limits) + string singleLine = Convert.ToBase64String(derBytes); + + return singleLine; + } + + /// + /// Deserializes configuration from connection data + /// + /// Typed ACME client configuration + private AcmeClientConfig GetConfig() + { + if (Config?.CAConnectionData == null) + throw new InvalidOperationException("CA connection data is not configured"); + + var raw = JsonConvert.SerializeObject(Config.CAConnectionData); + var config = JsonConvert.DeserializeObject(raw); + + if (config == null) + throw new InvalidOperationException("Failed to deserialize ACME client configuration"); + + return config; + } + } +} \ No newline at end of file diff --git a/AcmeCaPlugin/AcmeCaPlugin.csproj b/AcmeCaPlugin/AcmeCaPlugin.csproj new file mode 100644 index 0000000..9b77aec --- /dev/null +++ b/AcmeCaPlugin/AcmeCaPlugin.csproj @@ -0,0 +1,33 @@ + + +net6.0 +disable +true +false +Keyfactor.Extensions.CAPlugin.Acme +AcmeCaPlugin + + + + + + + + + + + + + + + + + + + + + +Always + + + diff --git a/AcmeCaPlugin/AcmeCaPluginConfig.cs b/AcmeCaPlugin/AcmeCaPluginConfig.cs new file mode 100644 index 0000000..0118de2 --- /dev/null +++ b/AcmeCaPlugin/AcmeCaPluginConfig.cs @@ -0,0 +1,141 @@ +using Keyfactor.AnyGateway.Extensions; +using System.Collections.Generic; + +namespace Keyfactor.Extensions.CAPlugin.Acme +{ + public class AcmeCaPluginConfig + { + public static Dictionary GetPluginAnnotations() + { + return new Dictionary() + { + ["DirectoryUrl"] = new PropertyConfigInfo() + { + Comments = "ACME directory URL (e.g. Let's Encrypt, ZeroSSL, etc.)", + Hidden = false, + DefaultValue = "https://acme-v02.api.letsencrypt.org/directory", + Type = "String" + }, + ["Email"] = new PropertyConfigInfo() + { + Comments = "Email for ACME account registration.", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + ["EabKid"] = new PropertyConfigInfo() + { + Comments = "External Account Binding Key ID (optional)", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + ["EabHmacKey"] = new PropertyConfigInfo() + { + Comments = "External Account Binding HMAC key (optional)", + Hidden = true, + DefaultValue = "", + Type = "Secret" + }, + ["SignerEncryptionPhrase"] = new PropertyConfigInfo() + { + Comments = "Used to encrypt singer information when account is saved to disk (optional)", + Hidden = true, + DefaultValue = "", + Type = "Secret" + }, + ["DnsProvider"] = new PropertyConfigInfo() + { + Comments = "DNS Provider to use for ACME DNS-01 challenges (options Google, Cloudflare, AwsRoute53, Azure, Ns1)", + Hidden = false, + DefaultValue = "Google", + Type = "String" + }, + + // Google DNS + ["Google_ServiceAccountKeyPath"] = new PropertyConfigInfo() + { + Comments = "Google Cloud DNS: Path to service account JSON key file only if using Google DNS (Optional)", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + ["Google_ProjectId"] = new PropertyConfigInfo() + { + Comments = "Google Cloud DNS: Project ID only if using Google DNS (Optional)", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + + // Cloudflare DNS + ["Cloudflare_ApiToken"] = new PropertyConfigInfo() + { + Comments = "Cloudflare DNS: API Token only if using Cloudflare DNS (Optional)", + Hidden = true, + DefaultValue = "", + Type = "Secret" + }, + + // Azure DNS + ["Azure_ClientId"] = new PropertyConfigInfo() + { + Comments = "Azure DNS: ClientId only if using Azure DNS and Not Managed Itentity in Azure (Optional)", + Hidden = false, + DefaultValue = "", + Type = "Secret" + }, + ["Azure_ClientSecret"] = new PropertyConfigInfo() + { + Comments = "Azure DNS: ClientSecret only if using Azure DNS and Not Managed Itentity in Azure (Optional)", + Hidden = true, + DefaultValue = "", + Type = "Secret" + }, + ["Azure_SubscriptionId"] = new PropertyConfigInfo() + { + Comments = "Azure DNS: SubscriptionId only if using Azure DNS and Not Managed Itentity in Azure (Optional)", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + ["Azure_TenantId"] = new PropertyConfigInfo() + { + Comments = "Azure DNS: TenantId only if using Azure DNS and Not Managed Itentity in Azure (Optional)", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + ["AwsRoute53_AccessKey"] = new PropertyConfigInfo() + { + Comments = "Aws DNS: Access Key only if not using AWS DNS and default AWS Chain Creds on AWS (Optional)", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + ["AwsRoute53_SecretKey"] = new PropertyConfigInfo() + { + Comments = "Aws DNS: Secret Key only if using AWS DNS and not using default AWS Chain Creds on AWS (Optional)", + Hidden = true, + DefaultValue = "", + Type = "Secret" + } + //IBM NS1 DNS + , + ["Ns1_ApiKey"] = new PropertyConfigInfo() + { + Comments = "Ns1 DNS: Api Key only if Using Ns1 DNS (Optional)", + Hidden = true, + DefaultValue = "", + Type = "String" + } + + }; + } + + public static Dictionary GetTemplateParameterAnnotations() + { + return new Dictionary(); + } + } +} diff --git a/AcmeCaPlugin/AcmeClientConfig.cs b/AcmeCaPlugin/AcmeClientConfig.cs new file mode 100644 index 0000000..93963a8 --- /dev/null +++ b/AcmeCaPlugin/AcmeClientConfig.cs @@ -0,0 +1,38 @@ +using Amazon; + +namespace Keyfactor.Extensions.CAPlugin.Acme +{ + public class AcmeClientConfig + { + public string DirectoryUrl { get; set; } = "https://acme-v02.api.letsencrypt.org/directory"; + public string Email { get; set; } = string.Empty; + public string EabKid { get; set; } = null; + public string EabHmacKey { get; set; } = null; + public string SignerEncryptionPhrase{ get; set; } = null; + + // Chosen DNS Provider + public string DnsProvider { get; set; } = null; + + // Google Cloud DNS + public string Google_ServiceAccountKeyPath { get; set; } = null; + public string Google_ProjectId { get; set; } = null; + + // Cloudflare DNS + public string Cloudflare_ApiToken { get; set; } = null; + + + // Azure DNS + public string Azure_ClientId { get; set; } = null; + public string Azure_ClientSecret { get; set; } = null; + public string Azure_SubscriptionId { get; set; } = null; + public string Azure_TenantId { get; set; } = null; + + // AWS Route53 + public string AwsRoute53_AccessKey { get; set; } = null; + public string AwsRoute53_SecretKey { get; set; } = null; + + //IBM NS1 DNS Ns1_ApiKey + public string Ns1_ApiKey { get; set; } = null; + + } +} diff --git a/AcmeCaPlugin/App.config b/AcmeCaPlugin/App.config new file mode 100644 index 0000000..5da3322 --- /dev/null +++ b/AcmeCaPlugin/App.config @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/AcmeCaPlugin/Clients/Acme/Account.cs b/AcmeCaPlugin/Clients/Acme/Account.cs new file mode 100644 index 0000000..e86cc99 --- /dev/null +++ b/AcmeCaPlugin/Clients/Acme/Account.cs @@ -0,0 +1,27 @@ +using ACMESharp.Protocol; + +namespace Keyfactor.Extensions.CAPlugin.Acme.Clients.Acme +{ + internal class Account + { + /// + /// Constructor requires signer to be present + /// + /// + public Account(AccountDetails details, AccountSigner signer) + { + Details = details; + Signer = signer; + } + + /// + /// Account information + /// + public AccountDetails Details { get; set; } + + /// + /// Account "password" + /// + public AccountSigner Signer { get; set; } + } +} diff --git a/AcmeCaPlugin/Clients/Acme/AccountManager.cs b/AcmeCaPlugin/Clients/Acme/AccountManager.cs new file mode 100644 index 0000000..5345368 --- /dev/null +++ b/AcmeCaPlugin/Clients/Acme/AccountManager.cs @@ -0,0 +1,330 @@ +using ACMESharp.Protocol; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace Keyfactor.Extensions.CAPlugin.Acme.Clients.Acme +{ + /// + /// Manages ACME account storage, retrieval, and default account handling. + /// Handles account persistence to the file system and provides methods for + /// creating, loading, and storing ACME accounts with their associated signers. + /// + class AccountManager + { + #region Constants + + private const string SignerFileName = "Signer_v2"; + private const string RegistrationFileName = "Registration_v2"; + private const string DefaultAccountPointer = "default.txt"; + + #endregion + + #region Fields + + private readonly ILogger _log; + private readonly string _basePath; + private readonly string _passphrase; + + private readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + #endregion + + #region Constructor + + public AccountManager(ILogger log, string passphrase = null) + { + _log = log; + _passphrase = passphrase; + _basePath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "AcmeAccounts"); + } + + #endregion + + #region Public Methods + + internal Account NewAccount(string keyType = "ES256") + { + AccountSigner signer; + try + { + signer = NewSigner(keyType); + } + catch (CryptographicException cex) + { + if (keyType == "ES256") + { + _log.LogTrace("ES256 key generation failed, falling back to RS256: {error}", cex.Message); + signer = NewSigner("RS256"); + } + else + { + throw; + } + } + + return new Account(default, signer); + } + + internal Account LoadDefaultAccount(string directoryUrl) + { + var hostKey = ExtractHostKey(directoryUrl); + var defaultFile = Path.Combine(_basePath, $"default_{hostKey}.txt"); + + if (File.Exists(defaultFile)) + { + var accountId = File.ReadAllText(defaultFile).Trim(); + if (!string.IsNullOrWhiteSpace(accountId)) + { + return LoadAccount(accountId); + } + } + + return null; + } + + internal void SetDefaultAccount(string directoryUrl, string accountId) + { + var hostKey = ExtractHostKey(directoryUrl); + var defaultFile = Path.Combine(_basePath, $"default_{hostKey}.txt"); + File.WriteAllText(defaultFile, accountId); + } + + internal Account LoadAccount(string folderName) + { + var dir = EnsureAccountDirectory(folderName); + var signerPath = Path.Combine(dir, SignerFileName); + var detailsPath = Path.Combine(dir, RegistrationFileName); + + var signer = LoadSigner(signerPath); + var details = LoadDetails(detailsPath); + + if (details == default || signer == null) + { + return null; + } + + return new Account(details, signer); + } + + internal void StoreAccount(Account account, string directoryUrl) + { + if (account?.Details?.Kid == null) + { + _log.LogError("Account Kid is null, cannot determine storage location."); + return; + } + + var folderName = GetAccountDirectoryName(account.Details.Kid); + var dir = EnsureAccountDirectory(folderName); + var signerPath = Path.Combine(dir, SignerFileName); + var detailsPath = Path.Combine(dir, RegistrationFileName); + + StoreDetails(account.Details, detailsPath); + StoreSigner(account.Signer, signerPath); + + SetDefaultAccount(directoryUrl, folderName); + } + + internal IEnumerable ListAccountDirectories() + { + if (!Directory.Exists(_basePath)) + yield break; + + var baseDir = new DirectoryInfo(_basePath); + foreach (var dir in baseDir.GetDirectories()) + { + var regPath = Path.Combine(dir.FullName, RegistrationFileName); + if (File.Exists(regPath)) + yield return dir.Name; + } + } + + #endregion + + #region Private Helper Methods + + private AccountSigner NewSigner(string keyType) + { + _log.LogDebug("Creating new {keyType} signer", keyType); + return new AccountSigner(keyType); + } + + public string ExtractHostKey(string directoryUrl) + { + return new Uri(directoryUrl).Host.Replace(".", "-"); + } + + public string GetAccountDirectoryName(string kidUrl) + { + try + { + var uri = new Uri(kidUrl); + var accountId = uri.Segments[^1].Trim('/'); + var hostPart = uri.Host.Replace(".", "-"); + return SanitizeFileName($"{hostPart}_{accountId}"); + } + catch (Exception ex) + { + _log.LogError(ex, "Invalid kid URL: {kidUrl}", kidUrl); + throw; + } + } + + private string EnsureAccountDirectory(string folderName) + { + var accountDir = Path.Combine(_basePath, folderName); + if (!Directory.Exists(accountDir)) + { + _log.LogDebug("Creating account directory: {accountDir}", accountDir); + Directory.CreateDirectory(accountDir); + } + return accountDir; + } + + private string SanitizeFileName(string input) + { + foreach (var c in Path.GetInvalidFileNameChars()) + input = input.Replace(c, '_'); + return input; + } + + private AccountSigner LoadSigner(string path) + { + if (!File.Exists(path)) + { + _log.LogDebug("Signer not found at {signerPath}", path); + return null; + } + + try + { + _log.LogDebug("Loading signer from {signerPath}", path); + var data = File.ReadAllBytes(path); + + string json; + if (!string.IsNullOrEmpty(_passphrase)) + { + json = Decrypt(data, _passphrase); + } + else + { + json = Encoding.UTF8.GetString(data); + } + + return JsonSerializer.Deserialize(json, _jsonOptions); + } + catch (Exception ex) + { + _log.LogError(ex, "Unable to load signer from {path}", path); + return null; + } + } + + private void StoreSigner(AccountSigner signer, string path) + { + if (signer != null) + { + _log.LogDebug("Saving signer to {SignerPath}", path); + var json = JsonSerializer.Serialize(signer, _jsonOptions); + byte[] data; + + if (!string.IsNullOrEmpty(_passphrase)) + { + data = Encrypt(json, _passphrase); + } + else + { + data = Encoding.UTF8.GetBytes(json); + } + + File.WriteAllBytes(path, data); + } + } + + private AccountDetails LoadDetails(string path) + { + if (!File.Exists(path)) + return default; + + try + { + _log.LogDebug("Loading account details from {path}", path); + return JsonSerializer.Deserialize( + File.ReadAllText(path), _jsonOptions); + } + catch (Exception ex) + { + _log.LogError(ex, "Unable to load account details from {path}", path); + return default; + } + } + + private void StoreDetails(AccountDetails details, string path) + { + if (details != default) + { + _log.LogDebug("Saving account details to {AccountPath}", path); + File.WriteAllText(path, JsonSerializer.Serialize(details, _jsonOptions)); + } + } + + #endregion + + #region AES Cross-Platform Encrypt/Decrypt + + private static byte[] Encrypt(string plaintext, string passphrase) + { + using var aes = Aes.Create(); + byte[] salt = new byte[16]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(salt); + + using var derive = new Rfc2898DeriveBytes(passphrase, salt, 10000); + aes.Key = derive.GetBytes(32); + aes.IV = derive.GetBytes(16); + + using var ms = new MemoryStream(); + ms.Write(salt, 0, salt.Length); + ms.Write(aes.IV, 0, aes.IV.Length); + + using var cs = new CryptoStream(ms, aes.CreateEncryptor(), CryptoStreamMode.Write); + using var writer = new StreamWriter(cs); + writer.Write(plaintext); + writer.Flush(); + cs.FlushFinalBlock(); + return ms.ToArray(); + } + + private static string Decrypt(byte[] data, string passphrase) + { + using var ms = new MemoryStream(data); + + byte[] salt = new byte[16]; + ms.Read(salt, 0, salt.Length); + + byte[] iv = new byte[16]; + ms.Read(iv, 0, iv.Length); + + using var aes = Aes.Create(); + using var derive = new Rfc2898DeriveBytes(passphrase, salt, 10000); + aes.Key = derive.GetBytes(32); + aes.IV = iv; + + using var cs = new CryptoStream(ms, aes.CreateDecryptor(), CryptoStreamMode.Read); + using var reader = new StreamReader(cs); + return reader.ReadToEnd(); + } + + #endregion + } +} diff --git a/AcmeCaPlugin/Clients/Acme/AccountSigner.cs b/AcmeCaPlugin/Clients/Acme/AccountSigner.cs new file mode 100644 index 0000000..7d48e33 --- /dev/null +++ b/AcmeCaPlugin/Clients/Acme/AccountSigner.cs @@ -0,0 +1,197 @@ +using ACMESharp.Crypto.JOSE; +using ACMESharp.Crypto.JOSE.Impl; +using System; + +namespace Keyfactor.Extensions.CAPlugin.Acme.Clients.Acme +{ + /// + /// Represents the cryptographic signing component of an ACME account. + /// This class manages the private key used for ACME protocol authentication + /// and can handle both RSA and Elliptic Curve key types. + /// Acts as the "password" for ACME account operations. + /// + public class AccountSigner + { + #region Fields + + /// The cryptographic algorithm type (e.g., ES256, RS256) + private string _keyType; + + /// Serialized key data for persistence + private string _keyExport; + + /// Cached JWS tool instance to avoid recreation + private IJwsTool _jwsTool; + + #endregion + + #region Constructors + + /// + /// Default constructor for serialization/deserialization scenarios. + /// Creates an uninitialized AccountSigner that must be configured before use. + /// + public AccountSigner() + { + } + + /// + /// Creates a new AccountSigner with the specified key type. + /// Automatically generates a new key pair for the specified algorithm. + /// + /// The signing algorithm type (ES256, ES384, ES512, RS256, etc.) + /// Thrown if the key type is unsupported or key generation fails + public AccountSigner(string keyType) + { + KeyType = keyType; + KeyExport = JwsTool().Export(); + } + + /// + /// Creates an AccountSigner from an existing JWS tool. + /// Useful for converting existing cryptographic tools to AccountSigner instances. + /// + /// The source JWS tool containing the key material + /// Thrown if source is null + public AccountSigner(IJwsTool source) + { + if (source == null) + throw new ArgumentNullException(nameof(source)); + + KeyType = source.JwsAlg; + KeyExport = source.Export(); + } + + #endregion + + #region Properties + + /// + /// Gets or sets the cryptographic signature algorithm type. + /// Supported values include ES256, ES384, ES512 (Elliptic Curve) and RS256 (RSA). + /// Setting this property invalidates the cached JWS tool. + /// + /// The signature algorithm identifier (default: ES256) + public string KeyType + { + get => _keyType; + set + { + _keyType = value; + _jwsTool = null; // Invalidate cached tool when key type changes + } + } + + /// + /// Gets or sets the serialized key data for persistence. + /// Contains both public and private key information in a format + /// suitable for storage and later reconstruction. + /// Setting this property invalidates the cached JWS tool. + /// + /// Base64 or PEM encoded key data + public string KeyExport + { + get => _keyExport; + set + { + _keyExport = value; + _jwsTool = null; // Invalidate cached tool when key data changes + } + } + + #endregion + + #region Public Methods + + /// + /// Gets or creates the JWS (JSON Web Signature) tool for cryptographic operations. + /// This method implements lazy initialization and caching to avoid recreating + /// expensive cryptographic objects unnecessarily. + /// + /// An initialized IJwsTool instance ready for signing operations + /// + /// Thrown if KeyType is missing, unsupported, or if key initialization fails + /// + public IJwsTool JwsTool() + { + // Return cached instance if available + if (_jwsTool != null) + { + return _jwsTool; + } + + // Validate that we have a key type + if (string.IsNullOrWhiteSpace(KeyType)) + { + throw new Exception("Missing KeyType - cannot create JWS tool without specifying algorithm"); + } + + IJwsTool tool = CreateJwsToolForKeyType(KeyType); + + // Initialize the tool with default parameters + tool.Init(); + + // Import existing key data if available + if (!string.IsNullOrEmpty(KeyExport)) + { + tool.Import(KeyExport); + } + + // Cache the tool for future use + _jwsTool = tool; + return _jwsTool; + } + + /// + /// Convenience method that returns the JWS tool. + /// Provides an alternative method name for accessing the cryptographic tool. + /// + /// An initialized IJwsTool instance + /// + /// Thrown if KeyType is missing, unsupported, or if key initialization fails + /// + public IJwsTool GetJwsTool() => JwsTool(); + + #endregion + + #region Private Helper Methods + + /// + /// Creates the appropriate JWS tool instance based on the specified key type. + /// Supports Elliptic Curve (ES*) and RSA (RS*) algorithm families. + /// + /// The cryptographic algorithm identifier + /// An uninitialized IJwsTool instance of the appropriate type + /// Thrown if the key type is unknown or unsupported + private IJwsTool CreateJwsToolForKeyType(string keyType) + { + // Handle Elliptic Curve algorithms (ES256, ES384, ES512) + if (keyType.StartsWith("ES", StringComparison.OrdinalIgnoreCase)) + { + // Extract hash size from algorithm name (e.g., "ES256" -> 256) + if (int.TryParse(keyType.Substring(2), out int hashSize)) + { + return new ESJwsTool + { + HashSize = hashSize + }; + } + else + { + throw new Exception($"Invalid Elliptic Curve key type format: {keyType}. Expected format: ES[256|384|512]"); + } + } + // Handle RSA algorithms (RS256, RS384, RS512) + else if (keyType.StartsWith("RS", StringComparison.OrdinalIgnoreCase)) + { + return new RSJwsTool(); + } + else + { + throw new Exception($"Unknown or unsupported KeyType [{keyType}]. Supported types: ES256, ES384, ES512, RS256, RS384, RS512"); + } + } + + #endregion + } +} \ No newline at end of file diff --git a/AcmeCaPlugin/Clients/Acme/AcmeClient.cs b/AcmeCaPlugin/Clients/Acme/AcmeClient.cs new file mode 100644 index 0000000..0ce5a3a --- /dev/null +++ b/AcmeCaPlugin/Clients/Acme/AcmeClient.cs @@ -0,0 +1,748 @@ +using ACMESharp.Authorizations; +using ACMESharp.Crypto; +using ACMESharp.Protocol; +using ACMESharp.Protocol.Resources; +using Keyfactor.Extensions.CAPlugin.Acme.Interfaces; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Org.BouncyCastle.Asn1.X509; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Keyfactor.Extensions.CAPlugin.Acme.Clients.Acme +{ + /// + /// High-performance ACME protocol client implementing RFC 8555 (Automatic Certificate Management Environment). + /// Handles complete certificate lifecycle: account management, order creation, domain validation, + /// certificate issuance, renewal, and revocation with robust error handling and retry logic. + /// + internal sealed class AcmeClient : IDisposable + { + #region ACME Protocol Constants + + // Order status constants per RFC 8555 Section 7.1.6 + // https://tools.ietf.org/html/rfc8555#section-7.1.6 + public const string OrderPending = "pending"; // Created, awaiting authorizations + public const string OrderReady = "ready"; // Authorizations complete, ready for finalization + public const string OrderProcessing = "processing"; // CA processing certificate issuance + public const string OrderInvalid = "invalid"; // Validation failed or error occurred + public const string OrderValid = "valid"; // Certificate issued and available + + // Authorization status constants + public const string AuthorizationValid = "valid"; // Successfully validated + public const string AuthorizationInvalid = "invalid"; // Validation failed + public const string AuthorizationPending = "pending"; // Awaiting challenge completion + public const string AuthorizationProcessing = "processing"; // Validation in progress + + // Challenge status constants + public const string ChallengeValid = "valid"; // Challenge successfully validated + + // HTTP and retry configuration + private const int MaxNonceRetries = 3; + private const int MaxStatusPollingRetries = 30; + private const int StatusPollingDelayMs = 2000; + private const int ChallengePollingDelayMs = 1000; + private const int MaxChallengePollingRetries = 5; + private const string UserAgentString = "KeyfactorAcmePlugin/1.0"; + private const string JoseContentType = "application/jose+json"; + + #endregion + + #region Fields + + private readonly ILogger _log; + private AcmeProtocolClient _client; + private readonly AcmeClientConfig _config; + private readonly HttpClient _httpClient; + private bool _disposed; + + #endregion + + #region Properties + + /// + /// The authenticated ACME account with cryptographic signing capabilities. + /// Used for all protocol operations requiring authentication. + /// + public Account Account { get; private set; } + + #endregion + + #region Constructor & Disposal + + /// + /// Initializes a new high-performance AcmeClient with proper HTTP configuration. + /// + /// Logger for diagnostic output and debugging + /// ACME client configuration settings + /// Pre-configured HTTP client for network operations + /// ACME service directory with endpoint URLs + /// Authenticated ACME account + /// Thrown when required parameters are null + public AcmeClient( + ILogger log, + AcmeClientConfig config, + HttpClient httpClient, + ServiceDirectory directory, + Account account) + { + _log = log ?? throw new ArgumentNullException(nameof(log)); + _config = config ?? throw new ArgumentNullException(nameof(config)); + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + Account = account ?? throw new ArgumentNullException(nameof(account)); + + ConfigureHttpClient(); + InitializeAcmeProtocolClient(directory); + } + + /// + /// Configures the HTTP client with appropriate headers and settings. + /// + private void ConfigureHttpClient() + { + if (!_httpClient.DefaultRequestHeaders.UserAgent.Any()) + { + _httpClient.DefaultRequestHeaders.UserAgent.TryParseAdd(UserAgentString); + } + } + + /// + /// Initializes the underlying ACME protocol client with proper signing configuration. + /// + private void InitializeAcmeProtocolClient(ServiceDirectory directory) + { + var signer = Account.Signer.JwsTool(); + _client = new AcmeProtocolClient(_httpClient, usePostAsGet: true, signer: signer) + { + Directory = directory, + Account = Account.Details + }; + } + + public void Dispose() + { + if (!_disposed) + { + _client?.Dispose(); + _disposed = true; + } + } + + #endregion + + #region Core HTTP Operations + + /// + /// Executes HTTP POST with automatic nonce retry logic to handle badNonce errors. + /// Implements exponential backoff and fresh nonce retrieval on each attempt. + /// + /// Target ACME endpoint URL + /// Function that creates the HTTP request with a fresh nonce + /// HTTP response from successful request + /// Thrown after max retries or non-nonce related errors + private async Task PostWithNonceRetry(string endpoint, Func> postFunc) + { + for (int attempt = 1; attempt <= MaxNonceRetries; attempt++) + { + // Always get fresh nonce before each attempt + await _client.GetNonceAsync(); + var nonce = _client.NextNonce; + + var response = await postFunc(nonce); + + if (response.IsSuccessStatusCode) + { + return response; + } + + var responseBody = await response.Content.ReadAsStringAsync(); + + // Only retry on badNonce errors, fail fast on other errors + if (!responseBody.Contains("badNonce") || attempt == MaxNonceRetries) + { + _log.LogError("ACME request failed. Status: {Status}, Body: {Body}", response.StatusCode, responseBody); + return response; + } + + _log.LogWarning("badNonce received on attempt {Attempt}/{MaxAttempts}. Retrying with fresh nonce...", + attempt, MaxNonceRetries); + + // Brief delay before retry to allow server state to settle + await Task.Delay(500); + } + + throw new InvalidOperationException("ACME request failed after maximum retries due to repeated badNonce errors."); + } + + /// + /// Creates a JWS (JSON Web Signature) request payload for ACME protocol communication. + /// Handles both account-based (kid) and key-based (jwk) authentication. + /// + /// Target endpoint URL + /// Request payload object (null for POST-as-GET) + /// Fresh nonce from ACME server + /// Configured HTTP content ready for transmission + private StringContent CreateJwsRequest(string endpoint, object payload, string nonce) + { + // Build protected header - use 'kid' for established accounts, 'jwk' for new registrations + object protectedHeader = _client.Account?.Kid != null + ? new + { + alg = _client.Signer.JwsAlg, + kid = _client.Account.Kid, + url = endpoint, + nonce = nonce + } + : new + { + jwk = _client.Signer.ExportJwk(), // Must be first property for ZeroSSL compatibility + alg = _client.Signer.JwsAlg, + url = endpoint, + nonce = nonce + }; + + // Serialize and encode components + var protectedJson = JsonConvert.SerializeObject(protectedHeader, Formatting.None); + var payloadJson = payload != null ? JsonConvert.SerializeObject(payload, Formatting.None) : ""; + + var protectedB64 = CryptoHelper.Base64.UrlEncode(Encoding.UTF8.GetBytes(protectedJson)); + var payloadB64 = CryptoHelper.Base64.UrlEncode(Encoding.UTF8.GetBytes(payloadJson)); + + // Create signature over protected header and payload + var signingInput = $"{protectedB64}.{payloadB64}"; + var signatureB64 = CryptoHelper.Base64.UrlEncode(_client.Signer.Sign(Encoding.UTF8.GetBytes(signingInput))); + + // Construct final JWS + var jws = new + { + @protected = protectedB64, + payload = payloadB64, + signature = signatureB64 + }; + + var jwsJson = JsonConvert.SerializeObject(jws); + var content = new StringContent(jwsJson, Encoding.UTF8); + content.Headers.ContentType = MediaTypeHeaderValue.Parse(JoseContentType); + + return content; + } + + #endregion + + #region Order Management + + /// + /// Creates a new certificate order with optimized request handling. + /// Supports optional certificate expiration date and robust error handling. + /// + /// Domain identifiers for the certificate + /// Optional certificate expiration date + /// Created order details with status and authorization URLs + /// Thrown on order creation failure + internal async Task CreateOrderAsync(IEnumerable identifiers, DateTime? notAfter = null) + { + var identifiersList = identifiers.ToList(); + _log.LogDebug("Creating ACME order for {Count} identifiers", identifiersList.Count); + + var identifiersPayload = identifiersList.Select(i => new { type = i.Type, value = i.Value }); + + // Build order payload with optional expiration + object payload = notAfter.HasValue + ? new { identifiers = identifiersPayload, notAfter = notAfter.Value.ToString("o") } + : new { identifiers = identifiersPayload }; + + var endpoint = _client.Directory.NewOrder; + + var response = await PostWithNonceRetry(endpoint, async nonce => + { + var content = CreateJwsRequest(endpoint, payload, nonce); + return await _httpClient.PostAsync(endpoint, content); + }); + + var responseText = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + _log.LogError("Order creation failed. Status: {Status}, Response: {Response}", + response.StatusCode, responseText); + throw new InvalidOperationException($"CreateOrder failed: {response.StatusCode} - {responseText}"); + } + + var orderPayload = JsonConvert.DeserializeObject(responseText); + var orderDetails = new OrderDetails + { + OrderUrl = response.Headers.Location?.ToString(), + Payload = orderPayload + }; + + _log.LogInformation("Order created successfully with status: {Status}", orderPayload.Status); + return orderDetails; + } + + /// + /// Finalizes a certificate order by submitting the Certificate Signing Request (CSR). + /// Automatically waits for order to be ready and polls for completion. + /// + /// Order details in "ready" status + /// DER-encoded Certificate Signing Request + /// Updated order details, typically "processing" or "valid" status + /// Thrown if order not ready or missing finalize URL + internal async Task FinalizeOrderAsync(OrderDetails orderDetails, byte[] csr) + { + // Ensure order is ready for finalization + await WaitForOrderStatusAsync(orderDetails, OrderReady); + + if (orderDetails.Payload?.Status != OrderReady) + { + _log.LogWarning("Order status is {Status}, expected {Expected}", + orderDetails.Payload?.Status, OrderReady); + return orderDetails; + } + + var finalizeUrl = orderDetails.Payload.Finalize; + if (string.IsNullOrEmpty(finalizeUrl)) + { + throw new InvalidOperationException("Missing finalize URL - order may be corrupted"); + } + + _log.LogDebug("Finalizing order with CSR submission"); + + var response = await PostWithNonceRetry(finalizeUrl, async nonce => + { + var payload = new { csr = CryptoHelper.Base64.UrlEncode(csr) }; + var content = CreateJwsRequest(finalizeUrl, payload, nonce); + return await _httpClient.PostAsync(finalizeUrl, content); + }); + + var responseJson = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + _log.LogError("Order finalization failed: {Status} {Body}", response.StatusCode, responseJson); + throw new InvalidOperationException($"FinalizeOrder failed: {response.StatusCode} - {responseJson}"); + } + + var updatedPayload = JsonConvert.DeserializeObject(responseJson); + orderDetails.Payload = updatedPayload; + + // Wait for processing to complete + await WaitForOrderStatusAsync(orderDetails, OrderProcessing, negate: true); + + _log.LogInformation("Order finalized successfully with status: {Status}", updatedPayload.Status); + return orderDetails; + } + + /// + /// Retrieves current order details from the ACME server. + /// Uses POST-as-GET for secure, authenticated requests. + /// + /// Order URL from order creation response + /// Current order details and status + /// Thrown on retrieval failure + internal async Task GetOrderDetailsAsync(string orderUrl) + { + var response = await PostWithNonceRetry(orderUrl, async nonce => + { + var content = CreateJwsRequest(orderUrl, payload: null, nonce); // POST-as-GET + return await _httpClient.PostAsync(orderUrl, content); + }); + + var responseBody = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + _log.LogError("Failed to fetch order details: {Code} - {Body}", response.StatusCode, responseBody); + throw new InvalidOperationException($"Failed to fetch order details: {response.StatusCode} - {responseBody}"); + } + + var payload = JsonConvert.DeserializeObject(responseBody); + return new OrderDetails + { + OrderUrl = orderUrl, + Payload = payload + }; + } + + #endregion + + #region Authorization and Challenge Management + + /// + /// Retrieves authorization details for domain validation challenges. + /// + /// Authorization URL from order response + /// Authorization containing available challenges + /// Thrown on retrieval failure + internal async Task GetAuthorizationAsync(string authorizationUrl) + { + var response = await PostWithNonceRetry(authorizationUrl, async nonce => + { + var content = CreateJwsRequest(authorizationUrl, payload: null, nonce); // POST-as-GET + return await _httpClient.PostAsync(authorizationUrl, content); + }); + + var responseJson = await response.Content.ReadAsStringAsync(); + if (!response.IsSuccessStatusCode) + { + _log.LogError("Authorization retrieval failed: {Code} {Text}", response.StatusCode, responseJson); + throw new InvalidOperationException($"GetAuthorizationDetails failed: {response.StatusCode} - {responseJson}"); + } + + return JsonConvert.DeserializeObject(responseJson); + } + + /// + /// Decodes challenge validation requirements for domain verification. + /// Supports DNS-01, HTTP-01, and other challenge types. + /// + /// Authorization containing the challenge + /// Specific challenge to decode + /// Validation details (DNS record, HTTP response, etc.) + /// Thrown for missing or unsupported challenge types + internal IChallengeValidationDetails DecodeChallengeValidation(Authorization authorization, Challenge challenge) + { + if (string.IsNullOrEmpty(challenge.Type)) + { + throw new NotSupportedException("Missing challenge type - cannot decode validation requirements"); + } + + _log.LogDebug("Decoding {ChallengeType} challenge validation", challenge.Type); + return AuthorizationDecoder.DecodeChallengeValidation(authorization, challenge.Type, _client.Signer); + } + + /// + /// Submits challenge response and polls for completion with optimized retry logic. + /// Implements exponential backoff and proper error handling. + /// + /// Challenge to answer + /// Final challenge status after completion or timeout + /// Thrown if challenge URL is missing + internal async Task AnswerChallengeAsync(Challenge challenge) + { + if (string.IsNullOrEmpty(challenge.Url)) + { + throw new NotSupportedException("Missing challenge URL - cannot submit response"); + } + + _log.LogDebug("Submitting challenge response to {Url}", challenge.Url); + + // Submit challenge response + var response = await PostWithNonceRetry(challenge.Url, async nonce => + { + var payload = new { }; // Empty object for challenge response + var content = CreateJwsRequest(challenge.Url, payload, nonce); + return await _httpClient.PostAsync(challenge.Url, content); + }); + + var responseJson = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + _log.LogError("AnswerChallenge failed: {Code} {Body}", response.StatusCode, responseJson); + throw new InvalidOperationException($"AnswerChallenge failed: {response.StatusCode} - {responseJson}"); + } + + challenge = JsonConvert.DeserializeObject(responseJson); + + // Poll for challenge completion + return await PollChallengeStatusAsync(challenge); + } + + /// + /// Polls challenge status until completion or timeout with exponential backoff. + /// + /// Challenge to monitor + /// Final challenge status + private async Task PollChallengeStatusAsync(Challenge challenge) + { + var attempts = 0; + + while (IsChallengePending(challenge.Status) && attempts < MaxChallengePollingRetries) + { + await Task.Delay(ChallengePollingDelayMs); + attempts++; + + _log.LogDebug("Polling challenge status (attempt {Attempts}/{MaxRetries})", + attempts, MaxChallengePollingRetries); + + try + { + challenge = await GetChallengeDetailsAsync(challenge.Url); + } + catch (Exception ex) + { + _log.LogWarning(ex, "Error polling challenge status on attempt {Attempts}", attempts); + if (attempts >= MaxChallengePollingRetries) + throw; + } + } + + if (attempts >= MaxChallengePollingRetries) + { + _log.LogWarning("Challenge polling exceeded max retries ({MaxRetries})", MaxChallengePollingRetries); + } + + _log.LogInformation("Challenge completed with status: {Status}", challenge.Status); + return challenge; + } + + /// + /// Retrieves current challenge status and details. + /// + /// Challenge URL for status checking + /// Updated challenge details + private async Task GetChallengeDetailsAsync(string challengeUrl) + { + var response = await PostWithNonceRetry(challengeUrl, async nonce => + { + var content = CreateJwsRequest(challengeUrl, payload: null, nonce); // POST-as-GET + return await _httpClient.PostAsync(challengeUrl, content); + }); + + var responseJson = await response.Content.ReadAsStringAsync(); + if (!response.IsSuccessStatusCode) + { + _log.LogError("GetChallengeDetails failed: {Status} {Body}", response.StatusCode, responseJson); + throw new InvalidOperationException($"GetChallengeDetails failed: {response.StatusCode} - {responseJson}"); + } + + return JsonConvert.DeserializeObject(responseJson); + } + + /// + /// Determines if a challenge is still pending completion. + /// + private static bool IsChallengePending(string status) => + status == AuthorizationPending || status == AuthorizationProcessing; + + #endregion + + #region Certificate Management + + /// + /// Downloads the issued certificate from the ACME server. + /// Returns the complete certificate chain in PEM format. + /// + /// Completed order with certificate URL + /// Certificate data as byte array (PEM format) + /// Thrown if certificate URL missing or download fails + internal async Task GetCertificateAsync(OrderDetails order) + { + var certificateUrl = order.Payload?.Certificate; + + if (string.IsNullOrWhiteSpace(certificateUrl)) + { + throw new InvalidOperationException("Missing certificate URL in order payload"); + } + + _log.LogDebug("Downloading certificate from {Url}", certificateUrl); + + var response = await PostWithNonceRetry(certificateUrl, async nonce => + { + var content = CreateJwsRequest(certificateUrl, payload: null, nonce); // POST-as-GET + return await _httpClient.PostAsync(certificateUrl, content); + }); + + if (!response.IsSuccessStatusCode) + { + var error = await response.Content.ReadAsStringAsync(); + _log.LogError("Failed to retrieve certificate. Status: {StatusCode}, Body: {Body}", + response.StatusCode, error); + throw new InvalidOperationException($"Failed to retrieve certificate: {response.StatusCode}"); + } + + var certificateData = await response.Content.ReadAsByteArrayAsync(); + _log.LogInformation("Certificate downloaded successfully ({Size} bytes)", certificateData.Length); + + return certificateData; + } + + /// + /// Revokes a previously issued certificate for security or operational reasons. + /// + /// DER-encoded certificate to revoke + /// Reason for revocation (default: Unspecified) + /// True if revocation successful + /// Thrown on revocation failure + internal async Task RevokeCertificateAsync(byte[] certificateBytes, RevokeReason reason = RevokeReason.Unspecified) + { + _log.LogInformation("Revoking certificate with reason: {Reason}", reason); + + try + { + return await _client.Retry( + async () => + { + await _client.RevokeCertificateAsync(certificateBytes, reason, CancellationToken.None); + return true; + }, + _log + ); + } + catch (Exception ex) + { + _log.LogError(ex, "Certificate revocation failed"); + throw new InvalidOperationException("Certificate revocation failed", ex); + } + } + + #endregion + + #region Automatic Renewal Information (ARI) Support + + /// + /// Generates ARI-compliant certificate identifier for renewal timing queries. + /// Combines Authority Key Identifier and serial number per RFC requirements. + /// + /// Certificate to generate identifier for + /// Base64url-encoded certificate identifier + internal static string GetCertificateIdentifier(ICertificateInfo certificate) + { + try + { + // Extract certificate serial number + var serialBytes = certificate.Certificate.SerialNumber.ToByteArray(); + + // Extract Authority Key Identifier from extensions + var keyAuth = AuthorityKeyIdentifier.GetInstance( + certificate.Certificate.GetExtensionValue(X509Extensions.AuthorityKeyIdentifier).GetOctets()); + var keyAuthBytes = keyAuth.GetKeyIdentifier(); + + // Encode using base64url as required by ARI specification + var serialB64 = Base64UrlEncode(serialBytes); + var keyAuthB64 = Base64UrlEncode(keyAuthBytes); + + return $"{keyAuthB64}.{serialB64}"; + } + catch (Exception ex) + { + throw new InvalidOperationException("Failed to generate certificate identifier for ARI", ex); + } + } + + /// + /// Encodes byte array using base64url encoding per ACME/ARI specifications. + /// + /// Bytes to encode + /// Base64url-encoded string + private static string Base64UrlEncode(byte[] input) => + Convert.ToBase64String(input) + .TrimEnd('=') // Remove padding + .Replace('+', '-') // Replace + with - + .Replace('/', '_'); // Replace / with _ + + #endregion + + #region Private Helper Methods + + /// + /// Waits for order to reach specified status with intelligent retry logic. + /// Supports both positive matching (wait for status) and negative matching (wait until not status). + /// + /// Order to monitor + /// Status to wait for + /// If true, wait until status is NOT the target + private async Task WaitForOrderStatusAsync(OrderDetails orderDetails, string targetStatus, bool negate = false) + { + if (string.IsNullOrEmpty(orderDetails.OrderUrl)) + { + _log.LogDebug("OrderUrl is null (possibly Buypass CA), using current status only"); + ValidateCurrentOrderStatus(orderDetails, targetStatus, negate); + return; + } + + var attempts = 0; + var operation = negate ? "NOT be" : "become"; + + do + { + if (attempts > 0) + { + if (attempts > MaxStatusPollingRetries) + { + _log.LogWarning("Maximum retries ({MaxRetries}) reached waiting for order to {Operation} {Status}", + MaxStatusPollingRetries, operation, targetStatus); + break; + } + + _log.LogDebug("Waiting for order to {Operation} {Status} (attempt {Attempts}/{MaxRetries})", + operation, targetStatus, attempts, MaxStatusPollingRetries); + + await Task.Delay(StatusPollingDelayMs); + + try + { + var updatedOrder = await GetOrderDetailsAsync(orderDetails.OrderUrl); + if (updatedOrder?.Payload != null) + { + orderDetails.Payload = updatedOrder.Payload; + _log.LogDebug("Order status updated to: {CurrentStatus}", orderDetails.Payload.Status); + } + } + catch (Exception ex) + { + _log.LogWarning(ex, "Error updating order details on attempt {Attempts}", attempts); + + // Fail fast after half the maximum attempts + if (attempts >= MaxStatusPollingRetries / 2) + throw; + } + } + + attempts++; + + // Validate order state + if (string.IsNullOrEmpty(orderDetails.Payload?.Status)) + { + _log.LogWarning("Order payload or status is null on attempt {Attempts}", attempts); + continue; + } + + // Handle terminal error states + if (orderDetails.Payload.Status == OrderInvalid) + { + _log.LogError("Order entered invalid state"); + throw new InvalidOperationException("Order validation failed. Check authorization details and try again."); + } + + _log.LogDebug("Current order status: {CurrentStatus}, target: {TargetStatus}, negate: {Negate}", + orderDetails.Payload.Status, targetStatus, negate); + + } while (ShouldContinuePolling(orderDetails.Payload.Status, targetStatus, negate)); + + _log.LogInformation("Order status polling completed. Final status: {FinalStatus}", orderDetails.Payload?.Status); + } + + /// + /// Validates current order status when polling is not possible. + /// + private void ValidateCurrentOrderStatus(OrderDetails orderDetails, string targetStatus, bool negate) + { + var currentStatus = orderDetails.Payload?.Status; + var statusMatches = currentStatus == targetStatus; + + if ((negate && !statusMatches) || (!negate && statusMatches)) + { + _log.LogDebug("Already in desired state: {CurrentStatus}", currentStatus); + return; + } + + _log.LogWarning("Cannot refresh order status and current status ({CurrentStatus}) doesn't match target ({TargetStatus})", + currentStatus, targetStatus); + } + + /// + /// Determines if status polling should continue based on current and target status. + /// + private static bool ShouldContinuePolling(string currentStatus, string targetStatus, bool negate) => + (negate && currentStatus == targetStatus) || (!negate && currentStatus != targetStatus); + + #endregion + } +} \ No newline at end of file diff --git a/AcmeCaPlugin/Clients/Acme/AcmeClientExtensions.cs b/AcmeCaPlugin/Clients/Acme/AcmeClientExtensions.cs new file mode 100644 index 0000000..e18f934 --- /dev/null +++ b/AcmeCaPlugin/Clients/Acme/AcmeClientExtensions.cs @@ -0,0 +1,182 @@ +using ACMESharp.Protocol; +using ACMESharp.Protocol.Resources; +using Microsoft.Extensions.Logging; +using System; +using System.Net; +using System.Threading; +using System.Threading.Tasks; + +namespace Keyfactor.Extensions.CAPlugin.Acme.Clients.Acme +{ + /// + /// Extension methods for AcmeProtocolClient that provide robust error handling, + /// retry logic, and rate limiting support according to ACME protocol standards. + /// + internal static class AcmeClientExtensions + { + /// + /// Semaphore to prevent simultaneous requests to the ACME service. + /// This is critical because simultaneous requests can interfere with + /// the nonce tracking mechanism required by the ACME protocol. + /// + private static readonly SemaphoreSlim _requestLock = new(1, 1); + + /// + /// Maximum number of retry attempts for bad nonce errors + /// + private const int MaxNonceRetries = 3; + + /// + /// Maximum number of backoff attempts for general ACME errors + /// + private const int MaxBackoffAttempts = 5; + + /// + /// Base delay in milliseconds for backoff retry attempts + /// + private const int BaseDelayMs = 1000; + + /// + /// Retrieves a new nonce from the ACME server for use in subsequent requests. + /// Nonces are required by the ACME protocol to prevent replay attacks. + /// + /// The ACME protocol client + /// Logger for capturing diagnostic information + /// Task that completes when the nonce is obtained + private static async Task GetNonce(this AcmeProtocolClient client, ILogger log) + { + await client.Backoff(async () => + { + await client.GetNonceAsync(); + return 1; // Return value is ignored, just needed for generic method + }, log); + } + + /// + /// Executes an ACME operation with automatic retry logic for bad nonce errors. + /// According to RFC 8555 (ACME specification), clients SHOULD retry requests + /// that fail due to invalid nonces, as nonces can become stale. + /// + /// The return type of the operation + /// The ACME protocol client + /// The operation to execute + /// Logger for capturing diagnostic information + /// Current retry attempt number (0-based) + /// The result of the executed operation + /// Thrown when ACME protocol errors occur that cannot be retried + /// Thrown when unexpected errors occur + internal static async Task Retry( + this AcmeProtocolClient client, + Func> executor, + ILogger log, + int attempt = 0) + { + // Acquire the semaphore on the first attempt to prevent concurrent requests + if (attempt == 0) + { + await _requestLock.WaitAsync(); + } + + try + { + return await client.Backoff(async () => + { + // Ensure we have a valid nonce before making the request + if (string.IsNullOrEmpty(client.NextNonce)) + { + await client.GetNonce(log); + } + + // Execute the actual operation - exceptions are intentionally not caught here + // to allow proper error handling in the outer catch blocks + return await executor(); + }, log); + } + catch (AcmeProtocolException apex) + { + // Handle bad nonce errors with retry logic (up to 3 attempts) + if (attempt < MaxNonceRetries && apex.ProblemType == ProblemType.BadNonce) + { + log.LogWarning("Bad nonce error occurred on attempt {Attempt}, retrying with fresh nonce...", attempt + 1); + await client.GetNonce(log); + return await client.Retry(executor, log, attempt + 1); + } + // Handle user action required errors (non-retryable) + else if (apex.ProblemType == ProblemType.UserActionRequired) + { + log.LogError("User action required: {Detail} (Problem Type: {ProblemType})", + apex.ProblemDetail, apex.ProblemType); + throw; + } + + // Log and re-throw all other ACME protocol exceptions + log.LogError("ACME Protocol Exception: {Message}", apex.Message); + throw; + } + catch (Exception ex) + { + log.LogError(ex, "Unexpected error occurred during ACME operation retry"); + throw; + } + finally + { + // Release the semaphore only on the initial attempt to avoid double-release + if (attempt == 0) + { + _requestLock.Release(); + } + } + } + + /// + /// Executes an ACME operation with exponential backoff retry logic for transient errors. + /// This handles temporary server issues and implements a progressive delay strategy + /// to avoid overwhelming busy servers. + /// + /// The return type of the operation + /// The ACME protocol client + /// The operation to execute + /// Logger for capturing diagnostic information + /// Current retry attempt number (0-based) + /// The result of the executed operation + /// Thrown when rate limits are hit (non-retryable) + /// Thrown when maximum retry attempts are exceeded + internal static async Task Backoff( + this AcmeProtocolClient client, + Func> executor, + ILogger log, + int attempt = 0) + { + try + { + return await executor(); + } + catch (AcmeProtocolException ape) + { + // Rate limiting errors should not be retried as they indicate + // the client has exceeded the server's request limits + if (ape.ProblemType == ProblemType.RateLimited) + { + log.LogWarning("Rate limit exceeded: {Detail}", ape.ProblemDetail); + throw; // Don't retry rate-limited requests + } + + // Stop retrying after maximum attempts to prevent infinite loops + if (attempt >= MaxBackoffAttempts) + { + log.LogError("Maximum retry attempts ({MaxAttempts}) exceeded", MaxBackoffAttempts); + throw new Exception($"ACME service is too busy after {MaxBackoffAttempts} attempts, try again later", ape); + } + + // Log the error and implement exponential backoff delay + log.LogWarning("ACME error on attempt {Attempt}, retrying in {Delay}ms: {Detail}", + attempt + 1, BaseDelayMs * (attempt + 1), ape.ProblemDetail); + + // Exponential backoff: delay increases with each attempt + await Task.Delay(BaseDelayMs * (attempt + 1)); + + return await client.Backoff(executor, log, attempt + 1); + } + } + } +} \ No newline at end of file diff --git a/AcmeCaPlugin/Clients/Acme/AcmeClientManager.cs b/AcmeCaPlugin/Clients/Acme/AcmeClientManager.cs new file mode 100644 index 0000000..5cbb1b8 --- /dev/null +++ b/AcmeCaPlugin/Clients/Acme/AcmeClientManager.cs @@ -0,0 +1,333 @@ +using ACMESharp.Crypto; +using ACMESharp.Crypto.JOSE; +using ACMESharp.Crypto.JOSE.Impl; +using ACMESharp.Protocol; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using System; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading.Tasks; + +namespace Keyfactor.Extensions.CAPlugin.Acme.Clients.Acme +{ + /// + /// Manages ACME client lifecycle including account creation, caching, and client initialization. + /// Handles both External Account Binding (EAB) and standard ACME account workflows. + /// + public class AcmeClientManager + { + #region Private Fields + + private readonly ILogger _log; + private readonly HttpClient _httpClient; + private readonly string _directoryUrl; + private readonly string _email; + private readonly string _eabKid; + private readonly string _eabHmac; + private readonly AccountManager _accountManager; + + #endregion + + #region Constants + + /// + /// User-Agent string identifying this plugin to ACME servers + /// + private const string UserAgentString = "KeyfactorAcmePlugin/1.0"; + + /// + /// HMAC algorithm used for External Account Binding + /// + private const string EabHmacAlgorithm = "HS256"; + + #endregion + + #region Constructor + + /// + /// Initializes a new instance of the AcmeClientManager with the specified configuration. + /// + /// Logger instance for diagnostic output + /// ACME client configuration containing directory URL, email, and EAB settings + /// HTTP client for making requests to the ACME server + public AcmeClientManager(ILogger log, AcmeClientConfig config, HttpClient httpClient) + { + _log = log ?? throw new ArgumentNullException(nameof(log)); + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + + if (config == null) + throw new ArgumentNullException(nameof(config)); + + _directoryUrl = config.DirectoryUrl; + _email = config.Email; + _eabKid = config.EabKid; + _eabHmac = config.EabHmacKey; + _accountManager = new AccountManager(log,config.SignerEncryptionPhrase); + + _log.LogDebug("AcmeClientManager initialized for directory: {DirectoryUrl}", _directoryUrl); + } + + #endregion + + #region Public Methods + + /// + /// Creates and configures an ACME protocol client with associated account and signer. + /// First attempts to load a cached account, and if not found, creates a new account + /// with optional External Account Binding (EAB) support. + /// + /// + /// A tuple containing: + /// - Client: Configured AcmeProtocolClient ready for use + /// - Account: Account details from the ACME server + /// - Signer: Account signer for cryptographic operations + /// + /// Thrown when account creation or client setup fails + public async Task<(AcmeProtocolClient Client, AccountDetails Account, AccountSigner Signer)> CreateClientAsync() + { + // Configure HTTP client with base address and user-agent + await ConfigureHttpClientAsync(); + + // Attempt to load existing cached account first + var cachedAccount = await TryLoadCachedAccountAsync(); + if (cachedAccount.HasValue) + { + _log.LogInformation("Using cached ACME account for directory: {DirectoryUrl}", _directoryUrl); + return cachedAccount.Value; + } + + // No cached account found - create new account + _log.LogInformation("No cached account found, creating new ACME account"); + return await CreateNewAccountAsync(); + } + + #endregion + + #region Private Methods + + /// + /// Configures the HTTP client with the ACME directory URL and user-agent header. + /// + private async Task ConfigureHttpClientAsync() + { + _httpClient.BaseAddress = new Uri(_directoryUrl); + + // Set user-agent header if not already present + if (!_httpClient.DefaultRequestHeaders.UserAgent.Any()) + { + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(UserAgentString); + _log.LogDebug("User-Agent header set to: {UserAgent}", UserAgentString); + } + } + + /// + /// Manually creates an ACME account by constructing the JWS with correct field ordering. + /// This ensures compatibility with ZeroSSL's strict field ordering requirements. + /// + /// The ACME protocol client + /// The account signer + /// Contact information for the account + /// External Account Binding object (null if not using EAB) + /// The created account details + private async Task CreateAccountManuallyAsync( + AcmeProtocolClient client, + IJwsTool signer, + string[] contacts, + object eab) + { + // Get a fresh nonce + await client.GetNonceAsync(); + + // Create the payload + var payload = new + { + contact = contacts, + termsOfServiceAgreed = true, + externalAccountBinding = eab + }; + + // Create protected header with CORRECT field ordering for ZeroSSL + var protectedHeader = new + { + jwk = signer.ExportJwk(), // JWK MUST come first for ZeroSSL + alg = signer.JwsAlg, // Algorithm second + url = client.Directory.NewAccount, // URL third + nonce = client.NextNonce // Nonce last + }; + + // Serialize payload and protected header + var payloadJson = JsonConvert.SerializeObject(payload, + Formatting.None, + new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }); + + var protectedJson = JsonConvert.SerializeObject(protectedHeader, Formatting.None); + + // Base64url encode + var protectedB64 = CryptoHelper.Base64.UrlEncode(Encoding.UTF8.GetBytes(protectedJson)); + var payloadB64 = CryptoHelper.Base64.UrlEncode(Encoding.UTF8.GetBytes(payloadJson)); + + // Create signing input and sign + var signingInput = $"{protectedB64}.{payloadB64}"; + var signature = signer.Sign(Encoding.UTF8.GetBytes(signingInput)); + var signatureB64 = CryptoHelper.Base64.UrlEncode(signature); + + // Create JWS object + var jws = new + { + @protected = protectedB64, + payload = payloadB64, + signature = signatureB64 + }; + + var jwsJson = JsonConvert.SerializeObject(jws); + var requestContent = new StringContent(jwsJson, Encoding.UTF8); + + // Explicitly set content type + requestContent.Headers.ContentType = MediaTypeHeaderValue.Parse("application/jose+json"); + + var response = await _httpClient.PostAsync(client.Directory.NewAccount, requestContent); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + _log.LogError("Account creation failed. Status: {StatusCode}, Response: {Response}", + response.StatusCode, errorContent); + throw new Exception($"Account creation failed: {response.StatusCode} - {errorContent}"); + } + + // Parse the response + var responseContent = await response.Content.ReadAsStringAsync(); + var accountDetails = JsonConvert.DeserializeObject(responseContent); + + // Set the account location from the Location header + if (response.Headers.Location != null) + { + accountDetails.Kid = response.Headers.Location.ToString(); + } + + // Note: NextNonce is read-only, the client will automatically get a new nonce on the next request + + return accountDetails; + } + + /// + /// Attempts to load a cached account and create a client from it. + /// + /// + /// A tuple with client, account, and signer if cached account exists and is valid; + /// otherwise null. + /// + private async Task<(AcmeProtocolClient Client, AccountDetails Account, AccountSigner Signer)?> TryLoadCachedAccountAsync() + { + var cachedAccount = _accountManager.LoadDefaultAccount(_directoryUrl); + + if (cachedAccount?.Signer == null) + { + _log.LogDebug("No valid cached account found for directory: {DirectoryUrl}", _directoryUrl); + return null; + } + + try + { + // Create client using cached account's signer + var signerTool = cachedAccount.Signer.GetJwsTool(); + var client = new AcmeProtocolClient(_httpClient, usePostAsGet: true, signer: signerTool); + + // Initialize client with directory and nonce + client.Directory = await client.GetDirectoryAsync(); + await client.GetNonceAsync(); + client.Account = cachedAccount.Details; + + _log.LogDebug("Successfully loaded cached account with key ID: {AccountId}", + cachedAccount.Details?.Kid); + + return (client, cachedAccount.Details, cachedAccount.Signer); + } + catch (Exception ex) + { + _log.LogWarning(ex, "Failed to initialize client with cached account, will create new account"); + return null; + } + } + + /// + /// Creates a new ACME account with optional External Account Binding (EAB) support. + /// + /// + /// A tuple containing the new client, account details, and signer. + /// + private async Task<(AcmeProtocolClient Client, AccountDetails Account, AccountSigner Signer)> CreateNewAccountAsync() + { + // Create temporary signer for account creation + var tempSigner = new ESJwsTool(); + tempSigner.Init(); + _log.LogDebug("Created temporary ES256 signer for account creation"); + + // Create setup client for account creation + var setupClient = new AcmeProtocolClient(_httpClient, usePostAsGet: true, signer: tempSigner); + setupClient.Directory = await setupClient.GetDirectoryAsync(); + await setupClient.GetNonceAsync(); + + var contacts = new[] { $"mailto:{_email}" }; + object eab = null; + + if (IsEabConfigured()) + { + eab = ExternalAccountBindingHelper.CreateExternalAccountBinding( + setupClient, tempSigner, _eabKid, _eabHmac, EabHmacAlgorithm); + } + + // Create account with or without EAB + //AccountDetails account = await CreateAccountWithEabSupportAsync(setupClient, tempSigner); + AccountDetails account = await CreateAccountManuallyAsync(setupClient, tempSigner,contacts,eab); + + // Cache the new account for future use + var newSigner = new AccountSigner(tempSigner); + var newAccount = new Account(account, newSigner); + _accountManager.StoreAccount(newAccount, _directoryUrl); + _log.LogInformation("New ACME account created and cached with key ID: {AccountId}", account.Kid); + + // Create final client with the new account + return await CreateFinalClientAsync(setupClient, newSigner, account); + } + + + /// + /// Creates the final ACME client using the newly created account. + /// + /// The setup client containing the directory information + /// The account signer + /// The account details + /// A tuple with the final client, account, and signer + private async Task<(AcmeProtocolClient Client, AccountDetails Account, AccountSigner Signer)> CreateFinalClientAsync( + AcmeProtocolClient setupClient, AccountSigner signer, AccountDetails account) + { + var finalSignerTool = signer.GetJwsTool(); + var finalClient = new AcmeProtocolClient(_httpClient, usePostAsGet: true, signer: finalSignerTool); + + // Reuse directory from setup client to avoid additional roundtrip + finalClient.Directory = setupClient.Directory; + await finalClient.GetNonceAsync(); + finalClient.Account = account; + + _log.LogDebug("Final ACME client created and configured"); + + return (finalClient, account, signer); + } + + /// + /// Determines if External Account Binding (EAB) is configured by checking + /// if both the key identifier and HMAC key are provided. + /// + /// True if EAB is configured, false otherwise + private bool IsEabConfigured() + { + return !string.IsNullOrWhiteSpace(_eabKid) && !string.IsNullOrWhiteSpace(_eabHmac); + } + + #endregion + } +} \ No newline at end of file diff --git a/AcmeCaPlugin/Clients/Acme/ExternalAccountBinding.cs b/AcmeCaPlugin/Clients/Acme/ExternalAccountBinding.cs new file mode 100644 index 0000000..c1d1d74 --- /dev/null +++ b/AcmeCaPlugin/Clients/Acme/ExternalAccountBinding.cs @@ -0,0 +1,198 @@ +using ACMESharp.Crypto; +using ACMESharp.Crypto.JOSE; +using ACMESharp.Protocol; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; + +namespace Keyfactor.Extensions.CAPlugin.Acme.Clients.Acme +{ + /// + /// Helper class for creating External Account Binding (EAB) objects as specified in RFC 8555 Section 7.3.4. + /// EAB is used by Certificate Authorities to associate ACME accounts with pre-existing customer accounts + /// or to implement additional authorization controls. + /// + public static class ExternalAccountBindingHelper + { + #region Constants + + /// + /// Supported HMAC algorithm identifiers for EAB signatures + /// + private static readonly HashSet SupportedAlgorithms = new HashSet + { + "HS256", "HS384", "HS512" + }; + + #endregion + + #region Public Methods + + /// + /// Creates an External Account Binding (EAB) JWS object using manual JWS construction. + /// This method implements RFC 8555 Section 7.3.4 directly without relying on ACMESharp's JwsHelper. + /// This is the preferred method as it provides more reliable results. + /// + /// The ACME protocol client containing directory information + /// The account key signer whose public key will be bound to the external account + /// The key identifier provided by the Certificate Authority for EAB + /// The base64url-encoded HMAC key provided by the Certificate Authority + /// The HMAC algorithm to use (HS256, HS384, or HS512) + /// A JWS object representing the External Account Binding + /// Thrown when an unsupported algorithm is specified + /// Thrown when required parameters are null + public static object CreateExternalAccountBinding( + AcmeProtocolClient acmeProtocolClient, + IJwsTool signer, + string keyId, + string hmacKey, + string algorithm) + { + ValidateEabParameters(acmeProtocolClient, signer, keyId, hmacKey, algorithm); + + // Step 1: Create the EAB payload containing the account's public key + // The payload is the account key JWK serialized as JSON + var accountKey = signer.ExportJwk(); + var eabPayload = JsonConvert.SerializeObject(accountKey, + Formatting.None, + new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }); + + // Step 2: Create the EAB protected header + // This contains algorithm, key ID, and the target URL + var eabProtected = JsonConvert.SerializeObject(new + { + alg = algorithm, + kid = keyId, + url = acmeProtocolClient.Directory.NewAccount + }, Formatting.None); + + // Step 3: Base64url encode the protected header and payload + // This follows the JWS specification (RFC 7515) + var protectedEncoded = CryptoHelper.Base64.UrlEncode(Encoding.UTF8.GetBytes(eabProtected)); + var payloadEncoded = CryptoHelper.Base64.UrlEncode(Encoding.UTF8.GetBytes(eabPayload)); + + // Step 4: Create the signing input and compute HMAC signature + // Signing input format: base64url(protected) + "." + base64url(payload) + var signingInput = $"{protectedEncoded}.{payloadEncoded}"; + var signature = ComputeHmacSignature(Encoding.UTF8.GetBytes(signingInput), hmacKey, algorithm); + var signatureEncoded = CryptoHelper.Base64.UrlEncode(signature); + + // Step 5: Return the complete EAB JWS in Flattened JSON Serialization format + // Note: Using anonymous object with @protected to handle the reserved keyword + return new + { + @protected = protectedEncoded, + payload = payloadEncoded, + signature = signatureEncoded + }; + } + + #endregion + + #region Private Methods + + /// + /// Validates all required parameters for EAB creation. + /// + /// The ACME protocol client + /// The account signer + /// The EAB key identifier + /// The EAB HMAC key + /// The HMAC algorithm + /// Thrown when any required parameter is null or empty + /// Thrown when an unsupported algorithm is specified + private static void ValidateEabParameters( + AcmeProtocolClient acmeProtocolClient, + IJwsTool signer, + string keyId, + string hmacKey, + string algorithm) + { + if (acmeProtocolClient == null) + throw new ArgumentNullException(nameof(acmeProtocolClient)); + + if (signer == null) + throw new ArgumentNullException(nameof(signer)); + + if (string.IsNullOrWhiteSpace(keyId)) + throw new ArgumentNullException(nameof(keyId)); + + if (string.IsNullOrWhiteSpace(hmacKey)) + throw new ArgumentNullException(nameof(hmacKey)); + + if (string.IsNullOrWhiteSpace(algorithm)) + throw new ArgumentNullException(nameof(algorithm)); + + if (!SupportedAlgorithms.Contains(algorithm)) + throw new NotSupportedException($"Algorithm '{algorithm}' is not supported. Supported algorithms: {string.Join(", ", SupportedAlgorithms)}"); + + if (acmeProtocolClient.Directory?.NewAccount == null) + throw new ArgumentException("ACME client directory must be initialized with NewAccount URL", nameof(acmeProtocolClient)); + } + + /// + /// Computes HMAC signature for the given data using the specified algorithm and key. + /// + /// The data to sign + /// The base64url-encoded HMAC key + /// The HMAC algorithm (HS256, HS384, or HS512) + /// The computed HMAC signature as byte array + /// Thrown when an unsupported algorithm is specified + private static byte[] ComputeHmacSignature(byte[] data, string hmacKey, string algorithm) + { + var keyBytes = CryptoHelper.Base64.UrlDecode(hmacKey); + + // Create appropriate HMAC algorithm instance based on the algorithm parameter + HMAC hmacAlgorithm = algorithm switch + { + "HS256" => new HMACSHA256(keyBytes), + "HS384" => new HMACSHA384(keyBytes), + "HS512" => new HMACSHA512(keyBytes), + _ => throw new NotSupportedException($"HMAC algorithm '{algorithm}' is not supported") + }; + + using (hmacAlgorithm) + { + return hmacAlgorithm.ComputeHash(data); + } + } + + /// + /// Creates a JWS object manually when ACMESharp's JwsHelper fails. + /// This provides a fallback mechanism for EAB creation. + /// + /// The JSON payload to be signed + /// The protected headers object + /// Function to compute the signature + /// A dictionary representing the JWS in Flattened JSON Serialization format + private static Dictionary CreateManualJws( + string payload, + object protectedHeaders, + Func signFunc) + { + // Serialize and encode the protected headers + var protectedJson = JsonConvert.SerializeObject(protectedHeaders, Formatting.None); + var protectedB64 = CryptoHelper.Base64.UrlEncode(Encoding.UTF8.GetBytes(protectedJson)); + + // Encode the payload + var payloadB64 = CryptoHelper.Base64.UrlEncode(Encoding.UTF8.GetBytes(payload)); + + // Create signing input and compute signature + var signingInput = $"{protectedB64}.{payloadB64}"; + var signature = signFunc(Encoding.UTF8.GetBytes(signingInput)); + var signatureB64 = CryptoHelper.Base64.UrlEncode(signature); + + // Return JWS in Flattened JSON Serialization format + return new Dictionary + { + { "protected", protectedB64 }, + { "payload", payloadB64 }, + { "signature", signatureB64 } + }; + } + + #endregion + } +} \ No newline at end of file diff --git a/AcmeCaPlugin/Clients/DNS/AwsRoute53DnsProvider.cs b/AcmeCaPlugin/Clients/DNS/AwsRoute53DnsProvider.cs new file mode 100644 index 0000000..90ab845 --- /dev/null +++ b/AcmeCaPlugin/Clients/DNS/AwsRoute53DnsProvider.cs @@ -0,0 +1,171 @@ +using Amazon; +using Amazon.Route53; +using Amazon.Route53.Model; +using Amazon.Runtime; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +/// +/// AWS Route 53 DNS provider implementation for ACME DNS-01 challenges. +/// This class handles creating and deleting TXT records for domain validation. +/// Supports explicit access key or automatic EC2 instance role credentials. +/// +public class AwsRoute53DnsProvider : IDnsProvider +{ + private readonly IAmazonRoute53 _route53Client; + + /// + /// Initializes the Route 53 provider. + /// If access key & secret key are provided, they are used. + /// Otherwise, it uses the default AWS credentials chain (e.g., EC2 instance profile). + /// + /// AWS Access Key ID (optional) + /// AWS Secret Access Key (optional) + /// Region endpoint (optional, Route 53 is global so usually us-east-1 works) + public AwsRoute53DnsProvider(string? awsAccessKeyId = null, string? awsSecretAccessKey = null, RegionEndpoint? region = null) + { + if (!string.IsNullOrEmpty(awsAccessKeyId) && !string.IsNullOrEmpty(awsSecretAccessKey)) + { + Console.WriteLine("Using explicit AWS credentials."); + var creds = new BasicAWSCredentials(awsAccessKeyId, awsSecretAccessKey); + _route53Client = new AmazonRoute53Client(creds, region ?? RegionEndpoint.USEast1); + } + else + { + Console.WriteLine("Using default AWS credential chain (instance role, environment, or config)."); + _route53Client = new AmazonRoute53Client(region ?? RegionEndpoint.USEast1); + } + } + + /// + /// Creates or updates a TXT record. + /// + public async Task CreateRecordAsync(string recordName, string txtValue) + => await UpsertRecordAsync(recordName, txtValue); + + /// + /// Creates or updates a TXT record in Route 53. + /// + public async Task UpsertRecordAsync(string recordName, string txtValue) + { + try + { + var zone = await FindHostedZoneAsync(recordName); + if (zone == null) + { + Console.WriteLine($"No hosted zone found for {recordName}"); + return false; + } + + var request = new ChangeResourceRecordSetsRequest + { + HostedZoneId = zone.Id, + ChangeBatch = new ChangeBatch + { + Changes = new List + { + new Change + { + Action = ChangeAction.UPSERT, + ResourceRecordSet = new ResourceRecordSet + { + Name = EnsureTrailingDot(recordName), + Type = RRType.TXT, + TTL = 60, + ResourceRecords = new List + { + new ResourceRecord { Value = $"\"{txtValue}\"" } + } + } + } + } + } + }; + + var response = await _route53Client.ChangeResourceRecordSetsAsync(request); + Console.WriteLine($"[UPsert] TXT record for {recordName} requested. Status: {response.HttpStatusCode}"); + return response.HttpStatusCode == System.Net.HttpStatusCode.OK; + } + catch (Exception ex) + { + Console.WriteLine($"[Error] Upserting TXT record: {ex}"); + return false; + } + } + + /// + /// Deletes a TXT record in Route 53. + /// + public async Task DeleteRecordAsync(string recordName) + { + try + { + var zone = await FindHostedZoneAsync(recordName); + if (zone == null) + { + Console.WriteLine($"No hosted zone found for {recordName}"); + return false; + } + + var listResponse = await _route53Client.ListResourceRecordSetsAsync(new ListResourceRecordSetsRequest + { + HostedZoneId = zone.Id, + StartRecordName = EnsureTrailingDot(recordName), + StartRecordType = RRType.TXT + }); + + var existing = listResponse.ResourceRecordSets.FirstOrDefault(r => + r.Name.TrimEnd('.') == recordName.TrimEnd('.') && r.Type == RRType.TXT); + + if (existing == null) + { + Console.WriteLine($"No existing TXT record found for {recordName}"); + return false; + } + + var deleteRequest = new ChangeResourceRecordSetsRequest + { + HostedZoneId = zone.Id, + ChangeBatch = new ChangeBatch + { + Changes = new List + { + new Change + { + Action = ChangeAction.DELETE, + ResourceRecordSet = existing + } + } + } + }; + + var deleteResponse = await _route53Client.ChangeResourceRecordSetsAsync(deleteRequest); + Console.WriteLine($"[Delete] TXT record for {recordName} requested. Status: {deleteResponse.HttpStatusCode}"); + return deleteResponse.HttpStatusCode == System.Net.HttpStatusCode.OK; + } + catch (Exception ex) + { + Console.WriteLine($"[Error] Deleting TXT record: {ex}"); + return false; + } + } + + /// + /// Finds the most specific hosted zone matching the given record name. + /// + private async Task FindHostedZoneAsync(string recordName) + { + var response = await _route53Client.ListHostedZonesAsync(); + var zones = response.HostedZones; + + return zones + .Where(z => recordName.EndsWith(z.Name.TrimEnd('.'), StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(z => z.Name.Length) + .FirstOrDefault(); + } + + private static string EnsureTrailingDot(string name) + => name.EndsWith(".") ? name : name + "."; +} diff --git a/AcmeCaPlugin/Clients/DNS/AzureDnsProvider.cs b/AcmeCaPlugin/Clients/DNS/AzureDnsProvider.cs new file mode 100644 index 0000000..e5fd841 --- /dev/null +++ b/AcmeCaPlugin/Clients/DNS/AzureDnsProvider.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Azure.Core; +using Azure.Identity; +using Azure.ResourceManager; +using Azure.ResourceManager.Dns; +using Azure.ResourceManager.Dns.Models; +using Azure.ResourceManager.Resources; + +/// +/// Azure DNS provider for ACME DNS-01 challenges. +/// Supports both Managed Identity and ClientSecret auth. +/// +public class AzureDnsProvider : IDnsProvider +{ + private readonly ArmClient _armClient; + private readonly SubscriptionResource _subscription; + + /// + /// Constructor that supports either explicit credentials or default credentials. + /// If tenantId, clientId, clientSecret are provided, uses them. + /// If not, uses DefaultAzureCredential (Managed Identity, env vars, VS sign-in, etc.) + /// + public AzureDnsProvider(string? tenantId, string? clientId, string? clientSecret, string subscriptionId) + { + TokenCredential credential; + + if (!string.IsNullOrWhiteSpace(tenantId) && + !string.IsNullOrWhiteSpace(clientId) && + !string.IsNullOrWhiteSpace(clientSecret)) + { + Console.WriteLine("✅ Using explicit ClientSecretCredential."); + credential = new ClientSecretCredential(tenantId, clientId, clientSecret); + } + else + { + Console.WriteLine("✅ Using DefaultAzureCredential (Managed Identity, environment, VS sign-in, etc.)."); + credential = new DefaultAzureCredential(); + } + + _armClient = new ArmClient(credential, subscriptionId); + _subscription = _armClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{subscriptionId}")); + } + + /// + /// Creates or overwrites the TXT record with exactly one value. + /// + public async Task CreateRecordAsync(string recordName, string txtValue) + { + try + { + var zone = await GetDnsZoneAsync(recordName); + if (zone == null) + { + Console.WriteLine($"Zone not found for {recordName}"); + return false; + } + + var relativeName = GetRelativeRecordName(zone.Data.Name, recordName); + var txtRecords = zone.GetDnsTxtRecords(); + + DnsTxtRecordResource? existingResource = null; + try + { + var response = await txtRecords.GetAsync(relativeName); + existingResource = response.Value; + } + catch + { + // Not found — OK. + } + + var newData = new DnsTxtRecordData + { + TtlInSeconds = 60, + DnsTxtRecords = { new DnsTxtRecordInfo { Values = { txtValue } } } + }; + + await txtRecords.CreateOrUpdateAsync(Azure.WaitUntil.Completed, relativeName, newData); + + Console.WriteLine($"✅ TXT record upserted: {relativeName}.{zone.Data.Name} → \"{txtValue}\""); + return true; + } + catch (Exception ex) + { + Console.WriteLine($"❌ Azure CreateRecordAsync exception: {ex}"); + return false; + } + } + + /// + /// Deletes the specific TXT value or the whole record if empty. + /// + public async Task DeleteRecordAsync(string recordName, string txtValue) + { + try + { + var zone = await GetDnsZoneAsync(recordName); + if (zone == null) + { + Console.WriteLine($"Zone not found for {recordName}"); + return false; + } + + var relativeName = GetRelativeRecordName(zone.Data.Name, recordName); + var txtRecords = zone.GetDnsTxtRecords(); + + DnsTxtRecordResource txtResource; + try + { + var response = await txtRecords.GetAsync(relativeName); + txtResource = response.Value; + } + catch + { + Console.WriteLine($"TXT record not found for deletion: {relativeName}"); + return false; + } + + var data = txtResource.Data; + var toRemove = data.DnsTxtRecords.Where(r => r.Values.Contains(txtValue)).ToList(); + foreach (var r in toRemove) + data.DnsTxtRecords.Remove(r); + + if (data.DnsTxtRecords.Count == 0) + { + await txtResource.DeleteAsync(Azure.WaitUntil.Completed); + Console.WriteLine($"✅ Deleted empty TXT record: {relativeName}.{zone.Data.Name}"); + } + else + { + await txtRecords.CreateOrUpdateAsync(Azure.WaitUntil.Completed, relativeName, data); + Console.WriteLine($"✅ Removed value and updated TXT record: {relativeName}.{zone.Data.Name}"); + } + + return true; + } + catch (Exception ex) + { + Console.WriteLine($"❌ Azure DeleteRecordAsync exception: {ex}"); + return false; + } + } + + public Task DeleteRecordAsync(string recordName) + { + throw new NotImplementedException(); + } + + /// + /// Finds the most specific DNS zone by suffix. + /// + private async Task GetDnsZoneAsync(string fqdn) + { + var zones = _subscription.GetDnsZonesAsync(); + var allZones = new List(); + await foreach (var z in zones) + { + allZones.Add(z); + } + + return allZones + .OrderByDescending(z => z.Data.Name.Length) + .FirstOrDefault(z => fqdn.EndsWith(z.Data.Name, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Returns the relative record name inside the zone. + /// + private string GetRelativeRecordName(string zoneName, string fqdn) + { + if (fqdn.EndsWith("." + zoneName, StringComparison.OrdinalIgnoreCase)) + { + return fqdn.Substring(0, fqdn.Length - zoneName.Length - 1); + } + return fqdn; + } +} diff --git a/AcmeCaPlugin/Clients/DNS/CloudflareDnsProvider.cs b/AcmeCaPlugin/Clients/DNS/CloudflareDnsProvider.cs new file mode 100644 index 0000000..6e20dd1 --- /dev/null +++ b/AcmeCaPlugin/Clients/DNS/CloudflareDnsProvider.cs @@ -0,0 +1,141 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +public class CloudflareDnsProvider : IDnsProvider +{ + private readonly string _apiToken; + private readonly HttpClient _httpClient; + private readonly JsonSerializerOptions _jsonOptions; + + public CloudflareDnsProvider(string apiToken) + { + _apiToken = apiToken ?? throw new ArgumentNullException(nameof(apiToken)); + + _httpClient = new HttpClient + { + BaseAddress = new Uri("https://api.cloudflare.com/client/v4/") + }; + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _apiToken); + + _jsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + } + + public async Task CreateRecordAsync(string recordName, string txtValue) + { + // 1) Determine apex zone + var zoneName = ExtractZoneFromRecord(recordName); + var zoneId = await GetZoneIdAsync(zoneName); + if (zoneId == null) return false; + + // 2) Get the relative record name for Cloudflare + var relativeName = GetRelativeRecordName(recordName, zoneName); + + var payload = new + { + type = "TXT", + name = relativeName, + content = txtValue, + ttl = 1 + }; + + // Manual JSON serialization instead of PostAsJsonAsync + var json = JsonSerializer.Serialize(payload, _jsonOptions); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync($"zones/{zoneId}/dns_records", content); + var result = await response.Content.ReadAsStringAsync(); + + Console.WriteLine($"Create TXT: {response.StatusCode} - {result}"); + return response.IsSuccessStatusCode; + } + + public async Task DeleteRecordAsync(string recordName) + { + // 1) Determine apex zone + var zoneName = ExtractZoneFromRecord(recordName); + var zoneId = await GetZoneIdAsync(zoneName); + if (zoneId == null) return false; + + // 2) Get the relative record name for Cloudflare + var relativeName = GetRelativeRecordName(recordName, zoneName); + + var recordsResp = await _httpClient.GetAsync($"zones/{zoneId}/dns_records?type=TXT&name={relativeName}"); + if (!recordsResp.IsSuccessStatusCode) return false; + + var json = await recordsResp.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(json); + + var recordId = doc.RootElement.GetProperty("result").EnumerateArray() + .FirstOrDefault().GetProperty("id").GetString(); + + if (recordId == null) return false; + + var deleteResp = await _httpClient.DeleteAsync($"zones/{zoneId}/dns_records/{recordId}"); + var result = await deleteResp.Content.ReadAsStringAsync(); + + Console.WriteLine($"Delete TXT: {deleteResp.StatusCode} - {result}"); + return deleteResp.IsSuccessStatusCode; + } + + private async Task GetZoneIdAsync(string zoneName) + { + var response = await _httpClient.GetAsync($"zones?name={zoneName}"); + if (!response.IsSuccessStatusCode) return null; + + var json = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(json); + return doc.RootElement.GetProperty("result").EnumerateArray() + .FirstOrDefault().GetProperty("id").GetString(); + } + + private string ExtractZoneFromRecord(string recordName) + { + if (string.IsNullOrWhiteSpace(recordName)) + return string.Empty; + + var parts = recordName.TrimEnd('.').Split('.'); + if (parts.Length < 2) + return recordName; + + // Use last two labels as default zone: e.g., "keyfactoracme.com" + return string.Join(".", parts.Skip(parts.Length - 2)); + } + + private string GetRelativeRecordName(string recordName, string zoneName) + { + var cleanName = recordName.TrimEnd('.'); + var cleanZone = zoneName.TrimEnd('.'); + + // The recordName should be something like "_acme-challenge.www.keyfactorcloudflareacme.com" + // We need to return the name relative to the zone + + // If the record name ends with the zone name, remove the zone suffix + if (cleanName.EndsWith("." + cleanZone, StringComparison.OrdinalIgnoreCase)) + { + // Remove the zone suffix, keeping the subdomain part + var relativePart = cleanName.Substring(0, cleanName.Length - cleanZone.Length - 1); + return relativePart; + } + else if (cleanName.Equals(cleanZone, StringComparison.OrdinalIgnoreCase)) + { + // If the record name is exactly the zone name, it's the root + return "@"; + } + + // If we can't determine the relative name, return as-is + return cleanName; + } + + public void Dispose() + { + _httpClient?.Dispose(); + } +} \ No newline at end of file diff --git a/AcmeCaPlugin/Clients/DNS/DnsProviderFactory.cs b/AcmeCaPlugin/Clients/DNS/DnsProviderFactory.cs new file mode 100644 index 0000000..011a528 --- /dev/null +++ b/AcmeCaPlugin/Clients/DNS/DnsProviderFactory.cs @@ -0,0 +1,47 @@ +using Microsoft.Extensions.Logging; +using System; + +namespace Keyfactor.Extensions.CAPlugin.Acme +{ + public static class DnsProviderFactory + { + public static IDnsProvider Create(AcmeClientConfig config, ILogger logger) + { + if (config == null || string.IsNullOrWhiteSpace(config.DnsProvider)) + throw new ArgumentException("DNS provider type is missing in config."); + + switch (config.DnsProvider.Trim().ToLowerInvariant()) + { + case "google": + return new GoogleDnsProvider( + config.Google_ServiceAccountKeyPath, + config.Google_ProjectId + ); + + case "cloudflare": + return new CloudflareDnsProvider( + config.Cloudflare_ApiToken + ); + + case "azure": + return new AzureDnsProvider( + config.Azure_TenantId, + config.Azure_ClientId, + config.Azure_ClientSecret, + config.Azure_SubscriptionId + ); + case "awsroute53": + return new AwsRoute53DnsProvider( + config.AwsRoute53_AccessKey, + config.AwsRoute53_SecretKey + ); + case "ns1": + return new Ns1DnsProvider( + config.Ns1_ApiKey + ); + default: + throw new NotSupportedException($"DNS provider '{config.DnsProvider}' is not supported."); + } + } + } +} diff --git a/AcmeCaPlugin/Clients/DNS/DnsVerificationHelper.cs b/AcmeCaPlugin/Clients/DNS/DnsVerificationHelper.cs new file mode 100644 index 0000000..14329e1 --- /dev/null +++ b/AcmeCaPlugin/Clients/DNS/DnsVerificationHelper.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using DnsClient; + +namespace Keyfactor.Extensions.CAPlugin.Acme.Clients.DNS +{ + /// + /// Verifies DNS record propagation before submitting ACME challenges + /// + public class DnsVerificationHelper + { + private readonly ILogger _logger; + private readonly List _dnsServers; + private const int MaxVerificationAttempts = 3; + private const int VerificationDelaySeconds = 10; + + public DnsVerificationHelper(ILogger logger) + { + _logger = logger; + + // Use multiple public DNS servers for verification + _dnsServers = new List + { + IPAddress.Parse("8.8.8.8"), // Google Primary + IPAddress.Parse("8.8.4.4"), // Google Secondary + IPAddress.Parse("1.1.1.1"), // Cloudflare Primary + IPAddress.Parse("1.0.0.1"), // Cloudflare Secondary + IPAddress.Parse("208.67.222.222"), // OpenDNS + IPAddress.Parse("9.9.9.9") // Quad9 + }; + } + + /// + /// Waits for DNS TXT record to propagate across multiple DNS servers + /// + /// DNS record name (e.g., _acme-challenge.example.com) + /// Expected TXT record value + /// Minimum number of DNS servers that must see the record + /// True if record propagated successfully + public async Task WaitForDnsPropagationAsync( + string recordName, + string expectedValue, + int minimumServers = 3) + { + _logger.LogInformation("Waiting for DNS propagation of {RecordName}", recordName); + + for (int attempt = 1; attempt <= MaxVerificationAttempts; attempt++) + { + var successCount = 0; + var results = new List(); + + foreach (var dnsServer in _dnsServers) + { + try + { + var hasRecord = await CheckDnsRecordAsync(recordName, expectedValue, dnsServer); + if (hasRecord) + { + successCount++; + results.Add($"✓ {dnsServer}"); + } + else + { + results.Add($"✗ {dnsServer}"); + } + } + catch (Exception ex) + { + _logger.LogWarning("DNS query failed for server {Server}: {Error}", + dnsServer, ex.Message); + results.Add($"? {dnsServer} (error)"); + } + } + + _logger.LogDebug("DNS verification attempt {Attempt}/{MaxAttempts}: {SuccessCount}/{TotalServers} servers confirmed record. Results: {Results}", + attempt, MaxVerificationAttempts, successCount, _dnsServers.Count, string.Join(", ", results)); + + if (successCount >= minimumServers) + { + _logger.LogInformation("DNS record propagated successfully! {SuccessCount}/{TotalServers} servers confirmed record after {Attempt} attempts", + successCount, _dnsServers.Count, attempt); + return true; + } + + if (attempt < MaxVerificationAttempts) + { + _logger.LogDebug("Waiting {Delay} seconds before next DNS verification attempt...", VerificationDelaySeconds); + await Task.Delay(TimeSpan.FromSeconds(VerificationDelaySeconds)); + } + } + + _logger.LogWarning("DNS record did not propagate within {MaxAttempts} attempts ({TotalMinutes} minutes)", + MaxVerificationAttempts, MaxVerificationAttempts * VerificationDelaySeconds / 60); + return false; + } + + /// + /// Checks if a specific DNS server has the expected TXT record + /// + private async Task CheckDnsRecordAsync(string recordName, string expectedValue, IPAddress dnsServer) + { + var client = new LookupClient(dnsServer); + + try + { + var result = await client.QueryAsync(recordName, QueryType.TXT); + + if (result.Answers?.Any() != true) + { + return false; + } + + var txtRecords = result.Answers + .OfType() + .SelectMany(r => r.Text) + .ToList(); + + var hasExpectedValue = txtRecords.Any(txt => + string.Equals(txt, expectedValue, StringComparison.OrdinalIgnoreCase)); + + _logger.LogTrace("DNS server {Server} returned {Count} TXT records for {RecordName}. Expected: {Expected}, Found: {HasExpected}", + dnsServer, txtRecords.Count, recordName, expectedValue, hasExpectedValue); + + return hasExpectedValue; + } + catch (Exception ex) + { + _logger.LogTrace("DNS query to {Server} failed: {Error}", dnsServer, ex.Message); + throw; + } + } + + /// + /// Gets the authoritative DNS servers for a domain + /// + public async Task> GetAuthoritativeDnsServersAsync(string domain) + { + var authServers = new List(); + + try + { + var client = new LookupClient(); + var result = await client.QueryAsync(domain, QueryType.NS); + + foreach (var nsRecord in result.Answers.OfType()) + { + try + { + var nsResult = await client.QueryAsync(nsRecord.NSDName, QueryType.A); + authServers.AddRange( + nsResult.Answers + .OfType() + .Select(a => a.Address) + ); + } + catch (Exception ex) + { + _logger.LogWarning("Failed to resolve NS record {NSName}: {Error}", + nsRecord.NSDName, ex.Message); + } + } + } + catch (Exception ex) + { + _logger.LogWarning("Failed to get authoritative DNS servers for {Domain}: {Error}", + domain, ex.Message); + } + + return authServers.Distinct().ToList(); + } + } +} \ No newline at end of file diff --git a/AcmeCaPlugin/Clients/DNS/GoogleDnsProvider.cs b/AcmeCaPlugin/Clients/DNS/GoogleDnsProvider.cs new file mode 100644 index 0000000..c82de75 --- /dev/null +++ b/AcmeCaPlugin/Clients/DNS/GoogleDnsProvider.cs @@ -0,0 +1,184 @@ +using Google.Apis.Auth.OAuth2; +using Google.Apis.Dns.v1; +using Google.Apis.Dns.v1.Data; +using Google.Apis.Services; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +/// +/// Google Cloud DNS provider implementation for managing DNS TXT records. +/// Supports explicit Service Account key or Workload Identity (Application Default Credentials). +/// +public class GoogleDnsProvider : IDnsProvider +{ + private readonly DnsService _dnsService; + private readonly string _projectId; + + /// + /// Initializes a new instance of the GoogleDnsProvider class. + /// If serviceAccountKeyPath is null or empty, uses Application Default Credentials. + /// + /// Path to the Service Account JSON key file (optional) + /// Google Cloud project ID containing the DNS zones + public GoogleDnsProvider(string? serviceAccountKeyPath, string projectId) + { + _projectId = projectId; + + GoogleCredential credential; + + if (!string.IsNullOrWhiteSpace(serviceAccountKeyPath)) + { + Console.WriteLine("✅ Using explicit Service Account JSON key."); + credential = GoogleCredential.FromFile(serviceAccountKeyPath); + } + else + { + Console.WriteLine("✅ Using Google Application Default Credentials (Workload Identity if on GCP)."); + credential = GoogleCredential.GetApplicationDefault(); + } + + _dnsService = new DnsService(new BaseClientService.Initializer + { + HttpClientInitializer = credential, + ApplicationName = "Keyfactor-AcmeClient" + }); + } + + /// + /// Creates a new TXT record. Alias for UpsertRecordAsync. + /// + public async Task CreateRecordAsync(string recordName, string txtValue) + => await UpsertRecordAsync(recordName, txtValue); + + /// + /// Creates or updates a TXT record in Google Cloud DNS. + /// If the record already exists, it will be replaced with the new value. + /// + public async Task UpsertRecordAsync(string recordName, string txtValue) + { + try + { + var zone = await GetZone(recordName); + if (zone == null) + { + Console.WriteLine($"❌ No zone found for record: {recordName}"); + return false; + } + + var formattedName = EnsureTrailingDot(recordName); + + // Get current records + var rrsetsRequest = _dnsService.ResourceRecordSets.List(_projectId, zone.Name); + var rrsets = await rrsetsRequest.ExecuteAsync(); + + var existing = rrsets.Rrsets?.FirstOrDefault(r => + r.Type == "TXT" && r.Name.TrimEnd('.') == recordName.TrimEnd('.')); + + var newRrset = new ResourceRecordSet + { + Name = formattedName, + Type = "TXT", + Ttl = 60, + Rrdatas = new List { $"\"{txtValue}\"" } + }; + + var change = new Change(); + + if (existing != null) + { + Console.WriteLine($"🔄 TXT record already exists. Replacing value for {recordName}."); + change.Deletions = new List { existing }; + } + + change.Additions = new List { newRrset }; + + var changeRequest = _dnsService.Changes.Create(change, _projectId, zone.Name); + await changeRequest.ExecuteAsync(); + + Console.WriteLine($"✅ Successfully upserted TXT record for {recordName}"); + return true; + } + catch (Exception ex) + { + Console.WriteLine($"❌ Error upserting TXT record for {recordName}: {ex}"); + return false; + } + } + + /// + /// Deletes a TXT record from Google Cloud DNS. + /// + public async Task DeleteRecordAsync(string recordName) + { + try + { + var zone = await GetZone(recordName); + if (zone == null) + { + Console.WriteLine($"❌ No zone found for record: {recordName}"); + return false; + } + + var formattedName = EnsureTrailingDot(recordName); + + var rrsetsRequest = _dnsService.ResourceRecordSets.List(_projectId, zone.Name); + var rrsets = await rrsetsRequest.ExecuteAsync(); + + var match = rrsets.Rrsets?.FirstOrDefault(r => + r.Type == "TXT" && r.Name.TrimEnd('.') == recordName.TrimEnd('.')); + + if (match == null) + { + Console.WriteLine($"⚠️ TXT record not found for deletion: {recordName}"); + return false; + } + + var change = new Change + { + Deletions = new List { match } + }; + + var deleteRequest = _dnsService.Changes.Create(change, _projectId, zone.Name); + await deleteRequest.ExecuteAsync(); + + Console.WriteLine($"✅ Successfully deleted TXT record for {recordName}"); + return true; + } + catch (Exception ex) + { + Console.WriteLine($"❌ Error deleting TXT record for {recordName}: {ex}"); + return false; + } + } + + /// + /// Finds the appropriate DNS zone for a given record name. + /// + private async Task GetZone(string recordName) + { + try + { + var zonesRequest = _dnsService.ManagedZones.List(_projectId); + var zonesResponse = await zonesRequest.ExecuteAsync(); + var zones = zonesResponse.ManagedZones; + + return zones? + .Where(z => recordName.EndsWith(z.DnsName.TrimEnd('.'))) + .OrderByDescending(z => z.DnsName.Length) + .FirstOrDefault(); + } + catch (Exception ex) + { + Console.WriteLine($"❌ Error fetching DNS zones: {ex}"); + return null; + } + } + + /// + /// Ensures record name is fully qualified (with trailing dot). + /// + private static string EnsureTrailingDot(string name) + => name.EndsWith(".") ? name : name + "."; +} diff --git a/AcmeCaPlugin/Clients/DNS/IDnsProvider.cs b/AcmeCaPlugin/Clients/DNS/IDnsProvider.cs new file mode 100644 index 0000000..dca9678 --- /dev/null +++ b/AcmeCaPlugin/Clients/DNS/IDnsProvider.cs @@ -0,0 +1,7 @@ +using System.Threading.Tasks; + +public interface IDnsProvider +{ + Task CreateRecordAsync(string recordName, string txtValue); + Task DeleteRecordAsync(string recordName); +} diff --git a/AcmeCaPlugin/Clients/DNS/Ns1DnsProvider.cs b/AcmeCaPlugin/Clients/DNS/Ns1DnsProvider.cs new file mode 100644 index 0000000..c5840f8 --- /dev/null +++ b/AcmeCaPlugin/Clients/DNS/Ns1DnsProvider.cs @@ -0,0 +1,258 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +/// +/// NS1 DNS provider implementation for managing DNS TXT records. +/// Uses NS1's REST API with an API key. +/// +public class Ns1DnsProvider : IDnsProvider +{ + private readonly HttpClient _httpClient; + private readonly string _apiKey; + private List _cachedZones; + + public Ns1DnsProvider(string apiKey) + { + _apiKey = apiKey ?? throw new ArgumentNullException(nameof(apiKey)); + _httpClient = new HttpClient + { + BaseAddress = new Uri("https://api.nsone.net/v1/") + }; + _httpClient.DefaultRequestHeaders.Add("X-NSONE-Key", _apiKey); + } + + /// + /// Creates or updates a TXT record. + /// + public async Task CreateRecordAsync(string recordName, string txtValue) + => await UpsertRecordAsync(recordName, txtValue); + + /// + /// Creates or updates a TXT record. + /// + public async Task UpsertRecordAsync(string recordName, string txtValue) + { + try + { + var (zoneName, relativeName) = await ExtractZoneAndRelativeNameAsync(recordName); + + Console.WriteLine($"🔄 Upserting TXT record for {recordName} (zone: {zoneName}, relative: '{relativeName}')"); + + // For NS1 API, the domain field should always be the full record name + var fullDomain = recordName.TrimEnd('.'); + + var record = new Ns1Record + { + zone = zoneName, + domain = fullDomain, + type = "TXT", + answers = new List + { + new Ns1Answer { answer = new List { txtValue } } + }, + ttl = 60, + use_client_subnet = true + }; + + // For NS1 API: zones/{zone}/{domain}/TXT where domain is the full record name + var urlPath = $"zones/{zoneName}/{fullDomain}/TXT"; + + Console.WriteLine($"🌐 API URL: {urlPath}"); + Console.WriteLine($"📄 Domain in body: {fullDomain}"); + + // Use PUT for both create and update - NS1 API handles this automatically + var response = await _httpClient.PutAsJsonAsync(urlPath, record); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"❌ NS1 API Error: {response.StatusCode} - {errorContent}"); + return false; + } + + Console.WriteLine($"✅ Successfully upserted TXT record for {recordName}"); + return true; + } + catch (Exception ex) + { + Console.WriteLine($"❌ Error upserting TXT record for {recordName}: {ex.Message}"); + return false; + } + } + + /// + /// Deletes a TXT record. + /// + public async Task DeleteRecordAsync(string recordName) + { + try + { + var (zoneName, relativeName) = await ExtractZoneAndRelativeNameAsync(recordName); + var fullDomain = recordName.TrimEnd('.'); + var urlPath = $"zones/{zoneName}/{fullDomain}/TXT"; + + var response = await _httpClient.DeleteAsync(urlPath); + + if (response.IsSuccessStatusCode) + { + Console.WriteLine($"✅ Successfully deleted TXT record for {recordName}"); + return true; + } + else if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + Console.WriteLine($"⚠️ TXT record not found for deletion: {recordName}"); + return true; // Consider it successful if already gone + } + else + { + var errorContent = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"❌ Error deleting TXT record: {response.StatusCode} - {errorContent}"); + return false; + } + } + catch (Exception ex) + { + Console.WriteLine($"❌ Error deleting TXT record for {recordName}: {ex.Message}"); + return false; + } + } + + /// + /// Fetches a TXT record if it exists. + /// + private async Task GetRecordAsync(string zoneName, string relativeName) + { + try + { + var fullDomain = $"{relativeName}.{zoneName}".TrimStart('.'); + var urlPath = $"zones/{zoneName}/{fullDomain}/TXT"; + + var response = await _httpClient.GetAsync(urlPath); + + if (!response.IsSuccessStatusCode) + return null; + + return await response.Content.ReadFromJsonAsync(); + } + catch + { + return null; + } + } + + /// + /// Gets all zones from NS1 API. + /// + private async Task> GetZonesAsync() + { + if (_cachedZones != null) + return _cachedZones; + + try + { + var response = await _httpClient.GetAsync("zones"); + response.EnsureSuccessStatusCode(); + + var zones = await response.Content.ReadFromJsonAsync>(); + _cachedZones = zones?.Select(z => z.zone).ToList() ?? new List(); + + return _cachedZones; + } + catch (Exception ex) + { + Console.WriteLine($"⚠️ Warning: Could not fetch zones from NS1: {ex.Message}"); + return new List(); + } + } + + /// + /// Extracts the zone name and relative record name by finding the longest matching zone. + /// + private async Task<(string zoneName, string relativeName)> ExtractZoneAndRelativeNameAsync(string fqdn) + { + var cleanFqdn = fqdn.TrimEnd('.'); + var labels = cleanFqdn.Split('.'); + + // Get available zones + var zones = await GetZonesAsync(); + + // Find the longest matching zone + for (int i = 0; i < labels.Length; i++) + { + var potentialZone = string.Join(".", labels.Skip(i)); + if (zones.Contains(potentialZone)) + { + var relativeName = i == 0 ? "" : string.Join(".", labels.Take(i)); + Console.WriteLine($"🔍 Found zone: {potentialZone}, relative: '{relativeName}' for {fqdn}"); + return (potentialZone, relativeName); + } + } + + // Fallback: assume zone is last two labels (works for most cases) + if (labels.Length >= 2) + { + var zoneName = string.Join(".", labels.TakeLast(2)); + var relativeName = labels.Length > 2 ? string.Join(".", labels.Take(labels.Length - 2)) : ""; + + Console.WriteLine($"⚠️ Warning: Using fallback zone detection for {fqdn} -> zone: {zoneName}, relative: {relativeName}"); + return (zoneName, relativeName); + } + + throw new InvalidOperationException($"Cannot determine zone for FQDN: {fqdn}"); + } + + /// + /// NS1 Zone model for API responses. + /// + private class Ns1Zone + { + [JsonPropertyName("zone")] + public string zone { get; set; } + } + + /// + /// NS1 Record model with all commonly required fields. + /// + private class Ns1Record + { + [JsonPropertyName("zone")] + public string zone { get; set; } + + [JsonPropertyName("domain")] + public string domain { get; set; } + + [JsonPropertyName("type")] + public string type { get; set; } + + [JsonPropertyName("ttl")] + public int ttl { get; set; } + + [JsonPropertyName("answers")] + public List answers { get; set; } + + [JsonPropertyName("use_client_subnet")] + public bool? use_client_subnet { get; set; } + } + + /// + /// NS1 Answer model. + /// + private class Ns1Answer + { + [JsonPropertyName("answer")] + public List answer { get; set; } + } + + /// + /// Dispose of HttpClient resources. + /// + public void Dispose() + { + _httpClient?.Dispose(); + } +} \ No newline at end of file diff --git a/AcmeCaPlugin/Clients/License.txt b/AcmeCaPlugin/Clients/License.txt new file mode 100644 index 0000000..32c5f73 --- /dev/null +++ b/AcmeCaPlugin/Clients/License.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2015 Bryan Livingston + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/AcmeCaPlugin/Interfaces/ICertificateInfo.cs b/AcmeCaPlugin/Interfaces/ICertificateInfo.cs new file mode 100644 index 0000000..2fd6f0d --- /dev/null +++ b/AcmeCaPlugin/Interfaces/ICertificateInfo.cs @@ -0,0 +1,39 @@ +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.X509; +using System.Collections.Generic; + +namespace Keyfactor.Extensions.CAPlugin.Acme.Interfaces +{ + public interface ICertificateInfo + { + /// + /// The main certificate + /// + X509Certificate Certificate { get; } + + /// + /// Private key in Bouncy Castle format + /// + AsymmetricKeyParameter PrivateKey { get; } + + /// + /// The certificate chain, in the correct order + /// + IEnumerable Chain { get; } + + /// + /// FriendlyName + /// + string FriendlyName { get; } + + /// + /// Main certificate hash + /// + byte[] GetHash(); + + /// + /// Main certificate thumbprint + /// + string Thumbprint { get; } + } +} \ No newline at end of file diff --git a/AcmeCaPlugin/manifest.json b/AcmeCaPlugin/manifest.json new file mode 100644 index 0000000..48e4401 --- /dev/null +++ b/AcmeCaPlugin/manifest.json @@ -0,0 +1,10 @@ +{ + "extensions": { + "Keyfactor.AnyGateway.Extensions.IAnyCAPlugin": { + "AcmeCaPlugin": { + "assemblypath": "AcmeCaPlugin.dll", + "TypeFullName": "Keyfactor.Extensions.CAPlugin.Acme.AcmeCaPlugin" + } + } + } +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29..4c9a3ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1,2 @@ +# v1.0.0 +* Initial Release. Support for Acme. Enroll and Revocation diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 16f2a3e..5762a55 100644 --- a/README.md +++ b/README.md @@ -1,93 +1,574 @@ -# cpr-cagateway-template +

+ Acme AnyCA Gateway REST Plugin +

-## Template for new CA Gateway integrations +

+ +Integration Status: production +Release +Issues +GitHub Downloads (all assets, all releases) +

-### Use this repository to create new integrations for new CA Gateway integration types. +

+ + + Support + + · + + Requirements + + · + + Installation + + · + + License + + · + + Related Integrations + +

-1. [Use this repository](#using-the-repository) -1. [Update the integration-manifest.json](#updating-the-integration-manifest.json) -1. [Add Keyfactor Bootstrap Workflow (keyfactor-bootstrap-workflow.yml)](#add-bootstrap) -1. [Create required branches](#create-required-branches) -1. [Replace template files/folders](#replace-template-files-and-folders) -1. [Create initial prerelease](#create-initial-prerelease) ---- +The **Keyfactor ACME CA Gateway Plugin** enables certificate enrollment using the [ACME protocol (RFC 8555)](https://datatracker.ietf.org/doc/html/rfc8555), providing automated certificate issuance via any compliant Certificate Authority. This plugin is designed for **enrollment-only workflows** — it **does not support synchronization or revocation** of certificates. -#### Using the repository -1. Select the ```Use this template``` button at the top of this page -1. Update the repository name following [these guidelines](https://keyfactorinc.sharepoint.com/sites/IntegrationWiki/SitePages/GitHub-Processes.aspx#repository-naming-conventions) - 1. All repositories must be in lower-case - 1. General pattern: company-product-type - 1. e.g. hashicorp-vault-orchestator -1. Click the ```Create repository``` button +### 🔧 What It Does +This plugin allows Keyfactor Gateways to: +- Submit CSRs to ACME-based CAs. +- Complete domain validation via DNS-01 challenges. +- Automatically retrieve and return signed certificates. ---- +Once a certificate is issued, the plugin returns the PEM-encoded certificate to the Gateway. + +### ✅ ACME Providers Tested +This plugin has been tested and confirmed to work with the following ACME providers: +- **Let's Encrypt** +- **Google ACME (Certificate Authority Service)** +- **ZeroSSL** (functional but known slowness may cause timeouts) +- **Buypass** -#### Updating the integration-manifest.json +It is designed to be provider-agnostic and should work with any standards-compliant ACME server. -*The following properties must be updated in the integration-manifest.json* +### 🌐 Supported DNS Providers (Initial Release) +DNS-01 challenge automation is supported through the following providers: +- **Google Cloud DNS** +- **AWS Route 53** +- **Azure DNS** +- **Cloudflare** +- **NS1** -Clone the repository locally, use vsdev.io, or the GitHub online editor to update the file. +Additional DNS providers can be added by extending the included `IDnsProvider` interface. -* "name": "Friendly name for the integration" - * This will be used in the readme file generation and catalog entries -* "description": "Brief description of the integration." - * This will be used in the readme file generation - * If the repository description is empty this value will be used for the repository description upon creating a release branch -* "release_dir": "PATH\\\TO\\\BINARY\\\RELEASE\\\OUTPUT\\\FOLDER" - * Path separators can be "\\\\" or "/" - * Be sure to specify the release folder name. This can be found by running a Release build and noting the output folder - * Example: "AzureAppGatewayOrchestrator\\bin\\Release" -* "gateway_framework": "" string denoting the required command gateway framework version --- -#### Add Bootstrap -Add Keyfactor Bootstrap Workflow (keyfactor-bootstrap-workflow.yml). This can be copied directly from the workflow templates or through the Actions tab -* Directly: - 1. Create a file named ```.github\workflows\keyfactor-bootstrap-workflow.yml``` - 1. Copy the contents of [keyfactor/.github/workflow-templates/keyfactor-bootstrap-workflow.yml](https://raw.githubusercontent.com/Keyfactor/.github/main/workflow-templates/keyfactor-bootstrap-workflow.yml) into the file created in the previous step -* Actions tab: - 1. Navigate to the [Actions tab](./actions) in the new repository - 1. Click the ```New workflow``` button - 1. Find the ```Keyfactor Bootstrap Workflow``` and click the ```Configure``` button - 1. Click the ```Commit changes...``` button on this screen and the next to add the bootstrap workflow to the main branch - -A new build will run the tasks of a *Push* trigger on the main branch - -*Ensure there are no errors during the workflow run in the Actions tab.* +### 🔁 Enrollment Flow Summary ---- +```text +1. Keyfactor Gateway submits CSR and SAN metadata to plugin. +2. Plugin initializes ACME client and creates a new order. +3. For each domain: + a. Retrieve DNS-01 challenge. + b. Use the configured DNS provider to publish challenge record. + c. Wait for DNS propagation and validate record. + d. Notify ACME provider to trigger validation. +4. Once all challenges are valid, finalize the order using CSR. +5. Download the signed certificate from ACME provider. +6. Return PEM certificate to the Gateway. +``` + +The plugin uses a modular design that separates ACME communication logic and DNS challenge automation, allowing for future extensibility in both areas. + +> ⚠️ Revocation, certificate synchronization, and renewal tracking are intentionally **not implemented** in this plugin. All lifecycle tracking must be handled externally (e.g., via Keyfactor monitoring or Gateway automation). + +## Compatibility + +The Acme AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 24.2 and later. + +## Support +The Acme AnyCA Gateway REST plugin is supported by Keyfactor for Keyfactor customers. If you have a support issue, please open a support ticket with your Keyfactor representative. If you have a support issue, please open a support ticket via the Keyfactor Support Portal at https://support.keyfactor.com. + +> To report a problem or suggest a new feature, use the **[Issues](../../issues)** tab. If you want to contribute actual bug fixes or proposed enhancements, use the **[Pull requests](../../pulls)** tab. + +## Requirements + +### DNS Providers + +This plugin automates DNS-01 challenges using pluggable DNS provider implementations. These providers create and remove TXT records to prove domain control to ACME servers. + +
+✅ Supported DNS Providers (Initial Release) + +| Provider | Auth Methods Supported | Config Keys Required | +|--------------|-----------------------------------------------|--------------------------------------------------------| +| Google DNS | Service Account Key or ADC | `Google_ServiceAccountKeyPath`, `Google_ProjectId` | +| AWS Route 53 | Access Key/Secret or IAM Role | `AwsRoute53_AccessKey`, `AwsRoute53_SecretKey` | +| Azure DNS | Client Secret or Managed Identity | `Azure_TenantId`, `Azure_ClientId`, `Azure_ClientSecret`, `Azure_SubscriptionId` | +| Cloudflare | API Token | `Cloudflare_ApiToken` | +| NS1 | API Key | `Ns1_ApiKey` | + +
+ +
+⏱ DNS Propagation Logic + +Before submitting ACME challenges, the plugin verifies DNS propagation using multiple public resolvers (Google, Cloudflare, OpenDNS, Quad9). A record must be visible on **at least 3 servers** to proceed, with up to **3 retries** spaced by 10 seconds. + +This logic is handled by the `DnsVerificationHelper` class and ensures a high-confidence validation before proceeding. + +
+ +
+🔑 Credential Flow + +Each provider supports multiple credential strategies: + +- **Google DNS**: + - ✅ **Service Account Key** (via `Google_ServiceAccountKeyPath`) + - ✅ **Application Default Credentials** (e.g., GCP Workload Identity or developer auth) + +- **AWS Route 53**: + - ✅ **Access/Secret Keys** (`AwsRoute53_AccessKey`, `AwsRoute53_SecretKey`) + - ✅ **IAM Role via EC2 Instance Metadata** (no explicit credentials) + +- **Azure DNS**: + - ✅ **Client Secret** (explicit `TenantId`, `ClientId`, `ClientSecret`) + - ✅ **Managed Identity** or environment-based credentials via `DefaultAzureCredential` + +- **Cloudflare**: + - ✅ **Bearer API Token** for zone-level DNS control + +- **NS1**: + - ✅ **API Key** passed in header `X-NSONE-Key` + +
+ +
+🧩 Adding New DNS Providers + +To add support for new DNS services: + +1. Implement the `IDnsProvider` interface: + ```csharp + public interface IDnsProvider + { + Task CreateRecordAsync(string recordName, string txtValue); + Task DeleteRecordAsync(string recordName); + } + ``` + +2. Register the new provider in the `DnsProviderFactory`: + ```csharp + case "yourprovider": + return new YourCustomDnsProvider(config.YourProviderConfigValues...); + ``` + +3. Use zone detection logic similar to `GoogleDnsProvider`, `AzureDnsProvider`, or `Ns1DnsProvider`. + +Each provider is instantiated dynamically based on the `DnsProvider` field in the `AcmeClientConfig`. + +> 🔁 This modular DNS system ensures challenge automation works across cloud providers and is easily extensible. + +
+ +
+🔒 CA-Level DNS Provider Binding + +Each ACME/DNS combination is supported **at the CA level**, meaning that only **one DNS provider** is configured per CA entry in Keyfactor. This ensures a clear and isolated challenge path for each ACME CA connector instance. + +If you need to support multiple DNS zones/providers (e.g., both AWS and Cloudflare), configure **separate CA entries**, each with its own DNS provider configuration. + +
+ +
+🚫 No Offline Challenge Retry (Initial Release) + +In this initial release, there is **no background or offline retry** for ACME challenges that timeout. If DNS propagation takes too long and the challenge is not verified in time, the certificate **request will fail immediately**. + +> ⚠️ However, in testing across all supported DNS providers and ACME services (e.g., Let's Encrypt, Google CAS, ZeroSSL, Buypass), propagation has been fast enough to avoid these timeouts in all observed cases. -#### Create required branches -1. Create a release branch from main: release-1.0 -1. Create a dev branch from the starting with the devops id in the format ab#\, e.g. ab#53535. - 1. For the cleanest pull request merge, create the dev branch from the release branch. - 1. Optionally, add a suffix to the branch name indicating initial release. e.g. ab#53535-initial-release +
--- +### ACME Provider Configuration -#### Replace template files and folders -1. Replace the contents of readme_source.md -1. Create a CHANGELOG.md file in the root of the repository indicating ```1.0: Initial release``` -1. Replace the SampleOrchestratorExtension.sln solution file and SampleOrchestratorExtension folder with your new orchestrator dotnet solution -1. Push your updates to the dev branch (ab#xxxxx) +Each ACME CA (Certificate Authority) has slightly different expectations for account creation and request handling. This plugin supports multiple providers and dynamically handles credentials based on your configuration. + +
+🧩 External Account Binding (EAB) Support + +Some providers **require** External Account Binding (EAB), which includes: +- `eabKid`: External Account Binding Key ID +- `eabHmacKey`: HMAC Key to sign the JWK thumbprint + +Others **do not require EAB**, and can create accounts automatically with just an email address. + +
+ +
+✅ Supported Providers & Credential Expectations + +| Provider | Directory URL | Requires EAB | Notes | +|----------------|----------------------------------------------------------------|--------------|-----------------------------------------------------------------------| +| Let's Encrypt | `https://acme-v02.api.letsencrypt.org/directory` | ❌ No | Free and public; account created using only an email address | +| Buypass | `https://api.buypass.com/acme/directory` | ❌ No | Free and public; supports long-lived certs; no EAB required | +| ZeroSSL | `https://acme.zerossl.com/v2/DV90/directory` | ✅ Yes | Requires EAB; keys available via [ZeroSSL Developer Portal](https://zerossl.com) | +| Google CAS | `https://dv.acme-v02.api.pki.goog/directory` | ✅ Yes | Requires EAB; keys issued via [Google CAS UI](https://console.cloud.google.com) | + +> ⚠️ If a provider requires EAB and it is not supplied, the request will fail during account registration. + +
+ +
+📋 Configuration Fields (Per ACME Provider) + +These values are set in the Keyfactor Command Gateway Configuration UI for each ACME provider: + +| Field | Description | Required | +|---------------|---------------------------------------------------|-----------------| +| `directoryUrl`| The full ACME directory URL for the CA | ✅ Yes | +| `email` | Account email address for ACME registration | ✅ Yes | +| `eabKid` | External Account Binding Key ID (if applicable) | 🚫 Only if EAB | +| `eabHmacKey` | HMAC key used to sign EAB binding (if applicable) | 🚫 Only if EAB | + +
+ +
+🔐 How to Get EAB Credentials + +- **ZeroSSL**: + Log into your account and go to **"ACME EAB Credentials"** in the developer section. + +- **Google CAS**: + Enable your CA Pool for ACME and generate EAB credentials under the **ACME Integration** tab in Google Cloud Console. + +
+ +
+⚙️ Plugin Behavior + +- If both `eabKid` and `eabHmacKey` are provided, they will be used to create the ACME account. +- If either is omitted and the provider requires it, account creation will fail. +- If neither is provided and the provider does not require EAB, the account will be created using only the email. + +Each provider is configured in the JSON config under `acmeProviders`, and only **one provider** is active per enrollment. + +
--- +### Account Storage and Signer Encryption + +This ACME Gateway implementation uses a local file-based store to persist ACME accounts and their associated cryptographic signers. Accounts are cached on disk using a structured format, and signers (private keys) can be encrypted with a passphrase for enhanced security. + +
+📁 Account Directory Structure + +Each account is saved in its own directory within: + +``` +%APPDATA%\AcmeAccounts\{host}_{accountId} +``` + +Where: +- `{host}` is the ACME directory host with dots replaced by dashes (e.g., `acme-zerossl-com`) +- `{accountId}` is the final segment of the account's KID URL + +
+ +
+📄 Files per Account + +- `Registration_v2`: Contains serialized `AccountDetails` in JSON format +- `Signer_v2`: Contains encrypted or plaintext signer key material, depending on passphrase usage +- `default_{host}.txt`: Tracks the default account for a given ACME directory host + +
+ +
+🔐 Encryption with Passphrase + +If the `SignerEncryptionPhrase` configuration value is set, the plugin encrypts signer files (`Signer_v2`) using AES with a PBKDF2-derived key and IV. The encrypted data includes a prepended salt and IV to support cross-platform decryption. + +```text +[Salt (16 bytes)] [IV (16 bytes)] [AES-CBC encrypted signer JSON] +``` + +The encryption ensures that even if the account files are accessed on disk, the private keys remain unreadable without the configured passphrase. + +
+ +
+🔗 External Account Binding (EAB) + +For ACME providers requiring EAB (e.g., ZeroSSL, Google CAS), the gateway constructs a manually signed JWS payload containing: + +- Protected Header: `alg`, `kid`, `url` +- Payload: Public JWK of the account signer +- Signature: HMAC using `eabHmacKey` + +This JWS is included during account creation to bind the account to the pre-provisioned identity provided by the CA. + +
+ +
+⚙️ Algorithm Support + +- Signers support `ES256`, `ES384`, `ES512` (ECDSA) and `RS256`, `RS384`, `RS512` (RSA) +- EAB HMAC support includes `HS256`, `HS384`, `HS512` + +If `ES256` key generation fails (e.g., due to platform constraints), the system automatically falls back to `RS256`. + +
+ +### Account Caching and Auto-Creation + +On startup or during enrollment/sync, the plugin: + +1. Attempts to load a cached account for the specified ACME directory. +2. If no account is found, it automatically creates a new one, using EAB if configured. +3. The new account is saved to disk and set as default for future use. + +
+🔗 External Account Binding (EAB) + +For ACME providers requiring EAB (e.g., ZeroSSL, Google CAS), the gateway constructs a manually signed JWS payload containing: + +- Protected Header: `alg`, `kid`, `url` +- Payload: Public JWK of the account signer +- Signature: HMAC using `eabHmacKey` + +This JWS is included during account creation to bind the account to the pre-provisioned identity provided by the CA. + +
+ +
+🔧 Algorithm Support + +- Signers support `ES256`, `ES384`, `ES512` (ECDSA) and `RS256`, `RS384`, `RS512` (RSA) +- EAB HMAC support includes `HS256`, `HS384`, `HS512` + +If `ES256` key generation fails (e.g., due to platform constraints), the system automatically falls back to `RS256`. + +
+ +### Network and File System Requirements + +This section outlines all required ports, file access, permissions, and validation behaviors for operating the ACME Gateway Plugin in a Keyfactor Orchestrator environment. + +
+🔌 Port Usage + +#### Incoming Connections + +- **None.** This plugin does not expose any HTTP or network listeners. + +#### Outgoing Connections + +| Protocol | Port | Target | Purpose | +|----------|------|------------------------------|-----------------------------------------------------| +| HTTPS | 443 | ACME Directory URL | Connect to the ACME CA for account, challenge, and certificate operations | +| HTTPS | 443 | DNS Provider APIs | Used for DNS-01 challenge automation (Google DNS, AWS, etc.) | + +
+ +
+💾 File System Requirements + +#### Directory Layout + +| Path | Purpose | +|----------------------------------------------------|----------------------------------------------| +| `%APPDATA%\AcmeAccounts\` | Default base path for ACME account storage | +| `AcmeAccounts\{account_id}\Registration_v2` | Contains serialized ACME account metadata | +| `AcmeAccounts\{account_id}\Signer_v2` | Contains the encrypted private signer key | +| `AcmeAccounts\default_{host}.txt` | Stores the default account pointer for a given directory | + +#### File Access & Permissions + +| Path | Operation | Required Permission | +|--------------------------|-----------|---------------------| +| Account directory | Create | `Write` | +| Account files | Read/Write| `Read`, `Write` | + +- Files may be optionally encrypted using AES if a passphrase is configured. +- Ensure the service account under which the orchestrator runs has read/write access to `%APPDATA%` or the custom configured base path. + +
+ +
+👤 Windows Account Permissions + +- The orchestrator service account (usually `NT AUTHORITY\SYSTEM` or a custom `Network Service`) must have: + - File I/O permissions to read/write within the configured base directory. + - Network access to ACME CA endpoints and DNS APIs over HTTPS. + - DNS provider credentials (Cloudflare API token, Google credentials, etc.) stored securely. + +
+ +
+🌐 DNS Propagation Check Behavior + +- **Initial Release Behavior**: + - DNS challenge propagation is checked during the interactive enrollment phase only. + - If propagation takes too long (> 60s), the request will fail. No deferred background polling occurs. + - There is **no offline retry mechanism** (e.g., for sync jobs) to pick up completed validations that succeeded after a delay. + +- **Future Considerations**: + - Support for file-based or database-backed challenge persistence may be added to allow background sync to re-check and finalize challenge state. + +
+ +## Installation + +1. Install the AnyCA Gateway REST per the [official Keyfactor documentation](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/InstallIntroduction.htm). + +2. On the server hosting the AnyCA Gateway REST, download and unzip the latest [Acme AnyCA Gateway REST plugin](https://github.com/Keyfactor/acme-provider-caplugin/releases/latest) from GitHub. + +3. Copy the unzipped directory (usually called `net6.0`) to the Extensions directory: + + ```shell + Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net6.0\Extensions + ``` + + > The directory containing the Acme AnyCA Gateway REST plugin DLLs (`net6.0`) can be named anything, as long as it is unique within the `Extensions` directory. + +4. Restart the AnyCA Gateway REST service. + +5. Navigate to the AnyCA Gateway REST portal and verify that the Gateway recognizes the Acme plugin by hovering over the ⓘ symbol to the right of the Gateway on the top left of the portal. + +## Configuration + +1. Follow the [official AnyCA Gateway REST documentation](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCA-Gateway.htm) to define a new Certificate Authority, and use the notes below to configure the **Gateway Registration** and **CA Connection** tabs: + + * **Gateway Registration** + + Each ACME CA issues certificates that chain to a specific intermediate and root certificate. For trust validation and proper integration with the Keyfactor Gateway, the following steps are required for **every ACME CA** used in your environment. + + --- + + ### 🔍 Retrieving Root and Intermediate Certificates + + Here is how to obtain the root and intermediate CA certificates from supported ACME providers: + + #### Let's Encrypt + + - **Root**: ISRG Root X1 + - **Intermediate**: R3 + + **How to Get:** + - Browse to: https://letsencrypt.org/certificates/ + - Download both the **ISRG Root X1** and **R3 Intermediate Certificate (PEM format)**. + + #### Google Certificate Authority Service (CAS) + + - **Root** and **Intermediate** are custom per CA Pool. + + **How to Get:** + 1. In the [Google Cloud Console](https://console.cloud.google.com/security/privateca), navigate to your CA pool. + 2. Click the CA name and go to the **Certificates** tab. + 3. Download the **root** and **intermediate** certificates for the issuing CA in PEM format. + + #### ZeroSSL + + - **Root**: USERTrust RSA Certification Authority + - **Intermediate**: ZeroSSL RSA Domain Secure Site CA + + **How to Get:** + - Visit: https://zerossl.com + - Download the full certificate chain in PEM format. + - Extract individual certs if needed using OpenSSL or a text editor. + + #### Buypass + + - **Root**: Buypass Class 3 Root CA + - **Intermediate**: Buypass Class 3 CA 1 / G2 (depends on issuance) + + **How to Get:** + - Go to: https://www.buypass.com + - Download both root and intermediate in PEM or DER format. + + --- + + ### 🧩 Installing Certificates on the Keyfactor Gateway Server + + Once downloaded, the **root and intermediate certificates must be installed** in the proper Windows certificate stores on the Gateway server. + + #### Steps: + + 1. **Open** `certlm.msc` (Local Computer Certificates) + 2. Install the **Root CA certificate** into: + - `Trusted Root Certification Authorities` → `Certificates` + 3. Install the **Intermediate CA certificate** into: + - `Intermediate Certification Authorities` → `Certificates` + + You can import certificates using the GUI or PowerShell: + + ```powershell + Import-Certificate -FilePath "C:\path\to\intermediate.crt" -CertStoreLocation "Cert:\LocalMachine\CA" + Import-Certificate -FilePath "C:\path\to\root.crt" -CertStoreLocation "Cert:\LocalMachine\Root" + ``` + + --- + + ### 🔑 Using the Intermediate Thumbprint + + When registering a new CA in Keyfactor Command: + + - You must specify the **thumbprint** of the Intermediate CA certificate. + - This is used to associate issued certificates with the correct issuing chain. + + **How to Get the Thumbprint:** + + 1. In `certlm.msc`, open the certificate under **Intermediate Certification Authorities**. + 2. Go to **Details** tab → Scroll to **Thumbprint**. + 3. Copy the hex string (ignore spaces). + + --- + + ⚠️ All certificate chains must be trusted by the Gateway OS. If the intermediate is missing or untrusted, issuance will fail or returned certificates may not chain properly. + + * **CA Connection** + + Populate using the configuration fields collected in the [requirements](#requirements) section. + + * **DirectoryUrl** - ACME directory URL (e.g. Let's Encrypt, ZeroSSL, etc.) + * **Email** - Email for ACME account registration. + * **EabKid** - External Account Binding Key ID (optional) + * **EabHmacKey** - External Account Binding HMAC key (optional) + * **SignerEncryptionPhrase** - Used to encrypt singer information when account is saved to disk (optional) + * **DnsProvider** - DNS Provider to use for ACME DNS-01 challenges (options Google, Cloudflare, AwsRoute53, Azure, Ns1) + * **Google_ServiceAccountKeyPath** - Google Cloud DNS: Path to service account JSON key file only if using Google DNS (Optional) + * **Google_ProjectId** - Google Cloud DNS: Project ID only if using Google DNS (Optional) + * **Cloudflare_ApiToken** - Cloudflare DNS: API Token only if using Cloudflare DNS (Optional) + * **Azure_ClientId** - Azure DNS: ClientId only if using Azure DNS and Not Managed Itentity in Azure (Optional) + * **Azure_ClientSecret** - Azure DNS: ClientSecret only if using Azure DNS and Not Managed Itentity in Azure (Optional) + * **Azure_SubscriptionId** - Azure DNS: SubscriptionId only if using Azure DNS and Not Managed Itentity in Azure (Optional) + * **Azure_TenantId** - Azure DNS: TenantId only if using Azure DNS and Not Managed Itentity in Azure (Optional) + * **AwsRoute53_AccessKey** - Aws DNS: Access Key only if not using AWS DNS and default AWS Chain Creds on AWS (Optional) + * **AwsRoute53_SecretKey** - Aws DNS: Secret Key only if using AWS DNS and not using default AWS Chain Creds on AWS (Optional) + * **Ns1_ApiKey** - Ns1 DNS: Api Key only if Using Ns1 DNS (Optional) + +2. Define [Certificate Profiles](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCP-Gateway.htm) and [Certificate Templates](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCA-Gateway.htm) for the Certificate Authority as required. One Certificate Profile must be defined per Certificate Template. It's recommended that each Certificate Profile be named after the Product ID. The Acme plugin supports the following product IDs: + + * **default** + +3. Follow the [official Keyfactor documentation](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCA-Keyfactor.htm) to add each defined Certificate Authority to Keyfactor Command and import the newly defined Certificate Templates. + + +## Compatibility -#### Create initial prerelease -1. Create a pull request from the dev branch to the release-1.0 branch +The Acme AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 24.2 and later. ----- +## License -When the repository is ready for SE Demo, change the following property: -* "status": "pilot" +Apache License 2.0, see [LICENSE](LICENSE). -When the integration has been approved by Support and Delivery teams, change the following property: -* "status": "production" +## Related Integrations -If the repository is ready to be published in the public catalog, the following properties must be updated: -* "update_catalog": true -* "link_github": true +See all [Keyfactor Any CA Gateways (REST)](https://github.com/orgs/Keyfactor/repositories?q=anycagateway). \ No newline at end of file diff --git a/TestProgram/Program.cs b/TestProgram/Program.cs new file mode 100644 index 0000000..ebeedf0 --- /dev/null +++ b/TestProgram/Program.cs @@ -0,0 +1,450 @@ +using Microsoft.Extensions.Logging; +using Keyfactor.Extensions.CAPlugin.Acme; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Logging; +using Keyfactor.PKI.Enums.EJBCA; +using Org.BouncyCastle.Asn1.Pkcs; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Asn1; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Security; +using Org.BouncyCastle.OpenSsl; +using Org.BouncyCastle.Pkcs; +using System.Text; +using System.Text.Json; +using System.Collections.Concurrent; + +internal class Program +{ + private const string CONFIG_FILE_PATH = "c:\\acme\\config\\acme-config.json"; + + public static async Task Main() + { + + // ================================ + // 📌 === LOAD CONFIGURATION === + // ================================ + AcmeConfig config; + try + { + config = await LoadConfigurationAsync(CONFIG_FILE_PATH); + } + catch (Exception ex) + { + Console.WriteLine($"❌ Failed to load configuration: {ex.Message}"); + Console.WriteLine($"Please ensure {CONFIG_FILE_PATH} exists and is properly formatted."); + return; + } + + // ================================ + // ✅ Setup logging + plugin + // ================================ + using var loggerFactory = LoggerFactory.Create(builder => + { + builder.AddSimpleConsole(options => + { + options.SingleLine = true; + options.TimestampFormat = "[HH:mm:ss] "; + }); + builder.SetMinimumLevel(LogLevel.Debug); + }); + + ILogger logger = LogHandler.GetClassLogger(); + logger.LogInformation("🚀 Starting secure ACME test client..."); + + var selectedAcmeProvider = GetSelectedAcmeProvider(config); + logger.LogInformation($"📋 Using ACME Provider: {config.AcmeProvider}"); + logger.LogInformation($"🌐 Directory URL: {selectedAcmeProvider.DirectoryUrl}"); + logger.LogInformation($"📧 Email: {selectedAcmeProvider.Email}"); + logger.LogInformation($"🔒 EAB Required: {(!string.IsNullOrEmpty(selectedAcmeProvider.EabKid) ? "Yes" : "No")}"); + logger.LogInformation($"🌍 DNS Provider: {config.DnsProvider}"); + logger.LogInformation($"🏷️ Domain: {config.Domain}"); + + // ✅ Convert to flat dictionary for AnyGateway + var configDict = BuildConfigurationDictionary(config); + + var configProvider = new MockConfigProvider(configDict); + var plugin = new AcmeCaPlugin(); + plugin.Initialize(configProvider, null); + + if (config.RunEnroll) + { + // ================================ + // ✅ Generate CSR dynamically + // ================================ + string privateKeyPem; + string csrString = CsrHelper.GenerateCsrBase64(config.Domain, new List { config.Domain }, config.KeySize, out privateKeyPem); + + logger.LogInformation($"Generated CSR (Base64): {csrString[..Math.Min(80, csrString.Length)]}..."); + logger.LogInformation($"Generated Private Key PEM:\n{privateKeyPem[..Math.Min(200, privateKeyPem.Length)]}..."); + + var san = new Dictionary + { + { "dns", new[] { config.Domain } } + }; + + // ================================ + // ✅ Run ACME enrollment + // ================================ + var result = await plugin.Enroll( + csr: csrString, + subject: $"CN={config.Domain}", + san: san, + productInfo: new EnrollmentProductInfo { ProductID = "default" }, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New + ); + + logger.LogInformation("✅ Enrollment Result:"); + logger.LogInformation($"Status: {(EndEntityStatus)result.Status}"); + logger.LogInformation($"Certificate:\n{(string.IsNullOrEmpty(result.Certificate) ? "None" : result.Certificate[..Math.Min(result.Certificate.Length, 300)] + "...")}"); + logger.LogInformation($"CA Request ID: {result.CARequestID}"); + + // ================================ + // ✅ Save outputs to disk + // ================================ + await File.WriteAllTextAsync($"{config.Domain}_privatekey.pem", privateKeyPem); + await File.WriteAllTextAsync($"{config.Domain}_certificate.pem", result.Certificate ?? ""); + + logger.LogInformation($"✅ Saved private key and certificate: {config.Domain}_*.pem"); + } + else + { + // ================================ + // ✅ Run Synchronize always (or stand-alone) + // ================================ + using var cancelTokenSource = new CancellationTokenSource(); + var buffer = new BlockingCollection(); + + logger.LogInformation("🔄 Running Synchronize to check for pending orders..."); + + await plugin.Synchronize( + buffer, + lastSync: null, + fullSync: true, + cancelToken: cancelTokenSource.Token); + + foreach (var cert in buffer) + { + logger.LogInformation($"🔑 Synced Certificate: CARequestID={cert.CARequestID}, Status={(EndEntityStatus)cert.Status}"); + if (!string.IsNullOrWhiteSpace(cert.Certificate)) + { + var filename = $"{config.Domain}_synced_certificate.pem"; + await File.WriteAllTextAsync(filename, cert.Certificate); + logger.LogInformation($"✅ Saved synced certificate to: {filename}"); + } + } + + logger.LogInformation("✅ Synchronize call completed."); + } + } + + private static async Task LoadConfigurationAsync(string configPath) + { + if (!File.Exists(configPath)) + { + // Create a sample configuration file + await CreateSampleConfigAsync(configPath); + throw new FileNotFoundException($"Configuration file not found. A sample configuration has been created at {configPath}. Please edit it with your actual values."); + } + + var jsonString = await File.ReadAllTextAsync(configPath); + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + AllowTrailingCommas = true, + ReadCommentHandling = JsonCommentHandling.Skip + }; + + return JsonSerializer.Deserialize(jsonString, options) + ?? throw new InvalidOperationException("Failed to deserialize configuration file."); + } + + private static async Task CreateSampleConfigAsync(string configPath) + { + var sampleConfig = new AcmeConfig + { + RunEnroll = true, + Domain = "www.example.com", + KeySize = 4096, + AcmeProvider = "LetsEncrypt", // Options: LetsEncrypt, Buypass, ZeroSsl, GoogleCas, Custom + AcmeProviders = new AcmeProvidersSettings + { + LetsEncrypt = new AcmeProviderSettings + { + DirectoryUrl = "https://acme-v02.api.letsencrypt.org/directory", + Email = "your-email@example.com", + EabKid = null, // Not required for Let's Encrypt + EabHmacKey = null, // Not required for Let's Encrypt + Description = "Let's Encrypt Production Environment" + }, + Buypass = new AcmeProviderSettings + { + DirectoryUrl = "https://api.buypass.com/acme/directory", + Email = "your-email@example.com", + EabKid = "your-buypass-eab-kid", + EabHmacKey = "your-buypass-eab-hmac-key", + Description = "Buypass ACME CA" + }, + ZeroSsl = new AcmeProviderSettings + { + DirectoryUrl = "https://acme.zerossl.com/v2/DV90/directory", + Email = "your-email@example.com", + EabKid = "your-zerossl-eab-kid", + EabHmacKey = "your-zerossl-eab-hmac-key", + Description = "ZeroSSL ACME CA" + }, + GoogleCas = new AcmeProviderSettings + { + DirectoryUrl = "https://dv.acme-v02.api.pki.goog/directory", + Email = "your-email@example.com", + EabKid = "your-google-cas-eab-kid", + EabHmacKey = "your-google-cas-eab-hmac-key", + Description = "Google Certificate Authority Service" + }, + Custom = new AcmeProviderSettings + { + DirectoryUrl = "https://your-custom-acme-server.com/directory", + Email = "your-email@example.com", + EabKid = "your-custom-eab-kid", + EabHmacKey = "your-custom-eab-hmac-key", + Description = "Custom ACME Provider" + } + }, + DnsProvider = "Google", // Options: Google, Cloudflare, AwsRoute53, Azure, Ns1 + DnsProviderSettings = new DnsProviderSettings + { + Google = new GoogleDnsSettings + { + ServiceAccountKeyPath = "C:\\path\\to\\service-account.json", + ProjectId = "your-project-id" + }, + Cloudflare = new CloudflareDnsSettings + { + ApiToken = "your-cloudflare-api-token" + }, + AwsRoute53 = new AwsRoute53Settings + { + AccessKeyId = "your-aws-access-key", + SecretAccessKey = "your-aws-secret-key", + Region = "us-east-1" + }, + Azure = new AzureDnsSettings + { + TenantId = "your-tenant-id", + ClientId = "your-client-id", + SubscriptionId = "your-subscription-id", + ClientSecret = "your-client-secret" + }, + Ns1 = new Ns1DnsSettings + { + ApiKey = "your-ns1-api-key" + } + } + }; + + var options = new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + var jsonString = JsonSerializer.Serialize(sampleConfig, options); + await File.WriteAllTextAsync(configPath, jsonString); + } + + private static Dictionary BuildConfigurationDictionary(AcmeConfig config) + { + // Get the selected ACME provider settings + var selectedAcmeProvider = GetSelectedAcmeProvider(config); + + var dict = new Dictionary + { + ["DirectoryUrl"] = selectedAcmeProvider.DirectoryUrl, + ["Email"] = selectedAcmeProvider.Email, + ["EabKid"] = selectedAcmeProvider.EabKid ?? "", + ["EabHmacKey"] = selectedAcmeProvider.EabHmacKey ?? "", + ["DnsProvider"] = config.DnsProvider, + ["SignerEncryptionPhrase"] = config.SignerEncryptionPhrase + }; + + // Add DNS provider credentials based on selected provider + var dns = config.DnsProviderSettings; + + if (dns.Google != null) + { + dict["Google_ServiceAccountKeyPath"] = dns.Google.ServiceAccountKeyPath ?? ""; + dict["Google_ProjectId"] = dns.Google.ProjectId ?? ""; + } + + if (dns.Cloudflare != null) + { + dict["Cloudflare_ApiToken"] = dns.Cloudflare.ApiToken ?? ""; + } + + if (dns.AwsRoute53 != null) + { + dict["AwsRoute53_AccessKey"] = dns.AwsRoute53.AccessKeyId ?? ""; + dict["AwsRoute53_SecretKey"] = dns.AwsRoute53.SecretAccessKey ?? ""; + } + + if (dns.Azure != null) + { + dict["Azure_ClientId"] = dns.Azure.ClientId ?? ""; + dict["Azure_TenantId"] = dns.Azure.TenantId ?? ""; + dict["Azure_SubscriptionId"] = dns.Azure.SubscriptionId ?? ""; + dict["Azure_ClientSecret"] = dns.Azure.ClientSecret ?? ""; + } + + if (dns.Ns1 != null) + { + dict["Ns1_ApiKey"] = dns.Ns1.ApiKey ?? ""; + } + + return dict; + } + + private static AcmeProviderSettings GetSelectedAcmeProvider(AcmeConfig config) + { + var providers = config.AcmeProviders; + + return config.AcmeProvider.ToLower() switch + { + "letsencrypt" => providers.LetsEncrypt ?? throw new InvalidOperationException("Let's Encrypt configuration not found"), + "buypass" => providers.Buypass ?? throw new InvalidOperationException("Buypass configuration not found"), + "zerossl" => providers.ZeroSsl ?? throw new InvalidOperationException("ZeroSSL configuration not found"), + "googlecas" => providers.GoogleCas ?? throw new InvalidOperationException("Google CAS configuration not found"), + "custom" => providers.Custom ?? throw new InvalidOperationException("Custom ACME provider configuration not found"), + _ => throw new InvalidOperationException($"Unknown ACME provider: {config.AcmeProvider}") + }; + } + + // === Configuration Classes === + public class AcmeConfig + { + public bool RunEnroll { get; set; } + public string Domain { get; set; } = ""; + public int KeySize { get; set; } + public string AcmeProvider { get; set; } = ""; + public AcmeProvidersSettings AcmeProviders { get; set; } = new(); + public string DnsProvider { get; set; } = ""; + public DnsProviderSettings DnsProviderSettings { get; set; } = new(); + public string SignerEncryptionPhrase { get; set; } = ""; + } + + public class AcmeProvidersSettings + { + public AcmeProviderSettings? LetsEncrypt { get; set; } + public AcmeProviderSettings? Buypass { get; set; } + public AcmeProviderSettings? ZeroSsl { get; set; } + public AcmeProviderSettings? GoogleCas { get; set; } + public AcmeProviderSettings? Custom { get; set; } + } + + public class AcmeProviderSettings + { + public string DirectoryUrl { get; set; } = ""; + public string Email { get; set; } = ""; + public string? EabKid { get; set; } + public string? EabHmacKey { get; set; } + public string? Description { get; set; } + } + + public class DnsProviderSettings + { + public GoogleDnsSettings? Google { get; set; } + public CloudflareDnsSettings? Cloudflare { get; set; } + public AwsRoute53Settings? AwsRoute53 { get; set; } + public AzureDnsSettings? Azure { get; set; } + public Ns1DnsSettings? Ns1 { get; set; } + } + + public class GoogleDnsSettings + { + public string? ServiceAccountKeyPath { get; set; } + public string? ProjectId { get; set; } + } + + public class CloudflareDnsSettings + { + public string? ApiToken { get; set; } + } + + public class AwsRoute53Settings + { + public string? AccessKeyId { get; set; } + public string? SecretAccessKey { get; set; } + public string Region { get; set; } = "us-east-1"; + } + + public class AzureDnsSettings + { + public string? TenantId { get; set; } + public string? ClientId { get; set; } + public string? SubscriptionId { get; set; } + public string? ClientSecret { get; set; } + } + + public class Ns1DnsSettings + { + public string? ApiKey { get; set; } + } + + // === Local config provider === + private class MockConfigProvider : IAnyCAPluginConfigProvider + { + public MockConfigProvider(Dictionary config) => + CAConnectionData = config; + + public Dictionary CAConnectionData { get; } + public Dictionary CertificateAuthorityData => new(); + public Dictionary Metadata => new(); + } + + // === CSR helper === + public static class CsrHelper + { + public static string GenerateCsrBase64(string domainName, List sanNames, int keySize, out string privateKeyPem) + { + var keyPairGenerator = new Org.BouncyCastle.Crypto.Generators.RsaKeyPairGenerator(); + keyPairGenerator.Init(new Org.BouncyCastle.Crypto.KeyGenerationParameters(new SecureRandom(), keySize)); + AsymmetricCipherKeyPair keyPair = keyPairGenerator.GenerateKeyPair(); + + var subject = new X509Name($"CN={domainName}"); + + var sanBuilder = new GeneralNames( + sanNames.ConvertAll(name => new GeneralName(GeneralName.DnsName, name)).ToArray() + ); + var extensionsGenerator = new X509ExtensionsGenerator(); + extensionsGenerator.AddExtension( + X509Extensions.SubjectAlternativeName, + false, + sanBuilder + ); + var extensions = extensionsGenerator.Generate(); + var attrSet = new AttributePkcs(PkcsObjectIdentifiers.Pkcs9AtExtensionRequest, new DerSet(extensions)); + + var csr = new Pkcs10CertificationRequest( + "SHA256WITHRSA", + subject, + keyPair.Public, + new DerSet(attrSet), + keyPair.Private + ); + + byte[] csrDer = csr.GetDerEncoded(); + string csrBase64 = Convert.ToBase64String(csrDer); + + StringBuilder sb = new StringBuilder(); + using (var writer = new StringWriter(sb)) + { + var pemWriter = new PemWriter(writer); + pemWriter.WriteObject(keyPair.Private); + pemWriter.Writer.Flush(); + privateKeyPem = sb.ToString(); + } + + return csrBase64; + } + } +} \ No newline at end of file diff --git a/TestProgram/TestProgram.csproj b/TestProgram/TestProgram.csproj new file mode 100644 index 0000000..127834b --- /dev/null +++ b/TestProgram/TestProgram.csproj @@ -0,0 +1,21 @@ + + + + Exe + net6.0;net8.0 + enable + enable + + + + + + + + + + + + + + diff --git a/cagateway-template.sln b/cagateway-template.sln deleted file mode 100644 index b953ecb..0000000 --- a/cagateway-template.sln +++ /dev/null @@ -1,32 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.31729.503 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cagateway-template", "cagateway-template\cagateway-template.csproj", "{9D2D6ED9-4626-430C-879D-0FE0FEBED146}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{431498A1-F30A-4307-9FBF-B1D634326444}" - ProjectSection(SolutionItems) = preProject - CHANGELOG.md = CHANGELOG.md - integration-manifest.json = integration-manifest.json - readme_source.md = readme_source.md - EndProjectSection -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {9D2D6ED9-4626-430C-879D-0FE0FEBED146}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {9D2D6ED9-4626-430C-879D-0FE0FEBED146}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9D2D6ED9-4626-430C-879D-0FE0FEBED146}.Release|Any CPU.ActiveCfg = Release|Any CPU - {9D2D6ED9-4626-430C-879D-0FE0FEBED146}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {5D2E21F6-120F-4B71-A596-991879B03943} - EndGlobalSection -EndGlobal diff --git a/cagateway-template/APIProxy/ProductNameBaseCall.cs b/cagateway-template/APIProxy/ProductNameBaseCall.cs deleted file mode 100644 index 1b92523..0000000 --- a/cagateway-template/APIProxy/ProductNameBaseCall.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Newtonsoft.Json; - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Keyfactor.Extensions.AnyGateway.Company.Product.APIProxy -{ - public abstract class ProductNameBaseRequest - { - [JsonIgnore] - public string Resource { get; internal set; } - - [JsonIgnore] - public string Method { get; internal set; } - - [JsonIgnore] - public string targetURI { get; set; } - - public string BuildParameters() - { - return ""; - } - } -} \ No newline at end of file diff --git a/cagateway-template/Client/ProductNameClient.cs b/cagateway-template/Client/ProductNameClient.cs deleted file mode 100644 index cbd16ab..0000000 --- a/cagateway-template/Client/ProductNameClient.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Keyfactor.Extensions.AnyGateway.Company.Product.Client -{ - public class ProductNameClient - { - } -} \ No newline at end of file diff --git a/cagateway-template/Constants.cs b/cagateway-template/Constants.cs deleted file mode 100644 index af6c50e..0000000 --- a/cagateway-template/Constants.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Keyfactor.Extensions.AnyGateway.Company.Product -{ - public class Constants - { - //Define any constants needed here (mostly field names for config parameters) - } -} \ No newline at end of file diff --git a/cagateway-template/GatewayNameCAConnector.cs b/cagateway-template/GatewayNameCAConnector.cs deleted file mode 100644 index abbe2ab..0000000 --- a/cagateway-template/GatewayNameCAConnector.cs +++ /dev/null @@ -1,193 +0,0 @@ -using CAProxy.AnyGateway; -using CAProxy.AnyGateway.Interfaces; -using CAProxy.AnyGateway.Models; -using CAProxy.AnyGateway.Models.Configuration; -using CAProxy.Common; - -using CSS.PKI; - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -using GatewayNameConstants = Keyfactor.Extensions.AnyGateway.Company.Product.Constants; - -namespace Keyfactor.Extensions.AnyGateway.Company.Product -{ - public class GatewayNameCAConnector : BaseCAConnector, ICAConnectorConfigInfoProvider - { - #region Fields and Constructors - - /// - /// Provides configuration information for the - /// - private ICAConnectorConfigProvider ConfigProvider { get; set; } - - //Define any additional private fields here - - #endregion Fields and Constructors - - #region ICAConnector Methods - - /// - /// Initialize the - /// - /// The config provider contains information required to connect to the CA. - public override void Initialize(ICAConnectorConfigProvider configProvider) - { - ConfigProvider = configProvider; - } - - /// - /// Enrolls for a certificate through the API. - /// - /// Reads certificate data from the database. - /// The certificate request CSR in PEM format. - /// The subject of the certificate request. - /// Any SANs added to the request. - /// Information about the CA product type. - /// The format of the request. - /// The type of the enrollment, i.e. new, renew, or reissue. - /// - public override EnrollmentResult Enroll(ICertificateDataReader certificateDataReader, string csr, string subject, Dictionary san, EnrollmentProductInfo productInfo, PKIConstants.X509.RequestFormat requestFormat, RequestUtilities.EnrollmentType enrollmentType) - { - throw new NotImplementedException(); - } - - /// - /// Returns a single certificate record by its serial number. - /// - /// The CA request ID for the certificate. - /// - public override CAConnectorCertificate GetSingleRecord(string caRequestID) - { - throw new NotImplementedException(); - } - - /// - /// Attempts to reach the CA over the network. - /// - public override void Ping() - { - throw new NotImplementedException(); - } - - /// - /// Revokes a certificate by its serial number. - /// - /// The CA request ID. - /// The hex-encoded serial number. - /// The revocation reason. - /// - public override int Revoke(string caRequestID, string hexSerialNumber, uint revocationReason) - { - throw new NotImplementedException(); - } - - /// - /// Synchronizes the gateway with the external CA - /// - /// Provides information about the gateway's certificate database. - /// Buffer into which certificates are places from the CA. - /// Information about the last CA sync. - /// The cancellation token. - public override void Synchronize(ICertificateDataReader certificateDataReader, BlockingCollection blockingBuffer, CertificateAuthoritySyncInfo certificateAuthoritySyncInfo, CancellationToken cancelToken) - { - throw new NotImplementedException(); - } - - /// - /// Validates that the CA connection info is correct. - /// - /// The information used to connect to the CA. - public override void ValidateCAConnectionInfo(Dictionary connectionInfo) - { - throw new NotImplementedException(); - } - - /// - /// Validates that the product information for the CA is correct - /// - /// The product information. - /// The CA connection information. - public override void ValidateProductInfo(EnrollmentProductInfo productInfo, Dictionary connectionInfo) - { - throw new NotImplementedException(); - } - - [Obsolete] - public override EnrollmentResult Enroll(string csr, string subject, Dictionary san, EnrollmentProductInfo productInfo, PKIConstants.X509.RequestFormat requestFormat, RequestUtilities.EnrollmentType enrollmentType) - { - throw new NotImplementedException(); - } - - [Obsolete] - public override void Synchronize(ICertificateDataReader certificateDataReader, BlockingCollection blockingBuffer, CertificateAuthoritySyncInfo certificateAuthoritySyncInfo, CancellationToken cancelToken, string logicalName) - { - throw new NotImplementedException(); - } - - #endregion ICAConnector Methods - - #region ICAConnectorConfigInfoProvider Methods - - /// - /// Returns the default CA connector section of the config file. - /// - /// - public Dictionary GetDefaultCAConnectorConfig() - { - return new Dictionary() - { - }; - } - - /// - /// Gets the default comment on the default product type. - /// - /// - public string GetProductIDComment() - { - return ""; - } - - /// - /// Gets annotations for the CA connector properties. - /// - /// - public Dictionary GetCAConnectorAnnotations() - { - return new Dictionary(); - } - - /// - /// Gets annotations for the template mapping parameters - /// - /// - public Dictionary GetTemplateParameterAnnotations() - { - throw new NotImplementedException(); - } - - /// - /// Gets default template map parameters for GlobalSign Atlas product types. - /// - /// - public Dictionary GetDefaultTemplateParametersConfig() - { - throw new NotImplementedException(); - } - - #endregion ICAConnectorConfigInfoProvider Methods - - #region Helper Methods - - // All private helper methods go here - - #endregion Helper Methods - } -} \ No newline at end of file diff --git a/cagateway-template/Properties/AssemblyInfo.cs b/cagateway-template/Properties/AssemblyInfo.cs deleted file mode 100644 index 8b68512..0000000 --- a/cagateway-template/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("cagateway-template")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("cagateway-template")] -[assembly: AssemblyCopyright("Copyright © 2022")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("9d2d6ed9-4626-430c-879d-0fe0febed146")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/cagateway-template/app.config b/cagateway-template/app.config deleted file mode 100644 index ad48466..0000000 --- a/cagateway-template/app.config +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/cagateway-template/cagateway-template.csproj b/cagateway-template/cagateway-template.csproj deleted file mode 100644 index a71a7f5..0000000 --- a/cagateway-template/cagateway-template.csproj +++ /dev/null @@ -1,93 +0,0 @@ - - - - - Debug - AnyCPU - {9D2D6ED9-4626-430C-879D-0FE0FEBED146} - Library - Properties - cagateway_template - cagateway-template - v4.7.2 - 512 - true - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - - - ..\packages\BouncyCastle.1.8.9\lib\BouncyCastle.Crypto.dll - - - ..\packages\Keyfactor.AnyGateway.SDK.21.3.2\lib\net462\CAProxy.AnyGateway.Core.dll - - - ..\packages\Keyfactor.AnyGateway.SDK.21.3.2\lib\net462\CAProxy.Interfaces.dll - - - ..\packages\Keyfactor.AnyGateway.SDK.21.3.2\lib\net462\CAProxyDAL.dll - - - ..\packages\Common.Logging.3.4.1\lib\net40\Common.Logging.dll - - - ..\packages\Common.Logging.Core.3.4.1\lib\net40\Common.Logging.Core.dll - - - ..\packages\Keyfactor.AnyGateway.SDK.21.3.2\lib\net462\CommonCAProxy.dll - - - ..\packages\CSS.Common.1.7.0\lib\net462\CSS.Common.dll - - - ..\packages\CSS.PKI.2.13.0\lib\net462\CSS.PKI.dll - - - ..\packages\Keyfactor.Logging.1.1.0\lib\netstandard2.0\Keyfactor.Logging.dll - - - ..\packages\Microsoft.Extensions.Logging.Abstractions.5.0.0\lib\net461\Microsoft.Extensions.Logging.Abstractions.dll - - - ..\packages\Newtonsoft.Json.12.0.3\lib\net45\Newtonsoft.Json.dll - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/cagateway-template/packages.config b/cagateway-template/packages.config deleted file mode 100644 index 5fd12f1..0000000 --- a/cagateway-template/packages.config +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/docsource/configuration.md b/docsource/configuration.md new file mode 100644 index 0000000..56b652b --- /dev/null +++ b/docsource/configuration.md @@ -0,0 +1,474 @@ +## Overview +The **Keyfactor ACME CA Gateway Plugin** enables certificate enrollment using the [ACME protocol (RFC 8555)](https://datatracker.ietf.org/doc/html/rfc8555), providing automated certificate issuance via any compliant Certificate Authority. This plugin is designed for **enrollment-only workflows** — it **does not support synchronization or revocation** of certificates. + +### 🔧 What It Does +This plugin allows Keyfactor Gateways to: +- Submit CSRs to ACME-based CAs. +- Complete domain validation via DNS-01 challenges. +- Automatically retrieve and return signed certificates. + +Once a certificate is issued, the plugin returns the PEM-encoded certificate to the Gateway. + +### ✅ ACME Providers Tested +This plugin has been tested and confirmed to work with the following ACME providers: +- **Let's Encrypt** +- **Google ACME (Certificate Authority Service)** +- **ZeroSSL** (functional but known slowness may cause timeouts) +- **Buypass** + +It is designed to be provider-agnostic and should work with any standards-compliant ACME server. + +### 🌐 Supported DNS Providers (Initial Release) +DNS-01 challenge automation is supported through the following providers: +- **Google Cloud DNS** +- **AWS Route 53** +- **Azure DNS** +- **Cloudflare** +- **NS1** + +Additional DNS providers can be added by extending the included `IDnsProvider` interface. + +--- + +### 🔁 Enrollment Flow Summary + +```text +1. Keyfactor Gateway submits CSR and SAN metadata to plugin. +2. Plugin initializes ACME client and creates a new order. +3. For each domain: + a. Retrieve DNS-01 challenge. + b. Use the configured DNS provider to publish challenge record. + c. Wait for DNS propagation and validate record. + d. Notify ACME provider to trigger validation. +4. Once all challenges are valid, finalize the order using CSR. +5. Download the signed certificate from ACME provider. +6. Return PEM certificate to the Gateway. +``` + +The plugin uses a modular design that separates ACME communication logic and DNS challenge automation, allowing for future extensibility in both areas. + +> ⚠️ Revocation, certificate synchronization, and renewal tracking are intentionally **not implemented** in this plugin. All lifecycle tracking must be handled externally (e.g., via Keyfactor monitoring or Gateway automation). + +## Compatibility + +The Acme AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 24.2 and later. + + +## Requirements + +### DNS Providers + +This plugin automates DNS-01 challenges using pluggable DNS provider implementations. These providers create and remove TXT records to prove domain control to ACME servers. + +
+✅ Supported DNS Providers (Initial Release) + +| Provider | Auth Methods Supported | Config Keys Required | +|--------------|-----------------------------------------------|--------------------------------------------------------| +| Google DNS | Service Account Key or ADC | `Google_ServiceAccountKeyPath`, `Google_ProjectId` | +| AWS Route 53 | Access Key/Secret or IAM Role | `AwsRoute53_AccessKey`, `AwsRoute53_SecretKey` | +| Azure DNS | Client Secret or Managed Identity | `Azure_TenantId`, `Azure_ClientId`, `Azure_ClientSecret`, `Azure_SubscriptionId` | +| Cloudflare | API Token | `Cloudflare_ApiToken` | +| NS1 | API Key | `Ns1_ApiKey` | + +
+ +
+⏱ DNS Propagation Logic + +Before submitting ACME challenges, the plugin verifies DNS propagation using multiple public resolvers (Google, Cloudflare, OpenDNS, Quad9). A record must be visible on **at least 3 servers** to proceed, with up to **3 retries** spaced by 10 seconds. + +This logic is handled by the `DnsVerificationHelper` class and ensures a high-confidence validation before proceeding. + +
+ +
+🔑 Credential Flow + +Each provider supports multiple credential strategies: + +- **Google DNS**: + - ✅ **Service Account Key** (via `Google_ServiceAccountKeyPath`) + - ✅ **Application Default Credentials** (e.g., GCP Workload Identity or developer auth) + +- **AWS Route 53**: + - ✅ **Access/Secret Keys** (`AwsRoute53_AccessKey`, `AwsRoute53_SecretKey`) + - ✅ **IAM Role via EC2 Instance Metadata** (no explicit credentials) + +- **Azure DNS**: + - ✅ **Client Secret** (explicit `TenantId`, `ClientId`, `ClientSecret`) + - ✅ **Managed Identity** or environment-based credentials via `DefaultAzureCredential` + +- **Cloudflare**: + - ✅ **Bearer API Token** for zone-level DNS control + +- **NS1**: + - ✅ **API Key** passed in header `X-NSONE-Key` + +
+ +
+🧩 Adding New DNS Providers + +To add support for new DNS services: + +1. Implement the `IDnsProvider` interface: + ```csharp + public interface IDnsProvider + { + Task CreateRecordAsync(string recordName, string txtValue); + Task DeleteRecordAsync(string recordName); + } + ``` + +2. Register the new provider in the `DnsProviderFactory`: + ```csharp + case "yourprovider": + return new YourCustomDnsProvider(config.YourProviderConfigValues...); + ``` + +3. Use zone detection logic similar to `GoogleDnsProvider`, `AzureDnsProvider`, or `Ns1DnsProvider`. + +Each provider is instantiated dynamically based on the `DnsProvider` field in the `AcmeClientConfig`. + +> 🔁 This modular DNS system ensures challenge automation works across cloud providers and is easily extensible. + +
+ +
+🔒 CA-Level DNS Provider Binding + +Each ACME/DNS combination is supported **at the CA level**, meaning that only **one DNS provider** is configured per CA entry in Keyfactor. This ensures a clear and isolated challenge path for each ACME CA connector instance. + +If you need to support multiple DNS zones/providers (e.g., both AWS and Cloudflare), configure **separate CA entries**, each with its own DNS provider configuration. + +
+ +
+🚫 No Offline Challenge Retry (Initial Release) + +In this initial release, there is **no background or offline retry** for ACME challenges that timeout. If DNS propagation takes too long and the challenge is not verified in time, the certificate **request will fail immediately**. + +> ⚠️ However, in testing across all supported DNS providers and ACME services (e.g., Let's Encrypt, Google CAS, ZeroSSL, Buypass), propagation has been fast enough to avoid these timeouts in all observed cases. + +
+ +--- + +### ACME Provider Configuration + +Each ACME CA (Certificate Authority) has slightly different expectations for account creation and request handling. This plugin supports multiple providers and dynamically handles credentials based on your configuration. + +
+🧩 External Account Binding (EAB) Support + +Some providers **require** External Account Binding (EAB), which includes: +- `eabKid`: External Account Binding Key ID +- `eabHmacKey`: HMAC Key to sign the JWK thumbprint + +Others **do not require EAB**, and can create accounts automatically with just an email address. + +
+ +
+✅ Supported Providers & Credential Expectations + +| Provider | Directory URL | Requires EAB | Notes | +|----------------|----------------------------------------------------------------|--------------|-----------------------------------------------------------------------| +| Let's Encrypt | `https://acme-v02.api.letsencrypt.org/directory` | ❌ No | Free and public; account created using only an email address | +| Buypass | `https://api.buypass.com/acme/directory` | ❌ No | Free and public; supports long-lived certs; no EAB required | +| ZeroSSL | `https://acme.zerossl.com/v2/DV90/directory` | ✅ Yes | Requires EAB; keys available via [ZeroSSL Developer Portal](https://zerossl.com) | +| Google CAS | `https://dv.acme-v02.api.pki.goog/directory` | ✅ Yes | Requires EAB; keys issued via [Google CAS UI](https://console.cloud.google.com) | + +> ⚠️ If a provider requires EAB and it is not supplied, the request will fail during account registration. + +
+ +
+📋 Configuration Fields (Per ACME Provider) + +These values are set in the Keyfactor Command Gateway Configuration UI for each ACME provider: + +| Field | Description | Required | +|---------------|---------------------------------------------------|-----------------| +| `directoryUrl`| The full ACME directory URL for the CA | ✅ Yes | +| `email` | Account email address for ACME registration | ✅ Yes | +| `eabKid` | External Account Binding Key ID (if applicable) | 🚫 Only if EAB | +| `eabHmacKey` | HMAC key used to sign EAB binding (if applicable) | 🚫 Only if EAB | + +
+ +
+🔐 How to Get EAB Credentials + +- **ZeroSSL**: + Log into your account and go to **"ACME EAB Credentials"** in the developer section. + +- **Google CAS**: + Enable your CA Pool for ACME and generate EAB credentials under the **ACME Integration** tab in Google Cloud Console. + +
+ +
+⚙️ Plugin Behavior + +- If both `eabKid` and `eabHmacKey` are provided, they will be used to create the ACME account. +- If either is omitted and the provider requires it, account creation will fail. +- If neither is provided and the provider does not require EAB, the account will be created using only the email. + +Each provider is configured in the JSON config under `acmeProviders`, and only **one provider** is active per enrollment. + +
+ +--- + +### Account Storage and Signer Encryption + +This ACME Gateway implementation uses a local file-based store to persist ACME accounts and their associated cryptographic signers. Accounts are cached on disk using a structured format, and signers (private keys) can be encrypted with a passphrase for enhanced security. + +
+📁 Account Directory Structure + +Each account is saved in its own directory within: + +``` +%APPDATA%\AcmeAccounts\{host}_{accountId} +``` + +Where: +- `{host}` is the ACME directory host with dots replaced by dashes (e.g., `acme-zerossl-com`) +- `{accountId}` is the final segment of the account's KID URL + +
+ +
+📄 Files per Account + +- `Registration_v2`: Contains serialized `AccountDetails` in JSON format +- `Signer_v2`: Contains encrypted or plaintext signer key material, depending on passphrase usage +- `default_{host}.txt`: Tracks the default account for a given ACME directory host + +
+ +
+🔐 Encryption with Passphrase + +If the `SignerEncryptionPhrase` configuration value is set, the plugin encrypts signer files (`Signer_v2`) using AES with a PBKDF2-derived key and IV. The encrypted data includes a prepended salt and IV to support cross-platform decryption. + +```text +[Salt (16 bytes)] [IV (16 bytes)] [AES-CBC encrypted signer JSON] +``` + +The encryption ensures that even if the account files are accessed on disk, the private keys remain unreadable without the configured passphrase. + +
+ +
+🔗 External Account Binding (EAB) + +For ACME providers requiring EAB (e.g., ZeroSSL, Google CAS), the gateway constructs a manually signed JWS payload containing: + +- Protected Header: `alg`, `kid`, `url` +- Payload: Public JWK of the account signer +- Signature: HMAC using `eabHmacKey` + +This JWS is included during account creation to bind the account to the pre-provisioned identity provided by the CA. + +
+ +
+⚙️ Algorithm Support + +- Signers support `ES256`, `ES384`, `ES512` (ECDSA) and `RS256`, `RS384`, `RS512` (RSA) +- EAB HMAC support includes `HS256`, `HS384`, `HS512` + +If `ES256` key generation fails (e.g., due to platform constraints), the system automatically falls back to `RS256`. + +
+ +### Account Caching and Auto-Creation + +On startup or during enrollment/sync, the plugin: + +1. Attempts to load a cached account for the specified ACME directory. +2. If no account is found, it automatically creates a new one, using EAB if configured. +3. The new account is saved to disk and set as default for future use. + +
+🔗 External Account Binding (EAB) + +For ACME providers requiring EAB (e.g., ZeroSSL, Google CAS), the gateway constructs a manually signed JWS payload containing: + +- Protected Header: `alg`, `kid`, `url` +- Payload: Public JWK of the account signer +- Signature: HMAC using `eabHmacKey` + +This JWS is included during account creation to bind the account to the pre-provisioned identity provided by the CA. + +
+ +
+🔧 Algorithm Support + +- Signers support `ES256`, `ES384`, `ES512` (ECDSA) and `RS256`, `RS384`, `RS512` (RSA) +- EAB HMAC support includes `HS256`, `HS384`, `HS512` + +If `ES256` key generation fails (e.g., due to platform constraints), the system automatically falls back to `RS256`. + +
+ +### Network and File System Requirements + +This section outlines all required ports, file access, permissions, and validation behaviors for operating the ACME Gateway Plugin in a Keyfactor Orchestrator environment. + +
+🔌 Port Usage + +#### Incoming Connections + +- **None.** This plugin does not expose any HTTP or network listeners. + +#### Outgoing Connections + +| Protocol | Port | Target | Purpose | +|----------|------|------------------------------|-----------------------------------------------------| +| HTTPS | 443 | ACME Directory URL | Connect to the ACME CA for account, challenge, and certificate operations | +| HTTPS | 443 | DNS Provider APIs | Used for DNS-01 challenge automation (Google DNS, AWS, etc.) | + +
+ +
+💾 File System Requirements + +#### Directory Layout + +| Path | Purpose | +|----------------------------------------------------|----------------------------------------------| +| `%APPDATA%\AcmeAccounts\` | Default base path for ACME account storage | +| `AcmeAccounts\{account_id}\Registration_v2` | Contains serialized ACME account metadata | +| `AcmeAccounts\{account_id}\Signer_v2` | Contains the encrypted private signer key | +| `AcmeAccounts\default_{host}.txt` | Stores the default account pointer for a given directory | + +#### File Access & Permissions + +| Path | Operation | Required Permission | +|--------------------------|-----------|---------------------| +| Account directory | Create | `Write` | +| Account files | Read/Write| `Read`, `Write` | + +- Files may be optionally encrypted using AES if a passphrase is configured. +- Ensure the service account under which the orchestrator runs has read/write access to `%APPDATA%` or the custom configured base path. + +
+ +
+👤 Windows Account Permissions + +- The orchestrator service account (usually `NT AUTHORITY\SYSTEM` or a custom `Network Service`) must have: + - File I/O permissions to read/write within the configured base directory. + - Network access to ACME CA endpoints and DNS APIs over HTTPS. + - DNS provider credentials (Cloudflare API token, Google credentials, etc.) stored securely. + +
+ +
+🌐 DNS Propagation Check Behavior + +- **Initial Release Behavior**: + - DNS challenge propagation is checked during the interactive enrollment phase only. + - If propagation takes too long (> 60s), the request will fail. No deferred background polling occurs. + - There is **no offline retry mechanism** (e.g., for sync jobs) to pick up completed validations that succeeded after a delay. + +- **Future Considerations**: + - Support for file-based or database-backed challenge persistence may be added to allow background sync to re-check and finalize challenge state. + +
+ + +## Gateway Registration + +Each ACME CA issues certificates that chain to a specific intermediate and root certificate. For trust validation and proper integration with the Keyfactor Gateway, the following steps are required for **every ACME CA** used in your environment. + +--- + +### 🔍 Retrieving Root and Intermediate Certificates + +Here is how to obtain the root and intermediate CA certificates from supported ACME providers: + +#### Let's Encrypt + +- **Root**: ISRG Root X1 +- **Intermediate**: R3 + +**How to Get:** +- Browse to: https://letsencrypt.org/certificates/ +- Download both the **ISRG Root X1** and **R3 Intermediate Certificate (PEM format)**. + +#### Google Certificate Authority Service (CAS) + +- **Root** and **Intermediate** are custom per CA Pool. + +**How to Get:** +1. In the [Google Cloud Console](https://console.cloud.google.com/security/privateca), navigate to your CA pool. +2. Click the CA name and go to the **Certificates** tab. +3. Download the **root** and **intermediate** certificates for the issuing CA in PEM format. + +#### ZeroSSL + +- **Root**: USERTrust RSA Certification Authority +- **Intermediate**: ZeroSSL RSA Domain Secure Site CA + +**How to Get:** +- Visit: https://zerossl.com +- Download the full certificate chain in PEM format. +- Extract individual certs if needed using OpenSSL or a text editor. + +#### Buypass + +- **Root**: Buypass Class 3 Root CA +- **Intermediate**: Buypass Class 3 CA 1 / G2 (depends on issuance) + +**How to Get:** +- Go to: https://www.buypass.com +- Download both root and intermediate in PEM or DER format. + +--- + +### 🧩 Installing Certificates on the Keyfactor Gateway Server + +Once downloaded, the **root and intermediate certificates must be installed** in the proper Windows certificate stores on the Gateway server. + +#### Steps: + +1. **Open** `certlm.msc` (Local Computer Certificates) +2. Install the **Root CA certificate** into: + - `Trusted Root Certification Authorities` → `Certificates` +3. Install the **Intermediate CA certificate** into: + - `Intermediate Certification Authorities` → `Certificates` + +You can import certificates using the GUI or PowerShell: + +```powershell +Import-Certificate -FilePath "C:\path\to\intermediate.crt" -CertStoreLocation "Cert:\LocalMachine\CA" +Import-Certificate -FilePath "C:\path\to\root.crt" -CertStoreLocation "Cert:\LocalMachine\Root" +``` + +--- + +### 🔑 Using the Intermediate Thumbprint + +When registering a new CA in Keyfactor Command: + +- You must specify the **thumbprint** of the Intermediate CA certificate. +- This is used to associate issued certificates with the correct issuing chain. + +**How to Get the Thumbprint:** + +1. In `certlm.msc`, open the certificate under **Intermediate Certification Authorities**. +2. Go to **Details** tab → Scroll to **Thumbprint**. +3. Copy the hex string (ignore spaces). + +--- + +⚠️ All certificate chains must be trusted by the Gateway OS. If the intermediate is missing or untrusted, issuance will fail or returned certificates may not chain properly. + diff --git a/integration-manifest.json b/integration-manifest.json index 4beca57..9be0a09 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -1,12 +1,87 @@ { - "$schema": "https://keyfactor.github.io/integration-manifest-schema.json", - "integration_type": "ca-gateway", - "name": "", - "status": "prototype", - "support_level": "community", - "link_github": false, - "update_catalog": false, - "description": "", - "gateway_framework": "10.x.x", - "release_dir": "UPDATE-THIS-WITH-PATH-TO-BINARY-BUILD-FOLDER" -} + "$schema": "https://keyfactor.github.io/v2/integration-manifest-schema.json", + "name": "Acme AnyCA REST plugin", + "release_dir": "AcmeCaPlugin/bin/Release", + "release_project": "AcmeCaPlugin/AcmeCaPlugin.csproj", + "description": "Enrollment Only AnyCA Gateway REST plugin that works with multiple ACME Providers and DNS Providers", + "status": "production", + "integration_type": "anyca-plugin", + "support_level": "kf-supported", + "link_github": true, + "update_catalog": true, + "gateway_framework": "24.2", + "about": { + "carest": { + "ca_plugin_config": [ + { + "name": "DirectoryUrl", + "description": "ACME directory URL (e.g. Let's Encrypt, ZeroSSL, etc.)" + }, + { + "name": "Email", + "description": "Email for ACME account registration." + }, + { + "name": "EabKid", + "description": "External Account Binding Key ID (optional)" + }, + { + "name": "EabHmacKey", + "description": "External Account Binding HMAC key (optional)" + }, + { + "name": "SignerEncryptionPhrase", + "description": "Used to encrypt singer information when account is saved to disk (optional)" + }, + { + "name": "DnsProvider", + "description": "DNS Provider to use for ACME DNS-01 challenges (options Google, Cloudflare, AwsRoute53, Azure, Ns1)" + }, + { + "name": "Google_ServiceAccountKeyPath", + "description": "Google Cloud DNS: Path to service account JSON key file only if using Google DNS (Optional)" + }, + { + "name": "Google_ProjectId", + "description": "Google Cloud DNS: Project ID only if using Google DNS (Optional)" + }, + { + "name": "Cloudflare_ApiToken", + "description": "Cloudflare DNS: API Token only if using Cloudflare DNS (Optional)" + }, + { + "name": "Azure_ClientId", + "description": "Azure DNS: ClientId only if using Azure DNS and Not Managed Itentity in Azure (Optional)" + }, + { + "name": "Azure_ClientSecret", + "description": "Azure DNS: ClientSecret only if using Azure DNS and Not Managed Itentity in Azure (Optional)" + }, + { + "name": "Azure_SubscriptionId", + "description": "Azure DNS: SubscriptionId only if using Azure DNS and Not Managed Itentity in Azure (Optional)" + }, + { + "name": "Azure_TenantId", + "description": "Azure DNS: TenantId only if using Azure DNS and Not Managed Itentity in Azure (Optional)" + }, + { + "name": "AwsRoute53_AccessKey", + "description": "Aws DNS: Access Key only if not using AWS DNS and default AWS Chain Creds on AWS (Optional)" + }, + { + "name": "AwsRoute53_SecretKey", + "description": "Aws DNS: Secret Key only if using AWS DNS and not using default AWS Chain Creds on AWS (Optional)" + }, + { + "name": "Ns1_ApiKey", + "description": "Ns1 DNS: Api Key only if Using Ns1 DNS (Optional)" + } + ], + "enrollment_config": [], + "product_ids": [ + "default" + ] + } + } +} \ No newline at end of file diff --git a/readme_source.md b/readme_source.md deleted file mode 100644 index 757b425..0000000 --- a/readme_source.md +++ /dev/null @@ -1,130 +0,0 @@ -# Introduction -This AnyGateway plug-in enables issuance, revocation, and synchronization of certificates from offering. -# Prerequisites - -## Certificate Chain - -In order to enroll for certificates the Keyfactor Command server must trust the trust chain. Once you create your Root and/or Subordinate CA, make sure to import the certificate chain into the AnyGateway and Command Server certificate store - - -# Install -* Download latest successful build from [GitHub Releases](../../releases/latest) - -* Copy .dll to the Program Files\Keyfactor\Keyfactor AnyGateway directory - -* Update the CAProxyServer.config file - * Update the CAConnection section to point at the DigiCertCAProxy class - ```xml - - ``` - -# Configuration -The following sections will breakdown the required configurations for the AnyGatewayConfig.json file that will be imported to configure the AnyGateway. - -## Templates -The Template section will map the CA's products to an AD template. -* ```ProductID``` -This is the ID of the product to map to the specified template. - - ```json - "Templates": { - "WebServer": { - "ProductID": "", - "Parameters": { - } - } -} - ``` - -## Security -The security section does not change specifically for the CA Gateway. Refer to the AnyGateway Documentation for more detail. -```json - /*Grant permissions on the CA to users or groups in the local domain. - READ: Enumerate and read contents of certificates. - ENROLL: Request certificates from the CA. - OFFICER: Perform certificate functions such as issuance and revocation. This is equivalent to "Issue and Manage" permission on the Microsoft CA. - ADMINISTRATOR: Configure/reconfigure the gateway. - Valid permission settings are "Allow", "None", and "Deny".*/ - "Security": { - "Keyfactor\\Administrator": { - "READ": "Allow", - "ENROLL": "Allow", - "OFFICER": "Allow", - "ADMINISTRATOR": "Allow" - }, - "Keyfactor\\gateway_test": { - "READ": "Allow", - "ENROLL": "Allow", - "OFFICER": "Allow", - "ADMINISTRATOR": "Allow" - }, - "Keyfactor\\SVC_TimerService": { - "READ": "Allow", - "ENROLL": "Allow", - "OFFICER": "Allow", - "ADMINISTRATOR": "None" - }, - "Keyfactor\\SVC_AppPool": { - "READ": "Allow", - "ENROLL": "Allow", - "OFFICER": "Allow", - "ADMINISTRATOR": "Allow" - } - } -``` -## CerificateManagers -The Certificate Managers section is optional. - If configured, all users or groups granted OFFICER permissions under the Security section - must be configured for at least one Template and one Requester. - Uses "" to specify all templates. Uses "Everyone" to specify all requesters. - Valid permission values are "Allow" and "Deny". -```json - "CertificateManagers":{ - "DOMAIN\\Username":{ - "Templates":{ - "MyTemplateShortName":{ - "Requesters":{ - "Everyone":"Allow", - "DOMAIN\\Groupname":"Deny" - } - }, - "":{ - "Requesters":{ - "Everyone":"Allow" - } - } - } - } - } -``` -## CAConnection -The CA Connection section will determine the API endpoint and configuration data used to connect to the API. - - -```json - "CAConnection": { - - }, -``` -## GatewayRegistration -There are no specific Changes for the GatewayRegistration section. Refer to the AnyGateway Documentation for more detail. -```json - "GatewayRegistration": { - "LogicalName": "CASandbox", - "GatewayCertificate": { - "StoreName": "CA", - "StoreLocation": "LocalMachine", - "Thumbprint": "0123456789abcdef" - } - } -``` - -## ServiceSettings -There are no specific Changes for the ServiceSettings section. Refer to the AnyGateway Documentation for more detail. -```json - "ServiceSettings": { - "ViewIdleMinutes": 8, - "FullScanPeriodHours": 24, - "PartialScanPeriodMinutes": 240 - } -```