Skip to content

Commit e230337

Browse files
Merge 74b2339 into d34f7b5
2 parents d34f7b5 + 74b2339 commit e230337

14 files changed

Lines changed: 310 additions & 74 deletions

.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: 37 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
{
@@ -262,6 +263,12 @@ public async Task<EnrollmentResult> Enroll(
262263
// Create order
263264
var order = await acmeClient.CreateOrderAsync(identifiers, null);
264265

266+
_logger.LogInformation("Order created. OrderUrl: {OrderUrl}, Status: {Status}",
267+
order.OrderUrl, order.Payload?.Status);
268+
269+
// Extract order identifier BEFORE finalization to ensure we use the original order URL
270+
var orderIdentifier = ExtractOrderIdentifier(order.OrderUrl);
271+
265272
// Store pending order immediately
266273
var accountId = accountDetails.Kid.Split('/').Last();
267274

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

287+
_logger.LogInformation("✅ Enrollment completed successfully. OrderUrl: {OrderUrl}, CARequestID: {OrderId}, Status: GENERATED",
288+
order.OrderUrl, orderIdentifier);
289+
280290
return new EnrollmentResult
281291
{
282-
CARequestID = order.Payload.Finalize,
292+
CARequestID = orderIdentifier,
283293
Certificate = certPem,
284294
Status = (int)EndEntityStatus.GENERATED
285295
};
286296
}
287297
else
288298
{
289-
_logger.LogInformation("⏳ Order not valid yet — will be synced later. Status: {Status}", order.Payload?.Status);
299+
_logger.LogInformation("⏳ Order not valid yet — will be synced later. OrderUrl: {OrderUrl}, CARequestID: {OrderId}, Status: {Status}",
300+
order.OrderUrl, orderIdentifier, order.Payload?.Status);
290301
// Order stays saved for next sync
291302
return new EnrollmentResult
292303
{
293-
CARequestID = order.Payload.Finalize,
304+
CARequestID = orderIdentifier,
294305
Status = (int)EndEntityStatus.FAILED,
295306
StatusMessage = "Could not retrieve order in allowed time."
296307
};
@@ -314,6 +325,29 @@ public async Task<EnrollmentResult> Enroll(
314325

315326

316327

328+
/// <summary>
329+
/// Generates a fixed-length SHA256 hash of the ACME order URL for database storage.
330+
/// Produces a consistent 40-char hex string regardless of URL length or ACME CA format.
331+
/// The full order URL is logged separately during enrollment for traceability.
332+
/// </summary>
333+
private static string ExtractOrderIdentifier(string orderUrl)
334+
{
335+
if (string.IsNullOrWhiteSpace(orderUrl))
336+
return orderUrl;
337+
338+
using (var sha256 = SHA256.Create())
339+
{
340+
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(orderUrl));
341+
// Take first 20 bytes (40 hex chars) — fits in DB column and is collision-safe
342+
var sb = new StringBuilder(40);
343+
for (int i = 0; i < 20; i++)
344+
{
345+
sb.Append(hashBytes[i].ToString("x2"));
346+
}
347+
return sb.ToString();
348+
}
349+
}
350+
317351
/// <summary>
318352
/// Extracts the domain name from X.509 subject string
319353
/// </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: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,13 @@ public static Dictionary<string, PropertyConfigInfo> GetPluginAnnotations()
6060
DefaultValue = "",
6161
Type = "String"
6262
},
63+
["Google_ServiceAccountKeyJson"] = new PropertyConfigInfo()
64+
{
65+
Comments = "Google Cloud DNS: Service account JSON key content (alternative to file path for containerized deployments)",
66+
Hidden = true,
67+
DefaultValue = "",
68+
Type = "Secret"
69+
},
6370
["Google_ProjectId"] = new PropertyConfigInfo()
6471
{
6572
Comments = "Google Cloud DNS: Project ID only if using Google DNS (Optional)",
@@ -68,6 +75,15 @@ public static Dictionary<string, PropertyConfigInfo> GetPluginAnnotations()
6875
Type = "String"
6976
},
7077

78+
// Container Deployment
79+
["AccountStoragePath"] = new PropertyConfigInfo()
80+
{
81+
Comments = "Path for ACME account storage. Defaults to %APPDATA%\\AcmeAccounts on Windows or ./AcmeAccounts in containers.",
82+
Hidden = false,
83+
DefaultValue = "",
84+
Type = "String"
85+
},
86+
7187
// Cloudflare DNS
7288
["Cloudflare_ApiToken"] = new PropertyConfigInfo()
7389
{

AcmeCaPlugin/AcmeClientConfig.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ public class AcmeClientConfig
1515

1616
// Google Cloud DNS
1717
public string Google_ServiceAccountKeyPath { get; set; } = null;
18+
public string Google_ServiceAccountKeyJson { get; set; } = null;
1819
public string Google_ProjectId { get; set; } = null;
1920

2021
// Cloudflare DNS
@@ -34,6 +35,8 @@ public class AcmeClientConfig
3435
//IBM NS1 DNS Ns1_ApiKey
3536
public string Ns1_ApiKey { get; set; } = null;
3637

38+
// Container Deployment Support
39+
public string AccountStoragePath { get; set; } = null;
3740
// RFC 2136 Dynamic DNS (BIND)
3841
public string Rfc2136_Server { get; set; } = null;
3942
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
}

AcmeCaPlugin/Clients/DNS/CloudflareDnsProvider.cs

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,9 @@ public CloudflareDnsProvider(string apiToken)
3030

3131
public async Task<bool> CreateRecordAsync(string recordName, string txtValue)
3232
{
33-
// 1) Determine apex zone
34-
var zoneName = ExtractZoneFromRecord(recordName);
35-
var zoneId = await GetZoneIdAsync(zoneName);
36-
if (zoneId == null) return false;
33+
var (zoneName, zoneId) = await FindZoneForRecordAsync(recordName);
34+
if (zoneId == null || zoneName == null) return false;
3735

38-
// 2) Get the relative record name for Cloudflare
3936
var relativeName = GetRelativeRecordName(recordName, zoneName);
4037

4138
var payload = new
@@ -59,12 +56,9 @@ public async Task<bool> CreateRecordAsync(string recordName, string txtValue)
5956

6057
public async Task<bool> DeleteRecordAsync(string recordName)
6158
{
62-
// 1) Determine apex zone
63-
var zoneName = ExtractZoneFromRecord(recordName);
64-
var zoneId = await GetZoneIdAsync(zoneName);
65-
if (zoneId == null) return false;
59+
var (zoneName, zoneId) = await FindZoneForRecordAsync(recordName);
60+
if (zoneId == null || zoneName == null) return false;
6661

67-
// 2) Get the relative record name for Cloudflare
6862
var relativeName = GetRelativeRecordName(recordName, zoneName);
6963

7064
var recordsResp = await _httpClient.GetAsync($"zones/{zoneId}/dns_records?type=TXT&name={relativeName}");
@@ -73,8 +67,9 @@ public async Task<bool> DeleteRecordAsync(string recordName)
7367
var json = await recordsResp.Content.ReadAsStringAsync();
7468
var doc = JsonDocument.Parse(json);
7569

76-
var recordId = doc.RootElement.GetProperty("result").EnumerateArray()
77-
.FirstOrDefault().GetProperty("id").GetString();
70+
var resultArray = doc.RootElement.GetProperty("result");
71+
if (resultArray.GetArrayLength() == 0) return false;
72+
var recordId = resultArray[0].GetProperty("id").GetString();
7873

7974
if (recordId == null) return false;
8075

@@ -92,21 +87,35 @@ public async Task<bool> DeleteRecordAsync(string recordName)
9287

9388
var json = await response.Content.ReadAsStringAsync();
9489
var doc = JsonDocument.Parse(json);
95-
return doc.RootElement.GetProperty("result").EnumerateArray()
96-
.FirstOrDefault().GetProperty("id").GetString();
90+
var resultArray = doc.RootElement.GetProperty("result");
91+
if (resultArray.GetArrayLength() == 0) return null;
92+
return resultArray[0].GetProperty("id").GetString();
9793
}
9894

99-
private string ExtractZoneFromRecord(string recordName)
95+
private async Task<(string? zoneName, string? zoneId)> FindZoneForRecordAsync(string recordName)
10096
{
10197
if (string.IsNullOrWhiteSpace(recordName))
102-
return string.Empty;
98+
return (null, null);
10399

104100
var parts = recordName.TrimEnd('.').Split('.');
105-
if (parts.Length < 2)
106-
return recordName;
107101

108-
// Use last two labels as default zone: e.g., "keyfactoracme.com"
109-
return string.Join(".", parts.Skip(parts.Length - 2));
102+
// Try progressively shorter domain parts to find the actual zone
103+
// e.g., for "_acme-challenge.www.keyfactor.ssl4saas.com", try:
104+
// - www.keyfactor.ssl4saas.com
105+
// - keyfactor.ssl4saas.com
106+
// - ssl4saas.com
107+
for (int i = 1; i < parts.Length - 1; i++)
108+
{
109+
var candidateZone = string.Join(".", parts.Skip(i));
110+
var zoneId = await GetZoneIdAsync(candidateZone);
111+
if (zoneId != null)
112+
{
113+
Console.WriteLine($"Found zone: {candidateZone} (id: {zoneId})");
114+
return (candidateZone, zoneId);
115+
}
116+
}
117+
118+
return (null, null);
110119
}
111120

112121
private string GetRelativeRecordName(string recordName, string zoneName)

AcmeCaPlugin/Clients/DNS/DnsProviderFactory.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ public static IDnsProvider Create(AcmeClientConfig config, ILogger logger)
1515
case "google":
1616
return new GoogleDnsProvider(
1717
config.Google_ServiceAccountKeyPath,
18+
config.Google_ServiceAccountKeyJson,
1819
config.Google_ProjectId
1920
);
2021

AcmeCaPlugin/Clients/DNS/GoogleDnsProvider.cs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
/// <summary>
1111
/// Google Cloud DNS provider implementation for managing DNS TXT records.
12-
/// Supports explicit Service Account key or Workload Identity (Application Default Credentials).
12+
/// Supports explicit Service Account key (file or JSON), or Workload Identity (Application Default Credentials).
1313
/// </summary>
1414
public class GoogleDnsProvider : IDnsProvider
1515
{
@@ -18,19 +18,26 @@ public class GoogleDnsProvider : IDnsProvider
1818

1919
/// <summary>
2020
/// Initializes a new instance of the GoogleDnsProvider class.
21-
/// If serviceAccountKeyPath is null or empty, uses Application Default Credentials.
21+
/// Credential resolution order: JSON key > File path > Application Default Credentials.
2222
/// </summary>
2323
/// <param name="serviceAccountKeyPath">Path to the Service Account JSON key file (optional)</param>
24+
/// <param name="serviceAccountKeyJson">Service Account JSON key as a string (optional, for containerized deployments)</param>
2425
/// <param name="projectId">Google Cloud project ID containing the DNS zones</param>
25-
public GoogleDnsProvider(string? serviceAccountKeyPath, string projectId)
26+
public GoogleDnsProvider(string? serviceAccountKeyPath, string? serviceAccountKeyJson, string projectId)
2627
{
2728
_projectId = projectId;
2829

2930
GoogleCredential credential;
3031

31-
if (!string.IsNullOrWhiteSpace(serviceAccountKeyPath))
32+
if (!string.IsNullOrWhiteSpace(serviceAccountKeyJson))
3233
{
33-
Console.WriteLine("✅ Using explicit Service Account JSON key.");
34+
// JSON key provided directly (for container deployments)
35+
Console.WriteLine("✅ Using Service Account JSON key from configuration.");
36+
credential = GoogleCredential.FromJson(serviceAccountKeyJson);
37+
}
38+
else if (!string.IsNullOrWhiteSpace(serviceAccountKeyPath))
39+
{
40+
Console.WriteLine("✅ Using Service Account JSON key from file.");
3441
credential = GoogleCredential.FromFile(serviceAccountKeyPath);
3542
}
3643
else

0 commit comments

Comments
 (0)