Skip to content

Commit af8add5

Browse files
indrorabhillkeyfactorKeyfactor
authored
Release: 1.3.0
--------- Co-authored-by: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Co-authored-by: Keyfactor <keyfactor@keyfactor.github.io>
1 parent d34f7b5 commit af8add5

15 files changed

+363
-75
lines changed

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,3 +328,9 @@ ASALocalRun/
328328

329329
# MFractors (Xamarin productivity tool) working folder
330330
.mfractor/
331+
/.claude
332+
MIGRATION-SUMMARY.md
333+
PLUGIN-MIGRATION-GUIDE.md
334+
ReflectFramework.csx
335+
InspectFramework/InspectFramework.csproj
336+
InspectFramework/Program.cs

AcmeCaPlugin/AcmeCaPlugin.cs

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
using Org.BouncyCastle.Asn1.Pkcs;
2323
using Org.BouncyCastle.Asn1.X509;
2424
using Org.BouncyCastle.Pkcs;
25+
using System.Security.Cryptography;
2526

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

6668
// Constants for better maintainability
6769
private const string DEFAULT_PRODUCT_ID = "default";
@@ -76,6 +78,16 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa
7678
{
7779
_logger.MethodEntry();
7880
Config = configProvider ?? throw new ArgumentNullException(nameof(configProvider));
81+
_config = GetConfig();
82+
_logger.LogTrace("Enabled: {Enabled}", _config.Enabled);
83+
84+
if (!_config.Enabled)
85+
{
86+
_logger.LogWarning("The CA is currently in the Disabled state. It must be Enabled to perform operations. Skipping config validation...");
87+
_logger.MethodExit();
88+
return;
89+
}
90+
7991
_logger.MethodExit();
8092
}
8193

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

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

