Skip to content

Commit d34f7b5

Browse files
indrorabhillkeyfactorKeyfactorclaude
authored
Release: 1.2.0 (#12)
* Added RFC 2136 Dynamic DNS Provider Support (BIND with TSIG authentication) * Added Infoblox DNS Provider Support * Added configurable DNS verification server for private/local DNS zones * Fixed issue with Multiple Sans in the CSR * Infoblox NIOS DNS Provider Support Added --------- Signed-off-by: Morgan Gangwere <Morgan.gangwere@keyfactor.com> Co-authored-by: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Co-authored-by: Keyfactor <keyfactor@keyfactor.github.io> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 25325fa commit d34f7b5

11 files changed

Lines changed: 621 additions & 29 deletions

AcmeCaPlugin/AcmeCaPlugin.cs

Lines changed: 94 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
using System.Text;
1919
using Keyfactor.Extensions.CAPlugin.Acme.Clients.DNS;
2020
using System.Text.RegularExpressions;
21+
using Org.BouncyCastle.Asn1;
22+
using Org.BouncyCastle.Asn1.Pkcs;
23+
using Org.BouncyCastle.Asn1.X509;
24+
using Org.BouncyCastle.Pkcs;
2125

2226
namespace Keyfactor.Extensions.CAPlugin.Acme
2327
{
@@ -248,12 +252,12 @@ public async Task<EnrollmentResult> Enroll(
248252
var acmeClient = new AcmeClient(_logger, config, httpClient, protocolClient.Directory,
249253
new Clients.Acme.Account(accountDetails, signer));
250254

251-
// Extract domain
252-
var cleanDomain = ExtractDomainFromSubject(subject);
253-
var identifiers = new List<Identifier>
254-
{
255-
new Identifier { Type = "dns", Value = cleanDomain }
256-
};
255+
// Decode CSR first so we can extract all domains from it
256+
var csrBytes = Convert.FromBase64String(csr);
257+
258+
// Extract all domains directly from CSR (CN + SANs) for the ACME order
259+
// This ensures we authorize exactly what's in the CSR
260+
var identifiers = ExtractDomainsFromCsr(csrBytes);
257261

258262
// Create order
259263
var order = await acmeClient.CreateOrderAsync(identifiers, null);
@@ -264,8 +268,7 @@ public async Task<EnrollmentResult> Enroll(
264268
// Process challenges
265269
await ProcessAuthorizations(acmeClient, order, config);
266270

267-
// Finalize
268-
var csrBytes = Convert.FromBase64String(csr);
271+
// Finalize with original CSR bytes
269272
order = await acmeClient.FinalizeOrderAsync(order, csrBytes);
270273

271274
// If order is valid immediately, download cert
@@ -331,6 +334,88 @@ private static string ExtractDomainFromSubject(string subject)
331334
throw new ArgumentException($"Could not extract CN from subject: {subject}", nameof(subject));
332335
}
333336

337+
/// <summary>
338+
/// Extracts all DNS names (CN + SANs) directly from the CSR.
339+
/// This ensures the ACME order authorizes exactly what's in the CSR.
340+
/// </summary>
341+
/// <param name="csrBytes">DER-encoded CSR bytes</param>
342+
/// <returns>List of ACME identifiers for all domains in the CSR</returns>
343+
private List<Identifier> ExtractDomainsFromCsr(byte[] csrBytes)
344+
{
345+
var domains = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
346+
347+
try
348+
{
349+
// Parse the CSR using BouncyCastle
350+
var pkcs10 = new Pkcs10CertificationRequest(csrBytes);
351+
var csrInfo = pkcs10.GetCertificationRequestInfo();
352+
353+
// Extract CN from subject
354+
var subject = csrInfo.Subject;
355+
var cnValues = subject.GetValueList(X509Name.CN);
356+
if (cnValues != null && cnValues.Count > 0)
357+
{
358+
var cn = cnValues[0]?.ToString();
359+
if (!string.IsNullOrWhiteSpace(cn))
360+
{
361+
domains.Add(cn);
362+
_logger.LogDebug("Extracted CN from CSR: {Domain}", cn);
363+
}
364+
}
365+
366+
// Extract SANs from CSR attributes
367+
var attributes = csrInfo.Attributes;
368+
if (attributes != null)
369+
{
370+
foreach (var attr in attributes)
371+
{
372+
var attribute = Org.BouncyCastle.Asn1.Pkcs.AttributePkcs.GetInstance(attr);
373+
if (attribute.AttrType.Equals(PkcsObjectIdentifiers.Pkcs9AtExtensionRequest))
374+
{
375+
// This attribute contains extension requests
376+
var extensions = X509Extensions.GetInstance(attribute.AttrValues[0]);
377+
var sanExtension = extensions.GetExtension(X509Extensions.SubjectAlternativeName);
378+
379+
if (sanExtension != null)
380+
{
381+
var sanNames = GeneralNames.GetInstance(sanExtension.GetParsedValue());
382+
foreach (var name in sanNames.GetNames())
383+
{
384+
// TagNo 2 = dNSName
385+
if (name.TagNo == GeneralName.DnsName)
386+
{
387+
var dnsName = name.Name.ToString();
388+
if (!string.IsNullOrWhiteSpace(dnsName))
389+
{
390+
domains.Add(dnsName);
391+
_logger.LogDebug("Extracted SAN from CSR: {Domain}", dnsName);
392+
}
393+
}
394+
}
395+
}
396+
}
397+
}
398+
}
399+
}
400+
catch (Exception ex)
401+
{
402+
_logger.LogError(ex, "Failed to parse CSR for domain extraction");
403+
throw new InvalidOperationException("Failed to parse CSR to extract domains", ex);
404+
}
405+
406+
if (domains.Count == 0)
407+
{
408+
_logger.LogError("No DNS names found in CSR. CSR may be malformed or missing CN/SANs.");
409+
throw new InvalidOperationException("No DNS names found in CSR (neither CN nor SANs)");
410+
}
411+
412+
var identifiers = domains.Select(d => new Identifier { Type = "dns", Value = d }).ToList();
413+
_logger.LogInformation("CSR domain extraction complete. Creating ACME order for {Count} domain(s): [{Domains}]",
414+
identifiers.Count, string.Join(", ", domains));
415+
416+
return identifiers;
417+
}
418+
334419
/// <summary>
335420
/// Processes ACME authorizations for domain validation
336421
/// Currently hardcoded to use DNS-01 challenge with Google DNS provider
@@ -345,7 +430,7 @@ private async Task ProcessAuthorizations(AcmeClient acmeClient, OrderDetails ord
345430
throw new InvalidOperationException("Missing or invalid authorization list in order payload.");
346431
}
347432

348-
var dnsVerifier = new DnsVerificationHelper(_logger);
433+
var dnsVerifier = new DnsVerificationHelper(_logger, config.DnsVerificationServer);
349434
var pendingChallenges = new List<(Authorization authz, Challenge challenge, Dns01ChallengeValidationDetails validation)>();
350435

351436
// First pass: Create all DNS records

AcmeCaPlugin/AcmeCaPlugin.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
<PackageReference Include="Azure.ResourceManager.Cdn" Version="1.4.0"/>
1717
<PackageReference Include="Azure.ResourceManager.Dns" Version="1.1.1"/>
1818
<PackageReference Include="DnsClient" Version="1.8.0"/>
19+
<PackageReference Include="ARSoft.Tools.Net" Version="3.6.0"/>
1920
<PackageReference Include="Google.Apis.Dns.v1" Version="1.69.0.3753"/>
2021
<PackageReference Include="Keyfactor.AnyGateway.IAnyCAPlugin" Version="3.0.0"/>
2122
<PackageReference Include="Keyfactor.Logging" Version="1.1.1"/>

AcmeCaPlugin/AcmeCaPluginConfig.cs

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ public static Dictionary<string, PropertyConfigInfo> GetPluginAnnotations()
4646
},
4747
["DnsProvider"] = new PropertyConfigInfo()
4848
{
49-
Comments = "DNS Provider to use for ACME DNS-01 challenges (options Google, Cloudflare, AwsRoute53, Azure, Ns1, Infoblox)",
49+
Comments = "DNS Provider to use for ACME DNS-01 challenges (options: Google, Cloudflare, AwsRoute53, Azure, Ns1, Rfc2136, Infoblox)",
5050
Hidden = false,
5151
DefaultValue = "Google",
5252
Type = "String"
@@ -128,6 +128,83 @@ public static Dictionary<string, PropertyConfigInfo> GetPluginAnnotations()
128128
Hidden = true,
129129
DefaultValue = "",
130130
Type = "String"
131+
},
132+
133+
// RFC 2136 Dynamic DNS (BIND/Microsoft DNS)
134+
["Rfc2136_Server"] = new PropertyConfigInfo()
135+
{
136+
Comments = "RFC 2136 DNS: Server hostname or IP address (Optional)",
137+
Hidden = false,
138+
DefaultValue = "",
139+
Type = "String"
140+
},
141+
["Rfc2136_Port"] = new PropertyConfigInfo()
142+
{
143+
Comments = "RFC 2136 DNS: Server port (default 53) (Optional)",
144+
Hidden = false,
145+
DefaultValue = "53",
146+
Type = "Number"
147+
},
148+
["Rfc2136_Zone"] = new PropertyConfigInfo()
149+
{
150+
Comments = "RFC 2136 DNS: Zone name (e.g., example.com) (Optional)",
151+
Hidden = false,
152+
DefaultValue = "",
153+
Type = "String"
154+
},
155+
["Rfc2136_TsigKeyName"] = new PropertyConfigInfo()
156+
{
157+
Comments = "RFC 2136 DNS: TSIG key name for authentication (Optional)",
158+
Hidden = false,
159+
DefaultValue = "",
160+
Type = "String"
161+
},
162+
["Rfc2136_TsigKey"] = new PropertyConfigInfo()
163+
{
164+
Comments = "RFC 2136 DNS: TSIG key (base64 encoded) for authentication (Optional)",
165+
Hidden = true,
166+
DefaultValue = "",
167+
Type = "Secret"
168+
},
169+
["Rfc2136_TsigAlgorithm"] = new PropertyConfigInfo()
170+
{
171+
Comments = "RFC 2136 DNS: TSIG algorithm (default hmac-sha256) (Optional)",
172+
Hidden = false,
173+
DefaultValue = "hmac-sha256",
174+
Type = "String"
175+
},
176+
177+
// DNS Verification Settings
178+
["DnsVerificationServer"] = new PropertyConfigInfo()
179+
{
180+
Comments = "DNS server to use for verifying TXT record propagation. For private/local DNS zones, set this to your authoritative DNS server IP (e.g., 10.3.10.37). Leave empty to use public DNS servers (Google, Cloudflare, etc.).",
181+
Hidden = false,
182+
DefaultValue = "",
183+
Type = "String"
184+
}
185+
186+
//Infoblox DNS
187+
,
188+
["Infoblox_Host"] = new PropertyConfigInfo()
189+
{
190+
Comments = "Infoblox DNS: API URL (e.g., https://infoblox.example.com/wapi/v2.12) only if using Infoblox DNS (Optional)",
191+
Hidden = false,
192+
DefaultValue = "",
193+
Type = "String"
194+
},
195+
["Infoblox_Username"] = new PropertyConfigInfo()
196+
{
197+
Comments = "Infoblox DNS: Username for authentication only if using Infoblox DNS (Optional)",
198+
Hidden = false,
199+
DefaultValue = "",
200+
Type = "String"
201+
},
202+
["Infoblox_Password"] = new PropertyConfigInfo()
203+
{
204+
Comments = "Infoblox DNS: Password for authentication only if using Infoblox DNS (Optional)",
205+
Hidden = true,
206+
DefaultValue = "",
207+
Type = "Secret"
131208
}
132209

133210
//Infoblox DNS

AcmeCaPlugin/AcmeClientConfig.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,23 @@ public class AcmeClientConfig
3434
//IBM NS1 DNS Ns1_ApiKey
3535
public string Ns1_ApiKey { get; set; } = null;
3636

37+
// RFC 2136 Dynamic DNS (BIND)
38+
public string Rfc2136_Server { get; set; } = null;
39+
public int Rfc2136_Port { get; set; } = 53;
40+
public string Rfc2136_Zone { get; set; } = null;
41+
public string Rfc2136_TsigKeyName { get; set; } = null;
42+
public string Rfc2136_TsigKey { get; set; } = null;
43+
public string Rfc2136_TsigAlgorithm { get; set; } = "hmac-sha256";
44+
3745
// Infoblox DNS
3846
public string Infoblox_Host { get; set; } = null;
3947
public string Infoblox_Username { get; set; } = null;
4048
public string Infoblox_Password { get; set; } = null;
4149
public string Infoblox_WapiVersion { get; set; } = "2.12";
4250
public bool Infoblox_IgnoreSslErrors { get; set; } = false;
4351

52+
// DNS Verification Settings
53+
public string DnsVerificationServer { get; set; } = null;
54+
4455
}
4556
}

