Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -328,3 +328,9 @@ ASALocalRun/

# MFractors (Xamarin productivity tool) working folder
.mfractor/
/.claude
MIGRATION-SUMMARY.md
PLUGIN-MIGRATION-GUIDE.md
ReflectFramework.csx
InspectFramework/InspectFramework.csproj
InspectFramework/Program.cs
75 changes: 72 additions & 3 deletions AcmeCaPlugin/AcmeCaPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
using Org.BouncyCastle.Asn1.Pkcs;
using Org.BouncyCastle.Asn1.X509;
using Org.BouncyCastle.Pkcs;
using System.Security.Cryptography;

namespace Keyfactor.Extensions.CAPlugin.Acme
{
Expand Down Expand Up @@ -62,6 +63,7 @@ public class AcmeCaPlugin : IAnyCAPlugin
{
private static readonly ILogger _logger = LogHandler.GetClassLogger<AcmeCaPlugin>();
private IAnyCAPluginConfigProvider Config { get; set; }
private AcmeClientConfig _config;

// Constants for better maintainability
private const string DEFAULT_PRODUCT_ID = "default";
Expand All @@ -76,6 +78,16 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa
{
_logger.MethodEntry();
Config = configProvider ?? throw new ArgumentNullException(nameof(configProvider));
_config = GetConfig();
_logger.LogTrace("Enabled: {Enabled}", _config.Enabled);

if (!_config.Enabled)
{
_logger.LogWarning("The CA is currently in the Disabled state. It must be Enabled to perform operations. Skipping config validation...");
_logger.MethodExit();
return;
}

_logger.MethodExit();
}

Expand All @@ -88,6 +100,12 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa
public async Task Ping()
{
_logger.MethodEntry();
if (!_config.Enabled)
{
_logger.LogWarning("The CA is currently in the Disabled state. It must be Enabled to perform operations. Skipping connectivity test...");
_logger.MethodExit();
return;
}

HttpClient httpClient = null;
try
Expand Down Expand Up @@ -165,6 +183,13 @@ public Task ValidateCAConnectionInfo(Dictionary<string, object> connectionInfo)
var rawData = JsonConvert.SerializeObject(connectionInfo);
var config = JsonConvert.DeserializeObject<AcmeClientConfig>(rawData);

if (config != null && !config.Enabled)
{
_logger.LogWarning("The CA is currently in the Disabled state. It must be Enabled to perform operations. Skipping config validation...");
_logger.MethodExit();
return Task.CompletedTask;
}

// Validate required configuration fields
var missingFields = new List<string>();
if (string.IsNullOrWhiteSpace(config?.DirectoryUrl))
Expand Down Expand Up @@ -230,6 +255,17 @@ public async Task<EnrollmentResult> Enroll(
{
_logger.MethodEntry();

if (!_config.Enabled)
{
_logger.LogWarning("The CA is currently in the Disabled state. It must be Enabled to perform operations. Enrollment rejected.");
_logger.MethodExit();
return new EnrollmentResult
{
Status = (int)EndEntityStatus.FAILED,
StatusMessage = "CA connector is disabled. Enable it in the CA configuration to perform enrollments."
};
}

if (string.IsNullOrWhiteSpace(csr))
throw new ArgumentException("CSR cannot be null or empty", nameof(csr));
if (string.IsNullOrWhiteSpace(subject))
Expand Down Expand Up @@ -262,6 +298,12 @@ public async Task<EnrollmentResult> Enroll(
// Create order
var order = await acmeClient.CreateOrderAsync(identifiers, null);

_logger.LogInformation("Order created. OrderUrl: {OrderUrl}, Status: {Status}",
order.OrderUrl, order.Payload?.Status);

// Extract order identifier BEFORE finalization to ensure we use the original order URL
var orderIdentifier = ExtractOrderIdentifier(order.OrderUrl);

// Store pending order immediately
var accountId = accountDetails.Kid.Split('/').Last();

Expand All @@ -277,20 +319,24 @@ public async Task<EnrollmentResult> Enroll(
var certBytes = await acmeClient.GetCertificateAsync(order);
var certPem = EncodeToPem(certBytes, "CERTIFICATE");

_logger.LogInformation("✅ Enrollment completed successfully. OrderUrl: {OrderUrl}, CARequestID: {OrderId}, Status: GENERATED",
order.OrderUrl, orderIdentifier);

return new EnrollmentResult
{
CARequestID = order.Payload.Finalize,
CARequestID = orderIdentifier,
Certificate = certPem,
Status = (int)EndEntityStatus.GENERATED
};
}
else
{
_logger.LogInformation("⏳ Order not valid yet — will be synced later. Status: {Status}", order.Payload?.Status);
_logger.LogInformation("⏳ Order not valid yet — will be synced later. OrderUrl: {OrderUrl}, CARequestID: {OrderId}, Status: {Status}",
order.OrderUrl, orderIdentifier, order.Payload?.Status);
// Order stays saved for next sync
return new EnrollmentResult
{
CARequestID = order.Payload.Finalize,
CARequestID = orderIdentifier,
Status = (int)EndEntityStatus.FAILED,
StatusMessage = "Could not retrieve order in allowed time."
};
Expand All @@ -314,6 +360,29 @@ public async Task<EnrollmentResult> Enroll(



/// <summary>
/// Generates a fixed-length SHA256 hash of the ACME order URL for database storage.
/// Produces a consistent 40-char hex string regardless of URL length or ACME CA format.
/// The full order URL is logged separately during enrollment for traceability.
/// </summary>
private static string ExtractOrderIdentifier(string orderUrl)
{
if (string.IsNullOrWhiteSpace(orderUrl))
return orderUrl;

using (var sha256 = SHA256.Create())
{
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(orderUrl));
// Take first 20 bytes (40 hex chars) — fits in DB column and is collision-safe
var sb = new StringBuilder(40);
for (int i = 0; i < 20; i++)
{
sb.Append(hashBytes[i].ToString("x2"));
}
return sb.ToString();
}
}

/// <summary>
/// Extracts the domain name from X.509 subject string
/// </summary>
Expand Down
41 changes: 22 additions & 19 deletions AcmeCaPlugin/AcmeCaPlugin.csproj
Original file line number Diff line number Diff line change
@@ -1,31 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net6.0;net8.0</TargetFrameworks>
<TargetFrameworks>net6.0;net8.0;net10.0</TargetFrameworks>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>disable</Nullable>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
<RootNamespace>Keyfactor.Extensions.CAPlugin.Acme</RootNamespace>
<AssemblyName>AcmeCaPlugin</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ACMESharpCore" Version="2.2.0.148"/>
<PackageReference Include="Autofac" Version="8.3.0"/>
<PackageReference Include="AWSSDK.Route53" Version="4.0.1"/>
<PackageReference Include="Azure.Identity" Version="1.14.0"/>
<PackageReference Include="Azure.ResourceManager.Cdn" Version="1.4.0"/>
<PackageReference Include="Azure.ResourceManager.Dns" Version="1.1.1"/>
<PackageReference Include="DnsClient" Version="1.8.0"/>
<PackageReference Include="ARSoft.Tools.Net" Version="3.6.0"/>
<PackageReference Include="Google.Apis.Dns.v1" Version="1.69.0.3753"/>
<PackageReference Include="Keyfactor.AnyGateway.IAnyCAPlugin" Version="3.0.0"/>
<PackageReference Include="Keyfactor.Logging" Version="1.1.1"/>
<PackageReference Include="Keyfactor.PKI" Version="5.5.0"/>
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.5"/>
<PackageReference Include="Nager.PublicSuffix" Version="3.5.0"/>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
<PackageReference Include="System.Net.Http.WinHttpHandler" Version="9.0.5"/>
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="9.0.5"/>
<PackageReference Include="ACMESharpCore" Version="2.2.0.148" />
<PackageReference Include="Autofac" Version="8.3.0" />
<PackageReference Include="AWSSDK.Core" Version="4.0.3.10" />
<PackageReference Include="AWSSDK.Route53" Version="4.0.8.8" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Azure.ResourceManager.Cdn" Version="1.4.0" />
<PackageReference Include="Azure.ResourceManager.Dns" Version="1.1.1" />
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
<PackageReference Include="DnsClient" Version="1.8.0" />
<PackageReference Include="ARSoft.Tools.Net" Version="3.6.0" />
<PackageReference Include="Google.Apis.Dns.v1" Version="1.69.0.3753" />
<PackageReference Include="Keyfactor.AnyGateway.IAnyCAPlugin" Version="3.1.0" />
<PackageReference Include="Keyfactor.Logging" Version="1.1.1" />
<PackageReference Include="Keyfactor.PKI" Version="5.5.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.5" />
<PackageReference Include="Nager.PublicSuffix" Version="3.5.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="System.Drawing.Common" Version="10.0.2" />
<PackageReference Include="System.Net.Http.WinHttpHandler" Version="9.0.5" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="9.0.5" />
</ItemGroup>
<ItemGroup>
<None Update="manifest.json">
Expand Down
23 changes: 23 additions & 0 deletions AcmeCaPlugin/AcmeCaPluginConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ public static Dictionary<string, PropertyConfigInfo> GetPluginAnnotations()
{
return new Dictionary<string, PropertyConfigInfo>()
{
["Enabled"] = new PropertyConfigInfo()
{
Comments = "Enable or disable this CA connector. When disabled, all operations (ping, enroll, sync) are skipped.",
Hidden = false,
DefaultValue = "true",
Type = "Bool"
},
["DirectoryUrl"] = new PropertyConfigInfo()
{
Comments = "ACME directory URL (e.g. Let's Encrypt, ZeroSSL, etc.)",
Expand Down Expand Up @@ -60,6 +67,13 @@ public static Dictionary<string, PropertyConfigInfo> GetPluginAnnotations()
DefaultValue = "",
Type = "String"
},
["Google_ServiceAccountKeyJson"] = new PropertyConfigInfo()
{
Comments = "Google Cloud DNS: Service account JSON key content (alternative to file path for containerized deployments)",
Hidden = true,
DefaultValue = "",
Type = "Secret"
},
["Google_ProjectId"] = new PropertyConfigInfo()
{
Comments = "Google Cloud DNS: Project ID only if using Google DNS (Optional)",
Expand All @@ -68,6 +82,15 @@ public static Dictionary<string, PropertyConfigInfo> GetPluginAnnotations()
Type = "String"
},

// Container Deployment
["AccountStoragePath"] = new PropertyConfigInfo()
{
Comments = "Path for ACME account storage. Defaults to %APPDATA%\\AcmeAccounts on Windows or ./AcmeAccounts in containers.",
Hidden = false,
DefaultValue = "",
Type = "String"
},

// Cloudflare DNS
["Cloudflare_ApiToken"] = new PropertyConfigInfo()
{
Expand Down
4 changes: 4 additions & 0 deletions AcmeCaPlugin/AcmeClientConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ namespace Keyfactor.Extensions.CAPlugin.Acme
{
public class AcmeClientConfig
{
public bool Enabled { get; set; } = true;
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;
Expand All @@ -15,6 +16,7 @@ public class AcmeClientConfig

// Google Cloud DNS
public string Google_ServiceAccountKeyPath { get; set; } = null;
public string Google_ServiceAccountKeyJson { get; set; } = null;
public string Google_ProjectId { get; set; } = null;

// Cloudflare DNS
Expand All @@ -34,6 +36,8 @@ public class AcmeClientConfig
//IBM NS1 DNS Ns1_ApiKey
public string Ns1_ApiKey { get; set; } = null;

// Container Deployment Support
public string AccountStoragePath { get; set; } = null;
// RFC 2136 Dynamic DNS (BIND)
public string Rfc2136_Server { get; set; } = null;
public int Rfc2136_Port { get; set; } = 53;
Expand Down
27 changes: 23 additions & 4 deletions AcmeCaPlugin/Clients/Acme/AccountManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,32 @@ class AccountManager

#region Constructor

public AccountManager(ILogger log, string passphrase = null)
public AccountManager(ILogger log, string passphrase = null, string storagePath = null)
{
_log = log;
_passphrase = passphrase;
_basePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AcmeAccounts");

if (!string.IsNullOrWhiteSpace(storagePath))
{
// Use the explicitly configured path
_basePath = storagePath;
}
else
{
// Default: Use platform-appropriate path
var appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
if (string.IsNullOrEmpty(appDataPath))
{
// In containers, APPDATA may not be set; use current directory
_basePath = Path.Combine(Directory.GetCurrentDirectory(), "AcmeAccounts");
}
else
{
_basePath = Path.Combine(appDataPath, "AcmeAccounts");
}
}

_log.LogDebug("Account storage path configured: {BasePath}", _basePath);
}

#endregion
Expand Down
2 changes: 1 addition & 1 deletion AcmeCaPlugin/Clients/Acme/AcmeClientManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public AcmeClientManager(ILogger log, AcmeClientConfig config, HttpClient httpCl
_email = config.Email;
_eabKid = config.EabKid;
_eabHmac = config.EabHmacKey;
_accountManager = new AccountManager(log,config.SignerEncryptionPhrase);
_accountManager = new AccountManager(log, config.SignerEncryptionPhrase, config.AccountStoragePath);

_log.LogDebug("AcmeClientManager initialized for directory: {DirectoryUrl}", _directoryUrl);
}
Expand Down
Loading