186+
if (config != null && !config.Enabled)
187+
{
188+
_logger.LogWarning("The CA is currently in the Disabled state. It must be Enabled to perform operations. Skipping config validation...");
189+
_logger.MethodExit();
190+
return Task.CompletedTask;
191+
}
192+
168193
// Validate required configuration fields
169194
var missingFields = new List<string>();
170195
if (string.IsNullOrWhiteSpace(config?.DirectoryUrl))
@@ -230,6 +255,17 @@ public async Task<EnrollmentResult> Enroll(
230255
{
231256
_logger.MethodEntry();
232257

258+
if (!_config.Enabled)
259+
{
260+
_logger.LogWarning("The CA is currently in the Disabled state. It must be Enabled to perform operations. Enrollment rejected.");
261+
_logger.MethodExit();
262+
return new EnrollmentResult
263+
{
264+
Status = (int)EndEntityStatus.FAILED,
265+
StatusMessage = "CA connector is disabled. Enable it in the CA configuration to perform enrollments."
266+
};
267+
}
268+
233269
if (string.IsNullOrWhiteSpace(csr))
234270
throw new ArgumentException("CSR cannot be null or empty", nameof(csr));
235271
if (string.IsNullOrWhiteSpace(subject))
@@ -262,6 +298,12 @@ public async Task<EnrollmentResult> Enroll(
262298
// Create order
263299
var order = await acmeClient.CreateOrderAsync(identifiers, null);
264300

301+
_logger.LogInformation("Order created. OrderUrl: {OrderUrl}, Status: {Status}",
302+
order.OrderUrl, order.Payload?.Status);
303+
304+
// Extract order identifier BEFORE finalization to ensure we use the original order URL
305+
var orderIdentifier = ExtractOrderIdentifier(order.OrderUrl);
306+
265307
// Store pending order immediately
266308
var accountId = accountDetails.Kid.Split('/').Last();
267309

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

322+
_logger.LogInformation("✅ Enrollment completed successfully. OrderUrl: {OrderUrl}, CARequestID: {OrderId}, Status: GENERATED",
323+
order.OrderUrl, orderIdentifier);
324+
280325
return new EnrollmentResult
281326
{
282-
CARequestID = order.Payload.Finalize,
327+
CARequestID = orderIdentifier,
283328
Certificate = certPem,
284329
Status = (int)EndEntityStatus.GENERATED
285330
};
286331
}
287332
else
288333
{
289-
_logger.LogInformation("⏳ Order not valid yet — will be synced later. Status: {Status}", order.Payload?.Status);
334+
_logger.LogInformation("⏳ Order not valid yet — will be synced later. OrderUrl: {OrderUrl}, CARequestID: {OrderId}, Status: {Status}",
335+
order.OrderUrl, orderIdentifier, order.Payload?.Status);
290336
// Order stays saved for next sync
291337
return new EnrollmentResult
292338
{
293-
CARequestID = order.Payload.Finalize,
339+
CARequestID = orderIdentifier,
294340
Status = (int)EndEntityStatus.FAILED,
295341
StatusMessage = "Could not retrieve order in allowed time."
296342
};
@@ -314,6 +360,29 @@ public async Task<EnrollmentResult> Enroll(
314360

315361

316362

363+
/// <summary>
364+
/// Generates a fixed-length SHA256 hash of the ACME order URL for database storage.
365+
/// Produces a consistent 40-char hex string regardless of URL length or ACME CA format.
366+
/// The full order URL is logged separately during enrollment for traceability.
367+
/// </summary>
368+
private static string ExtractOrderIdentifier(string orderUrl)
369+
{
370+
if (string.IsNullOrWhiteSpace(orderUrl))
371+
return orderUrl;
372+
373+
using (var sha256 = SHA256.Create())
374+
{
375+
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(orderUrl));
376+
// Take first 20 bytes (40 hex chars) — fits in DB column and is collision-safe
377+
var sb = new StringBuilder(40);
378+
for (int i = 0; i < 20; i++)
379+
{
380+
sb.Append(hashBytes[i].ToString("x2"));
381+
}
382+
return sb.ToString();
383+
}
384+
}
385+
317386
/// <summary>
318387
/// Extracts the domain name from X.509 subject string
319388
/// </summary>

AcmeCaPlugin/AcmeCaPlugin.csproj

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,34 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22
<PropertyGroup>
3-
<TargetFrameworks>net6.0;net8.0</TargetFrameworks>
3+
<TargetFrameworks>net6.0;net8.0;net10.0</TargetFrameworks>
44
<ImplicitUsings>disable</ImplicitUsings>
55
<Nullable>disable</Nullable>
66
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
7-
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
7+
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
88
<RootNamespace>Keyfactor.Extensions.CAPlugin.Acme</RootNamespace>
99
<AssemblyName>AcmeCaPlugin</AssemblyName>
1010
</PropertyGroup>
1111
<ItemGroup>
12-
<PackageReference Include="ACMESharpCore" Version="2.2.0.148"/>
13-
<PackageReference Include="Autofac" Version="8.3.0"/>
14-
<PackageReference Include="AWSSDK.Route53" Version="4.0.1"/>
15-
<PackageReference Include="Azure.Identity" Version="1.14.0"/>
16-
<PackageReference Include="Azure.ResourceManager.Cdn" Version="1.4.0"/>
17-
<PackageReference Include="Azure.ResourceManager.Dns" Version="1.1.1"/>
18-
<PackageReference Include="DnsClient" Version="1.8.0"/>
19-
<PackageReference Include="ARSoft.Tools.Net" Version="3.6.0"/>
20-
<PackageReference Include="Google.Apis.Dns.v1" Version="1.69.0.3753"/>
21-
<PackageReference Include="Keyfactor.AnyGateway.IAnyCAPlugin" Version="3.0.0"/>
22-
<PackageReference Include="Keyfactor.Logging" Version="1.1.1"/>
23-
<PackageReference Include="Keyfactor.PKI" Version="5.5.0"/>
24-
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.5"/>
25-
<PackageReference Include="Nager.PublicSuffix" Version="3.5.0"/>
26-
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
27-
<PackageReference Include="System.Net.Http.WinHttpHandler" Version="9.0.5"/>
28-
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="9.0.5"/>
12+
<PackageReference Include="ACMESharpCore" Version="2.2.0.148" />
13+
<PackageReference Include="Autofac" Version="8.3.0" />
14+
<PackageReference Include="AWSSDK.Core" Version="4.0.3.10" />
15+
<PackageReference Include="AWSSDK.Route53" Version="4.0.8.8" />
16+
<PackageReference Include="Azure.Identity" Version="1.17.1" />
17+
<PackageReference Include="Azure.ResourceManager.Cdn" Version="1.4.0" />
18+
<PackageReference Include="Azure.ResourceManager.Dns" Version="1.1.1" />
19+
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
20+
<PackageReference Include="DnsClient" Version="1.8.0" />
21+
<PackageReference Include="ARSoft.Tools.Net" Version="3.6.0" />
22+
<PackageReference Include="Google.Apis.Dns.v1" Version="1.69.0.3753" />
23+
<PackageReference Include="Keyfactor.AnyGateway.IAnyCAPlugin" Version="3.1.0" />
24+
<PackageReference Include="Keyfactor.Logging" Version="1.1.1" />
25+
<PackageReference Include="Keyfactor.PKI" Version="5.5.0" />
26+
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.5" />
27+
<PackageReference Include="Nager.PublicSuffix" Version="3.5.0" />
28+
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
29+
<PackageReference Include="System.Drawing.Common" Version="10.0.2" />
30+
<PackageReference Include="System.Net.Http.WinHttpHandler" Version="9.0.5" />
31+
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="9.0.5" />
2932
</ItemGroup>
3033
<ItemGroup>
3134
<None Update="manifest.json">

AcmeCaPlugin/AcmeCaPluginConfig.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ public static Dictionary<string, PropertyConfigInfo> GetPluginAnnotations()
99
{
1010
return new Dictionary<string, PropertyConfigInfo>()
1111
{
12+
["Enabled"] = new PropertyConfigInfo()
13+
{
14+
Comments = "Enable or disable this CA connector. When disabled, all operations (ping, enroll, sync) are skipped.",
15+
Hidden = false,
16+
DefaultValue = "true",
17+
Type = "Bool"
18+
},
1219
["DirectoryUrl"] = new PropertyConfigInfo()
1320
{
1421
Comments = "ACME directory URL (e.g. Let's Encrypt, ZeroSSL, etc.)",
@@ -60,6 +67,13 @@ public static Dictionary<string, PropertyConfigInfo> GetPluginAnnotations()
6067
DefaultValue = "",
6168
Type = "String"
6269
},
70+
["Google_ServiceAccountKeyJson"] = new PropertyConfigInfo()
71+
{
72+
Comments = "Google Cloud DNS: Service account JSON key content (alternative to file path for containerized deployments)",
73+
Hidden = true,
74+
DefaultValue = "",
75+
Type = "Secret"
76+
},
6377
["Google_ProjectId"] = new PropertyConfigInfo()
6478
{
6579
Comments = "Google Cloud DNS: Project ID only if using Google DNS (Optional)",
@@ -68,6 +82,15 @@ public static Dictionary<string, PropertyConfigInfo> GetPluginAnnotations()
6882
Type = "String"
6983
},
7084

85+
// Container Deployment
86+
["AccountStoragePath"] = new PropertyConfigInfo()
87+
{
88+
Comments = "Path for ACME account storage. Defaults to %APPDATA%\\AcmeAccounts on Windows or ./AcmeAccounts in containers.",
89+
Hidden = false,
90+
DefaultValue = "",
91+
Type = "String"
92+
},
93+
7194
// Cloudflare DNS
7295
["Cloudflare_ApiToken"] = new PropertyConfigInfo()
7396
{

AcmeCaPlugin/AcmeClientConfig.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ namespace Keyfactor.Extensions.CAPlugin.Acme
44
{
55
public class AcmeClientConfig
66
{
7+
public bool Enabled { get; set; } = true;
78
public string DirectoryUrl { get; set; } = "https://acme-v02.api.letsencrypt.org/directory";
89
public string Email { get; set; } = string.Empty;
910
public string EabKid { get; set; } = null;
@@ -15,6 +16,7 @@ public class AcmeClientConfig
1516

1617
// Google Cloud DNS
1718
public string Google_ServiceAccountKeyPath { get; set; } = null;
19+
public string Google_ServiceAccountKeyJson { get; set; } = null;
1820
public string Google_ProjectId { get; set; } = null;
1921

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

39+
// Container Deployment Support
40+
public string AccountStoragePath { get; set; } = null;
3741
// RFC 2136 Dynamic DNS (BIND)
3842
public string Rfc2136_Server { get; set; } = null;
3943
public int Rfc2136_Port { get; set; } = 53;

AcmeCaPlugin/Clients/Acme/AccountManager.cs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,32 @@ class AccountManager
3939

4040
#region Constructor
4141

42-
public AccountManager(ILogger log, string passphrase = null)
42+
public AccountManager(ILogger log, string passphrase = null, string storagePath = null)
4343
{
4444
_log = log;
4545
_passphrase = passphrase;
46-
_basePath = Path.Combine(
47-
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
48-
"AcmeAccounts");
46+
47+
if (!string.IsNullOrWhiteSpace(storagePath))
48+
{
49+
// Use the explicitly configured path
50+
_basePath = storagePath;
51+
}
52+
else
53+
{
54+
// Default: Use platform-appropriate path
55+
var appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
56+
if (string.IsNullOrEmpty(appDataPath))
57+
{
58+
// In containers, APPDATA may not be set; use current directory
59+
_basePath = Path.Combine(Directory.GetCurrentDirectory(), "AcmeAccounts");
60+
}
61+
else
62+
{
63+
_basePath = Path.Combine(appDataPath, "AcmeAccounts");
64+
}
65+
}
66+
67+
_log.LogDebug("Account storage path configured: {BasePath}", _basePath);
4968
}
5069

5170
#endregion

AcmeCaPlugin/Clients/Acme/AcmeClientManager.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ public AcmeClientManager(ILogger log, AcmeClientConfig config, HttpClient httpCl
6565
_email = config.Email;
6666
_eabKid = config.EabKid;
6767
_eabHmac = config.EabHmacKey;
68-
_accountManager = new AccountManager(log,config.SignerEncryptionPhrase);
68+
_accountManager = new AccountManager(log, config.SignerEncryptionPhrase, config.AccountStoragePath);
6969

7070
_log.LogDebug("AcmeClientManager initialized for directory: {DirectoryUrl}", _directoryUrl);
7171
}

0 commit comments

Comments
 (0)