AcmeCaPlugin/Clients/DNS/DnsProviderFactory.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@ public static IDnsProvider Create(AcmeClientConfig config, ILogger logger)
3939
return new Ns1DnsProvider(
4040
config.Ns1_ApiKey
4141
);
42+
case "rfc2136":
43+
return new Rfc2136DnsProvider(
44+
config.Rfc2136_Server,
45+
config.Rfc2136_Zone,
46+
config.Rfc2136_TsigKeyName,
47+
config.Rfc2136_TsigKey,
48+
config.Rfc2136_TsigAlgorithm,
49+
config.Rfc2136_Port,
50+
logger
51+
);
4252
case "infoblox":
4353
return new InfobloxDnsProvider(
4454
config.Infoblox_Host,

AcmeCaPlugin/Clients/DNS/DnsVerificationHelper.cs

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,31 +15,51 @@ public class DnsVerificationHelper
1515
{
1616
private readonly ILogger _logger;
1717
private readonly List<IPAddress> _dnsServers;
18+
private readonly bool _usePrivateDns;
1819
private const int MaxVerificationAttempts = 3;
1920
private const int VerificationDelaySeconds = 10;
2021

21-
public DnsVerificationHelper(ILogger logger)
22+
/// <summary>
23+
/// Creates a DNS verification helper.
24+
/// </summary>
25+
/// <param name="logger">Logger instance</param>
26+
/// <param name="verificationServer">Optional DNS server IP for verification.
27+
/// For private/local zones (e.g., .local), specify your authoritative DNS server.
28+
/// Leave null/empty to use public DNS servers.</param>
29+
public DnsVerificationHelper(ILogger logger, string verificationServer = null)
2230
{
2331
_logger = logger;
32+
_dnsServers = new List<IPAddress>();
2433

25-
// Use multiple public DNS servers for verification
26-
_dnsServers = new List<IPAddress>
34+
// Check if a private DNS server was specified
35+
if (!string.IsNullOrWhiteSpace(verificationServer) && IPAddress.TryParse(verificationServer, out var privateServer))
36+
{
37+
_usePrivateDns = true;
38+
_dnsServers.Add(privateServer);
39+
_logger.LogInformation("DNS verification will use private DNS server: {Server}", verificationServer);
40+
}
41+
else
2742
{
28-
IPAddress.Parse("8.8.8.8"), // Google Primary
29-
IPAddress.Parse("8.8.4.4"), // Google Secondary
30-
IPAddress.Parse("1.1.1.1"), // Cloudflare Primary
31-
IPAddress.Parse("1.0.0.1"), // Cloudflare Secondary
32-
IPAddress.Parse("208.67.222.222"), // OpenDNS
33-
IPAddress.Parse("9.9.9.9") // Quad9
34-
};
43+
_usePrivateDns = false;
44+
// Use multiple public DNS servers for verification
45+
_dnsServers = new List<IPAddress>
46+
{
47+
IPAddress.Parse("8.8.8.8"), // Google Primary
48+
IPAddress.Parse("8.8.4.4"), // Google Secondary
49+
IPAddress.Parse("1.1.1.1"), // Cloudflare Primary
50+
IPAddress.Parse("1.0.0.1"), // Cloudflare Secondary
51+
IPAddress.Parse("208.67.222.222"), // OpenDNS
52+
IPAddress.Parse("9.9.9.9") // Quad9
53+
};
54+
}
3555
}
3656

3757
/// <summary>
3858
/// Waits for DNS TXT record to propagate across multiple DNS servers
3959
/// </summary>
4060
/// <param name="recordName">DNS record name (e.g., _acme-challenge.example.com)</param>
4161
/// <param name="expectedValue">Expected TXT record value</param>
42-
/// <param name="minimumServers">Minimum number of DNS servers that must see the record</param>
62+
/// <param name="minimumServers">Minimum number of DNS servers that must see the record (ignored for private DNS)</param>
4363
/// <returns>True if record propagated successfully</returns>
4464
public async Task<bool> WaitForDnsPropagationAsync(
4565
string recordName,
@@ -48,6 +68,9 @@ public async Task<bool> WaitForDnsPropagationAsync(
4868
{
4969
_logger.LogInformation("Waiting for DNS propagation of {RecordName}", recordName);
5070

71+
// For private DNS, only require 1 server (the authoritative server)
72+
var requiredServers = _usePrivateDns ? 1 : minimumServers;
73+
5174
for (int attempt = 1; attempt <= MaxVerificationAttempts; attempt++)
5275
{
5376
var successCount = 0;
@@ -79,7 +102,7 @@ public async Task<bool> WaitForDnsPropagationAsync(
79102
_logger.LogDebug("DNS verification attempt {Attempt}/{MaxAttempts}: {SuccessCount}/{TotalServers} servers confirmed record. Results: {Results}",
80103
attempt, MaxVerificationAttempts, successCount, _dnsServers.Count, string.Join(", ", results));
81104

82-
if (successCount >= minimumServers)
105+
if (successCount >= requiredServers)
83106
{
84107
_logger.LogInformation("DNS record propagated successfully! {SuccessCount}/{TotalServers} servers confirmed record after {Attempt} attempts",
85108
successCount, _dnsServers.Count, attempt);

0 commit comments

Comments
 (0)