Skip to content

Commit f37ca93

Browse files
Merge branch 'release-1.3' into saasfixes
2 parents b222ef5 + d34f7b5 commit f37ca93

12 files changed

+1072
-45
lines changed

AcmeCaPlugin/AcmeCaPlugin.cs

Lines changed: 120 additions & 23 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
@@ -370,7 +455,7 @@ private async Task ProcessAuthorizations(AcmeClient acmeClient, OrderDetails ord
370455
if (validation == null)
371456
throw new InvalidOperationException($"Failed to decode {DNS_CHALLENGE_TYPE} challenge validation details");
372457

373-
// Create DNS record
458+
// Create DNS record (will throw exception with details if it fails)
374459
var dnsProvider = DnsProviderFactory.Create(config, _logger);
375460
await dnsProvider.CreateRecordAsync(validation.DnsRecordName, validation.DnsRecordValue);
376461

@@ -383,22 +468,34 @@ private async Task ProcessAuthorizations(AcmeClient acmeClient, OrderDetails ord
383468
// Second pass: Wait for DNS propagation and submit challenges
384469
foreach (var (authz, challenge, validation) in pendingChallenges)
385470
{
386-
_logger.LogInformation("Waiting for DNS propagation for {Domain}...", authz.Identifier.Value);
387-
388-
// Wait for DNS propagation with verification
389-
var propagated = await dnsVerifier.WaitForDnsPropagationAsync(
390-
validation.DnsRecordName,
391-
validation.DnsRecordValue,
392-
minimumServers: 3 // Require at least 3 DNS servers to confirm
393-
);
471+
// Skip external DNS verification for Infoblox since it cannot ping external DNS providers
472+
bool isInfoblox = config.DnsProvider?.Trim().Equals("infoblox", StringComparison.OrdinalIgnoreCase) ?? false;
394473

395-
if (!propagated)
474+
if (isInfoblox)
475+
{
476+
_logger.LogInformation("Skipping external DNS propagation check for Infoblox provider for {Domain}. Adding short delay...", authz.Identifier.Value);
477+
// Add a short delay to allow Infoblox to process the record internally
478+
await Task.Delay(TimeSpan.FromSeconds(5));
479+
}
480+
else
396481
{
397-
_logger.LogWarning("DNS record may not have fully propagated for {Domain}. Proceeding anyway...",
398-
authz.Identifier.Value);
482+
_logger.LogInformation("Waiting for DNS propagation for {Domain}...", authz.Identifier.Value);
399483

400-
// Optional: Add a final delay as fallback
401-
await Task.Delay(TimeSpan.FromSeconds(30));
484+
// Wait for DNS propagation with verification
485+
var propagated = await dnsVerifier.WaitForDnsPropagationAsync(
486+
validation.DnsRecordName,
487+
validation.DnsRecordValue,
488+
minimumServers: 3 // Require at least 3 DNS servers to confirm
489+
);
490+
491+
if (!propagated)
492+
{
493+
_logger.LogWarning("DNS record may not have fully propagated for {Domain}. Proceeding anyway...",
494+
authz.Identifier.Value);
495+
496+
// Optional: Add a final delay as fallback
497+
await Task.Delay(TimeSpan.FromSeconds(30));
498+
}
402499
}
403500

404501
// Submit challenge response

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: 102 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)",
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"
@@ -144,6 +144,107 @@ public static Dictionary<string, PropertyConfigInfo> GetPluginAnnotations()
144144
Hidden = true,
145145
DefaultValue = "",
146146
Type = "String"
147+
},
148+
149+
// RFC 2136 Dynamic DNS (BIND/Microsoft DNS)
150+
["Rfc2136_Server"] = new PropertyConfigInfo()
151+
{
152+
Comments = "RFC 2136 DNS: Server hostname or IP address (Optional)",
153+
Hidden = false,
154+
DefaultValue = "",
155+
Type = "String"
156+
},
157+
["Rfc2136_Port"] = new PropertyConfigInfo()
158+
{
159+
Comments = "RFC 2136 DNS: Server port (default 53) (Optional)",
160+
Hidden = false,
161+
DefaultValue = "53",
162+
Type = "Number"
163+
},
164+
["Rfc2136_Zone"] = new PropertyConfigInfo()
165+
{
166+
Comments = "RFC 2136 DNS: Zone name (e.g., example.com) (Optional)",
167+
Hidden = false,
168+
DefaultValue = "",
169+
Type = "String"
170+
},
171+
["Rfc2136_TsigKeyName"] = new PropertyConfigInfo()
172+
{
173+
Comments = "RFC 2136 DNS: TSIG key name for authentication (Optional)",
174+
Hidden = false,
175+
DefaultValue = "",
176+
Type = "String"
177+
},
178+
["Rfc2136_TsigKey"] = new PropertyConfigInfo()
179+
{
180+
Comments = "RFC 2136 DNS: TSIG key (base64 encoded) for authentication (Optional)",
181+
Hidden = true,
182+
DefaultValue = "",
183+
Type = "Secret"
184+
},
185+
["Rfc2136_TsigAlgorithm"] = new PropertyConfigInfo()
186+
{
187+
Comments = "RFC 2136 DNS: TSIG algorithm (default hmac-sha256) (Optional)",
188+
Hidden = false,
189+
DefaultValue = "hmac-sha256",
190+
Type = "String"
191+
},
192+
193+
// DNS Verification Settings
194+
["DnsVerificationServer"] = new PropertyConfigInfo()
195+
{
196+
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.).",
197+
Hidden = false,
198+
DefaultValue = "",
199+
Type = "String"
200+
}
201+
202+
//Infoblox DNS
203+
,
204+
["Infoblox_Host"] = new PropertyConfigInfo()
205+
{
206+
Comments = "Infoblox DNS: API URL (e.g., https://infoblox.example.com/wapi/v2.12) only if using Infoblox DNS (Optional)",
207+
Hidden = false,
208+
DefaultValue = "",
209+
Type = "String"
210+
},
211+
["Infoblox_Username"] = new PropertyConfigInfo()
212+
{
213+
Comments = "Infoblox DNS: Username for authentication only if using Infoblox DNS (Optional)",
214+
Hidden = false,
215+
DefaultValue = "",
216+
Type = "String"
217+
},
218+
["Infoblox_Password"] = new PropertyConfigInfo()
219+
{
220+
Comments = "Infoblox DNS: Password for authentication only if using Infoblox DNS (Optional)",
221+
Hidden = true,
222+
DefaultValue = "",
223+
Type = "Secret"
224+
}
225+
226+
//Infoblox DNS
227+
,
228+
["Infoblox_Host"] = new PropertyConfigInfo()
229+
{
230+
Comments = "Infoblox DNS: API URL (e.g., https://infoblox.example.com/wapi/v2.12) only if using Infoblox DNS (Optional)",
231+
Hidden = false,
232+
DefaultValue = "",
233+
Type = "String"
234+
},
235+
["Infoblox_Username"] = new PropertyConfigInfo()
236+
{
237+
Comments = "Infoblox DNS: Username for authentication only if using Infoblox DNS (Optional)",
238+
Hidden = false,
239+
DefaultValue = "",
240+
Type = "String"
241+
},
242+
["Infoblox_Password"] = new PropertyConfigInfo()
243+
{
244+
Comments = "Infoblox DNS: Password for authentication only if using Infoblox DNS (Optional)",
245+
Hidden = true,
246+
DefaultValue = "",
247+
Type = "Secret"
147248
}
148249

