Skip to content
Merged
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;
}
Comment on lines 100 to +108
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ping() checks the cached _config.Enabled value, but the plugin later reads the live config via GetConfig(). If CAConnectionData is updated at runtime (enabled/disabled toggled), Ping may incorrectly skip or execute. Consider fetching the current config at the start of Ping (or refreshing _config) and using that value for the Enabled check.

Copilot uses AI. Check for mistakes.

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."
};
}
Comment on lines 256 to +267
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Enroll() also gates behavior on the cached _config.Enabled value before calling GetConfig(). If the connector is enabled/disabled after Initialize(), this can produce incorrect behavior (e.g., rejecting enrollments even after enabling, or allowing enrollments after disabling). Use the current config’s Enabled value (and/or update _config) at the start of the method.

Copilot uses AI. Check for mistakes.

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);
Comment on lines +363 to +377
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The XML doc for ExtractOrderIdentifier says it "Generates a fixed-length SHA256 hash" but the implementation truncates the SHA-256 digest to the first 20 bytes (40 hex chars). Please adjust the docstring to reflect that this is a truncated SHA-256 (160-bit) identifier, or change the implementation to return the full SHA-256 if you intend an actual SHA-256 hash output.

Copilot uses AI. Check for mistakes.
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>
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding net10.0 to TargetFrameworks will require a .NET 10 SDK in all build environments. Since the repo doesn’t include a global.json to pin SDK selection, consider adding one (or otherwise ensuring CI/dev docs explicitly install the required SDK) to avoid unexpected build failures.

Suggested change
<TargetFrameworks>net6.0;net8.0;net10.0</TargetFrameworks>
<TargetFrameworks>net6.0;net8.0</TargetFrameworks>

Copilot uses AI. Check for mistakes.
<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" />
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

System.Drawing.Common is referenced but doesn’t appear to be used anywhere in the repo. If it’s not required, consider removing it to reduce dependency surface area (and avoid cross-platform/runtime caveats associated with System.Drawing on non-Windows).

Suggested change
<PackageReference Include="System.Drawing.Common" Version="10.0.2" />

Copilot uses AI. Check for mistakes.
<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
Loading