149250
};

AcmeCaPlugin/AcmeClientConfig.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,23 @@ public class AcmeClientConfig
3737

3838
// Container Deployment Support
3939
public string AccountStoragePath { get; set; } = null;
40+
// RFC 2136 Dynamic DNS (BIND)
41+
public string Rfc2136_Server { get; set; } = null;
42+
public int Rfc2136_Port { get; set; } = 53;
43+
public string Rfc2136_Zone { get; set; } = null;
44+
public string Rfc2136_TsigKeyName { get; set; } = null;
45+
public string Rfc2136_TsigKey { get; set; } = null;
46+
public string Rfc2136_TsigAlgorithm { get; set; } = "hmac-sha256";
47+
48+
// Infoblox DNS
49+
public string Infoblox_Host { get; set; } = null;
50+
public string Infoblox_Username { get; set; } = null;
51+
public string Infoblox_Password { get; set; } = null;
52+
public string Infoblox_WapiVersion { get; set; } = "2.12";
53+
public bool Infoblox_IgnoreSslErrors { get; set; } = false;
54+
55+
// DNS Verification Settings
56+
public string DnsVerificationServer { get; set; } = null;
4057

4158
}
4259
}

AcmeCaPlugin/Clients/DNS/DnsProviderFactory.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,25 @@ public static IDnsProvider Create(AcmeClientConfig config, ILogger logger)
4040
return new Ns1DnsProvider(
4141
config.Ns1_ApiKey
4242
);
43+
case "rfc2136":
44+
return new Rfc2136DnsProvider(
45+
config.Rfc2136_Server,
46+
config.Rfc2136_Zone,
47+
config.Rfc2136_TsigKeyName,
48+
config.Rfc2136_TsigKey,
49+
config.Rfc2136_TsigAlgorithm,
50+
config.Rfc2136_Port,
51+
logger
52+
);
53+
case "infoblox":
54+
return new InfobloxDnsProvider(
55+
config.Infoblox_Host,
56+
config.Infoblox_Username,
57+
config.Infoblox_Password,
58+
config.Infoblox_WapiVersion,
59+
config.Infoblox_IgnoreSslErrors,
60+
logger
61+
);
4362
default:
4463
throw new NotSupportedException($"DNS provider '{config.DnsProvider}' is not supported.");
4564
}

0 commit comments

Comments
 (0)