From f8e67eb50837cbbc8414ec780cc861537f4a09d1 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Mon, 10 Aug 2026 15:39:35 -0400 Subject: [PATCH 01/29] Reapply "dns validation" This reverts commit e65fa8affd3aa775e4242dd7e4d9efa2cc79f4c7. --- HydrantCAProxy.Tests/RequestManagerTests.cs | 74 ++++++++++ HydrantCAProxy/Client/HydrantIdClient.cs | 131 ++++++++++++++++++ .../Models/CreateDomainValidationPayload.cs | 34 +++++ HydrantCAProxy/Client/Models/Domain.cs | 64 +++++++++ .../Client/Models/Enums/DomainStatusEnum.cs | 23 +++ .../Client/Models/Enums/ValidationMethod.cs | 24 ++++ HydrantCAProxy/Client/Models/PolicyDetails.cs | 3 + HydrantCAProxy/Client/Models/Validator.cs | 28 ++++ HydrantCAProxy/HydrantIdCAPlugin.cs | 100 +++++++++++++ .../ICreateDomainValidationPayload.cs | 22 +++ HydrantCAProxy/Interfaces/IDomain.cs | 32 +++++ HydrantCAProxy/Interfaces/IPolicyDetails.cs | 1 + HydrantCAProxy/Interfaces/IValidator.cs | 20 +++ HydrantCAProxy/RequestManager.cs | 67 +++++++++ 14 files changed, 623 insertions(+) create mode 100644 HydrantCAProxy/Client/Models/CreateDomainValidationPayload.cs create mode 100644 HydrantCAProxy/Client/Models/Domain.cs create mode 100644 HydrantCAProxy/Client/Models/Enums/DomainStatusEnum.cs create mode 100644 HydrantCAProxy/Client/Models/Enums/ValidationMethod.cs create mode 100644 HydrantCAProxy/Client/Models/Validator.cs create mode 100644 HydrantCAProxy/Interfaces/ICreateDomainValidationPayload.cs create mode 100644 HydrantCAProxy/Interfaces/IDomain.cs create mode 100644 HydrantCAProxy/Interfaces/IValidator.cs diff --git a/HydrantCAProxy.Tests/RequestManagerTests.cs b/HydrantCAProxy.Tests/RequestManagerTests.cs index 509df49..2fbb482 100644 --- a/HydrantCAProxy.Tests/RequestManagerTests.cs +++ b/HydrantCAProxy.Tests/RequestManagerTests.cs @@ -282,6 +282,80 @@ public void GetSansRequest_AllTypes_Populated() Assert.Single(result.Upn); } + // --------------------------------------------------------------------- + // GetDomainsToValidate + // --------------------------------------------------------------------- + + [Fact] + public void GetDomainsToValidate_CnOnly_ReturnsSingleDomain() + { + var result = _sut.GetDomainsToValidate(SampleCsr, null); + + Assert.Single(result); + Assert.Equal("unit.test.hydrantid.local", result[0]); + } + + [Fact] + public void GetDomainsToValidate_CnPlusDnsSans_ReturnsDeduped() + { + var sans = new Dictionary + { + ["dnsname"] = new[] { "unit.test.hydrantid.local", "www.example.com" } + }; + + var result = _sut.GetDomainsToValidate(SampleCsr, sans); + + Assert.Equal(2, result.Count); + Assert.Contains("unit.test.hydrantid.local", result); + Assert.Contains("www.example.com", result); + } + + [Fact] + public void GetDomainsToValidate_SansCaseVariant_DedupedAgainstCn() + { + var sans = new Dictionary + { + ["dnsname"] = new[] { "UNIT.TEST.HYDRANTID.LOCAL" } + }; + + var result = _sut.GetDomainsToValidate(SampleCsr, sans); + + Assert.Single(result); + } + + [Fact] + public void GetDomainsToValidate_NullCsr_ThrowsArgumentNullException() + { + Assert.Throws(() => _sut.GetDomainsToValidate(null, null)); + } + + // --------------------------------------------------------------------- + // GetCreateDomainValidationRequest + // --------------------------------------------------------------------- + + [Fact] + public void GetCreateDomainValidationRequest_Valid_SetsDnsMethodAndOmitsAccountId() + { + var result = _sut.GetCreateDomainValidationRequest("example.com", "validator-1"); + + Assert.Equal("example.com", result.DomainName); + Assert.Equal("validator-1", result.Validator); + Assert.Equal(ValidationMethod.Dns, result.Method); + Assert.Null(result.AccountId); + } + + [Fact] + public void GetCreateDomainValidationRequest_NullDomain_ThrowsArgumentNullException() + { + Assert.Throws(() => _sut.GetCreateDomainValidationRequest(null, "validator-1")); + } + + [Fact] + public void GetCreateDomainValidationRequest_NullValidatorId_ThrowsArgumentNullException() + { + Assert.Throws(() => _sut.GetCreateDomainValidationRequest("example.com", null)); + } + // --------------------------------------------------------------------- // GetCertificatesListRequest // --------------------------------------------------------------------- diff --git a/HydrantCAProxy/Client/HydrantIdClient.cs b/HydrantCAProxy/Client/HydrantIdClient.cs index 3b5a77b..542ae10 100644 --- a/HydrantCAProxy/Client/HydrantIdClient.cs +++ b/HydrantCAProxy/Client/HydrantIdClient.cs @@ -238,6 +238,137 @@ public async Task> GetPolicyList() + public async Task> GetDomainListAsync() + { + Log.MethodEntry(); + var apiEndpoint = "/api/v2/domains/"; + var fullUrl = BaseUrl + apiEndpoint; + Log.LogTrace("GetDomainListAsync: API Url={Url}", fullUrl); + + var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; + + try + { + var restClient = ConfigureRestClient("get", fullUrl); + using var resp = await restClient.GetAsync(apiEndpoint); + var responseContent = await resp.Content.ReadAsStringAsync(); + + Log.LogTrace("GetDomainListAsync: HTTP status={StatusCode}, response length={Len}", + resp.StatusCode, responseContent?.Length ?? 0); + + if (!resp.IsSuccessStatusCode) + { + Log.LogError("GetDomainListAsync: request failed with status {StatusCode}: {Response}", resp.StatusCode, responseContent); + throw new HttpRequestException($"GetDomainListAsync failed with HTTP {resp.StatusCode}: {responseContent}"); + } + + var domains = JsonConvert.DeserializeObject>(responseContent, settings); + + if (domains == null) + { + Log.LogWarning("GetDomainListAsync: deserialized domain list is null"); + return new List(); + } + + Log.LogTrace("GetDomainListAsync: returned {Count} domains", domains.Count); + return domains; + } + catch (Exception e) + { + Log.LogError(e, "GetDomainListAsync: exception: {Message}", e.Message); + throw; + } + } + + + + public async Task GetSubmitCreateDomainValidationAsync(CreateDomainValidationPayload payload) + { + Log.MethodEntry(); + Log.LogTrace("GetSubmitCreateDomainValidationAsync: payload is {Null}", payload == null ? "NULL" : "present"); + + if (payload == null) + throw new ArgumentNullException(nameof(payload), "payload cannot be null."); + + var apiEndpoint = "/api/v2/domains/"; + var fullUrl = BaseUrl + apiEndpoint; + Log.LogTrace("GetSubmitCreateDomainValidationAsync: API Url={Url}", fullUrl); + + var json = JsonConvert.SerializeObject(payload); + Log.LogTrace("GetSubmitCreateDomainValidationAsync: request JSON: {Json}", json); + + var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; + + try + { + var restClient = ConfigureRestClient("post", fullUrl); + using var resp = await restClient.PostAsync(apiEndpoint, new StringContent(json, Encoding.UTF8, "application/json")); + var responseContent = await resp.Content.ReadAsStringAsync(); + + Log.LogTrace("GetSubmitCreateDomainValidationAsync: HTTP status={StatusCode}, response length={Len}", + resp.StatusCode, responseContent?.Length ?? 0); + + if (!resp.IsSuccessStatusCode) + { + Log.LogError("GetSubmitCreateDomainValidationAsync: request failed with status {StatusCode}: {Response}", resp.StatusCode, responseContent); + throw new HttpRequestException($"GetSubmitCreateDomainValidationAsync failed with HTTP {resp.StatusCode}: {responseContent}"); + } + + var domain = JsonConvert.DeserializeObject(responseContent, settings); + Log.LogTrace("GetSubmitCreateDomainValidationAsync: response JSON: {Json}", JsonConvert.SerializeObject(domain)); + return domain; + } + catch (Exception e) + { + Log.LogError(e, "GetSubmitCreateDomainValidationAsync: exception: {Message}", e.Message); + throw; + } + } + + + + public async Task GetSubmitCheckDomainValidationAsync(string domainId) + { + Log.MethodEntry(); + Log.LogTrace("GetSubmitCheckDomainValidationAsync: domainId='{DomainId}'", domainId ?? "(null)"); + + if (string.IsNullOrEmpty(domainId)) + throw new ArgumentNullException(nameof(domainId), "domainId cannot be null or empty."); + + var apiEndpoint = $"/api/v2/domains/{domainId}/validate"; + var fullUrl = BaseUrl + apiEndpoint; + Log.LogTrace("GetSubmitCheckDomainValidationAsync: API Url={Url}", fullUrl); + + var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; + + try + { + var restClient = ConfigureRestClient("get", fullUrl); + using var resp = await restClient.GetAsync(apiEndpoint); + var responseContent = await resp.Content.ReadAsStringAsync(); + + Log.LogTrace("GetSubmitCheckDomainValidationAsync: HTTP status={StatusCode}, response length={Len}", + resp.StatusCode, responseContent?.Length ?? 0); + + if (!resp.IsSuccessStatusCode) + { + Log.LogError("GetSubmitCheckDomainValidationAsync: request failed with status {StatusCode}: {Response}", resp.StatusCode, responseContent); + throw new HttpRequestException($"GetSubmitCheckDomainValidationAsync failed with HTTP {resp.StatusCode}: {responseContent}"); + } + + var domain = JsonConvert.DeserializeObject(responseContent, settings); + Log.LogTrace("GetSubmitCheckDomainValidationAsync: response JSON: {Json}", JsonConvert.SerializeObject(domain)); + return domain; + } + catch (Exception e) + { + Log.LogError(e, "GetSubmitCheckDomainValidationAsync: exception: {Message}", e.Message); + throw; + } + } + + + public async Task GetSubmitGetCertificateAsync(string certificateId) { Log.MethodEntry(); diff --git a/HydrantCAProxy/Client/Models/CreateDomainValidationPayload.cs b/HydrantCAProxy/Client/Models/CreateDomainValidationPayload.cs new file mode 100644 index 0000000..f5ec6ea --- /dev/null +++ b/HydrantCAProxy/Client/Models/CreateDomainValidationPayload.cs @@ -0,0 +1,34 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain a +// copy of the License at http://www.apache.org/licenses/LICENSE-2.0. Unless +// required by applicable law or agreed to in writing, software distributed +// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +// OR CONDITIONS OF ANY KIND, either express or implied. See the License for +// thespecific language governing permissions and limitations under the +// License. +using Keyfactor.HydrantId.Client.Models.Enums; +using Keyfactor.HydrantId.Interfaces; +using Newtonsoft.Json; + +namespace Keyfactor.HydrantId.Client.Models +{ + public class CreateDomainValidationPayload : ICreateDomainValidationPayload + { + [JsonProperty("accountId", NullValueHandling = NullValueHandling.Ignore)] + public string AccountId { get;set; } + + [JsonProperty("domain", NullValueHandling = NullValueHandling.Ignore)] + public string DomainName { get;set; } + + [JsonProperty("validator", NullValueHandling = NullValueHandling.Ignore)] + public string Validator { get;set; } + + [JsonProperty("method", NullValueHandling = NullValueHandling.Ignore)] + public ValidationMethod? Method { get;set; } + + [JsonProperty("payload", NullValueHandling = NullValueHandling.Ignore)] + public object Payload { get;set; } + + } +} diff --git a/HydrantCAProxy/Client/Models/Domain.cs b/HydrantCAProxy/Client/Models/Domain.cs new file mode 100644 index 0000000..db3db3c --- /dev/null +++ b/HydrantCAProxy/Client/Models/Domain.cs @@ -0,0 +1,64 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain a +// copy of the License at http://www.apache.org/licenses/LICENSE-2.0. Unless +// required by applicable law or agreed to in writing, software distributed +// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +// OR CONDITIONS OF ANY KIND, either express or implied. See the License for +// thespecific language governing permissions and limitations under the +// License. +using Keyfactor.HydrantId.Client.Models.Enums; +using Keyfactor.HydrantId.Interfaces; +using Newtonsoft.Json; + +namespace Keyfactor.HydrantId.Client.Models +{ + public class Domain : IDomain + { + [JsonProperty("id", NullValueHandling = NullValueHandling.Ignore)] + public string Id { get;set; } + + [JsonProperty("validator", NullValueHandling = NullValueHandling.Ignore)] + public string Validator { get;set; } + + [JsonProperty("accountId", NullValueHandling = NullValueHandling.Ignore)] + public string AccountId { get;set; } + + [JsonProperty("organizationIds", NullValueHandling = NullValueHandling.Ignore)] + public string OrganizationIds { get;set; } + + [JsonProperty("domain", NullValueHandling = NullValueHandling.Ignore)] + public string DomainName { get;set; } + + [JsonProperty("method", NullValueHandling = NullValueHandling.Ignore)] + public ValidationMethod? Method { get;set; } + + [JsonProperty("code", NullValueHandling = NullValueHandling.Ignore)] + public string Code { get;set; } + + [JsonProperty("codeInstructions", NullValueHandling = NullValueHandling.Ignore)] + public string CodeInstructions { get;set; } + + [JsonProperty("message", NullValueHandling = NullValueHandling.Ignore)] + public string Message { get;set; } + + [JsonProperty("payload", NullValueHandling = NullValueHandling.Ignore)] + public object Payload { get;set; } + + [JsonProperty("status", NullValueHandling = NullValueHandling.Ignore)] + public DomainStatusEnum? Status { get;set; } + + [JsonProperty("domainValidUntil", NullValueHandling = NullValueHandling.Ignore)] + public string DomainValidUntil { get;set; } + + [JsonProperty("codeValidUntil", NullValueHandling = NullValueHandling.Ignore)] + public string CodeValidUntil { get;set; } + + [JsonProperty("createdAt", NullValueHandling = NullValueHandling.Ignore)] + public string CreatedAt { get;set; } + + [JsonProperty("updatedAt", NullValueHandling = NullValueHandling.Ignore)] + public string UpdatedAt { get;set; } + + } +} diff --git a/HydrantCAProxy/Client/Models/Enums/DomainStatusEnum.cs b/HydrantCAProxy/Client/Models/Enums/DomainStatusEnum.cs new file mode 100644 index 0000000..81bf011 --- /dev/null +++ b/HydrantCAProxy/Client/Models/Enums/DomainStatusEnum.cs @@ -0,0 +1,23 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain a +// copy of the License at http://www.apache.org/licenses/LICENSE-2.0. Unless +// required by applicable law or agreed to in writing, software distributed +// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +// OR CONDITIONS OF ANY KIND, either express or implied. See the License for +// thespecific language governing permissions and limitations under the +// License. +using System.Runtime.Serialization; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace Keyfactor.HydrantId.Client.Models.Enums +{ + [JsonConverter(typeof(StringEnumConverter))] + public enum DomainStatusEnum + { + [EnumMember(Value = "PENDING")] Pending = 1, + [EnumMember(Value = "VALIDATED")] Validated = 2, + [EnumMember(Value = "EXPIRED")] Expired = 3 + } +} diff --git a/HydrantCAProxy/Client/Models/Enums/ValidationMethod.cs b/HydrantCAProxy/Client/Models/Enums/ValidationMethod.cs new file mode 100644 index 0000000..2c54ae5 --- /dev/null +++ b/HydrantCAProxy/Client/Models/Enums/ValidationMethod.cs @@ -0,0 +1,24 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain a +// copy of the License at http://www.apache.org/licenses/LICENSE-2.0. Unless +// required by applicable law or agreed to in writing, software distributed +// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +// OR CONDITIONS OF ANY KIND, either express or implied. See the License for +// thespecific language governing permissions and limitations under the +// License. +using System.Runtime.Serialization; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace Keyfactor.HydrantId.Client.Models.Enums +{ + [JsonConverter(typeof(StringEnumConverter))] + public enum ValidationMethod + { + [EnumMember(Value = "DNS")] Dns = 1, + [EnumMember(Value = "WELLKNOWN")] WellKnown = 2, + [EnumMember(Value = "IMPORT")] Import = 3, + [EnumMember(Value = "PERSISTENTDNSTXT")] PersistentDnsTxt = 4 + } +} diff --git a/HydrantCAProxy/Client/Models/PolicyDetails.cs b/HydrantCAProxy/Client/Models/PolicyDetails.cs index 14ff0f6..245736f 100644 --- a/HydrantCAProxy/Client/Models/PolicyDetails.cs +++ b/HydrantCAProxy/Client/Models/PolicyDetails.cs @@ -33,5 +33,8 @@ public class PolicyDetails : IPolicyDetails [JsonProperty("customExtensions", NullValueHandling = NullValueHandling.Ignore)] public List CustomExtensions { get;set; } + [JsonProperty("validator", NullValueHandling = NullValueHandling.Ignore)] + public string Validator { get;set; } + } } \ No newline at end of file diff --git a/HydrantCAProxy/Client/Models/Validator.cs b/HydrantCAProxy/Client/Models/Validator.cs new file mode 100644 index 0000000..12a4915 --- /dev/null +++ b/HydrantCAProxy/Client/Models/Validator.cs @@ -0,0 +1,28 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain a +// copy of the License at http://www.apache.org/licenses/LICENSE-2.0. Unless +// required by applicable law or agreed to in writing, software distributed +// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +// OR CONDITIONS OF ANY KIND, either express or implied. See the License for +// thespecific language governing permissions and limitations under the +// License. +using System.Collections.Generic; +using Keyfactor.HydrantId.Interfaces; +using Newtonsoft.Json; + +namespace Keyfactor.HydrantId.Client.Models +{ + public class Validator : IValidator + { + [JsonProperty("id", NullValueHandling = NullValueHandling.Ignore)] + public string Id { get;set; } + + [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)] + public string Name { get;set; } + + [JsonProperty("capabilities", NullValueHandling = NullValueHandling.Ignore)] + public List Capabilities { get;set; } + + } +} diff --git a/HydrantCAProxy/HydrantIdCAPlugin.cs b/HydrantCAProxy/HydrantIdCAPlugin.cs index 6c7f86e..d2d20b9 100644 --- a/HydrantCAProxy/HydrantIdCAPlugin.cs +++ b/HydrantCAProxy/HydrantIdCAPlugin.cs @@ -524,6 +524,10 @@ await flow.StepAsync("FetchPolicies", async () => _logger.LogTrace("Enroll: matched policy: {Json}", JsonConvert.SerializeObject(policyId)); flow.Step("MatchPolicy", $"policyId={policyId.Id}"); + var domainValidationResult = await EnsureDomainsValidatedForPolicyAsync(client, flow, policyId, csr, san); + if (domainValidationResult != null) + return domainValidationResult; + var enrollmentRequest = _requestManager.GetEnrollmentRequest(policyId.Id, productInfo, csr, san); _logger.LogTrace("Enroll: enrollment request JSON: {Json}", JsonConvert.SerializeObject(enrollmentRequest)); @@ -662,6 +666,10 @@ await flow.StepAsync("FetchPolicies", async () => }; } + var reissueDomainValidationResult = await EnsureDomainsValidatedForPolicyAsync(client, flow, policyId, csr, san); + if (reissueDomainValidationResult != null) + return reissueDomainValidationResult; + var reissueRequest = _requestManager.GetEnrollmentRequest(policyId.Id, productInfo, csr, san); _logger.LogTrace("Enroll: re-issue request JSON: {Json}", JsonConvert.SerializeObject(reissueRequest)); @@ -746,6 +754,98 @@ await flow.StepAsync("WaitForCertificate", async () => } } + /// + /// Resolves the validator for the matched policy, computes the domains (CN + DNS SANs) that + /// need DNS-based domain control validation, and ensures each is VALIDATED before a CSR is + /// submitted. Returns null when enrollment may proceed. Returns a non-null EnrollmentResult + /// (FAILED if the policy has no validator configured, EXTERNALVALIDATION if one or more + /// domains are still pending) when the caller should return immediately instead of proceeding. + /// + private async Task EnsureDomainsValidatedForPolicyAsync( + HydrantIdClient client, FlowLogger flow, Policy policyId, string csr, Dictionary san) + { + var validatorId = policyId.Details?.Validator; + if (string.IsNullOrWhiteSpace(validatorId)) + { + flow.Fail("ValidateValidator", "Matched policy has no Validator configured"); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"Enrollment failed: policy '{policyId.Name}' has no validator configured for domain validation." + }; + } + + var domainsToValidate = _requestManager.GetDomainsToValidate(csr, san); + flow.Step("ComputeDomainsToValidate", string.Join(", ", domainsToValidate)); + + bool allValidated = true; + string pendingMessage = null; + await flow.StepAsync("EnsureDomainsValidated", async () => + { + (allValidated, pendingMessage) = await EnsureDomainsValidatedAsync(client, flow, domainsToValidate, validatorId); + }); + + if (allValidated) + return null; + + flow.Fail("DomainValidation", "one or more domains pending DCV"); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.EXTERNALVALIDATION, + StatusMessage = pendingMessage + }; + } + + /// + /// Checks each domain against HydrantID's Domains resource, starting DNS validation for any + /// domain that has not been requested yet and re-checking any domain that is still pending. + /// Command re-invokes Enroll() from scratch on resubmit, and this plugin has no local state + /// store, so listing existing domains and filtering by name is the only way to recover a + /// previously-started validation's id across Enroll() calls. + /// + private async Task<(bool AllValidated, string PendingMessage)> EnsureDomainsValidatedAsync( + HydrantIdClient client, FlowLogger flow, List domainsToValidate, string validatorId) + { + var existingDomains = await client.GetDomainListAsync(); + + var pending = new List<(string Domain, string Instructions)>(); + + foreach (var domainName in domainsToValidate) + { + var match = existingDomains.FirstOrDefault(d => + string.Equals(d.DomainName, domainName, StringComparison.OrdinalIgnoreCase)); + + Domain domain; + if (match == null) + { + var payload = _requestManager.GetCreateDomainValidationRequest(domainName, validatorId); + domain = await client.GetSubmitCreateDomainValidationAsync(payload); + } + else if (match.Status != DomainStatusEnum.Validated) + { + domain = await client.GetSubmitCheckDomainValidationAsync(match.Id); + } + else + { + continue; + } + + if (domain?.Status != DomainStatusEnum.Validated) + { + pending.Add((domainName, domain?.CodeInstructions ?? "(no instructions returned by HydrantId)")); + } + } + + if (pending.Count == 0) + return (true, null); + + var message = "Domain validation required before this certificate can be issued. " + + "Publish the following DNS record(s), then resubmit:\n" + + string.Join("\n", pending.Select(p => $" - {p.Domain}: {p.Instructions}")); + + return (false, message); + } + public async Task Revoke(string caRequestID, string hexSerialNumber, uint revocationReason) { using var flow = new FlowLogger(_logger, $"Revoke({caRequestID ?? "null"})"); diff --git a/HydrantCAProxy/Interfaces/ICreateDomainValidationPayload.cs b/HydrantCAProxy/Interfaces/ICreateDomainValidationPayload.cs new file mode 100644 index 0000000..3d39880 --- /dev/null +++ b/HydrantCAProxy/Interfaces/ICreateDomainValidationPayload.cs @@ -0,0 +1,22 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain a +// copy of the License at http://www.apache.org/licenses/LICENSE-2.0. Unless +// required by applicable law or agreed to in writing, software distributed +// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +// OR CONDITIONS OF ANY KIND, either express or implied. See the License for +// thespecific language governing permissions and limitations under the +// License. +using Keyfactor.HydrantId.Client.Models.Enums; + +namespace Keyfactor.HydrantId.Interfaces +{ + public interface ICreateDomainValidationPayload + { + string AccountId { get;set; } + string DomainName { get;set; } + string Validator { get;set; } + ValidationMethod? Method { get;set; } + object Payload { get;set; } + } +} diff --git a/HydrantCAProxy/Interfaces/IDomain.cs b/HydrantCAProxy/Interfaces/IDomain.cs new file mode 100644 index 0000000..fb6ccc5 --- /dev/null +++ b/HydrantCAProxy/Interfaces/IDomain.cs @@ -0,0 +1,32 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain a +// copy of the License at http://www.apache.org/licenses/LICENSE-2.0. Unless +// required by applicable law or agreed to in writing, software distributed +// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +// OR CONDITIONS OF ANY KIND, either express or implied. See the License for +// thespecific language governing permissions and limitations under the +// License. +using Keyfactor.HydrantId.Client.Models.Enums; + +namespace Keyfactor.HydrantId.Interfaces +{ + public interface IDomain + { + string Id { get;set; } + string Validator { get;set; } + string AccountId { get;set; } + string OrganizationIds { get;set; } + string DomainName { get;set; } + ValidationMethod? Method { get;set; } + string Code { get;set; } + string CodeInstructions { get;set; } + string Message { get;set; } + object Payload { get;set; } + DomainStatusEnum? Status { get;set; } + string DomainValidUntil { get;set; } + string CodeValidUntil { get;set; } + string CreatedAt { get;set; } + string UpdatedAt { get;set; } + } +} diff --git a/HydrantCAProxy/Interfaces/IPolicyDetails.cs b/HydrantCAProxy/Interfaces/IPolicyDetails.cs index b6670e0..f97ecee 100644 --- a/HydrantCAProxy/Interfaces/IPolicyDetails.cs +++ b/HydrantCAProxy/Interfaces/IPolicyDetails.cs @@ -20,5 +20,6 @@ public interface IPolicyDetails PolicyDetailsExpiryEmails ExpiryEmails { get;set; } List CustomFields { get;set; } List CustomExtensions { get;set; } + string Validator { get;set; } } } \ No newline at end of file diff --git a/HydrantCAProxy/Interfaces/IValidator.cs b/HydrantCAProxy/Interfaces/IValidator.cs new file mode 100644 index 0000000..9984ef8 --- /dev/null +++ b/HydrantCAProxy/Interfaces/IValidator.cs @@ -0,0 +1,20 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain a +// copy of the License at http://www.apache.org/licenses/LICENSE-2.0. Unless +// required by applicable law or agreed to in writing, software distributed +// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +// OR CONDITIONS OF ANY KIND, either express or implied. See the License for +// thespecific language governing permissions and limitations under the +// License. +using System.Collections.Generic; + +namespace Keyfactor.HydrantId.Interfaces +{ + public interface IValidator + { + string Id { get;set; } + string Name { get;set; } + List Capabilities { get;set; } + } +} diff --git a/HydrantCAProxy/RequestManager.cs b/HydrantCAProxy/RequestManager.cs index f2fe8fa..b596126 100644 --- a/HydrantCAProxy/RequestManager.cs +++ b/HydrantCAProxy/RequestManager.cs @@ -333,6 +333,73 @@ public CertRequestBodySubjectAltNames GetSansRequest(Dictionary GetDomainsToValidate(string csr, Dictionary san) + { + try + { + Log.MethodEntry(); + Log.LogTrace("GetDomainsToValidate: csr length={CsrLen}, san count={Count}", csr?.Length ?? 0, san?.Count ?? 0); + + if (string.IsNullOrEmpty(csr)) + throw new ArgumentNullException(nameof(csr), "CSR cannot be null or empty."); + + var domains = new List(); + + var cn = GetDnComponentsRequest(csr)?.Cn; + if (!string.IsNullOrWhiteSpace(cn)) + domains.Add(cn.Trim()); + + var sanNames = GetSansRequest(san)?.Dnsname; + if (sanNames != null) + domains.AddRange(sanNames.Where(n => !string.IsNullOrWhiteSpace(n)).Select(n => n.Trim())); + + var deduped = domains + .GroupBy(d => d, StringComparer.OrdinalIgnoreCase) + .Select(g => g.First()) + .ToList(); + + Log.LogTrace("GetDomainsToValidate: {Count} unique domain(s): {Domains}", deduped.Count, string.Join(", ", deduped)); + Log.MethodExit(); + return deduped; + } + catch (Exception e) + { + Log.LogError(e, "Error occurred in RequestManager.GetDomainsToValidate: {Message}", e.Message); + throw; + } + } + + public CreateDomainValidationPayload GetCreateDomainValidationRequest(string domain, string validatorId) + { + try + { + Log.MethodEntry(); + Log.LogTrace("GetCreateDomainValidationRequest: domain='{Domain}', validatorId='{ValidatorId}'", + domain ?? "(null)", validatorId ?? "(null)"); + + if (string.IsNullOrEmpty(domain)) + throw new ArgumentNullException(nameof(domain), "domain cannot be null or empty."); + if (string.IsNullOrEmpty(validatorId)) + throw new ArgumentNullException(nameof(validatorId), "validatorId cannot be null or empty."); + + var payload = new CreateDomainValidationPayload + { + DomainName = domain, + Validator = validatorId, + Method = ValidationMethod.Dns + // AccountId intentionally omitted -- Hawk auth already scopes the account. + }; + + Log.MethodExit(); + return payload; + } + catch (Exception e) + { + Log.LogError(e, "Error occurred in RequestManager.GetCreateDomainValidationRequest: {Message}", e.Message); + throw; + } + } + public EnrollmentResult GetEnrollmentResult(ICertificate enrollmentResult, AnyCAPluginCertificate cert) { try From a74a26207016d787e358e21784325877e8d824d7 Mon Sep 17 00:00:00 2001 From: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:18:36 -0400 Subject: [PATCH 02/29] Update HydrantIdCAPlugin.csproj --- HydrantCAProxy/HydrantIdCAPlugin.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HydrantCAProxy/HydrantIdCAPlugin.csproj b/HydrantCAProxy/HydrantIdCAPlugin.csproj index d2ea4cd..e654db7 100644 --- a/HydrantCAProxy/HydrantIdCAPlugin.csproj +++ b/HydrantCAProxy/HydrantIdCAPlugin.csproj @@ -1,6 +1,6 @@  - net6.0;net8.0;net10.0 + net10.0 disable true false From 97a2f77739aaab715bc1ad3364714408d82ff29a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 10 Aug 2026 20:19:09 +0000 Subject: [PATCH 03/29] docs: auto-generate README and documentation [skip ci] --- README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 124566b..705b353 100644 --- a/README.md +++ b/README.md @@ -174,17 +174,15 @@ The plugin supports the following standard CRL revocation reasons: 2. On the server hosting the AnyCA Gateway REST, download and unzip the latest [HID Global AnyCA Gateway REST plugin](https://github.com/Keyfactor/hydrantid-caplugin/releases/latest) from GitHub. -3. Copy the unzipped directory (usually called `net6.0` or `net8.0` or `net10.0`) to the Extensions directory: +3. Copy the unzipped directory (usually called `net10.0`) to the Extensions directory: ```shell Depending on your AnyCA Gateway REST version, copy the unzipped directory to one of the following locations: - Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net6.0\Extensions - Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net8.0\Extensions Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net10.0\Extensions ``` - > The directory containing the HID Global AnyCA Gateway REST plugin DLLs (`net6.0` or `net8.0` or `net10.0`) can be named anything, as long as it is unique within the `Extensions` directory. + > The directory containing the HID Global AnyCA Gateway REST plugin DLLs (`net10.0`) can be named anything, as long as it is unique within the `Extensions` directory. 4. Restart the AnyCA Gateway REST service. From ee41c62b289343bb91a148f77c2788421884c609 Mon Sep 17 00:00:00 2001 From: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:53:13 -0400 Subject: [PATCH 04/29] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4fe490..3c812d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,5 @@ # v1.0.3 -* Added support for revocation reason 0 (Unspecified) now that HydrantId accepts it +* Added support for revocation reason 0 (Unspecified) now that HydrantId accepts it * Fixed sensitive credentials (HydrantIdAuthId, HydrantIdAuthKey) being written to trace logs in plain text; raw config JSON is now masked before logging # v1.0.2 From 8c54d72c7cb922bdea0199438220ff36e0fd378a Mon Sep 17 00:00:00 2001 From: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:07:07 -0400 Subject: [PATCH 05/29] Update HydrantCAProxy.Tests.csproj --- HydrantCAProxy.Tests/HydrantCAProxy.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HydrantCAProxy.Tests/HydrantCAProxy.Tests.csproj b/HydrantCAProxy.Tests/HydrantCAProxy.Tests.csproj index 6c8eb2b..44c175b 100644 --- a/HydrantCAProxy.Tests/HydrantCAProxy.Tests.csproj +++ b/HydrantCAProxy.Tests/HydrantCAProxy.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net10 disable disable false From a80b846e945cf005ecd738209b542e66aa94991c Mon Sep 17 00:00:00 2001 From: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:10:42 -0400 Subject: [PATCH 06/29] Update HydrantCAProxy.Tests.csproj --- HydrantCAProxy.Tests/HydrantCAProxy.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HydrantCAProxy.Tests/HydrantCAProxy.Tests.csproj b/HydrantCAProxy.Tests/HydrantCAProxy.Tests.csproj index 44c175b..f2aacd0 100644 --- a/HydrantCAProxy.Tests/HydrantCAProxy.Tests.csproj +++ b/HydrantCAProxy.Tests/HydrantCAProxy.Tests.csproj @@ -1,7 +1,7 @@ - net10 + net10.0 disable disable false From 9a74614fd37f4f785a0e8344c15154bf8f182436 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 11 Aug 2026 13:03:18 -0400 Subject: [PATCH 07/29] make domain validator optional on a policy, skip DCV when unset --- HydrantCAProxy/HydrantIdCAPlugin.cs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/HydrantCAProxy/HydrantIdCAPlugin.cs b/HydrantCAProxy/HydrantIdCAPlugin.cs index d2d20b9..74f69eb 100644 --- a/HydrantCAProxy/HydrantIdCAPlugin.cs +++ b/HydrantCAProxy/HydrantIdCAPlugin.cs @@ -757,9 +757,11 @@ await flow.StepAsync("WaitForCertificate", async () => /// /// Resolves the validator for the matched policy, computes the domains (CN + DNS SANs) that /// need DNS-based domain control validation, and ensures each is VALIDATED before a CSR is - /// submitted. Returns null when enrollment may proceed. Returns a non-null EnrollmentResult - /// (FAILED if the policy has no validator configured, EXTERNALVALIDATION if one or more - /// domains are still pending) when the caller should return immediately instead of proceeding. + /// submitted. Returns null when enrollment may proceed -- either because every domain is + /// validated, or because the matched policy has no validator configured, in which case DCV + /// is not required and is skipped entirely (not every policy uses domain validation). + /// Returns a non-null EXTERNALVALIDATION EnrollmentResult when one or more domains are still + /// pending and the caller should return immediately instead of proceeding. /// private async Task EnsureDomainsValidatedForPolicyAsync( HydrantIdClient client, FlowLogger flow, Policy policyId, string csr, Dictionary san) @@ -767,12 +769,8 @@ private async Task EnsureDomainsValidatedForPolicyAsync( var validatorId = policyId.Details?.Validator; if (string.IsNullOrWhiteSpace(validatorId)) { - flow.Fail("ValidateValidator", "Matched policy has no Validator configured"); - return new EnrollmentResult - { - Status = (int)EndEntityStatus.FAILED, - StatusMessage = $"Enrollment failed: policy '{policyId.Name}' has no validator configured for domain validation." - }; + flow.Skip("DomainValidation", $"policy '{policyId.Name}' has no validator configured; skipping DCV"); + return null; } var domainsToValidate = _requestManager.GetDomainsToValidate(csr, san); From 95e20ba2e5b20976fc391e8898f4e5773fdd1ffe Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 1 Sep 2026 10:37:08 -0400 Subject: [PATCH 08/29] added logging --- .gitignore | 1 + HydrantCAProxy/HydrantIdCAPlugin.cs | 29 +- HydrantCAProxy/RequestManager.cs | 3 +- ...rantID-Plugin-Flow.postman_collection.json | 290 ++++++++++++++++++ ...HydrantID-Staging.postman_environment.json | 15 + postman/README.md | 35 +++ 6 files changed, 368 insertions(+), 5 deletions(-) create mode 100644 postman/HydrantID-Plugin-Flow.postman_collection.json create mode 100644 postman/HydrantID-Staging.postman_environment.json create mode 100644 postman/README.md diff --git a/.gitignore b/.gitignore index e57e8eb..e02f1b8 100644 --- a/.gitignore +++ b/.gitignore @@ -331,4 +331,5 @@ ASALocalRun/ .claude/settings.local.json sample change.txt .claude/settings.json +postman/*.postman_environment.local.json diff --git a/HydrantCAProxy/HydrantIdCAPlugin.cs b/HydrantCAProxy/HydrantIdCAPlugin.cs index 74f69eb..d5da5eb 100644 --- a/HydrantCAProxy/HydrantIdCAPlugin.cs +++ b/HydrantCAProxy/HydrantIdCAPlugin.cs @@ -104,8 +104,9 @@ private static string MaskConfigForLog(string rawJson) } return token.ToString(Newtonsoft.Json.Formatting.None); } - catch + catch (Exception ex) { + _logger.LogTrace("MaskConfigForLog: failed to parse config JSON for masking, redacting entire payload: {Message}", ex.Message); return "***REDACTED***"; } } @@ -145,8 +146,18 @@ public async Task Ping() return; } - flow.Step("PingCA"); _logger.LogDebug("Pinging HydrantId to validate connection"); + var client = new HydrantIdClient(Config); + var reachable = await client.Ping(); + + if (!reachable) + { + flow.Fail("PingCA", "GET /policies did not return a success status"); + _logger.LogError("Ping: HydrantId connectivity check failed -- GET /policies did not return a success status."); + throw new Exception("HydrantId connectivity check failed."); + } + + flow.Step("PingCA", "connectivity verified"); } finally { @@ -814,24 +825,36 @@ await flow.StepAsync("EnsureDomainsValidated", async () => string.Equals(d.DomainName, domainName, StringComparison.OrdinalIgnoreCase)); Domain domain; - if (match == null) + if (match == null || match.Status == DomainStatusEnum.Expired) { + // HydrantID's "regenerate code" action for an expired domain is the same + // POST used to start a validation from scratch -- confirmed idempotent per + // domain name (does not create a duplicate record) against staging. + flow.Step("DomainValidation.CreateOrRegenerate", + $"domain='{domainName}', priorStatus={(match == null ? "(none)" : match.Status.ToString())}"); var payload = _requestManager.GetCreateDomainValidationRequest(domainName, validatorId); domain = await client.GetSubmitCreateDomainValidationAsync(payload); } else if (match.Status != DomainStatusEnum.Validated) { + flow.Step("DomainValidation.Recheck", $"domain='{domainName}', status={match.Status}, domainId='{match.Id}'"); domain = await client.GetSubmitCheckDomainValidationAsync(match.Id); } else { + flow.Step("DomainValidation.AlreadyValidated", $"domain='{domainName}'"); continue; } if (domain?.Status != DomainStatusEnum.Validated) { + flow.Step("DomainValidation.StillPending", $"domain='{domainName}', status={domain?.Status.ToString() ?? "(null response)"}"); pending.Add((domainName, domain?.CodeInstructions ?? "(no instructions returned by HydrantId)")); } + else + { + flow.Step("DomainValidation.NowValidated", $"domain='{domainName}'"); + } } if (pending.Count == 0) diff --git a/HydrantCAProxy/RequestManager.cs b/HydrantCAProxy/RequestManager.cs index b596126..de7ff55 100644 --- a/HydrantCAProxy/RequestManager.cs +++ b/HydrantCAProxy/RequestManager.cs @@ -273,8 +273,7 @@ private CertRequestBodyValidity GetValidity(string period, int units) validity.Days = units; break; default: - Log.LogWarning("GetValidity: unrecognized period '{Period}', no validity set", period); - break; + throw new ArgumentException($"Unrecognized validity period '{period}'; expected 'Years', 'Months', or 'Days'.", nameof(period)); } return validity; diff --git a/postman/HydrantID-Plugin-Flow.postman_collection.json b/postman/HydrantID-Plugin-Flow.postman_collection.json new file mode 100644 index 0000000..b74f2a6 --- /dev/null +++ b/postman/HydrantID-Plugin-Flow.postman_collection.json @@ -0,0 +1,290 @@ +{ + "info": { + "name": "HydrantID ACM API - Plugin Flow Replica", + "description": "Replicates, one request per HydrantIdClient.cs method, every HTTP call the HydrantCAProxy plugin makes against the HydrantID ACM v2 API. Folders are ordered to match a typical enrollment-with-DCV flow: connectivity check -> list policies -> domain validation -> submit CSR -> poll/retrieve certificate -> revoke/renew.\n\nEach request's description names the HydrantIdClient method and file:line it mirrors, so a failing step here maps directly to the corresponding C# call.\n\nAuth: HydrantID uses Hawk (see swagger info block). This collection sets Hawk auth at the collection level using {{hawkAuthId}} / {{hawkAuthKey}} -- fill those in via an environment, never commit real values.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "auth": { + "type": "hawk", + "hawk": [ + { "key": "authId", "value": "{{hawkAuthId}}", "type": "string" }, + { "key": "authKey", "value": "{{hawkAuthKey}}", "type": "string" }, + { "key": "algorithm", "value": "sha256", "type": "string" } + ] + }, + "variable": [ + { "key": "baseUrl", "value": "https://acm-stage.hydrantid.com", "type": "string" }, + { "key": "hawkAuthId", "value": "", "type": "string" }, + { "key": "hawkAuthKey", "value": "", "type": "string" }, + { "key": "policyId", "value": "", "type": "string" }, + { "key": "validatorId", "value": "", "type": "string" }, + { "key": "domainName", "value": "example.com", "type": "string" }, + { "key": "domainId", "value": "", "type": "string" }, + { "key": "csr", "value": "-----BEGIN CERTIFICATE REQUEST-----\nPASTE CSR HERE\n-----END CERTIFICATE REQUEST-----", "type": "string" }, + { "key": "csrTrackingId", "value": "", "type": "string" }, + { "key": "certificateId", "value": "", "type": "string" }, + { "key": "orgName", "value": "", "type": "string" }, + { "key": "orgPrimaryContactFullName", "value": "", "type": "string" }, + { "key": "orgStreetAddress", "value": "", "type": "string" }, + { "key": "orgCityProvPostalCodeCountry", "value": "", "type": "string" }, + { "key": "emailAddress", "value": "", "type": "string" }, + { "key": "phoneNumber", "value": "", "type": "string" } + ], + "item": [ + { + "name": "00 - Connectivity", + "item": [ + { + "name": "Ping (GET /policies)", + "request": { + "method": "GET", + "header": [{ "key": "Accept", "value": "application/json" }], + "url": { + "raw": "{{baseUrl}}/api/v2/policies", + "host": ["{{baseUrl}}"], + "path": ["api", "v2", "policies"] + }, + "description": "Mirrors HydrantIdClient.Ping() (HydrantCAProxy/Client/HydrantIdClient.cs:623-653). The plugin uses this exact call -- GET /api/v2/policies and check for 2xx -- as its config-page \"Test Connection\" health check. Any Hawk auth or connectivity issue will surface here first." + } + } + ] + }, + { + "name": "01 - Policies", + "item": [ + { + "name": "List Policies", + "request": { + "method": "GET", + "header": [{ "key": "Accept", "value": "application/json" }], + "url": { + "raw": "{{baseUrl}}/api/v2/policies", + "host": ["{{baseUrl}}"], + "path": ["api", "v2", "policies"] + }, + "description": "Mirrors HydrantIdClient.GetPolicyList() (HydrantCAProxy/Client/HydrantIdClient.cs:197-237). Deserializes into List. Use this response to find the policy you want to test (note its id, and Details.Validator if present) and copy those into the {{policyId}} / {{validatorId}} collection variables.\n\nNote: the plugin's Policy/PolicyDetails models do not currently deserialize a policyType/CA-type field (e.g. IDENTRUST vs EJBCA) even though the HydrantID swagger defines PolicyType -- compare the raw JSON here against components.schemas.Policy in the swagger if you're chasing the Identrust support gap." + } + } + ] + }, + { + "name": "02 - Domain Validation (DCV)", + "item": [ + { + "name": "List Domain Validators", + "request": { + "method": "GET", + "header": [{ "key": "Accept", "value": "application/json" }], + "url": { + "raw": "{{baseUrl}}/api/v2/domains/validators", + "host": ["{{baseUrl}}"], + "path": ["api", "v2", "domains", "validators"] + }, + "description": "Not called anywhere by the plugin (HydrantIdClient.cs has no method for it), but documented in the HydrantID swagger as GET /api/v2/domains/validators, returning available Validator objects (id, name, capabilities). Use this to find a real validator id to put in {{validatorId}} before running 'Create Domain Validation' below -- the plugin only ever uses whatever id happens to already be set on Policy.details.validator, so if no policy has one configured yet, this is how you discover what ids exist to configure one." + } + }, + { + "name": "List Domains", + "request": { + "method": "GET", + "header": [{ "key": "Accept", "value": "application/json" }], + "url": { + "raw": "{{baseUrl}}/api/v2/domains/", + "host": ["{{baseUrl}}"], + "path": ["api", "v2", "domains", ""] + }, + "description": "Mirrors HydrantIdClient.GetDomainListAsync() (HydrantCAProxy/Client/HydrantIdClient.cs:241-281). Called at the top of EnsureDomainsValidatedAsync (HydrantIdCAPlugin.cs:807) to find any previously-started validation for a domain by name (case-insensitive), since the plugin keeps no local state between Enroll() calls.\n\nCheck each item's status and domainValidUntil/codeValidUntil fields here -- those expiry fields are currently modeled (Domain.cs) but never read by plugin logic, which is the gap behind the 200-day Identrust re-validation question." + } + }, + { + "name": "Create Domain Validation", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Accept", "value": "application/json" } + ], + "url": { + "raw": "{{baseUrl}}/api/v2/domains/", + "host": ["{{baseUrl}}"], + "path": ["api", "v2", "domains", ""] + }, + "body": { + "mode": "raw", + "raw": "{\n \"domain\": \"{{domainName}}\",\n \"validator\": \"{{validatorId}}\",\n \"method\": \"DNS\"\n}", + "options": { "raw": { "language": "json" } } + }, + "description": "Mirrors HydrantIdClient.GetSubmitCreateDomainValidationAsync() (HydrantCAProxy/Client/HydrantIdClient.cs:285-326), fed by RequestManager.GetCreateDomainValidationRequest() (RequestManager.cs:372-401) which always hardcodes method=DNS and intentionally omits accountId (Hawk auth already scopes the account).\n\nUsed by EnsureDomainsValidatedAsync (HydrantIdCAPlugin.cs:817-821) when List Domains found no existing match for the target domain. Response is a Domain with status/code/codeInstructions -- publish the returned DNS record, then use \"Check Domain Validation\" below to poll.\n\nNOTE: this body matches exactly what the plugin sends today -- no payload field. Against validator=IdenTrust this is expected to fail/be incomplete, since IdenTrust's requiredPayload (see 'List Domain Validators') demands org/contact info the plugin never collects or sends. Use 'Create Domain Validation (IdenTrust, with payload)' below to test with that payload included." + } + }, + { + "name": "Create Domain Validation (IdenTrust, with payload)", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Accept", "value": "application/json" } + ], + "url": { + "raw": "{{baseUrl}}/api/v2/domains/", + "host": ["{{baseUrl}}"], + "path": ["api", "v2", "domains", ""] + }, + "body": { + "mode": "raw", + "raw": "{\n \"domain\": \"{{domainName}}\",\n \"validator\": \"IdenTrust\",\n \"method\": \"DNS\",\n \"payload\": {\n \"orgName\": \"{{orgName}}\",\n \"orgPrimaryContactFullName\": \"{{orgPrimaryContactFullName}}\",\n \"orgStreetAddress\": \"{{orgStreetAddress}}\",\n \"orgCityProvPostalCodeCountry\": \"{{orgCityProvPostalCodeCountry}}\",\n \"emailAddress\": \"{{emailAddress}}\",\n \"phoneNumber\": \"{{phoneNumber}}\"\n }\n}", + "options": { "raw": { "language": "json" } } + }, + "description": "Not something the plugin can send today -- CreateDomainValidationPayload.Payload (CreateDomainValidationPayload.cs:30-31) exists in the model but RequestManager.GetCreateDomainValidationRequest() never populates it. This request manually includes the requiredPayload fields IdenTrust's validator listed under GET /api/v2/domains/validators (orgName, orgPrimaryContactFullName, orgStreetAddress, orgCityProvPostalCodeCountry, emailAddress, phoneNumber) so you can confirm whether HydrantID actually requires/accepts them for IdenTrust DCV before we add payload-building logic to the plugin.\n\nFill in orgName/orgPrimaryContactFullName/orgStreetAddress/orgCityProvPostalCodeCountry/emailAddress/phoneNumber as collection variables with real org details for this account." + } + }, + { + "name": "Check Domain Validation", + "request": { + "method": "GET", + "header": [{ "key": "Accept", "value": "application/json" }], + "url": { + "raw": "{{baseUrl}}/api/v2/domains/{{domainId}}/validate", + "host": ["{{baseUrl}}"], + "path": ["api", "v2", "domains", "{{domainId}}", "validate"] + }, + "description": "Mirrors HydrantIdClient.GetSubmitCheckDomainValidationAsync() (HydrantCAProxy/Client/HydrantIdClient.cs:330-368). Called from EnsureDomainsValidatedAsync (HydrantIdCAPlugin.cs:822-825) whenever an existing domain match's status != VALIDATED -- today that includes EXPIRED, which re-checks a stale validation id rather than creating a new one. Use {{domainId}} from the id returned by List Domains or Create Domain Validation." + } + }, + { + "name": "Delete Domain", + "request": { + "method": "DELETE", + "header": [{ "key": "Accept", "value": "application/json" }], + "url": { + "raw": "{{baseUrl}}/api/v2/domains/{{domainId}}", + "host": ["{{baseUrl}}"], + "path": ["api", "v2", "domains", "{{domainId}}"] + }, + "description": "Documented in the HydrantID swagger (DELETE /api/v2/domains/{id} -> DeleteDomainResponse{id, success}) but never called by the plugin -- HydrantIdClient.cs has no method for it. Useful for repeat-testing DCV: after you've validated (or abandoned) a domain and are done with it, delete its record here so you can re-run 'Create Domain Validation' for the exact same domain string again from scratch, instead of needing to buy/register a new domain for every test cycle." + } + } + ] + }, + { + "name": "03 - Certificate Requests (Enroll)", + "item": [ + { + "name": "Submit CSR", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Accept", "value": "application/json" } + ], + "url": { + "raw": "{{baseUrl}}/api/v2/csr", + "host": ["{{baseUrl}}"], + "path": ["api", "v2", "csr"] + }, + "body": { + "mode": "raw", + "raw": "{\n \"policy\": \"{{policyId}}\",\n \"csr\": \"{{csr}}\",\n \"validity\": {\n \"years\": 1\n },\n \"dnComponents\": {}\n}", + "options": { "raw": { "language": "json" } } + }, + "description": "Mirrors HydrantIdClient.GetSubmitEnrollmentAsync() (HydrantCAProxy/Client/HydrantIdClient.cs:85-136), body built by RequestManager.GetEnrollmentRequest() (RequestManager.cs:191-227) which fills dnComponents from the CSR itself and validity from the Command certificate template's ValidityPeriod/ValidityUnits annotation.\n\nIn the real plugin flow, this call only happens after EnsureDomainsValidatedForPolicyAsync (HydrantIdCAPlugin.cs:766-795) returns null (i.e. DCV skipped because Details.Validator is unset, or all target domains already VALIDATED) -- run '02 - Domain Validation' first if the policy has a validator configured. Response deserializes to CertRequestStatus; note its certificateId if issuanceStatus is ISSUED, otherwise its id is the CSR tracking id." + } + }, + { + "name": "Get Certificate by CSR Tracking Id", + "request": { + "method": "GET", + "header": [{ "key": "Accept", "value": "application/json" }], + "url": { + "raw": "{{baseUrl}}/api/v2/csr/{{csrTrackingId}}/certificate", + "host": ["{{baseUrl}}"], + "path": ["api", "v2", "csr", "{{csrTrackingId}}", "certificate"] + }, + "description": "Mirrors HydrantIdClient.GetSubmitGetCertificateByCsrAsync() (HydrantCAProxy/Client/HydrantIdClient.cs:411-449).\n\nFlag: the HydrantID swagger only documents GET /api/v2/csr/{certRequestId}/status (returning CertRequestStatus with a certificateId once ISSUED) -- it does NOT document a /csr/{id}/certificate path. If this request 404s against real HydrantID, the plugin may be calling an endpoint that doesn't exist in the current API version; worth confirming with HydrantID or checking whether this should instead be GET /csr/{id}/status followed by GET /certificates/{certificateId}." + } + } + ] + }, + { + "name": "04 - Certificates", + "item": [ + { + "name": "Get Certificate", + "request": { + "method": "GET", + "header": [{ "key": "Accept", "value": "application/json" }], + "url": { + "raw": "{{baseUrl}}/api/v2/certificates/{{certificateId}}", + "host": ["{{baseUrl}}"], + "path": ["api", "v2", "certificates", "{{certificateId}}"] + }, + "description": "Mirrors HydrantIdClient.GetSubmitGetCertificateAsync() (HydrantCAProxy/Client/HydrantIdClient.cs:372-408), used to retrieve the issued certificate (including PEM) once issuanceStatus is ISSUED." + } + }, + { + "name": "List Certificates (paged)", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Accept", "value": "application/json" } + ], + "url": { + "raw": "{{baseUrl}}/api/v2/certificates", + "host": ["{{baseUrl}}"], + "path": ["api", "v2", "certificates"] + }, + "body": { + "mode": "raw", + "raw": "{\n \"limit\": 100,\n \"offset\": 0,\n \"status\": \"VALID\",\n \"expired\": true\n}", + "options": { "raw": { "language": "json" } } + }, + "description": "Mirrors HydrantIdClient.GetSubmitCertificateListRequestAsync() (HydrantCAProxy/Client/HydrantIdClient.cs:498-621), used by the plugin's sync job to page through all certificates (100 per page) into a BlockingCollection. RequestManager.GetCertificatesListRequest() (RequestManager.cs:170-189) always sets status=0 (VALID) and expired=true; increment offset by 100 and re-run to fetch the next page, same as the plugin's do/while loop." + } + }, + { + "name": "Revoke Certificate", + "request": { + "method": "PATCH", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Accept", "value": "application/json" } + ], + "url": { + "raw": "{{baseUrl}}/api/v2/certificates/{{certificateId}}", + "host": ["{{baseUrl}}"], + "path": ["api", "v2", "certificates", "{{certificateId}}"] + }, + "body": { + "mode": "raw", + "raw": "{\n \"reason\": \"0\"\n}", + "options": { "raw": { "language": "json" } } + }, + "description": "Mirrors HydrantIdClient.GetSubmitRevokeCertificateAsync() (HydrantCAProxy/Client/HydrantIdClient.cs:451-495), body from RequestManager.GetRevokeRequest() (RequestManager.cs:152-168). RevocationReasons serializes as a string of the numeric code (StringEnumConverter + EnumMember) -- valid values are \"0\" (Unspecified), \"1\" (KeyCompromise), \"3\" (AffiliationChanged), \"4\" (Superseded), \"5\" (CessationOfOperation). Destructive against a real certificate -- use a disposable test cert on staging." + } + }, + { + "name": "Renew Certificate", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Accept", "value": "application/json" } + ], + "url": { + "raw": "{{baseUrl}}/api/v2/certificates/{{certificateId}}/renew", + "host": ["{{baseUrl}}"], + "path": ["api", "v2", "certificates", "{{certificateId}}", "renew"] + }, + "body": { + "mode": "raw", + "raw": "{\n \"reuseCsr\": false,\n \"csr\": \"{{csr}}\"\n}", + "options": { "raw": { "language": "json" } } + }, + "description": "Mirrors HydrantIdClient.GetSubmitRenewalAsync() (HydrantCAProxy/Client/HydrantIdClient.cs:139-193), body from RequestManager.GetRenewalRequest() (RequestManager.cs:230-251). Set reuseCsr=true and omit csr to test the renewCanReuseCSR policy path instead." + } + } + ] + } + ] +} diff --git a/postman/HydrantID-Staging.postman_environment.json b/postman/HydrantID-Staging.postman_environment.json new file mode 100644 index 0000000..4d9df66 --- /dev/null +++ b/postman/HydrantID-Staging.postman_environment.json @@ -0,0 +1,15 @@ +{ + "name": "HydrantID - Staging", + "values": [ + { "key": "baseUrl", "value": "https://acm-stage.hydrantid.com", "type": "default", "enabled": true }, + { "key": "hawkAuthId", "value": "", "type": "secret", "enabled": true }, + { "key": "hawkAuthKey", "value": "", "type": "secret", "enabled": true }, + { "key": "policyId", "value": "", "type": "default", "enabled": true }, + { "key": "validatorId", "value": "", "type": "default", "enabled": true }, + { "key": "domainName", "value": "", "type": "default", "enabled": true }, + { "key": "domainId", "value": "", "type": "default", "enabled": true }, + { "key": "csr", "value": "", "type": "default", "enabled": true }, + { "key": "csrTrackingId", "value": "", "type": "default", "enabled": true }, + { "key": "certificateId", "value": "", "type": "default", "enabled": true } + ] +} diff --git a/postman/README.md b/postman/README.md new file mode 100644 index 0000000..1f5da31 --- /dev/null +++ b/postman/README.md @@ -0,0 +1,35 @@ +# Postman collection: plugin flow replica + +`HydrantID-Plugin-Flow.postman_collection.json` has one request per method in +`HydrantIdClient.cs`, grouped into folders that mirror the plugin's actual +call order for an enroll-with-DCV flow: connectivity check, list policies, +domain validation (list/create/check), submit CSR, retrieve/list +certificates, revoke, renew. Each request's description names the +`HydrantIdClient`/`RequestManager` method and file:line it mirrors. + +## Setup + +1. Import both files into Postman: the collection and + `HydrantID-Staging.postman_environment.json`. +2. Copy the environment to a local file and fill in real values — **do not + put real Hawk credentials into the tracked template**: + + ``` + cp postman/HydrantID-Staging.postman_environment.json postman/HydrantID-Staging.postman_environment.local.json + ``` + + `*.postman_environment.local.json` under `postman/` is gitignored. +3. In Postman, select the local environment and fill in `hawkAuthId` / + `hawkAuthKey` (marked as secret) plus whichever of `policyId`, + `validatorId`, `domainName`, `csr`, etc. you're testing with. + +Auth (Hawk, `sha256`) is configured once at the collection level and +inherited by every request. + +## Known discrepancy to check + +"Get Certificate by CSR Tracking Id" calls `GET /api/v2/csr/{id}/certificate`, +which is not a path documented in HydrantID's swagger (which only documents +`GET /api/v2/csr/{id}/status`). If that request 404s against real HydrantID, +flag it — the plugin may need to poll `/status` and then fetch +`/certificates/{certificateId}` instead. From 9acb7d22a345cd39aa8d96d8028617ee0c6672d5 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 1 Sep 2026 11:36:39 -0400 Subject: [PATCH 09/29] Addded Tests --- HydrantCAProxy.Tests/FlowLoggerTests.cs | 179 +++ .../HydrantCAProxy.Tests.csproj | 1 + .../HydrantIdCAPluginTests.cs | 1308 +++++++++++++++++ HydrantCAProxy.Tests/HydrantIdClientTests.cs | 630 ++++++++ .../ModelSerializationTests.cs | 251 ++++ HydrantCAProxy.Tests/RequestManagerTests.cs | 12 + HydrantCAProxy.Tests/coverlet.runsettings | 15 + HydrantCAProxy/AssemblyInfo.cs | 12 + HydrantCAProxy/Client/HydrantIdClient.cs | 22 +- HydrantCAProxy/HydrantIdCAPlugin.cs | 61 +- HydrantCAProxy/Interfaces/IHydrantIdClient.cs | 33 + 11 files changed, 2475 insertions(+), 49 deletions(-) create mode 100644 HydrantCAProxy.Tests/FlowLoggerTests.cs create mode 100644 HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs create mode 100644 HydrantCAProxy.Tests/HydrantIdClientTests.cs create mode 100644 HydrantCAProxy.Tests/ModelSerializationTests.cs create mode 100644 HydrantCAProxy.Tests/coverlet.runsettings create mode 100644 HydrantCAProxy/AssemblyInfo.cs create mode 100644 HydrantCAProxy/Interfaces/IHydrantIdClient.cs diff --git a/HydrantCAProxy.Tests/FlowLoggerTests.cs b/HydrantCAProxy.Tests/FlowLoggerTests.cs new file mode 100644 index 0000000..f3208ff --- /dev/null +++ b/HydrantCAProxy.Tests/FlowLoggerTests.cs @@ -0,0 +1,179 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Keyfactor.HydrantId; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace HydrantCAProxy.Tests +{ + public class FlowLoggerTests + { + // A minimal ILogger test double that records the formatted message of every + // Log call, so assertions can check FlowLogger's actual output content instead + // of just "no exception was thrown." + private sealed class RecordingLogger : ILogger + { + public List Messages { get; } = new List(); + + public IDisposable BeginScope(TState state) => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) + { + Messages.Add(formatter(state, exception)); + } + } + + [Fact] + public void Constructor_NullLogger_Throws() + { + Assert.Throws(() => new FlowLogger(null, "Test")); + } + + [Fact] + public void Constructor_NullFlowName_DefaultsToUnknown() + { + var logger = new RecordingLogger(); + + using var flow = new FlowLogger(logger, null); + + Assert.Contains(logger.Messages, m => m.Contains("Unknown")); + } + + [Fact] + public void Step_WithDetail_LogsDetail() + { + var logger = new RecordingLogger(); + var flow = new FlowLogger(logger, "Test"); + + flow.Step("MyStep", "some detail"); + + Assert.Contains(logger.Messages, m => m.Contains("MyStep") && m.Contains("some detail")); + } + + [Fact] + public void Step_NullDetail_LogsOk() + { + var logger = new RecordingLogger(); + var flow = new FlowLogger(logger, "Test"); + + flow.Step("MyStep"); + + Assert.Contains(logger.Messages, m => m.Contains("MyStep") && m.Contains("OK")); + } + + [Fact] + public void Step_ActionSucceeds_LogsOk() + { + var logger = new RecordingLogger(); + var flow = new FlowLogger(logger, "Test"); + var ran = false; + + flow.Step("MyStep", () => { ran = true; }); + + Assert.True(ran); + Assert.Contains(logger.Messages, m => m.Contains("MyStep") && m.Contains("OK")); + } + + [Fact] + public void Step_ActionThrows_LogsFailureAndRethrows() + { + var logger = new RecordingLogger(); + var flow = new FlowLogger(logger, "Test"); + + var ex = Assert.Throws(() => + flow.Step("MyStep", () => throw new InvalidOperationException("boom"))); + + Assert.Equal("boom", ex.Message); + Assert.Contains(logger.Messages, m => m.Contains("MyStep") && m.Contains("FAILED") && m.Contains("boom")); + } + + [Fact] + public async Task StepAsync_ActionSucceeds_LogsOk() + { + var logger = new RecordingLogger(); + var flow = new FlowLogger(logger, "Test"); + var ran = false; + + await flow.StepAsync("MyStep", async () => + { + await Task.Delay(1); + ran = true; + }); + + Assert.True(ran); + Assert.Contains(logger.Messages, m => m.Contains("MyStep") && m.Contains("OK")); + } + + [Fact] + public async Task StepAsync_ActionThrows_LogsFailureAndRethrows() + { + var logger = new RecordingLogger(); + var flow = new FlowLogger(logger, "Test"); + + var ex = await Assert.ThrowsAsync(() => + flow.StepAsync("MyStep", () => throw new InvalidOperationException("async boom"))); + + Assert.Equal("async boom", ex.Message); + Assert.Contains(logger.Messages, m => m.Contains("MyStep") && m.Contains("FAILED") && m.Contains("async boom")); + } + + [Fact] + public void Fail_LogsFailedWithReason() + { + var logger = new RecordingLogger(); + var flow = new FlowLogger(logger, "Test"); + + flow.Fail("MyStep", "went wrong"); + + Assert.Contains(logger.Messages, m => m.Contains("MyStep") && m.Contains("FAILED") && m.Contains("went wrong")); + } + + [Fact] + public void Skip_LogsSkippedWithReason() + { + var logger = new RecordingLogger(); + var flow = new FlowLogger(logger, "Test"); + + flow.Skip("MyStep", "not needed"); + + Assert.Contains(logger.Messages, m => m.Contains("MyStep") && m.Contains("SKIPPED") && m.Contains("not needed")); + } + + [Fact] + public void Dispose_NoFailures_LogsSuccessSummary() + { + var logger = new RecordingLogger(); + var flow = new FlowLogger(logger, "Test"); + flow.Step("Step1", "ok"); + flow.Skip("Step2", "skipped"); + + flow.Dispose(); + + Assert.Contains(logger.Messages, m => m.Contains("FLOW DIAGRAM")); + Assert.Contains(logger.Messages, m => m.Contains("[OK]") && m.Contains("Step1")); + Assert.Contains(logger.Messages, m => m.Contains("[SKIP]") && m.Contains("Step2")); + Assert.Contains(logger.Messages, m => m.Contains("SUCCESS")); + } + + [Fact] + public void Dispose_WithFailure_LogsPartialFailureSummary() + { + var logger = new RecordingLogger(); + var flow = new FlowLogger(logger, "Test"); + flow.Fail("Step1", "broke"); + + flow.Dispose(); + + Assert.Contains(logger.Messages, m => m.Contains("[FAIL]") && m.Contains("Step1")); + Assert.Contains(logger.Messages, m => m.Contains("PARTIAL FAILURE")); + } + } +} diff --git a/HydrantCAProxy.Tests/HydrantCAProxy.Tests.csproj b/HydrantCAProxy.Tests/HydrantCAProxy.Tests.csproj index f2aacd0..8c78fe3 100644 --- a/HydrantCAProxy.Tests/HydrantCAProxy.Tests.csproj +++ b/HydrantCAProxy.Tests/HydrantCAProxy.Tests.csproj @@ -10,6 +10,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs b/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs new file mode 100644 index 0000000..7e52289 --- /dev/null +++ b/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs @@ -0,0 +1,1308 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.HydrantId; +using Keyfactor.HydrantId; +using Keyfactor.HydrantId.Client.Models; +using Keyfactor.HydrantId.Client.Models.Enums; +using Keyfactor.HydrantId.Interfaces; +using Keyfactor.PKI.Enums.EJBCA; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace HydrantCAProxy.Tests +{ + public class HydrantIdCAPluginTests + { + // A valid PEM CSR (CN=unit.test.hydrantid.local) -- same fixture used in RequestManagerTests, + // duplicated locally so this file has no cross-file test dependency. + private const string SampleCsr = + "-----BEGIN CERTIFICATE REQUEST-----\n" + + "MIICyDCCAbACAQAwgYIxCzAJBgNVBAYTAlVTMQ0wCwYDVQQIDARPaGlvMRUwEwYD\n" + + "VQQHDAxJbmRlcGVuZGVuY2UxEjAQBgNVBAoMCUtleWZhY3RvcjEVMBMGA1UECwwM\n" + + "SW50ZWdyYXRpb25zMSIwIAYDVQQDDBl1bml0LnRlc3QuaHlkcmFudGlkLmxvY2Fs\n" + + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA7HMrgfnq6o9t+7NAI4wZ\n" + + "XmiIY3lQcuEA2drbwDqx1HW78xbs6ajhIO8A68RpHUjdBfgOl+3zwCcjgbi8+whI\n" + + "OHubyMsonPCvCoKVUNv1CBclDcKEf+zAFuc7TWeL8n9aZNIeI/mLqhDxt2ZPIPuC\n" + + "tNh1wZToQ5gf4u/LQXSksLwbiITeBsATEKGNMsTERM7gYuldPQFS3bTof7LGRPWT\n" + + "shwNiBv6dw5QIgmXOBSJWdT0NfWVNudTF1wxV+41E/mvQCM+66Onw+ialH1nRefh\n" + + "LCiWIT48LLHLrYN045QorzqbDPzk8itpka+6JA04rlNKcSOBurAypkWBvhnU9N8F\n" + + "pQIDAQABoAAwDQYJKoZIhvcNAQELBQADggEBADO6dln9VOVkCG5qTBuifSxrGgDt\n" + + "IoQFIHxtMVhMI2CiPPeDDfJpPDX7CoHKRGKelilwxnWlOfzupv1Qb/02YXXq/F/Z\n" + + "twSyVAIbisuzL6RLIGox3GSkwlM0JTiyjASUJyVextRvxlmMRWTdc4z2v7Wxgmbf\n" + + "k8wZ7VrUYofBmAj9S3ozilPWRKspl/BZrm+4IIoufa2BKfMnGQGbsad22mrpkRtG\n" + + "1gm6iZDzaVTSC3iO5+CA/ZNwRT2ShIAHAbZTUSf62n5+nfs8Wki67i96hQqX7qIT\n" + + "MRXVBIV6K2c9Ls9aEh5qnPR8wre/VMaufCliSb0Q4X50Tal8kJZbS6/ZfJo=\n" + + "-----END CERTIFICATE REQUEST-----"; + + private sealed class FakeConfigProvider : IAnyCAPluginConfigProvider + { + public Dictionary CAConnectionData { get; set; } + } + + private sealed class NoOpLogger : ILogger + { + public IDisposable BeginScope(TState state) => null; + public bool IsEnabled(LogLevel logLevel) => false; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) + { } + } + + private static Dictionary ValidConnectionData(bool enabled = true) => new Dictionary + { + [HydrantIdCAPluginConfig.ConfigConstants.HydrantIdBaseUrl] = "https://acm-stage.hydrantid.test", + [HydrantIdCAPluginConfig.ConfigConstants.HydrantIdAuthId] = "test-auth-id", + [HydrantIdCAPluginConfig.ConfigConstants.HydrantIdAuthKey] = "test-auth-key", + [HydrantIdCAPluginConfig.ConfigConstants.Enabled] = enabled + }; + + private static HydrantIdCAPlugin MakePlugin(Mock client = null, bool enabled = true) + { + var plugin = new HydrantIdCAPlugin(); + plugin.Initialize(new FakeConfigProvider { CAConnectionData = ValidConnectionData(enabled) }, + Mock.Of()); + if (client != null) + plugin.ClientFactory = _ => client.Object; + return plugin; + } + + private static (X509Certificate2 Cert, string Pem, string Base64) MakeSelfSignedCert(int notAfterDays = 365) + { + using var rsa = RSA.Create(2048); + var req = new CertificateRequest("CN=test.hydrantid.local", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(notAfterDays)); + return (cert, cert.ExportCertificatePem(), Convert.ToBase64String(cert.RawData)); + } + + private static EnrollmentProductInfo ProductInfo(Dictionary parameters = null) => + new EnrollmentProductInfo { ProductID = "Test Policy", ProductParameters = parameters ?? new Dictionary() }; + + // --------------------------------------------------------------------- + // Initialize + // --------------------------------------------------------------------- + + [Fact] + public void Initialize_NullConfigProvider_DoesNotThrow() + { + var plugin = new HydrantIdCAPlugin(); + plugin.Initialize(null, Mock.Of()); + } + + [Fact] + public void Initialize_NullCertDataReader_DoesNotThrow() + { + var plugin = new HydrantIdCAPlugin(); + plugin.Initialize(new FakeConfigProvider { CAConnectionData = ValidConnectionData() }, null); + } + + [Fact] + public void Initialize_ValidInputs_PopulatesConfig() + { + var plugin = MakePlugin(); + // No exception, and a subsequent ValidateCAConnectionInfo-independent operation (GetProductIds) + // that depends on Config being set should not throw ArgumentNullException from a missing Config. + Assert.NotNull(plugin); + } + + // --------------------------------------------------------------------- + // Ping + // --------------------------------------------------------------------- + + [Fact] + public async Task Ping_Disabled_DoesNotCallClient() + { + var mockClient = new Mock(MockBehavior.Strict); + var plugin = MakePlugin(mockClient, enabled: false); + + await plugin.Ping(); + + mockClient.VerifyNoOtherCalls(); + } + + [Fact] + public async Task Ping_ClientReachable_Succeeds() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.Ping()).ReturnsAsync(true); + var plugin = MakePlugin(mockClient); + + await plugin.Ping(); + + mockClient.Verify(c => c.Ping(), Times.Once); + } + + [Fact] + public async Task Ping_ClientUnreachable_Throws() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.Ping()).ReturnsAsync(false); + var plugin = MakePlugin(mockClient); + + await Assert.ThrowsAsync(() => plugin.Ping()); + } + + [Fact] + public async Task Ping_ConfigNeverInitialized_DoesNotThrow() + { + var plugin = new HydrantIdCAPlugin(); + await plugin.Ping(); + } + + // --------------------------------------------------------------------- + // ValidateCAConnectionInfo + // --------------------------------------------------------------------- + + [Fact] + public async Task ValidateCAConnectionInfo_NullInput_Throws() + { + var plugin = new HydrantIdCAPlugin(); + await Assert.ThrowsAsync(() => plugin.ValidateCAConnectionInfo(null)); + } + + [Fact] + public async Task ValidateCAConnectionInfo_MissingRequiredFields_Throws() + { + var plugin = new HydrantIdCAPlugin(); + var data = new Dictionary { [HydrantIdCAPluginConfig.ConfigConstants.Enabled] = true }; + + var ex = await Assert.ThrowsAsync(() => plugin.ValidateCAConnectionInfo(data)); + Assert.Contains("HydrantIdBaseUrl", ex.Message); + Assert.Contains("HydrantIdAuthId", ex.Message); + Assert.Contains("HydrantIdAuthKey", ex.Message); + } + + [Fact] + public async Task ValidateCAConnectionInfo_Disabled_SkipsValidationAndPing() + { + var plugin = new HydrantIdCAPlugin(); + var data = new Dictionary { [HydrantIdCAPluginConfig.ConfigConstants.Enabled] = false }; + + await plugin.ValidateCAConnectionInfo(data); + } + + [Fact] + public async Task ValidateCAConnectionInfo_AllFieldsPresent_DelegatesToPing() + { + var plugin = new HydrantIdCAPlugin(); + var mockClient = new Mock(); + mockClient.Setup(c => c.Ping()).ReturnsAsync(true); + plugin.ClientFactory = _ => mockClient.Object; + + await plugin.ValidateCAConnectionInfo(ValidConnectionData()); + + mockClient.Verify(c => c.Ping(), Times.Once); + } + + // --------------------------------------------------------------------- + // ValidateProductInfo + // --------------------------------------------------------------------- + + [Fact] + public async Task ValidateProductInfo_ReturnsCompletedTask() + { + var plugin = new HydrantIdCAPlugin(); + await plugin.ValidateProductInfo(ProductInfo(), ValidConnectionData()); + } + + // --------------------------------------------------------------------- + // GetProductIds + // --------------------------------------------------------------------- + + [Fact] + public void GetProductIds_NullPolicyList_ReturnsEmptyList() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.GetPolicyList()).ReturnsAsync((List)null); + var plugin = MakePlugin(mockClient); + + var result = plugin.GetProductIds(); + + Assert.Empty(result); + } + + [Fact] + public void GetProductIds_PoliciesReturned_MapsNamesForPoliciesWithIds() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.GetPolicyList()).ReturnsAsync(new List + { + new Policy { Id = Guid.NewGuid(), Name = "Policy A" }, + new Policy { Id = null, Name = "No Id Policy" } + }); + var plugin = MakePlugin(mockClient); + + var result = plugin.GetProductIds(); + + Assert.Equal(new List { "Policy A" }, result); + } + + [Fact] + public void GetProductIds_ClientThrows_Rethrows() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.GetPolicyList()).ThrowsAsync(new InvalidOperationException("boom")); + var plugin = MakePlugin(mockClient); + + Assert.Throws(() => plugin.GetProductIds()); + } + + // --------------------------------------------------------------------- + // MaskConfigForLog + // --------------------------------------------------------------------- + + [Fact] + public void MaskConfigForLog_NullOrEmpty_ReturnsInputUnchanged() + { + Assert.Null(HydrantIdCAPlugin.MaskConfigForLog(null)); + Assert.Equal("", HydrantIdCAPlugin.MaskConfigForLog("")); + } + + [Fact] + public void MaskConfigForLog_RedactsSensitiveKeys() + { + var json = "{\"HydrantIdAuthId\":\"secret-id\",\"HydrantIdAuthKey\":\"secret-key\",\"HydrantIdBaseUrl\":\"https://x\"}"; + + var masked = HydrantIdCAPlugin.MaskConfigForLog(json); + + Assert.DoesNotContain("secret-id", masked); + Assert.DoesNotContain("secret-key", masked); + Assert.Contains("https://x", masked); + Assert.Contains("REDACTED", masked); + } + + [Fact] + public void MaskConfigForLog_NonObjectToken_ReturnsTokenAsString() + { + var masked = HydrantIdCAPlugin.MaskConfigForLog("[1,2,3]"); + + Assert.Equal("[1,2,3]", masked); + } + + [Fact] + public void MaskConfigForLog_MalformedJson_RedactsEntirePayload() + { + var masked = HydrantIdCAPlugin.MaskConfigForLog("{not valid json"); + + Assert.Equal("***REDACTED***", masked); + } + + // --------------------------------------------------------------------- + // GetEndEntityCertificate / ExportCollectionToPem + // --------------------------------------------------------------------- + + [Fact] + public void GetEndEntityCertificate_NullOrWhitespace_ReturnsEmptyString() + { + var plugin = new HydrantIdCAPlugin(); + + Assert.Equal(string.Empty, plugin.GetEndEntityCertificate(null)); + Assert.Equal(string.Empty, plugin.GetEndEntityCertificate(" ")); + } + + [Fact] + public void GetEndEntityCertificate_ValidPem_ReturnsBase64Certificate() + { + var plugin = new HydrantIdCAPlugin(); + var (_, pem, _) = MakeSelfSignedCert(); + + var result = plugin.GetEndEntityCertificate(pem); + + Assert.False(string.IsNullOrEmpty(result)); + // Confirm the returned base64 is itself a valid, parseable certificate. + var reparsed = new X509Certificate2(Convert.FromBase64String(result)); + Assert.Equal("CN=test.hydrantid.local", reparsed.Subject); + } + + [Fact] + public void GetEndEntityCertificate_NoImportableSegments_ReturnsEmptyString() + { + var plugin = new HydrantIdCAPlugin(); + + var result = plugin.GetEndEntityCertificate("-----BEGIN CERTIFICATE-----\nnotvalidbase64!!!\n-----END CERTIFICATE-----"); + + Assert.Equal(string.Empty, result); + } + + [Fact] + public void ExportCollectionToPem_EmptyCollection_ReturnsEmptyString() + { + var plugin = new HydrantIdCAPlugin(); + + var result = plugin.ExportCollectionToPem(new X509Certificate2Collection()); + + Assert.Equal(string.Empty, result); + } + + [Fact] + public void ExportCollectionToPem_WithCertificate_ProducesPemMarkers() + { + var plugin = new HydrantIdCAPlugin(); + var (cert, _, _) = MakeSelfSignedCert(); + var collection = new X509Certificate2Collection { cert }; + + var result = plugin.ExportCollectionToPem(collection); + + Assert.Contains("-----BEGIN CERTIFICATE-----", result); + Assert.Contains("-----END CERTIFICATE-----", result); + } + + // --------------------------------------------------------------------- + // EnsureDomainsValidatedForPolicyAsync / EnsureDomainsValidatedAsync + // --------------------------------------------------------------------- + + private static FlowLogger NewFlow() => new FlowLogger(new NoOpLogger(), "Test"); + + [Fact] + public async Task EnsureDomainsValidatedForPolicyAsync_NoValidatorConfigured_ReturnsNull() + { + var plugin = new HydrantIdCAPlugin(); + var mockClient = new Mock(MockBehavior.Strict); + var policy = new Policy { Id = Guid.NewGuid(), Name = "P", Details = new PolicyDetails() }; + + var result = await plugin.EnsureDomainsValidatedForPolicyAsync(mockClient.Object, NewFlow(), policy, SampleCsr, null); + + Assert.Null(result); + mockClient.VerifyNoOtherCalls(); + } + + [Fact] + public async Task EnsureDomainsValidatedForPolicyAsync_AllDomainsAlreadyValidated_ReturnsNull() + { + var plugin = new HydrantIdCAPlugin(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List + { + new Domain { Id = "d1", DomainName = "unit.test.hydrantid.local", Status = DomainStatusEnum.Validated } + }); + var policy = new Policy { Id = Guid.NewGuid(), Name = "P", Details = new PolicyDetails { Validator = "IdenTrust" } }; + + var result = await plugin.EnsureDomainsValidatedForPolicyAsync(mockClient.Object, NewFlow(), policy, SampleCsr, null); + + Assert.Null(result); + mockClient.Verify(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny()), Times.Never); + mockClient.Verify(c => c.GetSubmitCheckDomainValidationAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task EnsureDomainsValidatedForPolicyAsync_DomainPending_ReturnsExternalValidationResult() + { + var plugin = new HydrantIdCAPlugin(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List()); + mockClient.Setup(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny())) + .ReturnsAsync(new Domain { Id = "d1", Status = DomainStatusEnum.Pending, CodeInstructions = "publish this TXT" }); + var policy = new Policy { Id = Guid.NewGuid(), Name = "P", Details = new PolicyDetails { Validator = "IdenTrust" } }; + + var result = await plugin.EnsureDomainsValidatedForPolicyAsync(mockClient.Object, NewFlow(), policy, SampleCsr, null); + + Assert.NotNull(result); + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Contains("publish this TXT", result.StatusMessage); + } + + [Fact] + public async Task EnsureDomainsValidatedAsync_NewDomain_CallsCreate() + { + var plugin = new HydrantIdCAPlugin(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List()); + mockClient.Setup(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny())) + .ReturnsAsync(new Domain { Status = DomainStatusEnum.Validated }); + + var result = await plugin.EnsureDomainsValidatedAsync(mockClient.Object, NewFlow(), new List { "new.example.com" }, "IdenTrust"); + + Assert.True(result.AllValidated); + mockClient.Verify(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task EnsureDomainsValidatedAsync_ExpiredDomain_CallsCreateNotCheck() + { + var plugin = new HydrantIdCAPlugin(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List + { + new Domain { Id = "d1", DomainName = "expired.example.com", Status = DomainStatusEnum.Expired } + }); + mockClient.Setup(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny())) + .ReturnsAsync(new Domain { Status = DomainStatusEnum.Pending, CodeInstructions = "new code" }); + + var result = await plugin.EnsureDomainsValidatedAsync(mockClient.Object, NewFlow(), new List { "expired.example.com" }, "IdenTrust"); + + Assert.False(result.AllValidated); + mockClient.Verify(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny()), Times.Once); + mockClient.Verify(c => c.GetSubmitCheckDomainValidationAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task EnsureDomainsValidatedAsync_PendingDomain_CallsCheckNotCreate() + { + var plugin = new HydrantIdCAPlugin(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List + { + new Domain { Id = "d1", DomainName = "pending.example.com", Status = DomainStatusEnum.Pending } + }); + mockClient.Setup(c => c.GetSubmitCheckDomainValidationAsync("d1")) + .ReturnsAsync(new Domain { Status = DomainStatusEnum.Validated }); + + var result = await plugin.EnsureDomainsValidatedAsync(mockClient.Object, NewFlow(), new List { "pending.example.com" }, "IdenTrust"); + + Assert.True(result.AllValidated); + mockClient.Verify(c => c.GetSubmitCheckDomainValidationAsync("d1"), Times.Once); + mockClient.Verify(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task EnsureDomainsValidatedAsync_MixedPendingAndValidated_AggregatesPendingMessage() + { + var plugin = new HydrantIdCAPlugin(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List + { + new Domain { Id = "d1", DomainName = "already.example.com", Status = DomainStatusEnum.Validated }, + new Domain { Id = "d2", DomainName = "pending.example.com", Status = DomainStatusEnum.Pending } + }); + mockClient.Setup(c => c.GetSubmitCheckDomainValidationAsync("d2")) + .ReturnsAsync(new Domain { Status = DomainStatusEnum.Pending, CodeInstructions = "still waiting" }); + + var result = await plugin.EnsureDomainsValidatedAsync(mockClient.Object, NewFlow(), + new List { "already.example.com", "pending.example.com" }, "IdenTrust"); + + Assert.False(result.AllValidated); + Assert.Contains("pending.example.com", result.PendingMessage); + Assert.Contains("still waiting", result.PendingMessage); + Assert.DoesNotContain("already.example.com", result.PendingMessage); + } + + // --------------------------------------------------------------------- + // Synchronize + // --------------------------------------------------------------------- + + private static Mock MakeItem(string id, RevocationStatusEnum status, string policyName = "P") + { + var item = new Mock(); + item.SetupGet(i => i.Id).Returns(id); + item.SetupGet(i => i.RevocationStatus).Returns(status); + item.SetupGet(i => i.Policy).Returns(new NameObject { Name = policyName }); + return item; + } + + private static void SetupCertList(Mock mockClient, params ICertificatesResponseItem[] items) + { + mockClient.Setup(c => c.GetSubmitCertificateListRequestAsync( + It.IsAny>(), It.IsAny())) + .Returns((BlockingCollection bc, CancellationToken ct) => + { + // Use CancellationToken.None here regardless of what the caller passed to + // Synchronize -- this only seeds the queue; whether cancellation actually + // fires is decided inside Synchronize's own loop, not while queuing test data. + foreach (var item in items) + bc.Add(item, CancellationToken.None); + bc.CompleteAdding(); + return Task.CompletedTask; + }); + } + + [Fact] + public async Task Synchronize_NullItemInQueue_SkipsIt() + { + var mockClient = new Mock(); + SetupCertList(mockClient, new ICertificatesResponseItem[] { null }); + var plugin = MakePlugin(mockClient); + var buffer = new BlockingCollection(10); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + Assert.Empty(buffer); + } + + [Fact] + public async Task Synchronize_CouldNotExtractCert_SkipsItem() + { + var mockClient = new Mock(); + SetupCertList(mockClient, MakeItem("c1", RevocationStatusEnum.Valid).Object); + mockClient.Setup(c => c.GetSubmitGetCertificateAsync("c1")) + .ReturnsAsync(new Certificate { Pem = "-----BEGIN CERTIFICATE-----\nnotvalidbase64!!!\n-----END CERTIFICATE-----" }); + var plugin = MakePlugin(mockClient); + var buffer = new BlockingCollection(10); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + Assert.Empty(buffer); + } + + [Fact] + public async Task Synchronize_SkipsNonGeneratedOrRevokedItems() + { + var mockClient = new Mock(); + SetupCertList(mockClient, MakeItem("c1", RevocationStatusEnum.Pending).Object); + var plugin = MakePlugin(mockClient); + var buffer = new BlockingCollection(10); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + Assert.Empty(buffer); + mockClient.Verify(c => c.GetSubmitGetCertificateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Synchronize_NullCertificateFromClient_SkipsItem() + { + var mockClient = new Mock(); + SetupCertList(mockClient, MakeItem("c1", RevocationStatusEnum.Valid).Object); + mockClient.Setup(c => c.GetSubmitGetCertificateAsync("c1")).ReturnsAsync((Certificate)null); + var plugin = MakePlugin(mockClient); + var buffer = new BlockingCollection(10); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + Assert.Empty(buffer); + } + + [Fact] + public async Task Synchronize_EmptyPem_SkipsItem() + { + var mockClient = new Mock(); + SetupCertList(mockClient, MakeItem("c1", RevocationStatusEnum.Valid).Object); + mockClient.Setup(c => c.GetSubmitGetCertificateAsync("c1")).ReturnsAsync(new Certificate { Pem = "" }); + var plugin = MakePlugin(mockClient); + var buffer = new BlockingCollection(10); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + Assert.Empty(buffer); + } + + [Fact] + public async Task Synchronize_ValidCertificate_AddsToBuffer() + { + var (_, pem, _) = MakeSelfSignedCert(); + var mockClient = new Mock(); + SetupCertList(mockClient, MakeItem("c1", RevocationStatusEnum.Valid, "Policy A").Object); + mockClient.Setup(c => c.GetSubmitGetCertificateAsync("c1")).ReturnsAsync(new Certificate { Pem = pem }); + var plugin = MakePlugin(mockClient); + var buffer = new BlockingCollection(10); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var result = buffer.Single(); + Assert.Equal("c1", result.CARequestID); + Assert.Equal((int)EndEntityStatus.GENERATED, result.Status); + Assert.Equal("Policy A", result.ProductID); + } + + [Fact] + public async Task Synchronize_PerItemExceptionDuringCertFetch_SkipsAndContinues() + { + var mockClient = new Mock(); + SetupCertList(mockClient, MakeItem("c1", RevocationStatusEnum.Valid).Object); + mockClient.Setup(c => c.GetSubmitGetCertificateAsync("c1")).ThrowsAsync(new InvalidOperationException("boom")); + var plugin = MakePlugin(mockClient); + var buffer = new BlockingCollection(10); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + Assert.Empty(buffer); + } + + [Fact] + public async Task Synchronize_ItemAccessThrows_ThrowsAndCompletesAdding() + { + var mockClient = new Mock(); + var badItem = new Mock(); + badItem.SetupGet(i => i.Id).Throws(new InvalidOperationException("boom")); + SetupCertList(mockClient, badItem.Object); + var plugin = MakePlugin(mockClient); + var buffer = new BlockingCollection(10); + + await Assert.ThrowsAsync(() => + plugin.Synchronize(buffer, null, true, CancellationToken.None)); + } + + [Fact] + public async Task Synchronize_ItemAccessThrowsAggregateException_ThrowsWithInnerFlattened() + { + var mockClient = new Mock(); + var badItem = new Mock(); + badItem.SetupGet(i => i.Id).Throws(new AggregateException(new InvalidOperationException("agg boom"))); + SetupCertList(mockClient, badItem.Object); + var plugin = MakePlugin(mockClient); + var buffer = new BlockingCollection(10); + + await Assert.ThrowsAsync(() => + plugin.Synchronize(buffer, null, true, CancellationToken.None)); + } + + [Fact] + public async Task Synchronize_Cancelled_ThrowsOperationCanceled() + { + var mockClient = new Mock(); + SetupCertList(mockClient, MakeItem("c1", RevocationStatusEnum.Valid).Object); + var plugin = MakePlugin(mockClient); + var buffer = new BlockingCollection(10); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync(() => + plugin.Synchronize(buffer, null, true, cts.Token)); + } + + // --------------------------------------------------------------------- + // Enroll -- New enrollment path + // --------------------------------------------------------------------- + + [Fact] + public async Task Enroll_EmptyCsr_ReturnsFailedResult() + { + var plugin = MakePlugin(new Mock()); + + var result = await plugin.Enroll("", "subj", null, ProductInfo(), RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + } + + [Fact] + public async Task Enroll_NullProductInfo_ReturnsFailedResult() + { + var plugin = MakePlugin(new Mock()); + + var result = await plugin.Enroll(SampleCsr, "subj", null, null, RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + } + + [Fact] + public async Task Enroll_New_NullPolicyList_ReturnsFailedResult() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.GetPolicyList()).ReturnsAsync((List)null); + var plugin = MakePlugin(mockClient); + + var result = await plugin.Enroll(SampleCsr, "subj", null, ProductInfo(), RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + } + + [Fact] + public async Task Enroll_New_NoPolicyMatch_ReturnsFailedResult() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.GetPolicyList()).ReturnsAsync(new List + { + new Policy { Id = Guid.NewGuid(), Name = "Other Policy" } + }); + var plugin = MakePlugin(mockClient); + + var result = await plugin.Enroll(SampleCsr, "subj", null, ProductInfo(), RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + Assert.Contains("no policy found", result.StatusMessage); + } + + [Fact] + public async Task Enroll_New_DomainValidationPending_ReturnsExternalValidationWithoutSubmitting() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.GetPolicyList()).ReturnsAsync(new List + { + new Policy { Id = Guid.NewGuid(), Name = "Test Policy", Details = new PolicyDetails { Validator = "IdenTrust" } } + }); + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List()); + mockClient.Setup(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny())) + .ReturnsAsync(new Domain { Status = DomainStatusEnum.Pending, CodeInstructions = "publish TXT" }); + var plugin = MakePlugin(mockClient); + + var result = await plugin.Enroll(SampleCsr, "subj", null, ProductInfo(), RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + mockClient.Verify(c => c.GetSubmitEnrollmentAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Enroll_New_NullEnrollmentResponse_ReturnsFailedResult() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.GetPolicyList()).ReturnsAsync(new List + { + new Policy { Id = Guid.NewGuid(), Name = "Test Policy", Details = new PolicyDetails() } + }); + mockClient.Setup(c => c.GetSubmitEnrollmentAsync(It.IsAny())).ReturnsAsync((CertRequestResult)null); + var plugin = MakePlugin(mockClient); + + var result = await plugin.Enroll(SampleCsr, "subj", null, ProductInfo(), RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + } + + [Fact] + public async Task Enroll_New_ErrorReturnStatusFailure_ReturnsFailedResult() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.GetPolicyList()).ReturnsAsync(new List + { + new Policy { Id = Guid.NewGuid(), Name = "Test Policy", Details = new PolicyDetails() } + }); + mockClient.Setup(c => c.GetSubmitEnrollmentAsync(It.IsAny())).ReturnsAsync(new CertRequestResult + { + ErrorReturn = new ErrorReturn { Status = "Failure", Error = "policy rejected" } + }); + var plugin = MakePlugin(mockClient); + + var result = await plugin.Enroll(SampleCsr, "subj", null, ProductInfo(), RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + Assert.Contains("policy rejected", result.StatusMessage); + } + + [Fact] + public async Task Enroll_New_NoRequestTrackingId_ReturnsFailedResult() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.GetPolicyList()).ReturnsAsync(new List + { + new Policy { Id = Guid.NewGuid(), Name = "Test Policy", Details = new PolicyDetails() } + }); + mockClient.Setup(c => c.GetSubmitEnrollmentAsync(It.IsAny())).ReturnsAsync(new CertRequestResult + { + RequestStatus = new CertRequestStatus { Id = null } + }); + var plugin = MakePlugin(mockClient); + + var result = await plugin.Enroll(SampleCsr, "subj", null, ProductInfo(), RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + } + + [Fact] + public async Task Enroll_New_PollingTimesOut_ReturnsFailedResult() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.GetPolicyList()).ReturnsAsync(new List + { + new Policy { Id = Guid.NewGuid(), Name = "Test Policy", Details = new PolicyDetails() } + }); + mockClient.Setup(c => c.GetSubmitEnrollmentAsync(It.IsAny())).ReturnsAsync(new CertRequestResult + { + RequestStatus = new CertRequestStatus { Id = "tracking-1" } + }); + mockClient.Setup(c => c.GetSubmitGetCertificateByCsrAsync("tracking-1")).ReturnsAsync((Certificate)null); + var plugin = MakePlugin(mockClient); + plugin.PollIntervalMs = 1; + plugin.PollTimeoutMs = 5; + + var result = await plugin.Enroll(SampleCsr, "subj", null, ProductInfo(), RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + } + + [Fact] + public async Task Enroll_New_FullSuccess_ReturnsGeneratedResult() + { + var (_, pem, _) = MakeSelfSignedCert(); + var trackingId = Guid.NewGuid(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetPolicyList()).ReturnsAsync(new List + { + new Policy { Id = Guid.NewGuid(), Name = "Test Policy", Details = new PolicyDetails() } + }); + mockClient.Setup(c => c.GetSubmitEnrollmentAsync(It.IsAny())).ReturnsAsync(new CertRequestResult + { + RequestStatus = new CertRequestStatus { Id = trackingId.ToString() } + }); + mockClient.Setup(c => c.GetSubmitGetCertificateByCsrAsync(trackingId.ToString())) + .ReturnsAsync(new Certificate { Id = trackingId }); + mockClient.Setup(c => c.GetSubmitGetCertificateAsync(trackingId.ToString())) + .ReturnsAsync(new Certificate { Pem = pem, RevocationStatus = RevocationStatusEnum.Valid }); + var plugin = MakePlugin(mockClient); + + var result = await plugin.Enroll(SampleCsr, "subj", null, ProductInfo(), RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.GENERATED, result.Status); + Assert.False(string.IsNullOrEmpty(result.Certificate)); + } + + [Fact] + public async Task Enroll_UnhandledException_ReturnsFailedResult() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.GetPolicyList()).ThrowsAsync(new InvalidOperationException("network exploded")); + var plugin = MakePlugin(mockClient); + + var result = await plugin.Enroll(SampleCsr, "subj", null, ProductInfo(), RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + Assert.Contains("network exploded", result.StatusMessage); + } + + // --------------------------------------------------------------------- + // Enroll -- Renew/Reissue path + // --------------------------------------------------------------------- + + [Fact] + public async Task Enroll_RenewOrReissue_MissingPriorCertSN_ReturnsFailedResult() + { + var plugin = MakePlugin(new Mock()); + + var result = await plugin.Enroll(SampleCsr, "subj", null, ProductInfo(), RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + Assert.Contains("PriorCertSN", result.StatusMessage); + } + + [Fact] + public async Task Enroll_RenewOrReissue_EmptyPriorCertSN_ReturnsFailedResult() + { + var plugin = MakePlugin(new Mock()); + var product = ProductInfo(new Dictionary { ["PriorCertSN"] = "" }); + + var result = await plugin.Enroll(SampleCsr, "subj", null, product, RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + } + + private static HydrantIdCAPlugin MakePluginWithCertReader(Mock client, Mock certReader) + { + var plugin = new HydrantIdCAPlugin(); + plugin.Initialize(new FakeConfigProvider { CAConnectionData = ValidConnectionData() }, certReader.Object); + plugin.ClientFactory = _ => client.Object; + return plugin; + } + + [Fact] + public async Task Enroll_RenewOrReissue_SerialLookupMiss_ReturnsFailedResult() + { + var certReader = new Mock(); + certReader.Setup(r => r.GetRequestIDBySerialNumber("SN123")).ReturnsAsync((string)null); + var plugin = MakePluginWithCertReader(new Mock(), certReader); + var product = ProductInfo(new Dictionary { ["PriorCertSN"] = "SN123" }); + + var result = await plugin.Enroll(SampleCsr, "subj", null, product, RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + Assert.Contains("SN123", result.StatusMessage); + } + + [Fact] + public async Task Enroll_RenewOrReissue_PreviousCertFetchFails_ReturnsFailedResult() + { + var certReader = new Mock(); + var certId = Guid.NewGuid().ToString(); + certReader.Setup(r => r.GetRequestIDBySerialNumber("SN123")).ReturnsAsync(certId); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetSubmitGetCertificateAsync(certId)).ReturnsAsync((Certificate)null); + var plugin = MakePluginWithCertReader(mockClient, certReader); + var product = ProductInfo(new Dictionary { ["PriorCertSN"] = "SN123" }); + + var result = await plugin.Enroll(SampleCsr, "subj", null, product, RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + } + + [Fact] + public async Task Enroll_RenewOrReissue_WithinRenewalWindow_SubmitsRenewal() + { + var (_, previousPem, previousBase64) = MakeSelfSignedCert(notAfterDays: 5); + var certId = Guid.NewGuid().ToString(); + var trackingId = Guid.NewGuid(); + var certReader = new Mock(); + certReader.Setup(r => r.GetRequestIDBySerialNumber("SN123")).ReturnsAsync(certId); + + var mockClient = new Mock(); + mockClient.Setup(c => c.GetSubmitGetCertificateAsync(certId)) + .ReturnsAsync(new Certificate { Pem = previousPem, RevocationStatus = RevocationStatusEnum.Valid }); + mockClient.Setup(c => c.GetSubmitRenewalAsync(certId, It.IsAny())).ReturnsAsync(new CertRequestResult + { + RequestStatus = new CertRequestStatus { Id = trackingId.ToString() } + }); + mockClient.Setup(c => c.GetSubmitGetCertificateByCsrAsync(trackingId.ToString())) + .ReturnsAsync(new Certificate { Id = trackingId }); + mockClient.Setup(c => c.GetSubmitGetCertificateAsync(trackingId.ToString())) + .ReturnsAsync(new Certificate { Pem = previousPem, RevocationStatus = RevocationStatusEnum.Valid }); + + var plugin = MakePluginWithCertReader(mockClient, certReader); + var product = ProductInfo(new Dictionary + { + ["PriorCertSN"] = "SN123", + ["RenewalDays"] = "30" + }); + + var result = await plugin.Enroll(SampleCsr, "subj", null, product, RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.Equal((int)EndEntityStatus.GENERATED, result.Status); + mockClient.Verify(c => c.GetSubmitRenewalAsync(certId, It.IsAny()), Times.Once); + mockClient.Verify(c => c.GetSubmitEnrollmentAsync(It.IsAny()), Times.Never); + Assert.True(previousBase64.Length > 0); + } + + [Fact] + public async Task Enroll_RenewOrReissue_OutsideRenewalWindow_SubmitsReissueViaPolicyMatch() + { + var (_, previousPem, _) = MakeSelfSignedCert(notAfterDays: 300); + var certId = Guid.NewGuid().ToString(); + var trackingId = Guid.NewGuid(); + var certReader = new Mock(); + certReader.Setup(r => r.GetRequestIDBySerialNumber("SN123")).ReturnsAsync(certId); + + var mockClient = new Mock(); + mockClient.Setup(c => c.GetSubmitGetCertificateAsync(certId)) + .ReturnsAsync(new Certificate { Pem = previousPem, RevocationStatus = RevocationStatusEnum.Valid }); + mockClient.Setup(c => c.GetPolicyList()).ReturnsAsync(new List + { + new Policy { Id = Guid.NewGuid(), Name = "Test Policy", Details = new PolicyDetails() } + }); + mockClient.Setup(c => c.GetSubmitEnrollmentAsync(It.IsAny())).ReturnsAsync(new CertRequestResult + { + RequestStatus = new CertRequestStatus { Id = trackingId.ToString() } + }); + mockClient.Setup(c => c.GetSubmitGetCertificateByCsrAsync(trackingId.ToString())) + .ReturnsAsync(new Certificate { Id = trackingId }); + mockClient.Setup(c => c.GetSubmitGetCertificateAsync(trackingId.ToString())) + .ReturnsAsync(new Certificate { Pem = previousPem, RevocationStatus = RevocationStatusEnum.Valid }); + + var plugin = MakePluginWithCertReader(mockClient, certReader); + var product = ProductInfo(new Dictionary + { + ["PriorCertSN"] = "SN123", + ["RenewalDays"] = "30" + }); + + var result = await plugin.Enroll(SampleCsr, "subj", null, product, RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.Equal((int)EndEntityStatus.GENERATED, result.Status); + mockClient.Verify(c => c.GetSubmitEnrollmentAsync(It.IsAny()), Times.Once); + mockClient.Verify(c => c.GetSubmitRenewalAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Enroll_RenewOrReissue_WithinWindowButCertIdTooShort_ReturnsFailedResult() + { + var (_, previousPem, _) = MakeSelfSignedCert(notAfterDays: 5); + var certReader = new Mock(); + certReader.Setup(r => r.GetRequestIDBySerialNumber("SN123")).ReturnsAsync("short-id"); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetSubmitGetCertificateAsync("short-id")) + .ReturnsAsync(new Certificate { Pem = previousPem, RevocationStatus = RevocationStatusEnum.Valid }); + var plugin = MakePluginWithCertReader(mockClient, certReader); + var product = ProductInfo(new Dictionary { ["PriorCertSN"] = "SN123", ["RenewalDays"] = "30" }); + + var result = await plugin.Enroll(SampleCsr, "subj", null, product, RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + Assert.Contains("too short", result.StatusMessage); + } + + [Fact] + public async Task Enroll_RenewOrReissue_ReissueNullPolicyList_ReturnsFailedResult() + { + var (_, previousPem, _) = MakeSelfSignedCert(notAfterDays: 300); + var certId = Guid.NewGuid().ToString(); + var certReader = new Mock(); + certReader.Setup(r => r.GetRequestIDBySerialNumber("SN123")).ReturnsAsync(certId); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetSubmitGetCertificateAsync(certId)) + .ReturnsAsync(new Certificate { Pem = previousPem, RevocationStatus = RevocationStatusEnum.Valid }); + mockClient.Setup(c => c.GetPolicyList()).ReturnsAsync((List)null); + var plugin = MakePluginWithCertReader(mockClient, certReader); + var product = ProductInfo(new Dictionary { ["PriorCertSN"] = "SN123", ["RenewalDays"] = "30" }); + + var result = await plugin.Enroll(SampleCsr, "subj", null, product, RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + Assert.Contains("Re-issue failed", result.StatusMessage); + } + + [Fact] + public async Task Enroll_RenewOrReissue_ReissueDomainValidationPending_ReturnsExternalValidation() + { + var (_, previousPem, _) = MakeSelfSignedCert(notAfterDays: 300); + var certId = Guid.NewGuid().ToString(); + var certReader = new Mock(); + certReader.Setup(r => r.GetRequestIDBySerialNumber("SN123")).ReturnsAsync(certId); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetSubmitGetCertificateAsync(certId)) + .ReturnsAsync(new Certificate { Pem = previousPem, RevocationStatus = RevocationStatusEnum.Valid }); + mockClient.Setup(c => c.GetPolicyList()).ReturnsAsync(new List + { + new Policy { Id = Guid.NewGuid(), Name = "Test Policy", Details = new PolicyDetails { Validator = "IdenTrust" } } + }); + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List()); + mockClient.Setup(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny())) + .ReturnsAsync(new Domain { Status = DomainStatusEnum.Pending, CodeInstructions = "publish TXT" }); + var plugin = MakePluginWithCertReader(mockClient, certReader); + var product = ProductInfo(new Dictionary { ["PriorCertSN"] = "SN123", ["RenewalDays"] = "30" }); + + var result = await plugin.Enroll(SampleCsr, "subj", null, product, RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + mockClient.Verify(c => c.GetSubmitEnrollmentAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Enroll_RenewOrReissue_ReissueNoPolicyMatch_ReturnsFailedResult() + { + var (_, previousPem, _) = MakeSelfSignedCert(notAfterDays: 300); + var certId = Guid.NewGuid().ToString(); + var certReader = new Mock(); + certReader.Setup(r => r.GetRequestIDBySerialNumber("SN123")).ReturnsAsync(certId); + + var mockClient = new Mock(); + mockClient.Setup(c => c.GetSubmitGetCertificateAsync(certId)) + .ReturnsAsync(new Certificate { Pem = previousPem, RevocationStatus = RevocationStatusEnum.Valid }); + mockClient.Setup(c => c.GetPolicyList()).ReturnsAsync(new List + { + new Policy { Id = Guid.NewGuid(), Name = "Some Other Policy" } + }); + + var plugin = MakePluginWithCertReader(mockClient, certReader); + var product = ProductInfo(new Dictionary + { + ["PriorCertSN"] = "SN123", + ["RenewalDays"] = "30" + }); + + var result = await plugin.Enroll(SampleCsr, "subj", null, product, RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + Assert.Contains("Re-issue failed", result.StatusMessage); + } + + // --------------------------------------------------------------------- + // GetCertificateOnTimerAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task GetCertificateOnTimerAsync_FoundImmediately_ReturnsCertificate() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.GetSubmitGetCertificateByCsrAsync("id1")).ReturnsAsync(new Certificate { Id = Guid.NewGuid() }); + var plugin = MakePlugin(mockClient); + + var result = await plugin.GetCertificateOnTimerAsync("id1"); + + Assert.NotNull(result); + } + + [Fact] + public async Task GetCertificateOnTimerAsync_PerIterationExceptionThenFound_ReturnsCertificate() + { + var mockClient = new Mock(); + var callCount = 0; + mockClient.Setup(c => c.GetSubmitGetCertificateByCsrAsync("id1")).Returns(() => + { + callCount++; + if (callCount == 1) + throw new InvalidOperationException("not ready"); + return Task.FromResult(new Certificate { Id = Guid.NewGuid() }); + }); + var plugin = MakePlugin(mockClient); + plugin.PollIntervalMs = 1; + + var result = await plugin.GetCertificateOnTimerAsync("id1"); + + Assert.NotNull(result); + Assert.True(callCount >= 2); + } + + [Fact] + public async Task GetCertificateOnTimerAsync_NeverFound_ReturnsNullAfterTimeout() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.GetSubmitGetCertificateByCsrAsync("id1")).ReturnsAsync((Certificate)null); + var plugin = MakePlugin(mockClient); + plugin.PollIntervalMs = 1; + plugin.PollTimeoutMs = 5; + + var result = await plugin.GetCertificateOnTimerAsync("id1"); + + Assert.Null(result); + } + + // --------------------------------------------------------------------- + // Revoke + // --------------------------------------------------------------------- + + [Fact] + public async Task Revoke_NullOrEmptyId_ThrowsWrappedException() + { + var plugin = MakePlugin(new Mock()); + + await Assert.ThrowsAsync(() => plugin.Revoke("", "sn", 0)); + } + + [Fact] + public async Task Revoke_TooShortId_ThrowsWrappedException() + { + var plugin = MakePlugin(new Mock()); + + await Assert.ThrowsAsync(() => plugin.Revoke("short-id", "sn", 0)); + } + + [Fact] + public async Task Revoke_NullResponse_ThrowsWrappedException() + { + var id = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetSubmitRevokeCertificateAsync(id, It.IsAny())).ReturnsAsync((CertificateStatus)null); + var plugin = MakePlugin(mockClient); + + await Assert.ThrowsAsync(() => plugin.Revoke(id, "sn", 0)); + } + + [Fact] + public async Task Revoke_Success_ReturnsRevokedStatus() + { + var id = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetSubmitRevokeCertificateAsync(id, It.IsAny())) + .ReturnsAsync(new CertificateStatus { RevocationStatus = RevocationStatusEnum.Revoked }); + var plugin = MakePlugin(mockClient); + + var result = await plugin.Revoke(id, "sn", 0); + + Assert.Equal((int)EndEntityStatus.REVOKED, result); + } + + [Fact] + public async Task Revoke_ClientThrowsHttpRequestException_Rethrows() + { + var id = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetSubmitRevokeCertificateAsync(id, It.IsAny())) + .ThrowsAsync(new System.Net.Http.HttpRequestException("network error")); + var plugin = MakePlugin(mockClient); + + await Assert.ThrowsAsync(() => plugin.Revoke(id, "sn", 0)); + } + + [Fact] + public async Task Revoke_ClientThrowsAggregateException_ThrowsWrappedInnerMessage() + { + var id = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetSubmitRevokeCertificateAsync(id, It.IsAny())) + .Throws(new AggregateException(new InvalidOperationException("agg boom"))); + var plugin = MakePlugin(mockClient); + + var ex = await Assert.ThrowsAsync(() => plugin.Revoke(id, "sn", 0)); + Assert.Contains("agg boom", ex.Message); + } + + // --------------------------------------------------------------------- + // GetSingleRecord + // --------------------------------------------------------------------- + + [Fact] + public async Task GetSingleRecord_NullOrEmptyId_ThrowsWrappedException() + { + var plugin = MakePlugin(new Mock()); + + await Assert.ThrowsAsync(() => plugin.GetSingleRecord("")); + } + + [Fact] + public async Task GetSingleRecord_TooShortId_ThrowsWrappedException() + { + var plugin = MakePlugin(new Mock()); + + await Assert.ThrowsAsync(() => plugin.GetSingleRecord("short")); + } + + [Fact] + public async Task GetSingleRecord_NullCertificateResponse_ReturnsFailedStatus() + { + var id = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetSubmitGetCertificateAsync(id)).ReturnsAsync((Certificate)null); + var plugin = MakePlugin(mockClient); + + var result = await plugin.GetSingleRecord(id); + + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + Assert.Equal(string.Empty, result.Certificate); + } + + [Fact] + public async Task GetSingleRecord_EmptyExtractedCert_ReturnsFailedStatus() + { + var id = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetSubmitGetCertificateAsync(id)).ReturnsAsync(new Certificate { Pem = "not a real cert" }); + var plugin = MakePlugin(mockClient); + + var result = await plugin.GetSingleRecord(id); + + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + } + + [Fact] + public async Task GetSingleRecord_Success_ReturnsMappedStatusAndCertificate() + { + var id = Guid.NewGuid().ToString(); + var (_, pem, _) = MakeSelfSignedCert(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetSubmitGetCertificateAsync(id)) + .ReturnsAsync(new Certificate { Pem = pem, RevocationStatus = RevocationStatusEnum.Revoked }); + var plugin = MakePlugin(mockClient); + + var result = await plugin.GetSingleRecord(id); + + Assert.Equal((int)EndEntityStatus.REVOKED, result.Status); + Assert.False(string.IsNullOrEmpty(result.Certificate)); + } + + [Fact] + public async Task GetSingleRecord_ClientThrows_ThrowsWrappedException() + { + var id = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetSubmitGetCertificateAsync(id)).ThrowsAsync(new InvalidOperationException("boom")); + var plugin = MakePlugin(mockClient); + + await Assert.ThrowsAsync(() => plugin.GetSingleRecord(id)); + } + + [Fact] + public async Task GetSingleRecord_ClientThrowsAggregateException_ThrowsWrappedInnerMessage() + { + var id = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetSubmitGetCertificateAsync(id)) + .Throws(new AggregateException(new InvalidOperationException("agg boom"))); + var plugin = MakePlugin(mockClient); + + var ex = await Assert.ThrowsAsync(() => plugin.GetSingleRecord(id)); + Assert.Contains("agg boom", ex.Message); + } + + // --------------------------------------------------------------------- + // Annotations + // --------------------------------------------------------------------- + + [Fact] + public void GetCAConnectorAnnotations_ReturnsNonEmptyDictionary() + { + var plugin = new HydrantIdCAPlugin(); + Assert.NotEmpty(plugin.GetCAConnectorAnnotations()); + } + + [Fact] + public void GetTemplateParameterAnnotations_ReturnsNonEmptyDictionary() + { + var plugin = new HydrantIdCAPlugin(); + Assert.NotEmpty(plugin.GetTemplateParameterAnnotations()); + } + } +} diff --git a/HydrantCAProxy.Tests/HydrantIdClientTests.cs b/HydrantCAProxy.Tests/HydrantIdClientTests.cs new file mode 100644 index 0000000..421a9f0 --- /dev/null +++ b/HydrantCAProxy.Tests/HydrantIdClientTests.cs @@ -0,0 +1,630 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.HydrantId; +using Keyfactor.HydrantId.Client; +using Keyfactor.HydrantId.Client.Models; +using Keyfactor.HydrantId.Client.Models.Enums; +using Keyfactor.HydrantId.Exceptions; +using Keyfactor.HydrantId.Interfaces; +using Xunit; + +namespace HydrantCAProxy.Tests +{ + public class HydrantIdClientTests + { + private sealed class FakeConfigProvider : IAnyCAPluginConfigProvider + { + public Dictionary CAConnectionData { get; set; } + } + + private sealed class FakeHttpMessageHandler : HttpMessageHandler + { + private readonly Func _responder; + + public FakeHttpMessageHandler(Func responder) + { + _responder = responder; + } + + public HttpRequestMessage LastRequest { get; private set; } + public string LastRequestBody { get; private set; } + public int CallCount { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + CallCount++; + LastRequest = request; + LastRequestBody = request.Content != null ? await request.Content.ReadAsStringAsync(cancellationToken) : null; + return _responder(request, LastRequestBody); + } + } + + private static HttpResponseMessage JsonResponse(HttpStatusCode status, string json) => + new HttpResponseMessage(status) { Content = new StringContent(json ?? string.Empty, Encoding.UTF8, "application/json") }; + + private static IAnyCAPluginConfigProvider ValidConfig(string baseUrl = "https://acm-stage.hydrantid.test") => + new FakeConfigProvider + { + CAConnectionData = new Dictionary + { + [HydrantIdCAPluginConfig.ConfigConstants.HydrantIdBaseUrl] = baseUrl, + [HydrantIdCAPluginConfig.ConfigConstants.HydrantIdAuthId] = "test-auth-id", + [HydrantIdCAPluginConfig.ConfigConstants.HydrantIdAuthKey] = "test-auth-key" + } + }; + + private static HydrantIdClient MakeClient( + Func responder, + out FakeHttpMessageHandler handler, + IAnyCAPluginConfigProvider config = null) + { + handler = new FakeHttpMessageHandler(responder); + return new HydrantIdClient(config ?? ValidConfig(), handler); + } + + // --------------------------------------------------------------------- + // Constructor validation + // --------------------------------------------------------------------- + + [Fact] + public void Constructor_NullConfig_Throws() + { + Assert.Throws(() => new HydrantIdClient(null)); + } + + [Fact] + public void Constructor_NullConnectionData_Throws() + { + var config = new FakeConfigProvider { CAConnectionData = null }; + Assert.Throws(() => new HydrantIdClient(config)); + } + + [Fact] + public void Constructor_MissingAuthIdKey_Throws() + { + var config = new FakeConfigProvider { CAConnectionData = new Dictionary() }; + Assert.Throws(() => new HydrantIdClient(config)); + } + + [Fact] + public void Constructor_EmptyBaseUrl_Throws() + { + var config = new FakeConfigProvider + { + CAConnectionData = new Dictionary + { + [HydrantIdCAPluginConfig.ConfigConstants.HydrantIdAuthId] = "id", + [HydrantIdCAPluginConfig.ConfigConstants.HydrantIdBaseUrl] = "" + } + }; + Assert.Throws(() => new HydrantIdClient(config)); + } + + // --------------------------------------------------------------------- + // ConfigureRestClient / Hawk header construction + // --------------------------------------------------------------------- + + [Fact] + public async Task ConfigureRestClient_BuildsWellFormedHawkAuthorizationHeader() + { + var client = MakeClient((req, body) => JsonResponse(HttpStatusCode.OK, "[]"), out var handler); + + await client.GetPolicyList(); + + var authHeader = handler.LastRequest.Headers.GetValues("Authorization").Single(); + Assert.StartsWith("Hawk ", authHeader); + Assert.Matches(new Regex("id=\"[^\"]+\", ts=\"\\d+\", nonce=\"[^\"]+\", mac=\"[^\"]+\""), authHeader); + } + + [Fact] + public async Task ConfigureRestClient_MissingAuthIdAtCallTime_Throws() + { + var config = new FakeConfigProvider + { + CAConnectionData = new Dictionary + { + [HydrantIdCAPluginConfig.ConfigConstants.HydrantIdAuthId] = "id", + [HydrantIdCAPluginConfig.ConfigConstants.HydrantIdBaseUrl] = "https://acm-stage.hydrantid.test" + } + }; + var client = MakeClient((req, body) => JsonResponse(HttpStatusCode.OK, "[]"), out _, config); + // Simulate the auth id being cleared out from underlying config after construction. + config.CAConnectionData[HydrantIdCAPluginConfig.ConfigConstants.HydrantIdAuthId] = ""; + + await Assert.ThrowsAsync(() => client.GetPolicyList()); + } + + [Fact] + public async Task ConfigureRestClient_EmptyAuthKeyAtCallTime_Throws() + { + var config = new FakeConfigProvider + { + CAConnectionData = new Dictionary + { + [HydrantIdCAPluginConfig.ConfigConstants.HydrantIdAuthId] = "test-auth-id", + [HydrantIdCAPluginConfig.ConfigConstants.HydrantIdAuthKey] = "", + [HydrantIdCAPluginConfig.ConfigConstants.HydrantIdBaseUrl] = "https://acm-stage.hydrantid.test" + } + }; + var client = MakeClient((req, body) => JsonResponse(HttpStatusCode.OK, "[]"), out _, config); + + await Assert.ThrowsAsync(() => client.GetPolicyList()); + } + + // --------------------------------------------------------------------- + // Ping + // --------------------------------------------------------------------- + + [Fact] + public async Task Ping_SuccessStatus_ReturnsTrue() + { + var client = MakeClient((req, body) => JsonResponse(HttpStatusCode.OK, "[]"), out _); + + Assert.True(await client.Ping()); + } + + [Fact] + public async Task Ping_NonSuccessStatus_ReturnsFalse() + { + var client = MakeClient((req, body) => JsonResponse(HttpStatusCode.Unauthorized, "{}"), out _); + + Assert.False(await client.Ping()); + } + + [Fact] + public async Task Ping_TransportThrows_ReturnsFalse() + { + var client = MakeClient((req, body) => throw new HttpRequestException("network down"), out _); + + Assert.False(await client.Ping()); + } + + // --------------------------------------------------------------------- + // GetPolicyList + // --------------------------------------------------------------------- + + [Fact] + public async Task GetPolicyList_Success_ReturnsPolicies() + { + var client = MakeClient((req, body) => + JsonResponse(HttpStatusCode.OK, "[{\"id\":\"" + Guid.NewGuid() + "\",\"name\":\"Test Policy\"}]"), out _); + + var result = await client.GetPolicyList(); + + Assert.Single(result); + Assert.Equal("Test Policy", result[0].Name); + } + + [Fact] + public async Task GetPolicyList_NullBody_ReturnsEmptyList() + { + var client = MakeClient((req, body) => JsonResponse(HttpStatusCode.OK, "null"), out _); + + var result = await client.GetPolicyList(); + + Assert.Empty(result); + } + + [Fact] + public async Task GetPolicyList_NonSuccess_Throws() + { + var client = MakeClient((req, body) => JsonResponse(HttpStatusCode.InternalServerError, "{}"), out _); + + await Assert.ThrowsAsync(() => client.GetPolicyList()); + } + + // --------------------------------------------------------------------- + // GetDomainListAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task GetDomainListAsync_Success_ReturnsDomains() + { + var client = MakeClient((req, body) => + JsonResponse(HttpStatusCode.OK, "[{\"id\":\"d1\",\"domain\":\"example.com\",\"status\":\"VALIDATED\"}]"), out _); + + var result = await client.GetDomainListAsync(); + + Assert.Single(result); + Assert.Equal(DomainStatusEnum.Validated, result[0].Status); + } + + [Fact] + public async Task GetDomainListAsync_NullBody_ReturnsEmptyList() + { + var client = MakeClient((req, body) => JsonResponse(HttpStatusCode.OK, "null"), out _); + + Assert.Empty(await client.GetDomainListAsync()); + } + + [Fact] + public async Task GetDomainListAsync_NonSuccess_Throws() + { + var client = MakeClient((req, body) => JsonResponse(HttpStatusCode.BadGateway, "{}"), out _); + + await Assert.ThrowsAsync(() => client.GetDomainListAsync()); + } + + // --------------------------------------------------------------------- + // GetSubmitCreateDomainValidationAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task GetSubmitCreateDomainValidationAsync_NullPayload_Throws() + { + var client = MakeClient((req, body) => JsonResponse(HttpStatusCode.OK, "{}"), out _); + + await Assert.ThrowsAsync(() => client.GetSubmitCreateDomainValidationAsync(null)); + } + + [Fact] + public async Task GetSubmitCreateDomainValidationAsync_Success_ReturnsDomain() + { + var client = MakeClient((req, body) => + JsonResponse(HttpStatusCode.OK, "{\"id\":\"d1\",\"domain\":\"example.com\",\"status\":\"PENDING\"}"), out var handler); + + var result = await client.GetSubmitCreateDomainValidationAsync(new CreateDomainValidationPayload + { + DomainName = "example.com", + Validator = "IdenTrust", + Method = ValidationMethod.Dns + }); + + Assert.Equal(DomainStatusEnum.Pending, result.Status); + Assert.Contains("example.com", handler.LastRequestBody); + } + + [Fact] + public async Task GetSubmitCreateDomainValidationAsync_NonSuccess_Throws() + { + var client = MakeClient((req, body) => JsonResponse(HttpStatusCode.Unauthorized, "{}"), out _); + + await Assert.ThrowsAsync(() => + client.GetSubmitCreateDomainValidationAsync(new CreateDomainValidationPayload { DomainName = "x", Validator = "y" })); + } + + // --------------------------------------------------------------------- + // GetSubmitCheckDomainValidationAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task GetSubmitCheckDomainValidationAsync_NullOrEmptyId_Throws() + { + var client = MakeClient((req, body) => JsonResponse(HttpStatusCode.OK, "{}"), out _); + + await Assert.ThrowsAsync(() => client.GetSubmitCheckDomainValidationAsync("")); + } + + [Fact] + public async Task GetSubmitCheckDomainValidationAsync_Success_ReturnsDomain() + { + var client = MakeClient((req, body) => + JsonResponse(HttpStatusCode.OK, "{\"id\":\"d1\",\"status\":\"EXPIRED\"}"), out _); + + var result = await client.GetSubmitCheckDomainValidationAsync("d1"); + + Assert.Equal(DomainStatusEnum.Expired, result.Status); + } + + [Fact] + public async Task GetSubmitCheckDomainValidationAsync_NonSuccess_Throws() + { + var client = MakeClient((req, body) => JsonResponse(HttpStatusCode.NotFound, "{}"), out _); + + await Assert.ThrowsAsync(() => client.GetSubmitCheckDomainValidationAsync("d1")); + } + + // --------------------------------------------------------------------- + // GetSubmitGetCertificateAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task GetSubmitGetCertificateAsync_NullOrEmptyId_Throws() + { + var client = MakeClient((req, body) => JsonResponse(HttpStatusCode.OK, "{}"), out _); + + await Assert.ThrowsAsync(() => client.GetSubmitGetCertificateAsync(null)); + } + + [Fact] + public async Task GetSubmitGetCertificateAsync_Success_ReturnsCertificate() + { + var client = MakeClient((req, body) => + JsonResponse(HttpStatusCode.OK, "{\"id\":\"" + Guid.NewGuid() + "\",\"pem\":\"PEMDATA\"}"), out _); + + var result = await client.GetSubmitGetCertificateAsync(Guid.NewGuid().ToString()); + + Assert.Equal("PEMDATA", result.Pem); + } + + [Fact] + public async Task GetSubmitGetCertificateAsync_NonSuccess_Throws() + { + var client = MakeClient((req, body) => JsonResponse(HttpStatusCode.NotFound, "{}"), out _); + + await Assert.ThrowsAsync(() => client.GetSubmitGetCertificateAsync("abc")); + } + + [Fact] + public async Task GetSubmitGetCertificateAsync_SuccessNullBody_ReturnsNull() + { + var client = MakeClient((req, body) => JsonResponse(HttpStatusCode.OK, "null"), out _); + + var result = await client.GetSubmitGetCertificateAsync("abc"); + + Assert.Null(result); + } + + // --------------------------------------------------------------------- + // GetSubmitGetCertificateByCsrAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task GetSubmitGetCertificateByCsrAsync_NullOrEmptyId_Throws() + { + var client = MakeClient((req, body) => JsonResponse(HttpStatusCode.OK, "{}"), out _); + + await Assert.ThrowsAsync(() => client.GetSubmitGetCertificateByCsrAsync("")); + } + + [Fact] + public async Task GetSubmitGetCertificateByCsrAsync_Success_ReturnsCertificate() + { + var client = MakeClient((req, body) => + JsonResponse(HttpStatusCode.OK, "{\"pem\":\"PEMDATA\"}"), out _); + + var result = await client.GetSubmitGetCertificateByCsrAsync("tracking-id"); + + Assert.Equal("PEMDATA", result.Pem); + } + + [Fact] + public async Task GetSubmitGetCertificateByCsrAsync_NonSuccess_Throws() + { + var client = MakeClient((req, body) => JsonResponse(HttpStatusCode.NotFound, "{}"), out _); + + await Assert.ThrowsAsync(() => client.GetSubmitGetCertificateByCsrAsync("tracking-id")); + } + + // --------------------------------------------------------------------- + // GetSubmitRevokeCertificateAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task GetSubmitRevokeCertificateAsync_NullOrEmptyId_Throws() + { + var client = MakeClient((req, body) => JsonResponse(HttpStatusCode.OK, "{}"), out _); + + await Assert.ThrowsAsync(() => + client.GetSubmitRevokeCertificateAsync(null, RevocationReasons.Unspecified)); + } + + [Fact] + public async Task GetSubmitRevokeCertificateAsync_Success_ReturnsStatus() + { + var client = MakeClient((req, body) => + JsonResponse(HttpStatusCode.OK, "{\"id\":\"" + Guid.NewGuid() + "\",\"revocationStatus\":\"REVOKED\"}"), out var handler); + + var result = await client.GetSubmitRevokeCertificateAsync("abc", RevocationReasons.KeyCompromise); + + Assert.Equal(RevocationStatusEnum.Revoked, result.RevocationStatus); + Assert.Equal(HttpMethod.Patch, handler.LastRequest.Method); + } + + [Fact] + public async Task GetSubmitRevokeCertificateAsync_NonSuccess_Throws() + { + var client = MakeClient((req, body) => JsonResponse(HttpStatusCode.Forbidden, "{}"), out _); + + await Assert.ThrowsAsync(() => + client.GetSubmitRevokeCertificateAsync("abc", RevocationReasons.Unspecified)); + } + + // --------------------------------------------------------------------- + // GetSubmitEnrollmentAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task GetSubmitEnrollmentAsync_NullRequest_Throws() + { + var client = MakeClient((req, body) => JsonResponse(HttpStatusCode.OK, "{}"), out _); + + await Assert.ThrowsAsync(() => client.GetSubmitEnrollmentAsync(null)); + } + + [Fact] + public async Task GetSubmitEnrollmentAsync_Success_ReturnsRequestStatus() + { + var client = MakeClient((req, body) => + JsonResponse(HttpStatusCode.OK, "{\"id\":\"tracking-1\",\"issuanceStatus\":\"PENDING\"}"), out _); + + var result = await client.GetSubmitEnrollmentAsync(new CertRequestBody { Csr = "csr" }); + + Assert.Equal("tracking-1", result.RequestStatus.Id); + Assert.Null(result.ErrorReturn); + } + + [Fact] + public async Task GetSubmitEnrollmentAsync_InternalServerError_ReturnsParsedErrorReturn() + { + var client = MakeClient((req, body) => + JsonResponse(HttpStatusCode.InternalServerError, "{\"status\":\"Failure\",\"message\":\"boom\"}"), out _); + + var result = await client.GetSubmitEnrollmentAsync(new CertRequestBody { Csr = "csr" }); + + Assert.Null(result.RequestStatus); + Assert.Equal("Failure", result.ErrorReturn.Status); + Assert.Equal("boom", result.ErrorReturn.Error); + } + + [Fact] + public async Task GetSubmitEnrollmentAsync_OtherNonSuccess_ReturnsSyntheticErrorReturn() + { + var client = MakeClient((req, body) => JsonResponse(HttpStatusCode.BadRequest, "bad request body"), out _); + + var result = await client.GetSubmitEnrollmentAsync(new CertRequestBody { Csr = "csr" }); + + Assert.Null(result.RequestStatus); + Assert.Equal("Failure", result.ErrorReturn.Status); + Assert.Contains("BadRequest", result.ErrorReturn.Error); + } + + // --------------------------------------------------------------------- + // GetSubmitRenewalAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task GetSubmitRenewalAsync_NullOrEmptyArgs_Throws() + { + var client = MakeClient((req, body) => JsonResponse(HttpStatusCode.OK, "{}"), out _); + + await Assert.ThrowsAsync(() => + client.GetSubmitRenewalAsync("", new RenewalRequest { Csr = "csr" })); + await Assert.ThrowsAsync(() => + client.GetSubmitRenewalAsync("cert-id", null)); + } + + [Fact] + public async Task GetSubmitRenewalAsync_Success_ReturnsRequestStatus() + { + var client = MakeClient((req, body) => + JsonResponse(HttpStatusCode.OK, "{\"id\":\"tracking-2\",\"issuanceStatus\":\"ISSUED\"}"), out _); + + var result = await client.GetSubmitRenewalAsync("cert-id", new RenewalRequest { Csr = "csr" }); + + Assert.Equal("tracking-2", result.RequestStatus.Id); + } + + [Fact] + public async Task GetSubmitRenewalAsync_InternalServerError_ReturnsParsedErrorReturn() + { + var client = MakeClient((req, body) => + JsonResponse(HttpStatusCode.InternalServerError, "{\"status\":\"Failure\",\"message\":\"renew boom\"}"), out _); + + var result = await client.GetSubmitRenewalAsync("cert-id", new RenewalRequest { Csr = "csr" }); + + Assert.Equal("renew boom", result.ErrorReturn.Error); + } + + [Fact] + public async Task GetSubmitRenewalAsync_OtherNonSuccess_ReturnsSyntheticErrorReturn() + { + var client = MakeClient((req, body) => JsonResponse(HttpStatusCode.Conflict, "conflict"), out _); + + var result = await client.GetSubmitRenewalAsync("cert-id", new RenewalRequest { Csr = "csr" }); + + Assert.Equal("Failure", result.ErrorReturn.Status); + } + + // --------------------------------------------------------------------- + // GetSubmitCertificateListRequestAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task GetSubmitCertificateListRequestAsync_SinglePartialPage_CompletesAfterOnePage() + { + var client = MakeClient((req, body) => + JsonResponse(HttpStatusCode.OK, + "{\"count\":1,\"items\":[{\"id\":\"c1\",\"commonName\":\"test.local\",\"revocationStatus\":\"VALID\"}]}"), + out var handler); + var bc = new BlockingCollection(10); + + await client.GetSubmitCertificateListRequestAsync(bc, CancellationToken.None); + + Assert.True(bc.IsAddingCompleted); + var items = new List(bc.GetConsumingEnumerable()); + Assert.Single(items); + Assert.Equal("c1", items[0].Id); + Assert.Equal(1, handler.CallCount); + } + + [Fact] + public async Task GetSubmitCertificateListRequestAsync_NullItems_CompletesWithNoItems() + { + var client = MakeClient((req, body) => JsonResponse(HttpStatusCode.OK, "{\"count\":0}"), out _); + var bc = new BlockingCollection(10); + + await client.GetSubmitCertificateListRequestAsync(bc, CancellationToken.None); + + Assert.True(bc.IsAddingCompleted); + Assert.Empty(bc.GetConsumingEnumerable()); + } + + [Fact] + public async Task GetSubmitCertificateListRequestAsync_NullItemInBatch_SkipsIt() + { + var client = MakeClient((req, body) => + JsonResponse(HttpStatusCode.OK, "{\"count\":1,\"items\":[null]}"), out _); + var bc = new BlockingCollection(10); + + await client.GetSubmitCertificateListRequestAsync(bc, CancellationToken.None); + + Assert.Empty(bc.GetConsumingEnumerable()); + } + + [Fact] + public async Task GetSubmitCertificateListRequestAsync_RepeatedFailures_ThrowsAfterFiveRetries() + { + var client = MakeClient((req, body) => JsonResponse(HttpStatusCode.InternalServerError, "{}"), out var handler); + var bc = new BlockingCollection(10); + + await Assert.ThrowsAsync(() => + client.GetSubmitCertificateListRequestAsync(bc, CancellationToken.None)); + + Assert.True(bc.IsAddingCompleted); + Assert.Equal(6, handler.CallCount); + } + + [Fact] + public async Task GetSubmitCertificateListRequestAsync_TransportThrows_ThrowsHttpRequestExceptionAndCompletesAdding() + { + var client = MakeClient((req, body) => throw new HttpRequestException("network down"), out _); + var bc = new BlockingCollection(10); + + await Assert.ThrowsAsync(() => + client.GetSubmitCertificateListRequestAsync(bc, CancellationToken.None)); + + Assert.True(bc.IsAddingCompleted); + } + + [Fact] + public async Task GetSubmitCertificateListRequestAsync_MalformedJson_ThrowsAndCompletesAdding() + { + var client = MakeClient((req, body) => JsonResponse(HttpStatusCode.OK, "not valid json"), out _); + var bc = new BlockingCollection(10); + + await Assert.ThrowsAsync(() => + client.GetSubmitCertificateListRequestAsync(bc, CancellationToken.None)); + + Assert.True(bc.IsAddingCompleted); + } + + [Fact] + public async Task GetSubmitCertificateListRequestAsync_CancelledToken_ThrowsOperationCanceled() + { + var client = MakeClient((req, body) => JsonResponse(HttpStatusCode.OK, "{}"), out _); + var bc = new BlockingCollection(10); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync(() => + client.GetSubmitCertificateListRequestAsync(bc, cts.Token)); + + Assert.True(bc.IsAddingCompleted); + } + } +} diff --git a/HydrantCAProxy.Tests/ModelSerializationTests.cs b/HydrantCAProxy.Tests/ModelSerializationTests.cs new file mode 100644 index 0000000..efd303a --- /dev/null +++ b/HydrantCAProxy.Tests/ModelSerializationTests.cs @@ -0,0 +1,251 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. + +using System; +using System.Collections.Generic; +using Keyfactor.HydrantId.Client.Models; +using Keyfactor.HydrantId.Client.Models.Enums; +using Newtonsoft.Json; +using Xunit; + +namespace HydrantCAProxy.Tests +{ + // Covers plain data-model classes that carry no logic of their own but are part of the + // public model surface (deserialized from HydrantID API responses even where the plugin's + // business logic doesn't currently read every field) plus the one custom JsonConverter, + // TagEnumConverter, which does carry real behavior worth testing directly. + public class ModelSerializationTests + { + [Fact] + public void Validator_PropertiesRoundTrip() + { + var v = new Validator { Id = "IdenTrust", Name = "IdenTrust", Capabilities = new List { "create", "validate" } }; + + Assert.Equal("IdenTrust", v.Id); + Assert.Equal("IdenTrust", v.Name); + Assert.Equal(2, v.Capabilities.Count); + } + + [Fact] + public void PolicyEnabled_PropertiesRoundTrip() + { + var e = new PolicyEnabled { Ui = true, Rest = true, Acme = false, Scep = false, Est = true }; + + Assert.True(e.Ui); + Assert.True(e.Rest); + Assert.False(e.Acme); + Assert.False(e.Scep); + Assert.True(e.Est); + } + + [Fact] + public void PolicyDetailsValidity_PropertiesRoundTrip() + { + var v = new PolicyDetailsValidity + { + Years = new List { "1-5" }, + Months = new List { "1-12" }, + Days = new List { "1-31" }, + Required = true, + Modifiable = true + }; + + Assert.Single(v.Years); + Assert.Single(v.Months); + Assert.Single(v.Days); + Assert.True(v.Required); + Assert.True(v.Modifiable); + } + + [Fact] + public void PolicyDetailsExpiryEmails_PropertiesRoundTrip() + { + var e = new PolicyDetailsExpiryEmails + { + Tag = PolicyDetailsExpiryEmails.TagEnum.ExpiryEmails, + Label = "Expiration Emails", + Required = false, + Modifiable = true, + DefaultValue = "${Requestor}" + }; + + Assert.Equal(PolicyDetailsExpiryEmails.TagEnum.ExpiryEmails, e.Tag); + Assert.Equal("Expiration Emails", e.Label); + Assert.Equal("${Requestor}", e.DefaultValue); + } + + [Fact] + public void PolicyDetailsDnComponents_PropertiesRoundTrip() + { + var d = new PolicyDetailsDnComponents + { + Tag = PolicyDetailsDnComponents.TagEnum.Cn, + Label = "Common Name", + Required = true, + Modifiable = true, + DefaultValue = "example.com", + CopyAsFirstSan = true + }; + + Assert.Equal(PolicyDetailsDnComponents.TagEnum.Cn, d.Tag); + Assert.True(d.CopyAsFirstSan); + } + + [Fact] + public void PolicyDetailsCustomFields_PropertiesRoundTrip() + { + var f = new PolicyDetailsCustomFields + { + Tag = "contract", + Label = "Contract #", + Required = true, + Modifiable = true, + DefaultValue = "" + }; + + Assert.Equal("contract", f.Tag); + Assert.Equal("", f.DefaultValue); + } + + [Fact] + public void PolicyDetailsCustomExtensions_PropertiesRoundTrip() + { + var x = new PolicyDetailsCustomExtensions + { + Oid = "1.3.6.1.4.1.311.21.7", + Label = "Template Info", + Required = true, + Modifiable = true, + DefaultValue = "302f" + }; + + Assert.Equal("1.3.6.1.4.1.311.21.7", x.Oid); + } + + [Fact] + public void CertRequestUser_PropertiesRoundTrip() + { + var u = new CertRequestUser { Id = Guid.NewGuid(), FirstName = "Jane", LastName = "Doe" }; + + Assert.Equal("Jane", u.FirstName); + Assert.Equal("Doe", u.LastName); + } + + [Fact] + public void CertRequestPolicy_PropertiesRoundTrip() + { + var p = new CertRequestPolicy { Id = Guid.NewGuid(), Name = "Test Policy" }; + + Assert.Equal("Test Policy", p.Name); + } + + [Fact] + public void CertificateUser_PropertiesRoundTrip() + { + var u = new CertificateUser { Id = Guid.NewGuid(), Email = "jane@example.com" }; + + Assert.Equal("jane@example.com", u.Email); + } + + [Fact] + public void CertRequest_PropertiesRoundTrip() + { + var r = new CertRequest + { + Source = CertRequest.SourceEnum.Acm, + Id = Guid.NewGuid(), + Fingerprint = "abc123", + Csr = "csr-data", + CommonName = "test.example.com", + Details = new Dictionary(), + IssuanceStatus = IssuanceStatus.Issued, + CreateAt = DateTime.UtcNow, + Policy = new CertRequestPolicy { Name = "P" }, + User = new CertRequestUser { FirstName = "Jane" } + }; + + Assert.Equal(CertRequest.SourceEnum.Acm, r.Source); + Assert.Equal("test.example.com", r.CommonName); + Assert.Equal(IssuanceStatus.Issued, r.IssuanceStatus); + Assert.Equal("P", r.Policy.Name); + Assert.Equal("Jane", r.User.FirstName); + } + + [Fact] + public void RevokeCertificateReasonIssuerDn_PropertiesRoundTrip() + { + var r = new RevokeCertificateReasonIssuerDn { Reason = RevocationReasons.Superseded, IssuerDn = "CN=Test CA" }; + + Assert.Equal(RevocationReasons.Superseded, r.Reason); + Assert.Equal("CN=Test CA", r.IssuerDn); + } + + [Fact] + public void CertificatesResponseItem_PropertiesRoundTrip() + { + var item = new CertificatesResponseItem + { + Id = "c1", + CommonName = "test.example.com", + Serial = "01", + NotBefore = DateTime.UtcNow, + NotAfter = DateTime.UtcNow.AddYears(1), + RevocationStatus = RevocationStatusEnum.Valid, + SaNs = new List { "a.example.com" }, + Policy = new NameObject { Name = "Policy A" } + }; + + Assert.NotNull(item.NotBefore); + Assert.NotNull(item.NotAfter); + Assert.Single(item.SaNs); + Assert.Equal("Policy A", item.Policy.Name); + } + + // --------------------------------------------------------------------- + // TagEnumConverter -- real converter logic, exercised via actual (de)serialization. + // --------------------------------------------------------------------- + + [Theory] + [InlineData("DNSNAME", PolicyDetailsSubjectAltNames.TagEnum.DnsName)] + [InlineData("IPADDRESS", PolicyDetailsSubjectAltNames.TagEnum.IpAddress)] + [InlineData("RFC822NAME", PolicyDetailsSubjectAltNames.TagEnum.Rfc822Name)] + [InlineData("RFS822NAME", PolicyDetailsSubjectAltNames.TagEnum.Rfc822Name)] + [InlineData("UPN", PolicyDetailsSubjectAltNames.TagEnum.Upn)] + public void TagEnumConverter_ReadJson_MapsKnownValues(string json, PolicyDetailsSubjectAltNames.TagEnum expected) + { + var result = JsonConvert.DeserializeObject($"{{\"tag\":\"{json}\"}}"); + + Assert.Equal(expected, result.Tag); + } + + [Fact] + public void TagEnumConverter_ReadJson_UnknownValue_Throws() + { + Assert.Throws(() => + JsonConvert.DeserializeObject("{\"tag\":\"BOGUS\"}")); + } + + [Theory] + [InlineData(PolicyDetailsSubjectAltNames.TagEnum.DnsName, "DNSNAME")] + [InlineData(PolicyDetailsSubjectAltNames.TagEnum.IpAddress, "IPADDRESS")] + [InlineData(PolicyDetailsSubjectAltNames.TagEnum.Rfc822Name, "RFC822NAME")] + [InlineData(PolicyDetailsSubjectAltNames.TagEnum.Upn, "UPN")] + public void TagEnumConverter_WriteJson_MapsKnownValues(PolicyDetailsSubjectAltNames.TagEnum tag, string expected) + { + var model = new PolicyDetailsSubjectAltNames { Tag = tag }; + + var json = JsonConvert.SerializeObject(model); + + Assert.Contains($"\"tag\":\"{expected}\"", json); + } + + [Fact] + public void TagEnumConverter_WriteJson_UnknownValue_Throws() + { + var model = new PolicyDetailsSubjectAltNames { Tag = (PolicyDetailsSubjectAltNames.TagEnum)999 }; + + Assert.Throws(() => JsonConvert.SerializeObject(model)); + } + } +} diff --git a/HydrantCAProxy.Tests/RequestManagerTests.cs b/HydrantCAProxy.Tests/RequestManagerTests.cs index 2fbb482..ad12513 100644 --- a/HydrantCAProxy.Tests/RequestManagerTests.cs +++ b/HydrantCAProxy.Tests/RequestManagerTests.cs @@ -164,6 +164,18 @@ public void GetEnrollmentRequest_NullProductInfo_Throws() Assert.Throws(() => _sut.GetEnrollmentRequest(Guid.NewGuid(), null, SampleCsr, null)); } + [Fact] + public void GetEnrollmentRequest_UnrecognizedValidityPeriod_Throws() + { + var productInfo = ProductInfo(new Dictionary + { + ["ValidityPeriod"] = "Fortnights", + ["ValidityUnits"] = "2" + }); + + Assert.Throws(() => _sut.GetEnrollmentRequest(Guid.NewGuid(), productInfo, SampleCsr, null)); + } + [Fact] public void GetEnrollmentRequest_WithSans_PopulatesSubjectAltNames() { diff --git a/HydrantCAProxy.Tests/coverlet.runsettings b/HydrantCAProxy.Tests/coverlet.runsettings new file mode 100644 index 0000000..b3b4930 --- /dev/null +++ b/HydrantCAProxy.Tests/coverlet.runsettings @@ -0,0 +1,15 @@ + + + + + + + cobertura + + [HydrantIdCAPlugin]HawkNet.* + + + + + diff --git a/HydrantCAProxy/AssemblyInfo.cs b/HydrantCAProxy/AssemblyInfo.cs new file mode 100644 index 0000000..51ea6b0 --- /dev/null +++ b/HydrantCAProxy/AssemblyInfo.cs @@ -0,0 +1,12 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain a +// copy of the License at http://www.apache.org/licenses/LICENSE-2.0. Unless +// required by applicable law or agreed to in writing, software distributed +// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +// OR CONDITIONS OF ANY KIND, either express or implied. See the License for +// thespecific language governing permissions and limitations under the +// License. +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("HydrantCAProxy.Tests")] diff --git a/HydrantCAProxy/Client/HydrantIdClient.cs b/HydrantCAProxy/Client/HydrantIdClient.cs index 542ae10..02e033d 100644 --- a/HydrantCAProxy/Client/HydrantIdClient.cs +++ b/HydrantCAProxy/Client/HydrantIdClient.cs @@ -32,9 +32,15 @@ namespace Keyfactor.HydrantId.Client { - public sealed class HydrantIdClient + public sealed class HydrantIdClient : IHydrantIdClient { private static readonly ILogger Log = LogHandler.GetClassLogger(); + private readonly HttpMessageHandler _handler; + + internal HydrantIdClient(IAnyCAPluginConfigProvider config, HttpMessageHandler handler) : this(config) + { + _handler = handler; + } public HydrantIdClient(IAnyCAPluginConfigProvider config) { @@ -702,9 +708,9 @@ private HttpClient ConfigureRestClient(string method, string url) var authorization = $"id=\"{ApiId}\", ts=\"{ts}\", nonce=\"{nOnce}\", mac=\"{mac}\""; - var clientHandler = new HttpClientHandler(); + var clientHandler = _handler ?? new HttpClientHandler(); - var returnClient = new HttpClient(clientHandler, disposeHandler: true) + var returnClient = new HttpClient(clientHandler, disposeHandler: _handler == null) { BaseAddress = bUrl }; @@ -722,16 +728,6 @@ private HttpClient ConfigureRestClient(string method, string url) } } - private static byte[] ConvertHexStringToBytes(string hex) - { - if (hex.Length % 2 != 0) - throw new ArgumentException("Invalid length for hex string."); - - var bytes = new byte[hex.Length / 2]; - for (int i = 0; i < bytes.Length; i++) - bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16); - return bytes; - } } } diff --git a/HydrantCAProxy/HydrantIdCAPlugin.cs b/HydrantCAProxy/HydrantIdCAPlugin.cs index d5da5eb..e846bcf 100644 --- a/HydrantCAProxy/HydrantIdCAPlugin.cs +++ b/HydrantCAProxy/HydrantIdCAPlugin.cs @@ -27,11 +27,14 @@ namespace Keyfactor.Extensions.CAPlugin.HydrantId public class HydrantIdCAPlugin : IAnyCAPlugin { private static readonly ILogger _logger = LogHandler.GetClassLogger(); - private RequestManager _requestManager; + private readonly RequestManager _requestManager = new RequestManager(); private IAnyCAPluginConfigProvider Config { get; set; } private ICertificateDataReader certDataReader; private HydrantIdCAPluginConfig.Config _config; + internal Func ClientFactory { get; set; } + = config => new HydrantIdClient(config); + public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDataReader certificateDataReader) { using var flow = new FlowLogger(_logger, "Initialize"); @@ -84,7 +87,7 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa HydrantIdCAPluginConfig.ConfigConstants.HydrantIdAuthKey }; - private static string MaskConfigForLog(string rawJson) + internal static string MaskConfigForLog(string rawJson) { if (string.IsNullOrEmpty(rawJson)) return rawJson; try @@ -111,18 +114,6 @@ private static string MaskConfigForLog(string rawJson) } } - private static List CheckRequiredValues(Dictionary connectionInfo, params string[] args) - { - List errors = new List(); - foreach (string s in args) - if (string.IsNullOrEmpty(connectionInfo[s] as string)) - errors.Add($"{s} is a required value"); - return errors; - } - - private static readonly Func pemify = ss => - ss.Length <= 64 ? ss : ss.Substring(0, 64) + "\n" + pemify(ss.Substring(64)); - public async Task Ping() { using var flow = new FlowLogger(_logger, "Ping"); @@ -147,7 +138,7 @@ public async Task Ping() } _logger.LogDebug("Pinging HydrantId to validate connection"); - var client = new HydrantIdClient(Config); + var client = ClientFactory(Config); var reachable = await client.Ping(); if (!reachable) @@ -232,7 +223,7 @@ public List GetProductIds() try { - var client = new HydrantIdClient(Config); + var client = ClientFactory(Config); List policies = null; flow.Step("FetchPolicies", () => @@ -273,10 +264,9 @@ public async Task Synchronize(BlockingCollection blockin using var flow = new FlowLogger(_logger, $"Synchronize(fullSync={fullSync})"); _logger.MethodEntry(); _logger.LogTrace("Synchronize: lastSync={LastSync}, fullSync={FullSync}", lastSync?.ToString() ?? "(null)", fullSync); - _requestManager = new RequestManager(); var certs = new BlockingCollection(100); - var client = new HydrantIdClient(Config); + var client = ClientFactory(Config); var processedCount = 0; var skippedCount = 0; @@ -393,7 +383,7 @@ public async Task Synchronize(BlockingCollection blockin } // Helper method to extract end entity certificate from PEM chain - private string GetEndEntityCertificate(string certData) + internal string GetEndEntityCertificate(string certData) { _logger.LogTrace("GetEndEntityCertificate: input length={Length}", certData?.Length ?? 0); @@ -464,7 +454,7 @@ private string GetEndEntityCertificate(string certData) } // Helper method to export X509Certificate2Collection to PEM format - private string ExportCollectionToPem(X509Certificate2Collection collection) + internal string ExportCollectionToPem(X509Certificate2Collection collection) { var sb = new StringBuilder(); foreach (var cert in collection) @@ -483,9 +473,8 @@ public async Task Enroll(string csr, string subject, Dictionar _logger.LogTrace("Enroll: csr length={CsrLen}, subject='{Subject}', enrollmentType={Type}, productID='{ProductId}'", csr?.Length ?? 0, subject ?? "(null)", enrollmentType, productInfo?.ProductID ?? "(null)"); - _requestManager = new RequestManager(); Certificate csrTrackingResponse = null; - var client = new HydrantIdClient(Config); + var client = ClientFactory(Config); try { @@ -774,8 +763,8 @@ await flow.StepAsync("WaitForCertificate", async () => /// Returns a non-null EXTERNALVALIDATION EnrollmentResult when one or more domains are still /// pending and the caller should return immediately instead of proceeding. /// - private async Task EnsureDomainsValidatedForPolicyAsync( - HydrantIdClient client, FlowLogger flow, Policy policyId, string csr, Dictionary san) + internal async Task EnsureDomainsValidatedForPolicyAsync( + IHydrantIdClient client, FlowLogger flow, Policy policyId, string csr, Dictionary san) { var validatorId = policyId.Details?.Validator; if (string.IsNullOrWhiteSpace(validatorId)) @@ -812,8 +801,8 @@ await flow.StepAsync("EnsureDomainsValidated", async () => /// store, so listing existing domains and filtering by name is the only way to recover a /// previously-started validation's id across Enroll() calls. /// - private async Task<(bool AllValidated, string PendingMessage)> EnsureDomainsValidatedAsync( - HydrantIdClient client, FlowLogger flow, List domainsToValidate, string validatorId) + internal async Task<(bool AllValidated, string PendingMessage)> EnsureDomainsValidatedAsync( + IHydrantIdClient client, FlowLogger flow, List domainsToValidate, string validatorId) { var existingDomains = await client.GetDomainListAsync(); @@ -874,8 +863,6 @@ public async Task Revoke(string caRequestID, string hexSerialNumber, uint r _logger.LogTrace("Revoke: caRequestID='{CaRequestId}', hexSerialNumber='{SerialNumber}', revocationReason={Reason}", caRequestID ?? "(null)", hexSerialNumber ?? "(null)", revocationReason); - _requestManager = new RequestManager(); - try { flow.Step("ValidateInput", () => @@ -886,7 +873,7 @@ public async Task Revoke(string caRequestID, string hexSerialNumber, uint r throw new ArgumentException($"caRequestID '{caRequestID}' is too short ({caRequestID.Length} chars) to extract a UUID.", nameof(caRequestID)); }); - var client = new HydrantIdClient(Config); + var client = ClientFactory(Config); var hydrantId = caRequestID.Substring(0, 36); _logger.LogTrace("Revoke: extracted UUID='{Uuid}'", hydrantId); @@ -942,13 +929,16 @@ await flow.StepAsync("SubmitRevoke", async () => } } - private async Task GetCertificateOnTimerAsync(string id) + internal int PollIntervalMs { get; set; } = 1000; + internal int PollTimeoutMs { get; set; } = 30000; + + internal async Task GetCertificateOnTimerAsync(string id) { _logger.LogTrace("GetCertificateOnTimerAsync: waiting for certificate with tracking ID='{Id}'", id ?? "(null)"); var stopwatch = Stopwatch.StartNew(); - var client = new HydrantIdClient(Config); + var client = ClientFactory(Config); - while (stopwatch.Elapsed < TimeSpan.FromSeconds(30)) + while (stopwatch.ElapsedMilliseconds < PollTimeoutMs) { try { @@ -965,10 +955,10 @@ private async Task GetCertificateOnTimerAsync(string id) stopwatch.ElapsedMilliseconds, e.Message); } - await Task.Delay(1000); + await Task.Delay(PollIntervalMs); } - _logger.LogWarning("GetCertificateOnTimerAsync: timed out after 30s for tracking ID='{Id}'", id ?? "(null)"); + _logger.LogWarning("GetCertificateOnTimerAsync: timed out after {TimeoutMs}ms for tracking ID='{Id}'", PollTimeoutMs, id ?? "(null)"); return null; } @@ -976,7 +966,6 @@ public async Task GetSingleRecord(string caRequestID) { using var flow = new FlowLogger(_logger, $"GetSingleRecord({caRequestID ?? "null"})"); _logger.MethodEntry(); - _requestManager = new RequestManager(); _logger.LogTrace("GetSingleRecord: caRequestID='{CaRequestId}'", caRequestID ?? "(null)"); try @@ -989,7 +978,7 @@ public async Task GetSingleRecord(string caRequestID) throw new ArgumentException($"caRequestID '{caRequestID}' is too short ({caRequestID.Length} chars) to extract a UUID.", nameof(caRequestID)); }); - var client = new HydrantIdClient(Config); + var client = ClientFactory(Config); var certId = caRequestID.Substring(0, 36); _logger.LogTrace("GetSingleRecord: extracted UUID='{CertId}'", certId); diff --git a/HydrantCAProxy/Interfaces/IHydrantIdClient.cs b/HydrantCAProxy/Interfaces/IHydrantIdClient.cs new file mode 100644 index 0000000..1a23838 --- /dev/null +++ b/HydrantCAProxy/Interfaces/IHydrantIdClient.cs @@ -0,0 +1,33 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain a +// copy of the License at http://www.apache.org/licenses/LICENSE-2.0. Unless +// required by applicable law or agreed to in writing, software distributed +// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +// OR CONDITIONS OF ANY KIND, either express or implied. See the License for +// thespecific language governing permissions and limitations under the +// License. +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Keyfactor.HydrantId.Client.Models; +using Keyfactor.HydrantId.Client.Models.Enums; + +namespace Keyfactor.HydrantId.Interfaces +{ + public interface IHydrantIdClient + { + Task GetSubmitEnrollmentAsync(CertRequestBody registerRequest); + Task GetSubmitRenewalAsync(string certificateId, RenewalRequest renewRequest); + Task> GetPolicyList(); + Task> GetDomainListAsync(); + Task GetSubmitCreateDomainValidationAsync(CreateDomainValidationPayload payload); + Task GetSubmitCheckDomainValidationAsync(string domainId); + Task GetSubmitGetCertificateAsync(string certificateId); + Task GetSubmitGetCertificateByCsrAsync(string requestTrackingId); + Task GetSubmitRevokeCertificateAsync(string hydrantId, RevocationReasons revokeReason); + Task GetSubmitCertificateListRequestAsync(BlockingCollection bc, CancellationToken ct); + Task Ping(); + } +} From 0bafe3984d5f14d1491beadd3b4b971272be4687 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 1 Sep 2026 13:32:33 -0400 Subject: [PATCH 10/29] functional test plan --- docsource/configuration.md | 85 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/docsource/configuration.md b/docsource/configuration.md index 0a31dd2..4706833 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -223,3 +223,88 @@ Populate using the configuration fields collected in the [requirements](#require - RenewalDays determines the behavior for certificate renewal: - Within window: Performs a renewal operation (maintains certificate lineage) - Outside window: Performs a re-issue operation (new certificate enrollment) + +## Functional Test Plan + +The test cases below are written as manual steps to run through the Keyfactor Command UI against a configured HydrantId CA, so a tester can execute them and record Pass/Fail results. They exercise the same code paths the plugin implements: connectivity/config validation, enrollment (with and without domain control validation), renewal vs. re-issue selection, revocation, and CA synchronization. + +> **Note on DCV and policy type**: whether an enrollment requires domain control validation (DCV) is driven entirely by whether the matched HydrantId policy has a `validator` configured (e.g. IdenTrust, DigiCert, PrivateCA) — not by the policy's CA type. EJBCA policies typically have no validator configured (since they aren't publicly-trusted CAs subject to CA/Browser Forum DCV requirements), which is why they issue directly with no DNS step. If a validator is ever configured on a non-public-CA policy, the plugin will still attempt DCV for it. + +### A. Connectivity / Configuration + +| # | Test Case | Steps in Command | Expected Result | +|---|---|---|---| +| A1 | Valid connection test | CAs > add/edit HydrantId CA > enter valid `HydrantIdBaseUrl`, `HydrantIdAuthId`, `HydrantIdAuthKey` > Save/Test Connection | Connection succeeds (calls `Ping` → `GET /policies`) | +| A2 | Invalid AuthKey | Same as A1 but with a wrong `HydrantIdAuthKey` | Test Connection fails with a clear auth error, not a silent success | +| A3 | Missing required field | Leave `HydrantIdBaseUrl` blank, attempt Save | Save is rejected with a validation message naming the missing field(s) | +| A4 | CA disabled | Set `Enabled = false` on the CA config, Save | Save succeeds; connectivity/config validation is skipped (no error), consistent with a deliberately paused CA | +| A5 | Product/policy list populates | Open the Certificate Template mapping / "Available Templates" picker for this CA | List of HydrantId policies (EJBCA + any IdenTrust/DigiCert/PrivateCA policies) appears by name | + +### B. Enrollment — policy has no validator configured (all current EJBCA policies) + +Confirm via the policy list that `details.validator` is unset for the policy under test — this is what actually determines the no-DCV path, not the EJBCA type itself. + +| # | Test Case | Steps | Expected Result | +|---|---|---|---| +| B1 | New enrollment, CSR | Enrollment > CSR Enrollment, select a Template mapped to a no-validator policy, submit a valid CSR | Certificate issues immediately (no DCV step); status shows Issued/Generated | +| B2 | New enrollment, PFX | Enrollment > PFX Enrollment, same policy | Certificate + private key returned; status Issued | +| B3 | Enrollment with SANs | Submit CSR with multiple DNS SANs against the same policy | All SANs present on issued cert | +| B4 | Enrollment against unmapped Product ID | Submit against a Template whose Product ID doesn't match any HydrantId policy name | Enrollment fails with "no policy found matching ProductID" — not a crash/500 | +| B5 | Enrollment with missing/invalid ValidityPeriod annotation | Submit against an unsaved/never-configured Template (annotation defaults only) | Falls back to annotation default (Years/1) rather than failing | + +### C. Enrollment — policy has a validator configured (IdenTrust / DigiCert / PrivateCA) + +| # | Test Case | Steps | Expected Result | +|---|---|---|---| +| C1 | First-time enrollment, domain never validated | CSR Enrollment against a policy with `validator` set, for a domain never validated before | Enrollment returns pending/"external validation" with DNS TXT record instructions in the status message — no cert issued yet | +| C2 | Publish TXT, resubmit | Publish the TXT record from C1 in real DNS, resubmit the same enrollment | Domain validates, certificate issues | +| C3 | Re-enroll same domain (already validated) | Submit a second CSR for the same already-validated domain | No new DCV required — issues directly (domain trust is reused while still valid) | +| C4 | Enroll for a domain, CN differs from all prior SANs | Use a brand-new subdomain not previously validated, same validator | Behaves like C1 (new pending validation), unless it's a subdomain of an already-validated parent domain — worth testing both ways | +| C5 | Never publish the TXT record | Same as C1 but don't publish the record, resubmit later | Stays pending; status message still shows the same/valid instructions, doesn't error | +| C6 | Domain validation expires mid-lifecycle (IdenTrust ~200 days) | Not practically testable end-to-end in a short QA pass — mark "not testable this cycle" unless a naturally-expired domain is available in the environment | Enrollment restarts DCV rather than getting stuck on a dead validation record | + +### D. Renewal + +| # | Test Case | Steps | Expected Result | +|---|---|---|---| +| D1 | Renew within RenewalDays window | Certificate Search > select a cert issued in B1/B3, close to expiry (or lower the Template's `RenewalDays` for testing) > Renew | Goes through the renewal path (reuses/updates same HydrantId cert record); new cert issued | +| D2 | Renew outside RenewalDays window | Renew a cert with plenty of validity left | Goes through the reissue path instead (new CSR against matched policy) | +| D3 | Renew with DCV policy, domain still valid | Renew a cert from the C-series tests | No DCV re-prompt, issues directly | +| D4 | Renew with `reuseCsr` scenario | If Command supports "renew without new CSR" for this Template | New cert issued reusing prior key/CSR per the policy's `renewCanReuseCSR` setting | + +### E. Reissue + +| # | Test Case | Steps | Expected Result | +|---|---|---|---| +| E1 | Reissue outside renewal window | Reissue a long-lived cert | New CSR submitted against the matched policy; new cert issued | +| E2 | Reissue where policy no longer exists/renamed | Reissue a cert whose original Template's policy was removed/renamed in HydrantId | Fails cleanly with "no policy found matching ProductID", not a crash | +| E3 | Reissue with pending DCV | Reissue for a domain whose validation just expired/was deleted in HydrantId | Returns external-validation-pending, same as C1 | + +### F. Revocation + +| # | Test Case | Steps | Expected Result | +|---|---|---|---| +| F1 | Revoke, unspecified reason | Certificate Search > select an issued cert > Revoke > reason "Unspecified" | Cert shows Revoked in both Command and the HydrantId portal | +| F2 | Revoke, key compromise | Revoke another cert with reason "Key Compromise" | Revoked; reason recorded correctly on the HydrantId side | +| F3 | Revoke already-revoked cert | Attempt to revoke F1's cert again | Fails/no-ops gracefully, no crash | +| F4 | Revoke cert not found in HydrantId | Revoke using a bad/stale CARequestID (if reproducible) | Clear error, not an unhandled exception | + +### G. Synchronization / Inventory + +| # | Test Case | Steps | Expected Result | +|---|---|---|---| +| G1 | Full sync | Orchestrators/Scheduled Jobs > run a full sync for this CA | All previously issued certs (B, C, D, E series) appear in Command's certificate inventory with correct status | +| G2 | Incremental sync | Issue/revoke one more cert, run an incremental sync | Only the delta is reflected; sync completes without re-processing everything | +| G3 | Sync reflects revocation | After F1's revoke, run sync | That cert's status updates to Revoked in Command if not already updated at revoke time | +| G4 | Sync with a large result set | If the HydrantId account has more than 100 certs | Paging completes without missing/duplicating certs | +| G5 | Sync cancellation | Start a sync and cancel it mid-run (if Command exposes this) | Job stops cleanly, no hung state | + +### H. Negative / edge cases + +| # | Test Case | Steps | Expected Result | +|---|---|---|---| +| H1 | Submit malformed CSR | CSR Enrollment with a truncated/corrupt CSR | Clear validation failure, not a 500 | +| H2 | Enroll while CA is Disabled | Set CA `Enabled=false`, attempt enrollment | Fails/blocked consistent with disabled state | +| H3 | Network/HydrantId outage simulated | Point `HydrantIdBaseUrl` at an unreachable host, attempt any operation | Fails with a clear connectivity error, not a hang | + +**Prerequisites for the C-series tests**: a domain you actually control DNS for, so you can publish the real TXT records HydrantId returns. From 41d0b09df7d9fa3396a01beb9bd3d2f9c95920af Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 1 Sep 2026 17:33:09 +0000 Subject: [PATCH 11/29] docs: auto-generate README and documentation [skip ci] --- README.md | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/README.md b/README.md index 705b353..258eef7 100644 --- a/README.md +++ b/README.md @@ -305,6 +305,91 @@ The plugin supports the following standard CRL revocation reasons: 5. Navigate to the AnyCA Gateway REST portal and verify that the Gateway recognizes the HID Global HydrantId plugin by hovering over the ⓘ symbol to the right of the Gateway on the top left of the portal. +## Functional Test Plan + +The test cases below are written as manual steps to run through the Keyfactor Command UI against a configured HydrantId CA, so a tester can execute them and record Pass/Fail results. They exercise the same code paths the plugin implements: connectivity/config validation, enrollment (with and without domain control validation), renewal vs. re-issue selection, revocation, and CA synchronization. + +> **Note on DCV and policy type**: whether an enrollment requires domain control validation (DCV) is driven entirely by whether the matched HydrantId policy has a `validator` configured (e.g. IdenTrust, DigiCert, PrivateCA) — not by the policy's CA type. EJBCA policies typically have no validator configured (since they aren't publicly-trusted CAs subject to CA/Browser Forum DCV requirements), which is why they issue directly with no DNS step. If a validator is ever configured on a non-public-CA policy, the plugin will still attempt DCV for it. + +### A. Connectivity / Configuration + +| # | Test Case | Steps in Command | Expected Result | +|---|---|---|---| +| A1 | Valid connection test | CAs > add/edit HydrantId CA > enter valid `HydrantIdBaseUrl`, `HydrantIdAuthId`, `HydrantIdAuthKey` > Save/Test Connection | Connection succeeds (calls `Ping` → `GET /policies`) | +| A2 | Invalid AuthKey | Same as A1 but with a wrong `HydrantIdAuthKey` | Test Connection fails with a clear auth error, not a silent success | +| A3 | Missing required field | Leave `HydrantIdBaseUrl` blank, attempt Save | Save is rejected with a validation message naming the missing field(s) | +| A4 | CA disabled | Set `Enabled = false` on the CA config, Save | Save succeeds; connectivity/config validation is skipped (no error), consistent with a deliberately paused CA | +| A5 | Product/policy list populates | Open the Certificate Template mapping / "Available Templates" picker for this CA | List of HydrantId policies (EJBCA + any IdenTrust/DigiCert/PrivateCA policies) appears by name | + +### B. Enrollment — policy has no validator configured (all current EJBCA policies) + +Confirm via the policy list that `details.validator` is unset for the policy under test — this is what actually determines the no-DCV path, not the EJBCA type itself. + +| # | Test Case | Steps | Expected Result | +|---|---|---|---| +| B1 | New enrollment, CSR | Enrollment > CSR Enrollment, select a Template mapped to a no-validator policy, submit a valid CSR | Certificate issues immediately (no DCV step); status shows Issued/Generated | +| B2 | New enrollment, PFX | Enrollment > PFX Enrollment, same policy | Certificate + private key returned; status Issued | +| B3 | Enrollment with SANs | Submit CSR with multiple DNS SANs against the same policy | All SANs present on issued cert | +| B4 | Enrollment against unmapped Product ID | Submit against a Template whose Product ID doesn't match any HydrantId policy name | Enrollment fails with "no policy found matching ProductID" — not a crash/500 | +| B5 | Enrollment with missing/invalid ValidityPeriod annotation | Submit against an unsaved/never-configured Template (annotation defaults only) | Falls back to annotation default (Years/1) rather than failing | + +### C. Enrollment — policy has a validator configured (IdenTrust / DigiCert / PrivateCA) + +| # | Test Case | Steps | Expected Result | +|---|---|---|---| +| C1 | First-time enrollment, domain never validated | CSR Enrollment against a policy with `validator` set, for a domain never validated before | Enrollment returns pending/"external validation" with DNS TXT record instructions in the status message — no cert issued yet | +| C2 | Publish TXT, resubmit | Publish the TXT record from C1 in real DNS, resubmit the same enrollment | Domain validates, certificate issues | +| C3 | Re-enroll same domain (already validated) | Submit a second CSR for the same already-validated domain | No new DCV required — issues directly (domain trust is reused while still valid) | +| C4 | Enroll for a domain, CN differs from all prior SANs | Use a brand-new subdomain not previously validated, same validator | Behaves like C1 (new pending validation), unless it's a subdomain of an already-validated parent domain — worth testing both ways | +| C5 | Never publish the TXT record | Same as C1 but don't publish the record, resubmit later | Stays pending; status message still shows the same/valid instructions, doesn't error | +| C6 | Domain validation expires mid-lifecycle (IdenTrust ~200 days) | Not practically testable end-to-end in a short QA pass — mark "not testable this cycle" unless a naturally-expired domain is available in the environment | Enrollment restarts DCV rather than getting stuck on a dead validation record | + +### D. Renewal + +| # | Test Case | Steps | Expected Result | +|---|---|---|---| +| D1 | Renew within RenewalDays window | Certificate Search > select a cert issued in B1/B3, close to expiry (or lower the Template's `RenewalDays` for testing) > Renew | Goes through the renewal path (reuses/updates same HydrantId cert record); new cert issued | +| D2 | Renew outside RenewalDays window | Renew a cert with plenty of validity left | Goes through the reissue path instead (new CSR against matched policy) | +| D3 | Renew with DCV policy, domain still valid | Renew a cert from the C-series tests | No DCV re-prompt, issues directly | +| D4 | Renew with `reuseCsr` scenario | If Command supports "renew without new CSR" for this Template | New cert issued reusing prior key/CSR per the policy's `renewCanReuseCSR` setting | + +### E. Reissue + +| # | Test Case | Steps | Expected Result | +|---|---|---|---| +| E1 | Reissue outside renewal window | Reissue a long-lived cert | New CSR submitted against the matched policy; new cert issued | +| E2 | Reissue where policy no longer exists/renamed | Reissue a cert whose original Template's policy was removed/renamed in HydrantId | Fails cleanly with "no policy found matching ProductID", not a crash | +| E3 | Reissue with pending DCV | Reissue for a domain whose validation just expired/was deleted in HydrantId | Returns external-validation-pending, same as C1 | + +### F. Revocation + +| # | Test Case | Steps | Expected Result | +|---|---|---|---| +| F1 | Revoke, unspecified reason | Certificate Search > select an issued cert > Revoke > reason "Unspecified" | Cert shows Revoked in both Command and the HydrantId portal | +| F2 | Revoke, key compromise | Revoke another cert with reason "Key Compromise" | Revoked; reason recorded correctly on the HydrantId side | +| F3 | Revoke already-revoked cert | Attempt to revoke F1's cert again | Fails/no-ops gracefully, no crash | +| F4 | Revoke cert not found in HydrantId | Revoke using a bad/stale CARequestID (if reproducible) | Clear error, not an unhandled exception | + +### G. Synchronization / Inventory + +| # | Test Case | Steps | Expected Result | +|---|---|---|---| +| G1 | Full sync | Orchestrators/Scheduled Jobs > run a full sync for this CA | All previously issued certs (B, C, D, E series) appear in Command's certificate inventory with correct status | +| G2 | Incremental sync | Issue/revoke one more cert, run an incremental sync | Only the delta is reflected; sync completes without re-processing everything | +| G3 | Sync reflects revocation | After F1's revoke, run sync | That cert's status updates to Revoked in Command if not already updated at revoke time | +| G4 | Sync with a large result set | If the HydrantId account has more than 100 certs | Paging completes without missing/duplicating certs | +| G5 | Sync cancellation | Start a sync and cancel it mid-run (if Command exposes this) | Job stops cleanly, no hung state | + +### H. Negative / edge cases + +| # | Test Case | Steps | Expected Result | +|---|---|---|---| +| H1 | Submit malformed CSR | CSR Enrollment with a truncated/corrupt CSR | Clear validation failure, not a 500 | +| H2 | Enroll while CA is Disabled | Set CA `Enabled=false`, attempt enrollment | Fails/blocked consistent with disabled state | +| H3 | Network/HydrantId outage simulated | Point `HydrantIdBaseUrl` at an unreachable host, attempt any operation | Fails with a clear connectivity error, not a hang | + +**Prerequisites for the C-series tests**: a domain you actually control DNS for, so you can publish the real TXT records HydrantId returns. + ## License Apache License 2.0, see [LICENSE](LICENSE). From f2641e651b111d0fc90c42dee2e8e0d5cc97de98 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 1 Sep 2026 14:23:31 -0400 Subject: [PATCH 12/29] fixed ping issue --- .../HydrantIdCAPluginTests.cs | 36 +++++++++++++++++-- HydrantCAProxy/HydrantIdCAPlugin.cs | 9 +++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs b/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs index 7e52289..23520ab 100644 --- a/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs +++ b/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs @@ -13,6 +13,7 @@ using Keyfactor.AnyGateway.Extensions; using Keyfactor.Extensions.CAPlugin.HydrantId; using Keyfactor.HydrantId; +using Keyfactor.HydrantId.Client; using Keyfactor.HydrantId.Client.Models; using Keyfactor.HydrantId.Client.Models.Enums; using Keyfactor.HydrantId.Interfaces; @@ -193,16 +194,47 @@ public async Task ValidateCAConnectionInfo_Disabled_SkipsValidationAndPing() } [Fact] - public async Task ValidateCAConnectionInfo_AllFieldsPresent_DelegatesToPing() + public async Task ValidateCAConnectionInfo_AllFieldsPresent_DelegatesToPingWithNonNullConfig() { var plugin = new HydrantIdCAPlugin(); var mockClient = new Mock(); mockClient.Setup(c => c.Ping()).ReturnsAsync(true); - plugin.ClientFactory = _ => mockClient.Object; + IAnyCAPluginConfigProvider capturedConfig = null; + plugin.ClientFactory = config => + { + capturedConfig = config; + return mockClient.Object; + }; await plugin.ValidateCAConnectionInfo(ValidConnectionData()); mockClient.Verify(c => c.Ping(), Times.Once); + // Regression: ValidateCAConnectionInfo runs before Initialize() is ever called by the + // Gateway, so Config must be populated from connectionInfo itself, not left null -- + // otherwise ClientFactory(Config) builds a HydrantIdClient with a null config provider. + Assert.NotNull(capturedConfig); + Assert.NotNull(capturedConfig.CAConnectionData); + } + + [Fact] + public async Task ValidateCAConnectionInfo_WithoutPriorInitialize_BuildsRealClientWithoutArgumentNullException() + { + // Reproduces the exact path the Gateway calls before Initialize() is ever invoked: + // ConfigurationController -> ValidateCAConnectionAsync -> ValidateCAConnectionInfo -> + // Ping -> ClientFactory(Config) -> new HydrantIdClient(Config). Constructs a real + // HydrantIdClient from whatever Config ends up being (this is where the original bug + // threw ArgumentNullException("config cannot be null")), then substitutes a mock for + // the actual Ping() call so no real network I/O happens. + var plugin = new HydrantIdCAPlugin(); + var mockClient = new Mock(); + mockClient.Setup(c => c.Ping()).ReturnsAsync(true); + plugin.ClientFactory = config => + { + _ = new HydrantIdClient(config); + return mockClient.Object; + }; + + await plugin.ValidateCAConnectionInfo(ValidConnectionData()); } // --------------------------------------------------------------------- diff --git a/HydrantCAProxy/HydrantIdCAPlugin.cs b/HydrantCAProxy/HydrantIdCAPlugin.cs index e846bcf..9cd77eb 100644 --- a/HydrantCAProxy/HydrantIdCAPlugin.cs +++ b/HydrantCAProxy/HydrantIdCAPlugin.cs @@ -35,6 +35,14 @@ public class HydrantIdCAPlugin : IAnyCAPlugin internal Func ClientFactory { get; set; } = config => new HydrantIdClient(config); + // Minimal IAnyCAPluginConfigProvider over a raw connectionInfo dictionary, used by + // ValidateCAConnectionInfo -- that entry point runs before the Gateway ever calls + // Initialize(), so Config would otherwise be null when Ping() builds a client. + private sealed class ConnectionInfoProvider : IAnyCAPluginConfigProvider + { + public Dictionary CAConnectionData { get; set; } + } + public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDataReader certificateDataReader) { using var flow = new FlowLogger(_logger, "Initialize"); @@ -171,6 +179,7 @@ public Task ValidateCAConnectionInfo(Dictionary connectionInfo) var rawData = JsonConvert.SerializeObject(connectionInfo); _logger.LogTrace("ValidateCAConnectionInfo: connectionInfo JSON (sensitive keys masked): {Json}", MaskConfigForLog(rawData)); + Config = new ConnectionInfoProvider { CAConnectionData = connectionInfo }; _config = JsonConvert.DeserializeObject(rawData); _logger.LogTrace("ValidateCAConnectionInfo: HydrantIdBaseUrl='{BaseUrl}', Enabled={Enabled}", From deea472a984da5f8555bb710adf0769e700d7875 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 1 Sep 2026 15:21:12 -0400 Subject: [PATCH 13/29] Added missing account id --- HydrantCAProxy.Tests/RequestManagerTests.cs | 8 ++++++++ HydrantCAProxy/HydrantIdCAPlugin.cs | 2 +- HydrantCAProxy/HydrantIdCAPluginConfig.cs | 9 +++++++++ HydrantCAProxy/RequestManager.cs | 13 ++++++++----- docsource/configuration.md | 2 ++ 5 files changed, 28 insertions(+), 6 deletions(-) diff --git a/HydrantCAProxy.Tests/RequestManagerTests.cs b/HydrantCAProxy.Tests/RequestManagerTests.cs index ad12513..efb1364 100644 --- a/HydrantCAProxy.Tests/RequestManagerTests.cs +++ b/HydrantCAProxy.Tests/RequestManagerTests.cs @@ -356,6 +356,14 @@ public void GetCreateDomainValidationRequest_Valid_SetsDnsMethodAndOmitsAccountI Assert.Null(result.AccountId); } + [Fact] + public void GetCreateDomainValidationRequest_AccountIdSupplied_SetsAccountId() + { + var result = _sut.GetCreateDomainValidationRequest("example.com", "validator-1", "account-123"); + + Assert.Equal("account-123", result.AccountId); + } + [Fact] public void GetCreateDomainValidationRequest_NullDomain_ThrowsArgumentNullException() { diff --git a/HydrantCAProxy/HydrantIdCAPlugin.cs b/HydrantCAProxy/HydrantIdCAPlugin.cs index 9cd77eb..62565f1 100644 --- a/HydrantCAProxy/HydrantIdCAPlugin.cs +++ b/HydrantCAProxy/HydrantIdCAPlugin.cs @@ -830,7 +830,7 @@ await flow.StepAsync("EnsureDomainsValidated", async () => // domain name (does not create a duplicate record) against staging. flow.Step("DomainValidation.CreateOrRegenerate", $"domain='{domainName}', priorStatus={(match == null ? "(none)" : match.Status.ToString())}"); - var payload = _requestManager.GetCreateDomainValidationRequest(domainName, validatorId); + var payload = _requestManager.GetCreateDomainValidationRequest(domainName, validatorId, _config?.HydrantIdAccountId); domain = await client.GetSubmitCreateDomainValidationAsync(payload); } else if (match.Status != DomainStatusEnum.Validated) diff --git a/HydrantCAProxy/HydrantIdCAPluginConfig.cs b/HydrantCAProxy/HydrantIdCAPluginConfig.cs index adcebcc..e889335 100644 --- a/HydrantCAProxy/HydrantIdCAPluginConfig.cs +++ b/HydrantCAProxy/HydrantIdCAPluginConfig.cs @@ -27,6 +27,7 @@ public class ConfigConstants public static string HydrantIdBaseUrl = "HydrantIdBaseUrl"; public static string HydrantIdAuthId = "HydrantIdAuthId"; public static string HydrantIdAuthKey = "HydrantIdAuthKey"; + public static string HydrantIdAccountId = "HydrantIdAccountId"; public static string DefaultPageSize = "DefaultPageSize"; public static string Enabled = "Enabled"; } @@ -36,6 +37,7 @@ public class Config public string HydrantIdBaseUrl { get; set; } public string HydrantIdAuthId { get; set; } public string HydrantIdAuthKey { get; set; } + public string HydrantIdAccountId { get; set; } public bool Enabled { get; set; } } @@ -71,6 +73,13 @@ public static Dictionary GetPluginAnnotations() DefaultValue = "", Type = "Secret" }, + [ConfigConstants.HydrantIdAccountId] = new PropertyConfigInfo() + { + Comments = "Optional. Some HydrantId tenants require the account id to be included when creating a domain validation request (POST /domains/); leave blank if domain validation already works without it. Obtain from the HydrantId portal's account settings, HydrantId support, or the 'account.id' field on any existing certificate returned by the API.", + Hidden = false, + DefaultValue = "", + Type = "String" + }, [ConfigConstants.Enabled] = new PropertyConfigInfo() { Comments = "Flag to Enable or Disable the CA connector.", diff --git a/HydrantCAProxy/RequestManager.cs b/HydrantCAProxy/RequestManager.cs index de7ff55..a518580 100644 --- a/HydrantCAProxy/RequestManager.cs +++ b/HydrantCAProxy/RequestManager.cs @@ -368,13 +368,13 @@ public List GetDomainsToValidate(string csr, Dictionary Date: Tue, 1 Sep 2026 19:21:43 +0000 Subject: [PATCH 14/29] docs: auto-generate README and documentation [skip ci] --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 258eef7..2058cd2 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,7 @@ The plugin supports the following standard CRL revocation reasons: | **HydrantIdBaseUrl** | Full URL to the HydrantId API endpoint | Yes | `https://acm.hydrantid.com` or `https://acm-stage.hydrantid.com` | | **HydrantIdAuthId** | API Authentication ID provided by HydrantId | Yes | `your-auth-id` | | **HydrantIdAuthKey** | API Authentication Key provided by HydrantId | Yes | `your-secret-auth-key` | + | **HydrantIdAccountId** | Account id required by some HydrantId tenants when creating a domain validation request (`POST /domains/`) as part of enrollment against a policy with a validator configured. Leave blank if domain validation already succeeds without it — if left blank and it turns out to be required, domain validation creation fails with `{"message":"Error: unauthorized","status":"Failure"}`. Obtain from the HydrantId portal's account settings, HydrantId support, or the `account.id` field on any existing certificate returned by the API. | No | `aba34551-51e9-4cb3-a5b8-895d64d45344` | ### Gateway Registration Notes @@ -233,6 +234,7 @@ The plugin supports the following standard CRL revocation reasons: * **HydrantIdBaseUrl** - The base URL for the HydrantId API endpoint. For example, `https://acm.hydrantid.com` or `https://acm-stage.hydrantid.com`. * **HydrantIdAuthId** - The API Authentication ID provided by HydrantId for API access. * **HydrantIdAuthKey** - The API Authentication Key (secret) provided by HydrantId for API access. + * **HydrantIdAccountId** - Optional. Required by some HydrantId tenants for domain validation to succeed; see the table above. 2. **Certificate Template Configuration** From aedb7bf451c8c0fe84e01088172afe5ccb6b3433 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 1 Sep 2026 15:51:04 -0400 Subject: [PATCH 15/29] org id --- .../HydrantIdCAPluginTests.cs | 80 +++++++++++++++++++ HydrantCAProxy.Tests/RequestManagerTests.cs | 18 +++++ .../Models/DomainValidationOrgPayload.cs | 39 +++++++++ HydrantCAProxy/HydrantIdCAPlugin.cs | 34 +++++++- HydrantCAProxy/HydrantIdCAPluginConfig.cs | 54 +++++++++++++ .../Interfaces/IDomainValidationOrgPayload.cs | 21 +++++ HydrantCAProxy/RequestManager.cs | 12 ++- docsource/configuration.md | 7 ++ 8 files changed, 260 insertions(+), 5 deletions(-) create mode 100644 HydrantCAProxy/Client/Models/DomainValidationOrgPayload.cs create mode 100644 HydrantCAProxy/Interfaces/IDomainValidationOrgPayload.cs diff --git a/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs b/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs index 23520ab..5b119ca 100644 --- a/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs +++ b/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs @@ -519,6 +519,86 @@ public async Task EnsureDomainsValidatedAsync_MixedPendingAndValidated_Aggregate Assert.DoesNotContain("already.example.com", result.PendingMessage); } + // --------------------------------------------------------------------- + // BuildOrgPayload + // --------------------------------------------------------------------- + + [Fact] + public void BuildOrgPayload_NoOrgFieldsConfigured_ReturnsNull() + { + var plugin = MakePlugin(); + + Assert.Null(plugin.BuildOrgPayload()); + } + + [Fact] + public void BuildOrgPayload_ConfigNeverInitialized_ReturnsNull() + { + var plugin = new HydrantIdCAPlugin(); + + Assert.Null(plugin.BuildOrgPayload()); + } + + [Fact] + public void BuildOrgPayload_OneFieldConfigured_ReturnsPopulatedPayload() + { + var data = ValidConnectionData(); + data[HydrantIdCAPluginConfig.ConfigConstants.HydrantIdOrgName] = "Acme Corp"; + var plugin = new HydrantIdCAPlugin(); + plugin.Initialize(new FakeConfigProvider { CAConnectionData = data }, Mock.Of()); + + var payload = plugin.BuildOrgPayload(); + + Assert.NotNull(payload); + Assert.Equal("Acme Corp", payload.OrgName); + Assert.Null(payload.EmailAddress); + } + + [Fact] + public void BuildOrgPayload_AllFieldsConfigured_ReturnsFullyPopulatedPayload() + { + var data = ValidConnectionData(); + data[HydrantIdCAPluginConfig.ConfigConstants.HydrantIdOrgName] = "Acme Corp"; + data[HydrantIdCAPluginConfig.ConfigConstants.HydrantIdOrgPrimaryContactFullName] = "Jane Doe"; + data[HydrantIdCAPluginConfig.ConfigConstants.HydrantIdOrgStreetAddress] = "123 Main St"; + data[HydrantIdCAPluginConfig.ConfigConstants.HydrantIdOrgCityProvPostalCodeCountry] = "Anytown, OH 44131, US"; + data[HydrantIdCAPluginConfig.ConfigConstants.HydrantIdEmailAddress] = "jane@acme.com"; + data[HydrantIdCAPluginConfig.ConfigConstants.HydrantIdPhoneNumber] = "+1-555-555-0100"; + var plugin = new HydrantIdCAPlugin(); + plugin.Initialize(new FakeConfigProvider { CAConnectionData = data }, Mock.Of()); + + var payload = plugin.BuildOrgPayload(); + + Assert.Equal("Acme Corp", payload.OrgName); + Assert.Equal("Jane Doe", payload.OrgPrimaryContactFullName); + Assert.Equal("123 Main St", payload.OrgStreetAddress); + Assert.Equal("Anytown, OH 44131, US", payload.OrgCityProvPostalCodeCountry); + Assert.Equal("jane@acme.com", payload.EmailAddress); + Assert.Equal("+1-555-555-0100", payload.PhoneNumber); + } + + [Fact] + public async Task EnsureDomainsValidatedAsync_OrgPayloadConfigured_IsIncludedInCreateRequest() + { + var data = ValidConnectionData(); + data[HydrantIdCAPluginConfig.ConfigConstants.HydrantIdOrgName] = "Acme Corp"; + var plugin = new HydrantIdCAPlugin(); + plugin.Initialize(new FakeConfigProvider { CAConnectionData = data }, Mock.Of()); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List()); + CreateDomainValidationPayload capturedPayload = null; + mockClient.Setup(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny())) + .Callback(p => capturedPayload = p) + .ReturnsAsync(new Domain { Status = DomainStatusEnum.Validated }); + plugin.ClientFactory = _ => mockClient.Object; + + await plugin.EnsureDomainsValidatedAsync(mockClient.Object, NewFlow(), new List { "new.example.com" }, "IdenTrust"); + + Assert.NotNull(capturedPayload.Payload); + Assert.IsType(capturedPayload.Payload); + Assert.Equal("Acme Corp", ((DomainValidationOrgPayload)capturedPayload.Payload).OrgName); + } + // --------------------------------------------------------------------- // Synchronize // --------------------------------------------------------------------- diff --git a/HydrantCAProxy.Tests/RequestManagerTests.cs b/HydrantCAProxy.Tests/RequestManagerTests.cs index efb1364..1f2a358 100644 --- a/HydrantCAProxy.Tests/RequestManagerTests.cs +++ b/HydrantCAProxy.Tests/RequestManagerTests.cs @@ -364,6 +364,24 @@ public void GetCreateDomainValidationRequest_AccountIdSupplied_SetsAccountId() Assert.Equal("account-123", result.AccountId); } + [Fact] + public void GetCreateDomainValidationRequest_OrgPayloadSupplied_SetsPayload() + { + var orgPayload = new DomainValidationOrgPayload { OrgName = "Acme Corp", EmailAddress = "admin@acme.com" }; + + var result = _sut.GetCreateDomainValidationRequest("example.com", "validator-1", orgPayload: orgPayload); + + Assert.Same(orgPayload, result.Payload); + } + + [Fact] + public void GetCreateDomainValidationRequest_NoOrgPayload_PayloadRemainsNull() + { + var result = _sut.GetCreateDomainValidationRequest("example.com", "validator-1"); + + Assert.Null(result.Payload); + } + [Fact] public void GetCreateDomainValidationRequest_NullDomain_ThrowsArgumentNullException() { diff --git a/HydrantCAProxy/Client/Models/DomainValidationOrgPayload.cs b/HydrantCAProxy/Client/Models/DomainValidationOrgPayload.cs new file mode 100644 index 0000000..8b34ca1 --- /dev/null +++ b/HydrantCAProxy/Client/Models/DomainValidationOrgPayload.cs @@ -0,0 +1,39 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain a +// copy of the License at http://www.apache.org/licenses/LICENSE-2.0. Unless +// required by applicable law or agreed to in writing, software distributed +// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +// OR CONDITIONS OF ANY KIND, either express or implied. See the License for +// thespecific language governing permissions and limitations under the +// License. +using Keyfactor.HydrantId.Interfaces; +using Newtonsoft.Json; + +namespace Keyfactor.HydrantId.Client.Models +{ + // Matches the "requiredPayload" fields some HydrantId domain validators (e.g. IdenTrust) + // report via GET /api/v2/domains/validators, and require on POST /api/v2/domains/ -- + // without this, IdenTrust rejects the request with "The domain request is missing the + // organization name". + public class DomainValidationOrgPayload : IDomainValidationOrgPayload + { + [JsonProperty("orgName", NullValueHandling = NullValueHandling.Ignore)] + public string OrgName { get; set; } + + [JsonProperty("orgPrimaryContactFullName", NullValueHandling = NullValueHandling.Ignore)] + public string OrgPrimaryContactFullName { get; set; } + + [JsonProperty("orgStreetAddress", NullValueHandling = NullValueHandling.Ignore)] + public string OrgStreetAddress { get; set; } + + [JsonProperty("orgCityProvPostalCodeCountry", NullValueHandling = NullValueHandling.Ignore)] + public string OrgCityProvPostalCodeCountry { get; set; } + + [JsonProperty("emailAddress", NullValueHandling = NullValueHandling.Ignore)] + public string EmailAddress { get; set; } + + [JsonProperty("phoneNumber", NullValueHandling = NullValueHandling.Ignore)] + public string PhoneNumber { get; set; } + } +} diff --git a/HydrantCAProxy/HydrantIdCAPlugin.cs b/HydrantCAProxy/HydrantIdCAPlugin.cs index 62565f1..634b7b9 100644 --- a/HydrantCAProxy/HydrantIdCAPlugin.cs +++ b/HydrantCAProxy/HydrantIdCAPlugin.cs @@ -803,6 +803,38 @@ await flow.StepAsync("EnsureDomainsValidated", async () => }; } + /// + /// Builds the org/contact "payload" some HydrantID validators (e.g. IdenTrust) require on + /// domain validation creation, from the optional Hydrant Id* config fields. Returns null + /// (and therefore omits "payload" from the request entirely) when none are configured, so + /// validators that don't need it (e.g. DigiCert, PrivateCA) are unaffected. + /// + internal DomainValidationOrgPayload BuildOrgPayload() + { + if (_config == null) + return null; + + if (string.IsNullOrEmpty(_config.HydrantIdOrgName) && + string.IsNullOrEmpty(_config.HydrantIdOrgPrimaryContactFullName) && + string.IsNullOrEmpty(_config.HydrantIdOrgStreetAddress) && + string.IsNullOrEmpty(_config.HydrantIdOrgCityProvPostalCodeCountry) && + string.IsNullOrEmpty(_config.HydrantIdEmailAddress) && + string.IsNullOrEmpty(_config.HydrantIdPhoneNumber)) + { + return null; + } + + return new DomainValidationOrgPayload + { + OrgName = _config.HydrantIdOrgName, + OrgPrimaryContactFullName = _config.HydrantIdOrgPrimaryContactFullName, + OrgStreetAddress = _config.HydrantIdOrgStreetAddress, + OrgCityProvPostalCodeCountry = _config.HydrantIdOrgCityProvPostalCodeCountry, + EmailAddress = _config.HydrantIdEmailAddress, + PhoneNumber = _config.HydrantIdPhoneNumber + }; + } + /// /// Checks each domain against HydrantID's Domains resource, starting DNS validation for any /// domain that has not been requested yet and re-checking any domain that is still pending. @@ -830,7 +862,7 @@ await flow.StepAsync("EnsureDomainsValidated", async () => // domain name (does not create a duplicate record) against staging. flow.Step("DomainValidation.CreateOrRegenerate", $"domain='{domainName}', priorStatus={(match == null ? "(none)" : match.Status.ToString())}"); - var payload = _requestManager.GetCreateDomainValidationRequest(domainName, validatorId, _config?.HydrantIdAccountId); + var payload = _requestManager.GetCreateDomainValidationRequest(domainName, validatorId, _config?.HydrantIdAccountId, BuildOrgPayload()); domain = await client.GetSubmitCreateDomainValidationAsync(payload); } else if (match.Status != DomainStatusEnum.Validated) diff --git a/HydrantCAProxy/HydrantIdCAPluginConfig.cs b/HydrantCAProxy/HydrantIdCAPluginConfig.cs index e889335..f641d5d 100644 --- a/HydrantCAProxy/HydrantIdCAPluginConfig.cs +++ b/HydrantCAProxy/HydrantIdCAPluginConfig.cs @@ -28,6 +28,12 @@ public class ConfigConstants public static string HydrantIdAuthId = "HydrantIdAuthId"; public static string HydrantIdAuthKey = "HydrantIdAuthKey"; public static string HydrantIdAccountId = "HydrantIdAccountId"; + public static string HydrantIdOrgName = "HydrantIdOrgName"; + public static string HydrantIdOrgPrimaryContactFullName = "HydrantIdOrgPrimaryContactFullName"; + public static string HydrantIdOrgStreetAddress = "HydrantIdOrgStreetAddress"; + public static string HydrantIdOrgCityProvPostalCodeCountry = "HydrantIdOrgCityProvPostalCodeCountry"; + public static string HydrantIdEmailAddress = "HydrantIdEmailAddress"; + public static string HydrantIdPhoneNumber = "HydrantIdPhoneNumber"; public static string DefaultPageSize = "DefaultPageSize"; public static string Enabled = "Enabled"; } @@ -38,6 +44,12 @@ public class Config public string HydrantIdAuthId { get; set; } public string HydrantIdAuthKey { get; set; } public string HydrantIdAccountId { get; set; } + public string HydrantIdOrgName { get; set; } + public string HydrantIdOrgPrimaryContactFullName { get; set; } + public string HydrantIdOrgStreetAddress { get; set; } + public string HydrantIdOrgCityProvPostalCodeCountry { get; set; } + public string HydrantIdEmailAddress { get; set; } + public string HydrantIdPhoneNumber { get; set; } public bool Enabled { get; set; } } @@ -80,6 +92,48 @@ public static Dictionary GetPluginAnnotations() DefaultValue = "", Type = "String" }, + [ConfigConstants.HydrantIdOrgName] = new PropertyConfigInfo() + { + Comments = "Optional. Organization name required by some HydrantId validators (e.g. IdenTrust) on domain validation requests. Leave blank if not required by your validator -- omitted from the request entirely when blank.", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + [ConfigConstants.HydrantIdOrgPrimaryContactFullName] = new PropertyConfigInfo() + { + Comments = "Optional. Organization primary contact full name required by some HydrantId validators (e.g. IdenTrust) on domain validation requests.", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + [ConfigConstants.HydrantIdOrgStreetAddress] = new PropertyConfigInfo() + { + Comments = "Optional. Organization street address required by some HydrantId validators (e.g. IdenTrust) on domain validation requests.", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + [ConfigConstants.HydrantIdOrgCityProvPostalCodeCountry] = new PropertyConfigInfo() + { + Comments = "Optional. Organization city/province/postal code/country required by some HydrantId validators (e.g. IdenTrust) on domain validation requests.", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + [ConfigConstants.HydrantIdEmailAddress] = new PropertyConfigInfo() + { + Comments = "Optional. Organization contact email address required by some HydrantId validators (e.g. IdenTrust) on domain validation requests.", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + [ConfigConstants.HydrantIdPhoneNumber] = new PropertyConfigInfo() + { + Comments = "Optional. Organization contact phone number required by some HydrantId validators (e.g. IdenTrust) on domain validation requests.", + Hidden = false, + DefaultValue = "", + Type = "String" + }, [ConfigConstants.Enabled] = new PropertyConfigInfo() { Comments = "Flag to Enable or Disable the CA connector.", diff --git a/HydrantCAProxy/Interfaces/IDomainValidationOrgPayload.cs b/HydrantCAProxy/Interfaces/IDomainValidationOrgPayload.cs new file mode 100644 index 0000000..220d978 --- /dev/null +++ b/HydrantCAProxy/Interfaces/IDomainValidationOrgPayload.cs @@ -0,0 +1,21 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain a +// copy of the License at http://www.apache.org/licenses/LICENSE-2.0. Unless +// required by applicable law or agreed to in writing, software distributed +// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +// OR CONDITIONS OF ANY KIND, either express or implied. See the License for +// thespecific language governing permissions and limitations under the +// License. +namespace Keyfactor.HydrantId.Interfaces +{ + public interface IDomainValidationOrgPayload + { + string OrgName { get; set; } + string OrgPrimaryContactFullName { get; set; } + string OrgStreetAddress { get; set; } + string OrgCityProvPostalCodeCountry { get; set; } + string EmailAddress { get; set; } + string PhoneNumber { get; set; } + } +} diff --git a/HydrantCAProxy/RequestManager.cs b/HydrantCAProxy/RequestManager.cs index a518580..a6c2db4 100644 --- a/HydrantCAProxy/RequestManager.cs +++ b/HydrantCAProxy/RequestManager.cs @@ -368,13 +368,14 @@ public List GetDomainsToValidate(string csr, Dictionary Date: Tue, 1 Sep 2026 19:51:39 +0000 Subject: [PATCH 16/29] docs: auto-generate README and documentation [skip ci] --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 2058cd2..727653a 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,12 @@ The plugin supports the following standard CRL revocation reasons: | **HydrantIdAuthId** | API Authentication ID provided by HydrantId | Yes | `your-auth-id` | | **HydrantIdAuthKey** | API Authentication Key provided by HydrantId | Yes | `your-secret-auth-key` | | **HydrantIdAccountId** | Account id required by some HydrantId tenants when creating a domain validation request (`POST /domains/`) as part of enrollment against a policy with a validator configured. Leave blank if domain validation already succeeds without it — if left blank and it turns out to be required, domain validation creation fails with `{"message":"Error: unauthorized","status":"Failure"}`. Obtain from the HydrantId portal's account settings, HydrantId support, or the `account.id` field on any existing certificate returned by the API. | No | `aba34551-51e9-4cb3-a5b8-895d64d45344` | + | **HydrantIdOrgName** | Organization name required by some HydrantId validators (e.g. IdenTrust) on domain validation requests. Leave blank if your validator doesn't need it — check `GET /api/v2/domains/validators`'s `requiredPayload` for the validator in use; a non-empty `requiredPayload` means these fields are needed. If required and left blank, domain validation creation fails with `{"message":"The domain request is missing the organization name","status":"Failure"}`. | No | `Acme Corp` | + | **HydrantIdOrgPrimaryContactFullName** | Organization primary contact full name, paired with HydrantIdOrgName. | No | `Jane Doe` | + | **HydrantIdOrgStreetAddress** | Organization street address, paired with HydrantIdOrgName. | No | `123 Main St` | + | **HydrantIdOrgCityProvPostalCodeCountry** | Organization city/province/postal code/country, paired with HydrantIdOrgName. | No | `Anytown, OH 44131, US` | + | **HydrantIdEmailAddress** | Organization contact email address, paired with HydrantIdOrgName. | No | `jane@acme.com` | + | **HydrantIdPhoneNumber** | Organization contact phone number, paired with HydrantIdOrgName. | No | `+1-555-555-0100` | ### Gateway Registration Notes @@ -235,6 +241,7 @@ The plugin supports the following standard CRL revocation reasons: * **HydrantIdAuthId** - The API Authentication ID provided by HydrantId for API access. * **HydrantIdAuthKey** - The API Authentication Key (secret) provided by HydrantId for API access. * **HydrantIdAccountId** - Optional. Required by some HydrantId tenants for domain validation to succeed; see the table above. + * **HydrantIdOrgName**, **HydrantIdOrgPrimaryContactFullName**, **HydrantIdOrgStreetAddress**, **HydrantIdOrgCityProvPostalCodeCountry**, **HydrantIdEmailAddress**, **HydrantIdPhoneNumber** - Optional. Required by some domain validators (e.g. IdenTrust); see the table above. 2. **Certificate Template Configuration** From bbdaf8c2cb693d064c63edea2fbac4b2cc299b3f Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 1 Sep 2026 16:11:26 -0400 Subject: [PATCH 17/29] fixed domain validation --- .../HydrantIdCAPluginTests.cs | 60 +++++++++++++++++++ HydrantCAProxy/HydrantIdCAPlugin.cs | 32 ++++++++++ docsource/configuration.md | 3 +- 3 files changed, 94 insertions(+), 1 deletion(-) diff --git a/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs b/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs index 5b119ca..a925c04 100644 --- a/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs +++ b/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs @@ -497,6 +497,66 @@ public async Task EnsureDomainsValidatedAsync_PendingDomain_CallsCheckNotCreate( mockClient.Verify(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny()), Times.Never); } + [Fact] + public async Task EnsureDomainsValidatedAsync_SubdomainOfValidatedParent_SkipsWithoutCreatingRecord() + { + var plugin = new HydrantIdCAPlugin(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List + { + new Domain { Id = "d1", DomainName = "keyfactorhydrantid.com", Status = DomainStatusEnum.Validated } + }); + + var result = await plugin.EnsureDomainsValidatedAsync(mockClient.Object, NewFlow(), + new List { "www.keyfactorhydrantid.com" }, "IdenTrust"); + + Assert.True(result.AllValidated); + mockClient.Verify(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny()), Times.Never); + mockClient.Verify(c => c.GetSubmitCheckDomainValidationAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task EnsureDomainsValidatedAsync_SubdomainOfPendingParent_StillCreatesOwnRecord() + { + var plugin = new HydrantIdCAPlugin(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List + { + new Domain { Id = "d1", DomainName = "keyfactorhydrantid.com", Status = DomainStatusEnum.Pending } + }); + mockClient.Setup(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny())) + .ReturnsAsync(new Domain { Status = DomainStatusEnum.Validated }); + + var result = await plugin.EnsureDomainsValidatedAsync(mockClient.Object, NewFlow(), + new List { "www.keyfactorhydrantid.com" }, "IdenTrust"); + + Assert.True(result.AllValidated); + mockClient.Verify(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny()), Times.Once); + } + + [Theory] + [InlineData("www.example.com", "example.com", true)] + [InlineData("a.b.example.com", "example.com", true)] + [InlineData("example.com", "example.com", true)] + [InlineData("notexample.com", "example.com", false)] + [InlineData("example.com.evil.com", "example.com", false)] + public void IsCoveredByValidatedAncestor_MatchesExpectedScope(string domainName, string validatedDomain, bool expected) + { + var existingDomains = new List { new Domain { DomainName = validatedDomain, Status = DomainStatusEnum.Validated } }; + + var result = HydrantIdCAPlugin.IsCoveredByValidatedAncestor(domainName, existingDomains, out _); + + Assert.Equal(expected, result); + } + + [Fact] + public void IsCoveredByValidatedAncestor_ParentNotValidated_ReturnsFalse() + { + var existingDomains = new List { new Domain { DomainName = "example.com", Status = DomainStatusEnum.Pending } }; + + Assert.False(HydrantIdCAPlugin.IsCoveredByValidatedAncestor("www.example.com", existingDomains, out _)); + } + [Fact] public async Task EnsureDomainsValidatedAsync_MixedPendingAndValidated_AggregatesPendingMessage() { diff --git a/HydrantCAProxy/HydrantIdCAPlugin.cs b/HydrantCAProxy/HydrantIdCAPlugin.cs index 634b7b9..b1806a1 100644 --- a/HydrantCAProxy/HydrantIdCAPlugin.cs +++ b/HydrantCAProxy/HydrantIdCAPlugin.cs @@ -854,6 +854,12 @@ internal DomainValidationOrgPayload BuildOrgPayload() var match = existingDomains.FirstOrDefault(d => string.Equals(d.DomainName, domainName, StringComparison.OrdinalIgnoreCase)); + if (match == null && IsCoveredByValidatedAncestor(domainName, existingDomains, out var coveringDomain)) + { + flow.Step("DomainValidation.CoveredByValidatedParent", $"domain='{domainName}', parent='{coveringDomain}'"); + continue; + } + Domain domain; if (match == null || match.Status == DomainStatusEnum.Expired) { @@ -897,6 +903,32 @@ internal DomainValidationOrgPayload BuildOrgPayload() return (false, message); } + /// + /// True when is itself, or a subdomain of, some other domain + /// in that is already Validated -- per HydrantID's own + /// domain-validation documentation, DCV is scoped to the base domain and subdomains at any + /// depth are covered without a separate validation record. + /// + internal static bool IsCoveredByValidatedAncestor(string domainName, List existingDomains, out string coveringDomain) + { + coveringDomain = null; + + foreach (var candidate in existingDomains) + { + if (candidate.Status != DomainStatusEnum.Validated || string.IsNullOrEmpty(candidate.DomainName)) + continue; + + if (string.Equals(domainName, candidate.DomainName, StringComparison.OrdinalIgnoreCase) || + domainName.EndsWith("." + candidate.DomainName, StringComparison.OrdinalIgnoreCase)) + { + coveringDomain = candidate.DomainName; + return true; + } + } + + return false; + } + public async Task Revoke(string caRequestID, string hexSerialNumber, uint revocationReason) { using var flow = new FlowLogger(_logger, $"Revoke({caRequestID ?? "null"})"); diff --git a/docsource/configuration.md b/docsource/configuration.md index 506d5ea..02a935e 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -268,7 +268,8 @@ Confirm via the policy list that `details.validator` is unset for the policy und | C1 | First-time enrollment, domain never validated | CSR Enrollment against a policy with `validator` set, for a domain never validated before | Enrollment returns pending/"external validation" with DNS TXT record instructions in the status message — no cert issued yet | | C2 | Publish TXT, resubmit | Publish the TXT record from C1 in real DNS, resubmit the same enrollment | Domain validates, certificate issues | | C3 | Re-enroll same domain (already validated) | Submit a second CSR for the same already-validated domain | No new DCV required — issues directly (domain trust is reused while still valid) | -| C4 | Enroll for a domain, CN differs from all prior SANs | Use a brand-new subdomain not previously validated, same validator | Behaves like C1 (new pending validation), unless it's a subdomain of an already-validated parent domain — worth testing both ways | +| C4 | Enroll for a subdomain of an already-validated domain | Use a subdomain (e.g. `www.example.com`) of a domain already `Validated` in the Domains list, same validator | Issues directly with no new domain validation record created — the plugin treats it as covered by the validated parent | +| C4b | Enroll for a subdomain of a still-pending (not yet validated) parent | Same as C4, but the parent domain's own validation is still `Pending` | Creates its own separate validation record for the subdomain (parent coverage only applies once the parent is actually `Validated`) | | C5 | Never publish the TXT record | Same as C1 but don't publish the record, resubmit later | Stays pending; status message still shows the same/valid instructions, doesn't error | | C6 | Domain validation expires mid-lifecycle (IdenTrust ~200 days) | Not practically testable end-to-end in a short QA pass — mark "not testable this cycle" unless a naturally-expired domain is available in the environment | Enrollment restarts DCV rather than getting stuck on a dead validation record | From 510cc8eb900ba29778d4be1b810619acbddbd29c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 1 Sep 2026 20:12:02 +0000 Subject: [PATCH 18/29] docs: auto-generate README and documentation [skip ci] --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 727653a..43c9fcb 100644 --- a/README.md +++ b/README.md @@ -349,7 +349,8 @@ Confirm via the policy list that `details.validator` is unset for the policy und | C1 | First-time enrollment, domain never validated | CSR Enrollment against a policy with `validator` set, for a domain never validated before | Enrollment returns pending/"external validation" with DNS TXT record instructions in the status message — no cert issued yet | | C2 | Publish TXT, resubmit | Publish the TXT record from C1 in real DNS, resubmit the same enrollment | Domain validates, certificate issues | | C3 | Re-enroll same domain (already validated) | Submit a second CSR for the same already-validated domain | No new DCV required — issues directly (domain trust is reused while still valid) | -| C4 | Enroll for a domain, CN differs from all prior SANs | Use a brand-new subdomain not previously validated, same validator | Behaves like C1 (new pending validation), unless it's a subdomain of an already-validated parent domain — worth testing both ways | +| C4 | Enroll for a subdomain of an already-validated domain | Use a subdomain (e.g. `www.example.com`) of a domain already `Validated` in the Domains list, same validator | Issues directly with no new domain validation record created — the plugin treats it as covered by the validated parent | +| C4b | Enroll for a subdomain of a still-pending (not yet validated) parent | Same as C4, but the parent domain's own validation is still `Pending` | Creates its own separate validation record for the subdomain (parent coverage only applies once the parent is actually `Validated`) | | C5 | Never publish the TXT record | Same as C1 but don't publish the record, resubmit later | Stays pending; status message still shows the same/valid instructions, doesn't error | | C6 | Domain validation expires mid-lifecycle (IdenTrust ~200 days) | Not practically testable end-to-end in a short QA pass — mark "not testable this cycle" unless a naturally-expired domain is available in the environment | Enrollment restarts DCV rather than getting stuck on a dead validation record | From 2c9d67982b70ec62bc87fb69be18415065802da5 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Wed, 2 Sep 2026 14:39:13 -0400 Subject: [PATCH 19/29] added dns control --- CHANGELOG.md | 11 + .../HydrantIdCAPluginTests.cs | 406 ++++++++++++++++++ HydrantCAProxy/Client/Models/Domain.cs | 5 + HydrantCAProxy/HydrantIdCAPlugin.cs | 381 ++++++++++++++-- HydrantCAProxy/HydrantIdCAPluginConfig.cs | 29 ++ HydrantCAProxy/Interfaces/IDomain.cs | 1 + HydrantCAProxy/manifest.json | 2 +- docsource/configuration.md | 60 ++- integration-manifest.json | 40 ++ 9 files changed, 894 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c812d7..c4b2517 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +# v1.1.0 +* Added automated DNS-01 style domain control validation: when the AnyCA Gateway supplies an IDomainValidatorFactory and a DNS provider plugin is configured for the domain's zone, the plugin now stages HydrantId's validation TXT record, polls until the domain is VALIDATED, removes the record, and issues the certificate within a single enrollment call +* Added per-domain fallback to external validation when automation is unavailable (no factory, no DNS plugin for the zone, staging failure, no validation code, or validation timeout), preserving the previous manual publish-and-resubmit behaviour +* Added DnsPropagationDelaySeconds, DomainValidationTimeoutSeconds and DomainValidationPollIntervalSeconds CA connection settings +* Added domain control validation for policies that declare a validator, including reuse of an already-validated parent domain for subdomains and regeneration of expired validation codes +* Added HydrantIdAccountId and the HydrantIdOrg*/contact CA connection settings required by validators (e.g. IdenTrust) that declare a non-empty requiredPayload +* Made the policy domain validator optional - policies with no validator configured skip domain control validation entirely +* Fixed soft-deleted HydrantId domain records (deletedAt) being matched during domain control validation; re-checking a deleted record returned HTTP 500 and failed the enrollment instead of starting a fresh validation +* Fixed the extension registration key in manifest.json, which was GCPCASCAPlugin copy-paste residue rather than HydrantIdCAPlugin +* Synchronized integration-manifest.json CA connection settings with the plugin annotations; HydrantIdAccountId and the organization fields were previously missing from the generated documentation + # v1.0.3 * Added support for revocation reason 0 (Unspecified) now that HydrantId accepts it * Fixed sensitive credentials (HydrantIdAuthId, HydrantIdAuthKey) being written to trace logs in plain text; raw config JSON is now masked before logging diff --git a/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs b/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs index a925c04..a7484c6 100644 --- a/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs +++ b/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs @@ -549,6 +549,76 @@ public void IsCoveredByValidatedAncestor_MatchesExpectedScope(string domainName, Assert.Equal(expected, result); } + [Fact] + public void IsCoveredByValidatedAncestor_SoftDeletedParent_ReturnsFalse() + { + var domains = new List + { + new Domain + { + DomainName = "example.com", + Status = DomainStatusEnum.Validated, + DeletedAt = "2026-09-01T20:23:52.000Z" + } + }; + + Assert.False(HydrantIdCAPlugin.IsCoveredByValidatedAncestor("www.example.com", domains, out var covering)); + Assert.Null(covering); + } + + [Fact] + public async Task EnsureDomainsValidatedAsync_SoftDeletedPendingRecord_IsIgnoredAndValidationRestarted() + { + var plugin = new HydrantIdCAPlugin(); + var mockClient = new Mock(); + // A soft-deleted record must not be re-checked: GET /domains/{id}/validate on a deleted + // id returns HTTP 500 from HydrantId, which would fail the enrollment outright. + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List + { + new Domain + { + Id = "deleted-1", + DomainName = "gone.example.com", + Status = DomainStatusEnum.Pending, + DeletedAt = "2026-09-01T20:23:52.000Z" + } + }); + mockClient.Setup(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny())) + .ReturnsAsync(new Domain { Id = "fresh-1", Status = DomainStatusEnum.Validated }); + + var result = await plugin.EnsureDomainsValidatedAsync( + mockClient.Object, NewFlow(), new List { "gone.example.com" }, "IdenTrust"); + + Assert.True(result.AllValidated); + mockClient.Verify(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny()), Times.Once); + mockClient.Verify(c => c.GetSubmitCheckDomainValidationAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task EnsureDomainsValidatedAsync_SoftDeletedValidatedRecord_DoesNotCountAsValidated() + { + var plugin = new HydrantIdCAPlugin(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List + { + new Domain + { + Id = "deleted-1", + DomainName = "gone.example.com", + Status = DomainStatusEnum.Validated, + DeletedAt = "2026-09-01T20:23:52.000Z" + } + }); + mockClient.Setup(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny())) + .ReturnsAsync(new Domain { Id = "fresh-1", Status = DomainStatusEnum.Validated }); + + var result = await plugin.EnsureDomainsValidatedAsync( + mockClient.Object, NewFlow(), new List { "gone.example.com" }, "IdenTrust"); + + Assert.True(result.AllValidated); + mockClient.Verify(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny()), Times.Once); + } + [Fact] public void IsCoveredByValidatedAncestor_ParentNotValidated_ReturnsFalse() { @@ -659,6 +729,342 @@ public async Task EnsureDomainsValidatedAsync_OrgPayloadConfigured_IsIncludedInC Assert.Equal("Acme Corp", ((DomainValidationOrgPayload)capturedPayload.Payload).OrgName); } + // --------------------------------------------------------------------- + // DNS provider plugin automation (IDomainValidatorFactory) + // --------------------------------------------------------------------- + + // Timings that keep these tests instant: no propagation wait, and a budget that allows + // exactly one status check before timing out. + private static Dictionary FastDnsConnectionData() + { + var data = ValidConnectionData(); + data[HydrantIdCAPluginConfig.ConfigConstants.DnsPropagationDelaySeconds] = 0; + data[HydrantIdCAPluginConfig.ConfigConstants.DomainValidationPollIntervalSeconds] = 1; + data[HydrantIdCAPluginConfig.ConfigConstants.DomainValidationTimeoutSeconds] = 1; + return data; + } + + private static HydrantIdCAPlugin MakePluginWithDnsFactory( + Mock client, IDomainValidatorFactory factory, Dictionary data = null) + { + var plugin = new HydrantIdCAPlugin(factory); + plugin.Initialize(new FakeConfigProvider { CAConnectionData = data ?? FastDnsConnectionData() }, + Mock.Of()); + if (client != null) + plugin.ClientFactory = _ => client.Object; + return plugin; + } + + private static Mock StubDnsValidator(bool stageSucceeds = true, bool cleanupSucceeds = true) + { + var validator = new Mock(); + validator.Setup(v => v.StageValidation(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new DomainValidationResult + { + Success = stageSucceeds, + ErrorMessage = stageSucceeds ? null : "zone not found" + }); + validator.Setup(v => v.CleanupValidation(It.IsAny(), It.IsAny())) + .ReturnsAsync(new DomainValidationResult + { + Success = cleanupSucceeds, + ErrorMessage = cleanupSucceeds ? null : "delete failed" + }); + return validator; + } + + private static Mock StubDnsFactory(IDomainValidator validator) + { + var factory = new Mock(); + factory.Setup(f => f.ResolveDomainValidator(It.IsAny(), HydrantIdCAPlugin.DnsValidationType)) + .Returns(validator); + return factory; + } + + [Fact] + public void ResolveDnsValidator_NoFactorySupplied_ReturnsNull() + { + var plugin = new HydrantIdCAPlugin(); + + Assert.Null(plugin.ResolveDnsValidator(NewFlow(), "example.com")); + } + + [Fact] + public void ResolveDnsValidator_NoPluginForZone_ReturnsNullAfterTryingBothValidationTypes() + { + var factory = new Mock(); + factory.Setup(f => f.ResolveDomainValidator(It.IsAny(), It.IsAny())) + .Returns((IDomainValidator)null); + var plugin = MakePluginWithDnsFactory(null, factory.Object); + + Assert.Null(plugin.ResolveDnsValidator(NewFlow(), "example.com")); + factory.Verify(f => f.ResolveDomainValidator("example.com", HydrantIdCAPlugin.DnsValidationType), Times.Once); + factory.Verify(f => f.ResolveDomainValidator("example.com", HydrantIdCAPlugin.DnsValidationTypeLegacy), Times.Once); + } + + [Fact] + public void ResolveDnsValidator_LegacyValidationType_IsUsedWhenCanonicalMisses() + { + var validator = StubDnsValidator().Object; + var factory = new Mock(); + factory.Setup(f => f.ResolveDomainValidator("example.com", HydrantIdCAPlugin.DnsValidationType)) + .Returns((IDomainValidator)null); + factory.Setup(f => f.ResolveDomainValidator("example.com", HydrantIdCAPlugin.DnsValidationTypeLegacy)) + .Returns(validator); + var plugin = MakePluginWithDnsFactory(null, factory.Object); + + Assert.Same(validator, plugin.ResolveDnsValidator(NewFlow(), "example.com")); + } + + [Fact] + public void ResolveDnsValidator_FactoryThrows_ReturnsNullRatherThanPropagating() + { + var factory = new Mock(); + factory.Setup(f => f.ResolveDomainValidator(It.IsAny(), It.IsAny())) + .Throws(new InvalidOperationException("plugin directory unreadable")); + var plugin = MakePluginWithDnsFactory(null, factory.Object); + + Assert.Null(plugin.ResolveDnsValidator(NewFlow(), "example.com")); + } + + [Fact] + public async Task EnsureDomainsValidatedAsync_StagedRecordValidates_ReturnsAllValidatedAndCleansUp() + { + var validator = StubDnsValidator(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List()); + mockClient.Setup(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny())) + .ReturnsAsync(new Domain + { + Id = "d1", + Status = DomainStatusEnum.Pending, + Code = "identrust_validate=abc123", + CodeInstructions = "publish TXT" + }); + mockClient.Setup(c => c.GetSubmitCheckDomainValidationAsync("d1")) + .ReturnsAsync(new Domain { Id = "d1", Status = DomainStatusEnum.Validated }); + var plugin = MakePluginWithDnsFactory(mockClient, StubDnsFactory(validator.Object).Object); + + var result = await plugin.EnsureDomainsValidatedAsync( + mockClient.Object, NewFlow(), new List { "auto.example.com" }, "IdenTrust"); + + Assert.True(result.AllValidated); + Assert.Null(result.PendingMessage); + // Record name is the domain itself, value is HydrantID's whole code string. + validator.Verify(v => v.StageValidation("auto.example.com", "identrust_validate=abc123", It.IsAny()), Times.Once); + validator.Verify(v => v.CleanupValidation("auto.example.com", It.IsAny()), Times.Once); + } + + [Fact] + public async Task EnsureDomainsValidatedAsync_StageFails_FallsBackToManualWithoutPollingOrCleanup() + { + var validator = StubDnsValidator(stageSucceeds: false); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List()); + mockClient.Setup(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny())) + .ReturnsAsync(new Domain + { + Id = "d1", + Status = DomainStatusEnum.Pending, + Code = "identrust_validate=abc123", + CodeInstructions = "publish TXT" + }); + var plugin = MakePluginWithDnsFactory(mockClient, StubDnsFactory(validator.Object).Object); + + var result = await plugin.EnsureDomainsValidatedAsync( + mockClient.Object, NewFlow(), new List { "auto.example.com" }, "IdenTrust"); + + Assert.False(result.AllValidated); + Assert.Contains("publish TXT", result.PendingMessage); + mockClient.Verify(c => c.GetSubmitCheckDomainValidationAsync(It.IsAny()), Times.Never); + validator.Verify(v => v.CleanupValidation(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task EnsureDomainsValidatedAsync_ValidationNeverCompletes_TimesOutToManualAndStillCleansUp() + { + var validator = StubDnsValidator(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List()); + mockClient.Setup(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny())) + .ReturnsAsync(new Domain + { + Id = "d1", + Status = DomainStatusEnum.Pending, + Code = "identrust_validate=abc123", + CodeInstructions = "publish TXT" + }); + mockClient.Setup(c => c.GetSubmitCheckDomainValidationAsync("d1")) + .ReturnsAsync(new Domain { Id = "d1", Status = DomainStatusEnum.Pending }); + var plugin = MakePluginWithDnsFactory(mockClient, StubDnsFactory(validator.Object).Object); + + var result = await plugin.EnsureDomainsValidatedAsync( + mockClient.Object, NewFlow(), new List { "slow.example.com" }, "IdenTrust"); + + Assert.False(result.AllValidated); + Assert.Contains("publish TXT", result.PendingMessage); + validator.Verify(v => v.CleanupValidation("slow.example.com", It.IsAny()), Times.Once); + } + + [Fact] + public async Task EnsureDomainsValidatedAsync_StatusCheckThrows_TreatedAsPendingNotFatal() + { + var validator = StubDnsValidator(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List()); + mockClient.Setup(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny())) + .ReturnsAsync(new Domain + { + Id = "d1", + Status = DomainStatusEnum.Pending, + Code = "identrust_validate=abc123", + CodeInstructions = "publish TXT" + }); + mockClient.Setup(c => c.GetSubmitCheckDomainValidationAsync("d1")) + .ThrowsAsync(new InvalidOperationException("HTTP 500")); + var plugin = MakePluginWithDnsFactory(mockClient, StubDnsFactory(validator.Object).Object); + + var result = await plugin.EnsureDomainsValidatedAsync( + mockClient.Object, NewFlow(), new List { "flaky.example.com" }, "IdenTrust"); + + Assert.False(result.AllValidated); + validator.Verify(v => v.CleanupValidation("flaky.example.com", It.IsAny()), Times.Once); + } + + [Fact] + public async Task EnsureDomainsValidatedAsync_CleanupThrows_DoesNotFailAnOtherwiseValidEnrollment() + { + var validator = StubDnsValidator(); + validator.Setup(v => v.CleanupValidation(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("DNS API rejected the delete")); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List()); + mockClient.Setup(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny())) + .ReturnsAsync(new Domain + { + Id = "d1", + Status = DomainStatusEnum.Pending, + Code = "identrust_validate=abc123", + CodeInstructions = "publish TXT" + }); + mockClient.Setup(c => c.GetSubmitCheckDomainValidationAsync("d1")) + .ReturnsAsync(new Domain { Id = "d1", Status = DomainStatusEnum.Validated }); + var plugin = MakePluginWithDnsFactory(mockClient, StubDnsFactory(validator.Object).Object); + + var result = await plugin.EnsureDomainsValidatedAsync( + mockClient.Object, NewFlow(), new List { "auto.example.com" }, "IdenTrust"); + + Assert.True(result.AllValidated); + } + + [Fact] + public async Task EnsureDomainsValidatedAsync_NoCodeReturned_FallsBackToManualWithoutStaging() + { + var validator = StubDnsValidator(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List()); + mockClient.Setup(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny())) + .ReturnsAsync(new Domain { Id = "d1", Status = DomainStatusEnum.Pending, CodeInstructions = "publish TXT" }); + var plugin = MakePluginWithDnsFactory(mockClient, StubDnsFactory(validator.Object).Object); + + var result = await plugin.EnsureDomainsValidatedAsync( + mockClient.Object, NewFlow(), new List { "nocode.example.com" }, "IdenTrust"); + + Assert.False(result.AllValidated); + Assert.Contains("publish TXT", result.PendingMessage); + validator.Verify(v => v.StageValidation(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task EnsureDomainsValidatedAsync_DomainAlreadyValidated_NeverResolvesADnsPlugin() + { + var factory = StubDnsFactory(StubDnsValidator().Object); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List + { + new Domain { Id = "d1", DomainName = "done.example.com", Status = DomainStatusEnum.Validated } + }); + var plugin = MakePluginWithDnsFactory(mockClient, factory.Object); + + var result = await plugin.EnsureDomainsValidatedAsync( + mockClient.Object, NewFlow(), new List { "done.example.com" }, "IdenTrust"); + + Assert.True(result.AllValidated); + factory.Verify(f => f.ResolveDomainValidator(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public void DnsTimingAccessors_Unconfigured_UseAnnotationDefaults() + { + var plugin = MakePlugin(); + + Assert.Equal(HydrantIdCAPlugin.DefaultDnsPropagationDelaySeconds, plugin.DnsPropagationDelaySeconds); + Assert.Equal(HydrantIdCAPlugin.DefaultDomainValidationTimeoutSeconds, plugin.DomainValidationTimeoutSeconds); + Assert.Equal(HydrantIdCAPlugin.DefaultDomainValidationPollIntervalSeconds, plugin.DomainValidationPollIntervalSeconds); + } + + [Fact] + public void DnsPropagationDelaySeconds_ExplicitZero_IsHonouredRatherThanDefaulted() + { + var plugin = MakePluginWithDnsFactory(null, Mock.Of()); + + Assert.Equal(0, plugin.DnsPropagationDelaySeconds); + } + + [Fact] + public void DomainValidationTimeoutSeconds_ExplicitZero_FallsBackToDefault() + { + var data = ValidConnectionData(); + data[HydrantIdCAPluginConfig.ConfigConstants.DomainValidationTimeoutSeconds] = 0; + var plugin = new HydrantIdCAPlugin(); + plugin.Initialize(new FakeConfigProvider { CAConnectionData = data }, Mock.Of()); + + Assert.Equal(HydrantIdCAPlugin.DefaultDomainValidationTimeoutSeconds, plugin.DomainValidationTimeoutSeconds); + } + + [Fact] + public async Task Enroll_New_DnsAutomationValidatesDomain_ProceedsToIssueInTheSameCall() + { + var (_, pem, _) = MakeSelfSignedCert(); + var trackingId = Guid.NewGuid(); + var validator = StubDnsValidator(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetPolicyList()).ReturnsAsync(new List + { + new Policy { Id = Guid.NewGuid(), Name = "Test Policy", Details = new PolicyDetails { Validator = "IdenTrust" } } + }); + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List()); + mockClient.Setup(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny())) + .ReturnsAsync(new Domain + { + Id = "d1", + Status = DomainStatusEnum.Pending, + Code = "identrust_validate=abc123", + CodeInstructions = "publish TXT" + }); + mockClient.Setup(c => c.GetSubmitCheckDomainValidationAsync("d1")) + .ReturnsAsync(new Domain { Id = "d1", Status = DomainStatusEnum.Validated }); + mockClient.Setup(c => c.GetSubmitEnrollmentAsync(It.IsAny())).ReturnsAsync(new CertRequestResult + { + RequestStatus = new CertRequestStatus { Id = trackingId.ToString() } + }); + mockClient.Setup(c => c.GetSubmitGetCertificateByCsrAsync(trackingId.ToString())) + .ReturnsAsync(new Certificate { Id = trackingId }); + mockClient.Setup(c => c.GetSubmitGetCertificateAsync(trackingId.ToString())) + .ReturnsAsync(new Certificate { Pem = pem, RevocationStatus = RevocationStatusEnum.Valid }); + var plugin = MakePluginWithDnsFactory(mockClient, StubDnsFactory(validator.Object).Object); + + var result = await plugin.Enroll(SampleCsr, "subj", null, ProductInfo(), RequestFormat.PKCS10, EnrollmentType.New); + + // The whole cycle -- stage TXT, wait for DCV, submit the CSR, wait for the cert -- + // completes inside one Enroll call, with no EXTERNALVALIDATION round trip. + Assert.Equal((int)EndEntityStatus.GENERATED, result.Status); + Assert.False(string.IsNullOrEmpty(result.Certificate)); + validator.Verify(v => v.StageValidation(It.IsAny(), "identrust_validate=abc123", It.IsAny()), Times.Once); + validator.Verify(v => v.CleanupValidation(It.IsAny(), It.IsAny()), Times.Once); + mockClient.Verify(c => c.GetSubmitEnrollmentAsync(It.IsAny()), Times.Once); + } + // --------------------------------------------------------------------- // Synchronize // --------------------------------------------------------------------- diff --git a/HydrantCAProxy/Client/Models/Domain.cs b/HydrantCAProxy/Client/Models/Domain.cs index db3db3c..aa7932a 100644 --- a/HydrantCAProxy/Client/Models/Domain.cs +++ b/HydrantCAProxy/Client/Models/Domain.cs @@ -60,5 +60,10 @@ public class Domain : IDomain [JsonProperty("updatedAt", NullValueHandling = NullValueHandling.Ignore)] public string UpdatedAt { get;set; } + // Non-null once HydrantID has soft-deleted the record. Such a record must never be + // matched or reused -- see the filtering in HydrantIdCAPlugin.EnsureDomainsValidatedAsync. + [JsonProperty("deletedAt", NullValueHandling = NullValueHandling.Ignore)] + public string DeletedAt { get;set; } + } } diff --git a/HydrantCAProxy/HydrantIdCAPlugin.cs b/HydrantCAProxy/HydrantIdCAPlugin.cs index b1806a1..fa4650d 100644 --- a/HydrantCAProxy/HydrantIdCAPlugin.cs +++ b/HydrantCAProxy/HydrantIdCAPlugin.cs @@ -35,6 +35,62 @@ public class HydrantIdCAPlugin : IAnyCAPlugin internal Func ClientFactory { get; set; } = config => new HydrantIdClient(config); + private readonly IDomainValidatorFactory _validatorFactory; + + // The validation type DNS provider plugins register themselves under. The ACME CA plugin + // resolves with "dns-01" at runtime, while that repo's DNS plugin documentation describes + // GetValidationType() as returning "DNS" -- so try the canonical value first and fall back + // to the legacy spelling rather than silently missing a plugin that is actually deployed. + internal const string DnsValidationType = "dns-01"; + internal const string DnsValidationTypeLegacy = "DNS"; + + internal const int DefaultDnsPropagationDelaySeconds = 30; + internal const int DefaultDomainValidationTimeoutSeconds = 300; + internal const int DefaultDomainValidationPollIntervalSeconds = 10; + + /// + /// Used when the Gateway does not supply a DNS provider factory. Domain validation still + /// works, but only on the manual path -- enrollment returns EXTERNALVALIDATION carrying the + /// TXT record for an operator to publish before resubmitting. + /// + public HydrantIdCAPlugin() + { + } + + /// + /// Preferred constructor. is supplied by the Gateway and + /// resolves whichever deployed DNS provider plugin owns a given zone, letting this plugin + /// write HydrantID's validation TXT record itself and issue without operator involvement. + /// Unlike the ACME CA plugin a null factory is tolerated rather than fatal, because HydrantID + /// policies using a private CA validator -- or no validator at all -- issue fine without any + /// DNS automation. + /// + public HydrantIdCAPlugin(IDomainValidatorFactory validatorFactory) + { + _validatorFactory = validatorFactory; + } + + // Command leaves a numeric connector field at 0 when the template has never been saved + // (the same gap RenewalDays works around -- ADO 81803), so the annotation default is + // re-applied here rather than trusting the deserialized value. + // A delay of 0 is meaningful (skip waiting), so only a null -- an absent connector + // field -- or a negative value falls back to the annotation default. + internal int DnsPropagationDelaySeconds => + _config?.DnsPropagationDelaySeconds is int delay && delay >= 0 + ? delay + : DefaultDnsPropagationDelaySeconds; + + // A budget or interval of 0 is nonsense, so those require a positive value. + internal int DomainValidationTimeoutSeconds => + _config?.DomainValidationTimeoutSeconds is int timeout && timeout > 0 + ? timeout + : DefaultDomainValidationTimeoutSeconds; + + internal int DomainValidationPollIntervalSeconds => + _config?.DomainValidationPollIntervalSeconds is int interval && interval > 0 + ? interval + : DefaultDomainValidationPollIntervalSeconds; + // Minimal IAnyCAPluginConfigProvider over a raw connectionInfo dictionary, used by // ValidateCAConnectionInfo -- that entry point runs before the Gateway ever calls // Initialize(), so Config would otherwise be null when Ping() builds a client. @@ -836,8 +892,21 @@ internal DomainValidationOrgPayload BuildOrgPayload() } /// - /// Checks each domain against HydrantID's Domains resource, starting DNS validation for any - /// domain that has not been requested yet and re-checking any domain that is still pending. + /// Ensures every domain in is VALIDATED at HydrantID + /// before a CSR is submitted, automating the TXT record through a Keyfactor DNS provider + /// plugin whenever one owns the zone. Runs in three phases, mirroring the ACME CA plugin's + /// stage / verify / cleanup lifecycle: + /// + /// 1. Stage -- create, regenerate or re-check each HydrantID domain record to obtain its + /// validation code, then have the resolved IDomainValidator write it. + /// 2. Wait -- after a propagation delay, poll HydrantID until every staged domain + /// reports VALIDATED or the configured budget runs out. + /// 3. Cleanup -- remove every record this call staged, whatever the outcome. + /// + /// Domains that could not be automated (no factory, no plugin for the zone, or no code + /// returned) fall back to the manual path and appear in the returned pending message, so a + /// CA with no DNS plugin deployed behaves exactly as it did before automation existed. + /// /// Command re-invokes Enroll() from scratch on resubmit, and this plugin has no local state /// store, so listing existing domains and filtering by name is the only way to recover a /// previously-started validation's id across Enroll() calls. @@ -845,53 +914,100 @@ internal DomainValidationOrgPayload BuildOrgPayload() internal async Task<(bool AllValidated, string PendingMessage)> EnsureDomainsValidatedAsync( IHydrantIdClient client, FlowLogger flow, List domainsToValidate, string validatorId) { - var existingDomains = await client.GetDomainListAsync(); - + // HydrantID soft-deletes domain records rather than removing them, and it is not + // established whether the list endpoint filters them out. A soft-deleted record must + // never be matched: re-checking one returns HTTP 500 ("Cannot read properties of null + // (reading 'accountId')"), which would fail the enrollment instead of simply starting + // a fresh validation for the domain. + var existingDomains = (await client.GetDomainListAsync()) + .Where(d => string.IsNullOrEmpty(d.DeletedAt)) + .ToList(); + + // Records this call wrote, and is therefore responsible for removing. + var staged = new List(); + // Domains left for an operator to publish by hand. var pending = new List<(string Domain, string Instructions)>(); - foreach (var domainName in domainsToValidate) + try { - var match = existingDomains.FirstOrDefault(d => - string.Equals(d.DomainName, domainName, StringComparison.OrdinalIgnoreCase)); - - if (match == null && IsCoveredByValidatedAncestor(domainName, existingDomains, out var coveringDomain)) + foreach (var domainName in domainsToValidate) { - flow.Step("DomainValidation.CoveredByValidatedParent", $"domain='{domainName}', parent='{coveringDomain}'"); - continue; - } + var match = existingDomains.FirstOrDefault(d => + string.Equals(d.DomainName, domainName, StringComparison.OrdinalIgnoreCase)); - Domain domain; - if (match == null || match.Status == DomainStatusEnum.Expired) - { - // HydrantID's "regenerate code" action for an expired domain is the same - // POST used to start a validation from scratch -- confirmed idempotent per - // domain name (does not create a duplicate record) against staging. - flow.Step("DomainValidation.CreateOrRegenerate", - $"domain='{domainName}', priorStatus={(match == null ? "(none)" : match.Status.ToString())}"); - var payload = _requestManager.GetCreateDomainValidationRequest(domainName, validatorId, _config?.HydrantIdAccountId, BuildOrgPayload()); - domain = await client.GetSubmitCreateDomainValidationAsync(payload); - } - else if (match.Status != DomainStatusEnum.Validated) - { - flow.Step("DomainValidation.Recheck", $"domain='{domainName}', status={match.Status}, domainId='{match.Id}'"); - domain = await client.GetSubmitCheckDomainValidationAsync(match.Id); - } - else - { - flow.Step("DomainValidation.AlreadyValidated", $"domain='{domainName}'"); - continue; - } + if (match == null && IsCoveredByValidatedAncestor(domainName, existingDomains, out var coveringDomain)) + { + flow.Step("DomainValidation.CoveredByValidatedParent", $"domain='{domainName}', parent='{coveringDomain}'"); + continue; + } + + Domain domain; + if (match == null || match.Status == DomainStatusEnum.Expired) + { + // HydrantID's "regenerate code" action for an expired domain is the same + // POST used to start a validation from scratch -- confirmed idempotent per + // domain name (does not create a duplicate record) against staging. + flow.Step("DomainValidation.CreateOrRegenerate", + $"domain='{domainName}', priorStatus={(match == null ? "(none)" : match.Status.ToString())}"); + var payload = _requestManager.GetCreateDomainValidationRequest(domainName, validatorId, _config?.HydrantIdAccountId, BuildOrgPayload()); + domain = await client.GetSubmitCreateDomainValidationAsync(payload); + } + else if (match.Status != DomainStatusEnum.Validated) + { + flow.Step("DomainValidation.Recheck", $"domain='{domainName}', status={match.Status}, domainId='{match.Id}'"); + domain = await client.GetSubmitCheckDomainValidationAsync(match.Id); + } + else + { + flow.Step("DomainValidation.AlreadyValidated", $"domain='{domainName}'"); + continue; + } + + if (domain?.Status == DomainStatusEnum.Validated) + { + flow.Step("DomainValidation.NowValidated", $"domain='{domainName}'"); + continue; + } - if (domain?.Status != DomainStatusEnum.Validated) - { flow.Step("DomainValidation.StillPending", $"domain='{domainName}', status={domain?.Status.ToString() ?? "(null response)"}"); - pending.Add((domainName, domain?.CodeInstructions ?? "(no instructions returned by HydrantId)")); + var instructions = domain?.CodeInstructions ?? "(no instructions returned by HydrantId)"; + + var dnsValidator = ResolveDnsValidator(flow, domainName); + if (dnsValidator == null) + { + pending.Add((domainName, instructions)); + continue; + } + + if (string.IsNullOrWhiteSpace(domain?.Code) || string.IsNullOrWhiteSpace(domain?.Id)) + { + flow.Skip($"DomainValidation.Stage:{domainName}", "HydrantId returned no validation code or domain id to publish"); + pending.Add((domainName, instructions)); + continue; + } + + if (await StageDnsRecordAsync(flow, dnsValidator, domainName, domain.Code)) + staged.Add(new StagedValidation(domainName, domain.Id, instructions, dnsValidator)); + else + pending.Add((domainName, instructions)); } - else + + if (staged.Count > 0) { - flow.Step("DomainValidation.NowValidated", $"domain='{domainName}'"); + var propagationDelay = DnsPropagationDelaySeconds; + flow.Step("DomainValidation.PropagationDelay", $"{propagationDelay}s for {staged.Count} staged record(s)"); + await Task.Delay(TimeSpan.FromSeconds(propagationDelay)); + + await flow.StepAsync("DomainValidation.AwaitValidation", async () => + { + await AwaitStagedValidationsAsync(client, flow, staged, pending); + }); } } + finally + { + await CleanupStagedRecordsAsync(flow, staged); + } if (pending.Count == 0) return (true, null); @@ -903,6 +1019,191 @@ internal DomainValidationOrgPayload BuildOrgPayload() return (false, message); } + /// + /// A HydrantID domain validation whose TXT record was written by this enrollment, and which + /// must therefore be polled to completion and then cleaned up. + /// + internal sealed class StagedValidation + { + public StagedValidation(string domain, string domainId, string instructions, IDomainValidator validator) + { + Domain = domain; + DomainId = domainId; + Instructions = instructions; + Validator = validator; + } + + public string Domain { get; } + public string DomainId { get; } + public string Instructions { get; } + public IDomainValidator Validator { get; } + } + + /// + /// Resolves the DNS provider plugin that owns 's zone, or null + /// when automation is unavailable for it. Never throws: any failure here degrades to the + /// manual validation path, which is strictly better than failing an enrollment because + /// plugin resolution misbehaved. + /// + internal IDomainValidator ResolveDnsValidator(FlowLogger flow, string domainName) + { + if (_validatorFactory == null) + { + flow.Skip($"DomainValidation.ResolveValidator:{domainName}", + "no IDomainValidatorFactory supplied by the Gateway; manual DNS validation only"); + return null; + } + + try + { + var validator = _validatorFactory.ResolveDomainValidator(domainName, DnsValidationType) + ?? _validatorFactory.ResolveDomainValidator(domainName, DnsValidationTypeLegacy); + + if (validator == null) + { + flow.Skip($"DomainValidation.ResolveValidator:{domainName}", + "no DNS provider plugin is configured for this zone"); + return null; + } + + flow.Step($"DomainValidation.ResolveValidator:{domainName}", validator.GetType().Name); + return validator; + } + catch (Exception ex) + { + flow.Fail($"DomainValidation.ResolveValidator:{domainName}", ex.Message); + _logger.LogWarning(ex, "ResolveDnsValidator: could not resolve a DNS provider plugin for '{Domain}', falling back to manual validation: {Message}", + domainName, ex.Message); + return null; + } + } + + /// + /// Writes HydrantID's validation TXT record via a DNS provider plugin. The record name is the + /// domain itself rather than an _acme-challenge subdomain, and the value is HydrantID's whole + /// code string, matching the codeInstructions HydrantID returns: "create a new DNS TXT record + /// for the domain containing the following data: <validator>_validate=<token>". + /// Returns false rather than throwing, so the domain falls back to the manual path. + /// + internal async Task StageDnsRecordAsync(FlowLogger flow, IDomainValidator dnsValidator, string domainName, string code) + { + try + { + var result = await dnsValidator.StageValidation(domainName, code, CancellationToken.None); + + if (result == null || !result.Success) + { + flow.Fail($"DomainValidation.Stage:{domainName}", + result?.ErrorMessage ?? "DNS provider plugin returned no result"); + _logger.LogWarning("StageDnsRecordAsync: {Validator} failed to write the TXT record for '{Domain}': {Error}", + dnsValidator.GetType().Name, domainName, result?.ErrorMessage ?? "(no result)"); + return false; + } + + flow.Step($"DomainValidation.Stage:{domainName}", $"TXT written via {dnsValidator.GetType().Name}"); + return true; + } + catch (Exception ex) + { + flow.Fail($"DomainValidation.Stage:{domainName}", ex.Message); + _logger.LogWarning(ex, "StageDnsRecordAsync: {Validator} threw writing the TXT record for '{Domain}': {Message}", + dnsValidator.GetType().Name, domainName, ex.Message); + return false; + } + } + + /// + /// Polls HydrantID until every staged domain reports VALIDATED or the configured budget is + /// exhausted. Anything still unvalidated at the deadline is appended to + /// , which sends the enrollment down the EXTERNALVALIDATION path + /// rather than failing it -- the staged code stays usable until codeValidUntil, so a resubmit + /// can still pick it up. + /// + internal async Task AwaitStagedValidationsAsync( + IHydrantIdClient client, FlowLogger flow, List staged, List<(string Domain, string Instructions)> pending) + { + var timeout = TimeSpan.FromSeconds(DomainValidationTimeoutSeconds); + var interval = TimeSpan.FromSeconds(DomainValidationPollIntervalSeconds); + var stopwatch = Stopwatch.StartNew(); + var remaining = staged.ToList(); + + while (true) + { + var stillPending = new List(); + + foreach (var entry in remaining) + { + Domain rechecked = null; + try + { + rechecked = await client.GetSubmitCheckDomainValidationAsync(entry.DomainId); + } + catch (Exception ex) + { + // A transient check failure should cost one tick, not the whole wait. + _logger.LogWarning(ex, "AwaitStagedValidationsAsync: check failed for '{Domain}' (domainId='{DomainId}'), retrying: {Message}", + entry.Domain, entry.DomainId, ex.Message); + } + + if (rechecked?.Status == DomainStatusEnum.Validated) + flow.Step("DomainValidation.NowValidated", $"domain='{entry.Domain}' after {stopwatch.Elapsed.TotalSeconds:F0}s"); + else + stillPending.Add(entry); + } + + remaining = stillPending; + + if (remaining.Count == 0) + return; + + if (stopwatch.Elapsed + interval >= timeout) + break; + + await Task.Delay(interval); + } + + foreach (var entry in remaining) + { + flow.Fail($"DomainValidation.Timeout:{entry.Domain}", + $"still pending after {stopwatch.Elapsed.TotalSeconds:F0}s (budget {DomainValidationTimeoutSeconds}s)"); + pending.Add((entry.Domain, entry.Instructions)); + } + } + + /// + /// Removes every TXT record staged by this enrollment. A leftover record cannot break + /// issuance, so a cleanup failure is logged and swallowed rather than allowed to fail an + /// enrollment that otherwise succeeded. + /// + internal async Task CleanupStagedRecordsAsync(FlowLogger flow, List staged) + { + foreach (var entry in staged) + { + try + { + var result = await entry.Validator.CleanupValidation(entry.Domain, CancellationToken.None); + + if (result == null || !result.Success) + { + flow.Fail($"DomainValidation.Cleanup:{entry.Domain}", + result?.ErrorMessage ?? "DNS provider plugin returned no result"); + _logger.LogWarning("CleanupStagedRecordsAsync: {Validator} failed to remove the TXT record for '{Domain}': {Error}", + entry.Validator.GetType().Name, entry.Domain, result?.ErrorMessage ?? "(no result)"); + } + else + { + flow.Step($"DomainValidation.Cleanup:{entry.Domain}", "TXT record removed"); + } + } + catch (Exception ex) + { + flow.Fail($"DomainValidation.Cleanup:{entry.Domain}", ex.Message); + _logger.LogWarning(ex, "CleanupStagedRecordsAsync: {Validator} threw removing the TXT record for '{Domain}': {Message}", + entry.Validator.GetType().Name, entry.Domain, ex.Message); + } + } + } + /// /// True when is itself, or a subdomain of, some other domain /// in that is already Validated -- per HydrantID's own @@ -915,7 +1216,9 @@ internal static bool IsCoveredByValidatedAncestor(string domainName, List GetPluginAnnotations() DefaultValue = "", Type = "String" }, + [ConfigConstants.DnsPropagationDelaySeconds] = new PropertyConfigInfo() + { + Comments = "Seconds to wait after a DNS provider plugin writes the validation TXT record before asking HydrantId to check it, allowing the record to propagate to the authoritative nameservers. Only used when a DNS provider plugin is handling the record; ignored on the manual validation path. Set to 0 to skip the delay and start polling immediately.", + Hidden = false, + DefaultValue = 30, + Type = "Number" + }, + [ConfigConstants.DomainValidationTimeoutSeconds] = new PropertyConfigInfo() + { + Comments = "Maximum seconds to hold the enrollment open while polling HydrantId for domain validation to complete after a DNS provider plugin has staged the TXT record. On timeout the enrollment falls back to external validation (manual DNS publish and resubmit) rather than failing.", + Hidden = false, + DefaultValue = 300, + Type = "Number" + }, + [ConfigConstants.DomainValidationPollIntervalSeconds] = new PropertyConfigInfo() + { + Comments = "Seconds between HydrantId domain validation status checks while waiting for a staged DNS record to be validated.", + Hidden = false, + DefaultValue = 10, + Type = "Number" + }, [ConfigConstants.Enabled] = new PropertyConfigInfo() { Comments = "Flag to Enable or Disable the CA connector.", diff --git a/HydrantCAProxy/Interfaces/IDomain.cs b/HydrantCAProxy/Interfaces/IDomain.cs index fb6ccc5..09f29fc 100644 --- a/HydrantCAProxy/Interfaces/IDomain.cs +++ b/HydrantCAProxy/Interfaces/IDomain.cs @@ -28,5 +28,6 @@ public interface IDomain string CodeValidUntil { get;set; } string CreatedAt { get;set; } string UpdatedAt { get;set; } + string DeletedAt { get;set; } } } diff --git a/HydrantCAProxy/manifest.json b/HydrantCAProxy/manifest.json index 7be5406..671232f 100644 --- a/HydrantCAProxy/manifest.json +++ b/HydrantCAProxy/manifest.json @@ -1,7 +1,7 @@ { "extensions": { "Keyfactor.AnyGateway.Extensions.IAnyCAPlugin": { - "GCPCASCAPlugin": { + "HydrantIdCAPlugin": { "assemblypath": "HydrantIdCAPlugin.dll", "TypeFullName": "Keyfactor.Extensions.CAPlugin.HydrantId.HydrantIdCAPlugin" } diff --git a/docsource/configuration.md b/docsource/configuration.md index 02a935e..a9efa3b 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -164,6 +164,57 @@ When registering the HydrantId CA in the AnyCA Gateway, you'll need to provide t | **HydrantIdOrgCityProvPostalCodeCountry** | Organization city/province/postal code/country, paired with HydrantIdOrgName. | No | `Anytown, OH 44131, US` | | **HydrantIdEmailAddress** | Organization contact email address, paired with HydrantIdOrgName. | No | `jane@acme.com` | | **HydrantIdPhoneNumber** | Organization contact phone number, paired with HydrantIdOrgName. | No | `+1-555-555-0100` | +| **DnsPropagationDelaySeconds** | Seconds to wait after a DNS provider plugin writes the validation TXT record before asking HydrantId to check it. Only used on the automated path; ignored when validation is done by hand. Set to `0` to start polling immediately. Defaults to `30` when blank. | No | `30` | +| **DomainValidationTimeoutSeconds** | Maximum seconds to hold the enrollment open while polling HydrantId for domain validation after a DNS provider plugin has staged the record. On timeout the enrollment falls back to external validation rather than failing. Defaults to `300` when blank. | No | `300` | +| **DomainValidationPollIntervalSeconds** | Seconds between HydrantId domain validation status checks while waiting for a staged record. Defaults to `10` when blank. | No | `10` | + +### Automated Domain Validation (DNS Provider Plugins) + +When a policy has a `validator` configured, HydrantId requires domain control validation (DCV) +before it will issue. The plugin can complete DCV either automatically or by hand, and picks +per domain without any configuration switch. + +**Automated path.** If the AnyCA Gateway supplies an `IDomainValidatorFactory` and a DNS provider +plugin is deployed and configured for the zone that owns the domain, a single enrollment does all +of the following without operator involvement: + +1. Creates (or re-checks) the HydrantId domain validation record to obtain its TXT code. +2. Asks the resolved DNS provider plugin to write that record. The record name is **the domain + itself** — not an `_acme-challenge` subdomain, as in ACME — and the value is HydrantId's whole + code string, e.g. `identrust_validate=1kiQrHax...`, matching the `codeInstructions` HydrantId + returns. +3. Waits `DnsPropagationDelaySeconds`, then polls HydrantId every + `DomainValidationPollIntervalSeconds` until the domain reports `VALIDATED`, up to + `DomainValidationTimeoutSeconds`. +4. Deletes the TXT record it wrote, whether or not validation succeeded. HydrantId's DCV remains + valid until `domainValidUntil` (roughly six months for IdenTrust), so no record needs to stay + in the zone. +5. Submits the CSR and waits for the certificate, returning the issued certificate from the same + enrollment call. + +A cleanup failure is logged but never fails an enrollment that otherwise succeeded — a leftover +TXT record cannot block issuance. + +**Manual fallback.** Any domain that cannot be automated falls back to the previous behaviour: +the enrollment returns an external-validation status carrying the TXT record to publish, and the +operator resubmits once it is live. This happens when: + +- the Gateway supplies no `IDomainValidatorFactory`; +- no DNS provider plugin is configured for that domain's zone; +- the DNS provider plugin fails or throws while writing the record; +- HydrantId returns no validation code; or +- validation is still pending when `DomainValidationTimeoutSeconds` runs out. + +Because the fallback is per domain, a certificate with some domains in an automated zone and +others outside it still makes progress on the automated ones. + +Domains that are already `VALIDATED`, or covered by an already-validated parent domain, skip DCV +entirely and never touch a DNS provider plugin. + +> **Note on validation type.** DNS provider plugins are resolved with a validation type of +> `dns-01` first, then `DNS`. The reference ACME CA plugin resolves with `dns-01` at runtime while +> that project's DNS plugin documentation describes `GetValidationType()` as returning `DNS`, so +> both spellings are attempted rather than silently missing a plugin that is deployed. ### Gateway Registration Notes @@ -196,6 +247,7 @@ Populate using the configuration fields collected in the [requirements](#require * **HydrantIdAuthKey** - The API Authentication Key (secret) provided by HydrantId for API access. * **HydrantIdAccountId** - Optional. Required by some HydrantId tenants for domain validation to succeed; see the table above. * **HydrantIdOrgName**, **HydrantIdOrgPrimaryContactFullName**, **HydrantIdOrgStreetAddress**, **HydrantIdOrgCityProvPostalCodeCountry**, **HydrantIdEmailAddress**, **HydrantIdPhoneNumber** - Optional. Required by some domain validators (e.g. IdenTrust); see the table above. +* **DnsPropagationDelaySeconds**, **DomainValidationTimeoutSeconds**, **DomainValidationPollIntervalSeconds** - Optional timing controls for automated domain validation; see [Automated Domain Validation](#automated-domain-validation-dns-provider-plugins). Leave blank to use the defaults. 2. **Certificate Template Configuration** @@ -272,6 +324,12 @@ Confirm via the policy list that `details.validator` is unset for the policy und | C4b | Enroll for a subdomain of a still-pending (not yet validated) parent | Same as C4, but the parent domain's own validation is still `Pending` | Creates its own separate validation record for the subdomain (parent coverage only applies once the parent is actually `Validated`) | | C5 | Never publish the TXT record | Same as C1 but don't publish the record, resubmit later | Stays pending; status message still shows the same/valid instructions, doesn't error | | C6 | Domain validation expires mid-lifecycle (IdenTrust ~200 days) | Not practically testable end-to-end in a short QA pass — mark "not testable this cycle" unless a naturally-expired domain is available in the environment | Enrollment restarts DCV rather than getting stuck on a dead validation record | +| C7 | Automated DCV, happy path | Deploy and configure a DNS provider plugin for a zone you control, then enroll for a never-validated domain in that zone | Certificate issues from the single enrollment with no operator step; the TXT record appears in the zone during validation and is gone afterwards | +| C8 | Automated DCV, cleanup verified | After C7, list TXT records for the domain at the DNS provider | No `*_validate=` record remains; HydrantId still shows the domain `VALIDATED` with a future `domainValidUntil` | +| C9 | Automated DCV times out | Set `DomainValidationTimeoutSeconds` to a low value (e.g. `15`) and enroll for a domain in a zone whose validation is slow, or point the plugin at a zone HydrantId cannot resolve | Enrollment returns external-validation with TXT instructions rather than failing; the staged record is cleaned up | +| C10 | DNS provider plugin misconfigured | Configure the DNS provider plugin with a bad credential, then enroll | Enrollment falls back to external validation with TXT instructions; Gateway log records the plugin's staging error; enrollment does not fail outright | +| C11 | Mixed zones on one certificate | Enroll for a CN in an automated zone plus a SAN in a zone with no DNS plugin | The automated domain validates; the un-automated one is reported in the external-validation message for manual publication | +| C12 | No DNS plugin deployed at all | With no DNS provider plugin configured, repeat C1/C2 | Behaves exactly as C1/C2 did before automation existed | ### D. Renewal @@ -317,4 +375,4 @@ Confirm via the policy list that `details.validator` is unset for the policy und | H2 | Enroll while CA is Disabled | Set CA `Enabled=false`, attempt enrollment | Fails/blocked consistent with disabled state | | H3 | Network/HydrantId outage simulated | Point `HydrantIdBaseUrl` at an unreachable host, attempt any operation | Fails with a clear connectivity error, not a hang | -**Prerequisites for the C-series tests**: a domain you actually control DNS for, so you can publish the real TXT records HydrantId returns. +**Prerequisites for the C-series tests**: a domain you actually control DNS for, so you can publish the real TXT records HydrantId returns. Tests C7–C11 additionally need a DNS provider plugin deployed to the Gateway and configured for that domain's zone. diff --git a/integration-manifest.json b/integration-manifest.json index 3f9f33b..9a6fac6 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -25,6 +25,46 @@ "name": "HydrantIdAuthKey", "description": "The AuthKey Obtained from HydrantId." }, + { + "name": "HydrantIdAccountId", + "description": "Optional. Some HydrantId tenants require the account id to be included when creating a domain validation request (POST /domains/); leave blank if domain validation already works without it. Obtain from the HydrantId portal's account settings, HydrantId support, or the 'account.id' field on any existing certificate returned by the API." + }, + { + "name": "HydrantIdOrgName", + "description": "Optional. Organization name required by some HydrantId validators (e.g. IdenTrust) on domain validation requests. Leave blank if not required by your validator -- omitted from the request entirely when blank." + }, + { + "name": "HydrantIdOrgPrimaryContactFullName", + "description": "Optional. Organization primary contact full name required by some HydrantId validators (e.g. IdenTrust) on domain validation requests." + }, + { + "name": "HydrantIdOrgStreetAddress", + "description": "Optional. Organization street address required by some HydrantId validators (e.g. IdenTrust) on domain validation requests." + }, + { + "name": "HydrantIdOrgCityProvPostalCodeCountry", + "description": "Optional. Organization city/province/postal code/country required by some HydrantId validators (e.g. IdenTrust) on domain validation requests." + }, + { + "name": "HydrantIdEmailAddress", + "description": "Optional. Organization contact email address required by some HydrantId validators (e.g. IdenTrust) on domain validation requests." + }, + { + "name": "HydrantIdPhoneNumber", + "description": "Optional. Organization contact phone number required by some HydrantId validators (e.g. IdenTrust) on domain validation requests." + }, + { + "name": "DnsPropagationDelaySeconds", + "description": "Seconds to wait after a DNS provider plugin writes the validation TXT record before asking HydrantId to check it, allowing the record to propagate to the authoritative nameservers. Only used when a DNS provider plugin is handling the record; ignored on the manual validation path. Set to 0 to skip the delay and start polling immediately." + }, + { + "name": "DomainValidationTimeoutSeconds", + "description": "Maximum seconds to hold the enrollment open while polling HydrantId for domain validation to complete after a DNS provider plugin has staged the TXT record. On timeout the enrollment falls back to external validation (manual DNS publish and resubmit) rather than failing." + }, + { + "name": "DomainValidationPollIntervalSeconds", + "description": "Seconds between HydrantId domain validation status checks while waiting for a staged DNS record to be validated." + }, { "name": "Enabled", "description": "Flag to Enable or Disable the CA connector." From 7992d2daa14f83e4487497eb0edef9753b84065c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 2 Sep 2026 18:39:53 +0000 Subject: [PATCH 20/29] docs: auto-generate README and documentation [skip ci] --- README.md | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 43c9fcb..25e5d2a 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,57 @@ The plugin supports the following standard CRL revocation reasons: | **HydrantIdOrgCityProvPostalCodeCountry** | Organization city/province/postal code/country, paired with HydrantIdOrgName. | No | `Anytown, OH 44131, US` | | **HydrantIdEmailAddress** | Organization contact email address, paired with HydrantIdOrgName. | No | `jane@acme.com` | | **HydrantIdPhoneNumber** | Organization contact phone number, paired with HydrantIdOrgName. | No | `+1-555-555-0100` | + | **DnsPropagationDelaySeconds** | Seconds to wait after a DNS provider plugin writes the validation TXT record before asking HydrantId to check it. Only used on the automated path; ignored when validation is done by hand. Set to `0` to start polling immediately. Defaults to `30` when blank. | No | `30` | + | **DomainValidationTimeoutSeconds** | Maximum seconds to hold the enrollment open while polling HydrantId for domain validation after a DNS provider plugin has staged the record. On timeout the enrollment falls back to external validation rather than failing. Defaults to `300` when blank. | No | `300` | + | **DomainValidationPollIntervalSeconds** | Seconds between HydrantId domain validation status checks while waiting for a staged record. Defaults to `10` when blank. | No | `10` | + + ### Automated Domain Validation (DNS Provider Plugins) + + When a policy has a `validator` configured, HydrantId requires domain control validation (DCV) + before it will issue. The plugin can complete DCV either automatically or by hand, and picks + per domain without any configuration switch. + + **Automated path.** If the AnyCA Gateway supplies an `IDomainValidatorFactory` and a DNS provider + plugin is deployed and configured for the zone that owns the domain, a single enrollment does all + of the following without operator involvement: + + 1. Creates (or re-checks) the HydrantId domain validation record to obtain its TXT code. + 2. Asks the resolved DNS provider plugin to write that record. The record name is **the domain + itself** — not an `_acme-challenge` subdomain, as in ACME — and the value is HydrantId's whole + code string, e.g. `identrust_validate=1kiQrHax...`, matching the `codeInstructions` HydrantId + returns. + 3. Waits `DnsPropagationDelaySeconds`, then polls HydrantId every + `DomainValidationPollIntervalSeconds` until the domain reports `VALIDATED`, up to + `DomainValidationTimeoutSeconds`. + 4. Deletes the TXT record it wrote, whether or not validation succeeded. HydrantId's DCV remains + valid until `domainValidUntil` (roughly six months for IdenTrust), so no record needs to stay + in the zone. + 5. Submits the CSR and waits for the certificate, returning the issued certificate from the same + enrollment call. + + A cleanup failure is logged but never fails an enrollment that otherwise succeeded — a leftover + TXT record cannot block issuance. + + **Manual fallback.** Any domain that cannot be automated falls back to the previous behaviour: + the enrollment returns an external-validation status carrying the TXT record to publish, and the + operator resubmits once it is live. This happens when: + + - the Gateway supplies no `IDomainValidatorFactory`; + - no DNS provider plugin is configured for that domain's zone; + - the DNS provider plugin fails or throws while writing the record; + - HydrantId returns no validation code; or + - validation is still pending when `DomainValidationTimeoutSeconds` runs out. + + Because the fallback is per domain, a certificate with some domains in an automated zone and + others outside it still makes progress on the automated ones. + + Domains that are already `VALIDATED`, or covered by an already-validated parent domain, skip DCV + entirely and never touch a DNS provider plugin. + + > **Note on validation type.** DNS provider plugins are resolved with a validation type of + > `dns-01` first, then `DNS`. The reference ACME CA plugin resolves with `dns-01` at runtime while + > that project's DNS plugin documentation describes `GetValidationType()` as returning `DNS`, so + > both spellings are attempted rather than silently missing a plugin that is deployed. ### Gateway Registration Notes @@ -242,6 +293,7 @@ The plugin supports the following standard CRL revocation reasons: * **HydrantIdAuthKey** - The API Authentication Key (secret) provided by HydrantId for API access. * **HydrantIdAccountId** - Optional. Required by some HydrantId tenants for domain validation to succeed; see the table above. * **HydrantIdOrgName**, **HydrantIdOrgPrimaryContactFullName**, **HydrantIdOrgStreetAddress**, **HydrantIdOrgCityProvPostalCodeCountry**, **HydrantIdEmailAddress**, **HydrantIdPhoneNumber** - Optional. Required by some domain validators (e.g. IdenTrust); see the table above. + * **DnsPropagationDelaySeconds**, **DomainValidationTimeoutSeconds**, **DomainValidationPollIntervalSeconds** - Optional timing controls for automated domain validation; see [Automated Domain Validation](#automated-domain-validation-dns-provider-plugins). Leave blank to use the defaults. 2. **Certificate Template Configuration** @@ -267,6 +319,16 @@ The plugin supports the following standard CRL revocation reasons: * **HydrantIdBaseUrl** - The Base URL For the HydrantId Endpoint similar to https://acm-stage.hydrantid.com. Get this from HydrantId. * **HydrantIdAuthId** - The AuthId Obtained from HydrantId. * **HydrantIdAuthKey** - The AuthKey Obtained from HydrantId. + * **HydrantIdAccountId** - Optional. Some HydrantId tenants require the account id to be included when creating a domain validation request (POST /domains/); leave blank if domain validation already works without it. Obtain from the HydrantId portal's account settings, HydrantId support, or the 'account.id' field on any existing certificate returned by the API. + * **HydrantIdOrgName** - Optional. Organization name required by some HydrantId validators (e.g. IdenTrust) on domain validation requests. Leave blank if not required by your validator -- omitted from the request entirely when blank. + * **HydrantIdOrgPrimaryContactFullName** - Optional. Organization primary contact full name required by some HydrantId validators (e.g. IdenTrust) on domain validation requests. + * **HydrantIdOrgStreetAddress** - Optional. Organization street address required by some HydrantId validators (e.g. IdenTrust) on domain validation requests. + * **HydrantIdOrgCityProvPostalCodeCountry** - Optional. Organization city/province/postal code/country required by some HydrantId validators (e.g. IdenTrust) on domain validation requests. + * **HydrantIdEmailAddress** - Optional. Organization contact email address required by some HydrantId validators (e.g. IdenTrust) on domain validation requests. + * **HydrantIdPhoneNumber** - Optional. Organization contact phone number required by some HydrantId validators (e.g. IdenTrust) on domain validation requests. + * **DnsPropagationDelaySeconds** - Seconds to wait after a DNS provider plugin writes the validation TXT record before asking HydrantId to check it, allowing the record to propagate to the authoritative nameservers. Only used when a DNS provider plugin is handling the record; ignored on the manual validation path. Set to 0 to skip the delay and start polling immediately. + * **DomainValidationTimeoutSeconds** - Maximum seconds to hold the enrollment open while polling HydrantId for domain validation to complete after a DNS provider plugin has staged the TXT record. On timeout the enrollment falls back to external validation (manual DNS publish and resubmit) rather than failing. + * **DomainValidationPollIntervalSeconds** - Seconds between HydrantId domain validation status checks while waiting for a staged DNS record to be validated. * **Enabled** - Flag to Enable or Disable the CA connector. 2. ### Template (Product) Configuration @@ -353,6 +415,12 @@ Confirm via the policy list that `details.validator` is unset for the policy und | C4b | Enroll for a subdomain of a still-pending (not yet validated) parent | Same as C4, but the parent domain's own validation is still `Pending` | Creates its own separate validation record for the subdomain (parent coverage only applies once the parent is actually `Validated`) | | C5 | Never publish the TXT record | Same as C1 but don't publish the record, resubmit later | Stays pending; status message still shows the same/valid instructions, doesn't error | | C6 | Domain validation expires mid-lifecycle (IdenTrust ~200 days) | Not practically testable end-to-end in a short QA pass — mark "not testable this cycle" unless a naturally-expired domain is available in the environment | Enrollment restarts DCV rather than getting stuck on a dead validation record | +| C7 | Automated DCV, happy path | Deploy and configure a DNS provider plugin for a zone you control, then enroll for a never-validated domain in that zone | Certificate issues from the single enrollment with no operator step; the TXT record appears in the zone during validation and is gone afterwards | +| C8 | Automated DCV, cleanup verified | After C7, list TXT records for the domain at the DNS provider | No `*_validate=` record remains; HydrantId still shows the domain `VALIDATED` with a future `domainValidUntil` | +| C9 | Automated DCV times out | Set `DomainValidationTimeoutSeconds` to a low value (e.g. `15`) and enroll for a domain in a zone whose validation is slow, or point the plugin at a zone HydrantId cannot resolve | Enrollment returns external-validation with TXT instructions rather than failing; the staged record is cleaned up | +| C10 | DNS provider plugin misconfigured | Configure the DNS provider plugin with a bad credential, then enroll | Enrollment falls back to external validation with TXT instructions; Gateway log records the plugin's staging error; enrollment does not fail outright | +| C11 | Mixed zones on one certificate | Enroll for a CN in an automated zone plus a SAN in a zone with no DNS plugin | The automated domain validates; the un-automated one is reported in the external-validation message for manual publication | +| C12 | No DNS plugin deployed at all | With no DNS provider plugin configured, repeat C1/C2 | Behaves exactly as C1/C2 did before automation existed | ### D. Renewal @@ -398,7 +466,7 @@ Confirm via the policy list that `details.validator` is unset for the policy und | H2 | Enroll while CA is Disabled | Set CA `Enabled=false`, attempt enrollment | Fails/blocked consistent with disabled state | | H3 | Network/HydrantId outage simulated | Point `HydrantIdBaseUrl` at an unreachable host, attempt any operation | Fails with a clear connectivity error, not a hang | -**Prerequisites for the C-series tests**: a domain you actually control DNS for, so you can publish the real TXT records HydrantId returns. +**Prerequisites for the C-series tests**: a domain you actually control DNS for, so you can publish the real TXT records HydrantId returns. Tests C7–C11 additionally need a DNS provider plugin deployed to the Gateway and configured for that domain's zone. ## License From 3b349c40fced51872e6044839f91a74262b6ffe5 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Wed, 2 Sep 2026 15:28:50 -0400 Subject: [PATCH 21/29] fixed domain validation --- CHANGELOG.md | 2 + .../HydrantIdCAPluginTests.cs | 220 ++++++++++++++--- HydrantCAProxy/HydrantIdCAPlugin.cs | 223 +++++++++++++++--- docsource/configuration.md | 35 ++- 4 files changed, 417 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4b2517..e942694 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # v1.1.0 * Added automated DNS-01 style domain control validation: when the AnyCA Gateway supplies an IDomainValidatorFactory and a DNS provider plugin is configured for the domain's zone, the plugin now stages HydrantId's validation TXT record, polls until the domain is VALIDATED, removes the record, and issues the certificate within a single enrollment call +* Changed domain control validation to target the registrable base domain rather than the CSR's fully-qualified name; HydrantId links the vetted organization to the base domain only, and validating a subdomain produced a record with a null organizationIds that POST /csr rejected with "No valid domains associated with organization". A base-domain validation additionally covers every subdomain until domainValidUntil +* Added a fallback to the fully-qualified name when HydrantId will not accept the derived base domain, so an unrecognized multi-label public suffix costs one rejected API call rather than a failed enrollment * Added per-domain fallback to external validation when automation is unavailable (no factory, no DNS plugin for the zone, staging failure, no validation code, or validation timeout), preserving the previous manual publish-and-resubmit behaviour * Added DnsPropagationDelaySeconds, DomainValidationTimeoutSeconds and DomainValidationPollIntervalSeconds CA connection settings * Added domain control validation for policies that declare a validator, including reuse of an already-validated parent domain for subdomains and regeneration of expired validation codes diff --git a/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs b/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs index a7484c6..575946e 100644 --- a/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs +++ b/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs @@ -453,7 +453,7 @@ public async Task EnsureDomainsValidatedAsync_NewDomain_CallsCreate() mockClient.Setup(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny())) .ReturnsAsync(new Domain { Status = DomainStatusEnum.Validated }); - var result = await plugin.EnsureDomainsValidatedAsync(mockClient.Object, NewFlow(), new List { "new.example.com" }, "IdenTrust"); + var result = await plugin.EnsureDomainsValidatedAsync(mockClient.Object, NewFlow(), new List { "new-example.com" }, "IdenTrust"); Assert.True(result.AllValidated); mockClient.Verify(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny()), Times.Once); @@ -466,12 +466,12 @@ public async Task EnsureDomainsValidatedAsync_ExpiredDomain_CallsCreateNotCheck( var mockClient = new Mock(); mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List { - new Domain { Id = "d1", DomainName = "expired.example.com", Status = DomainStatusEnum.Expired } + new Domain { Id = "d1", DomainName = "expired-example.com", Status = DomainStatusEnum.Expired } }); mockClient.Setup(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny())) .ReturnsAsync(new Domain { Status = DomainStatusEnum.Pending, CodeInstructions = "new code" }); - var result = await plugin.EnsureDomainsValidatedAsync(mockClient.Object, NewFlow(), new List { "expired.example.com" }, "IdenTrust"); + var result = await plugin.EnsureDomainsValidatedAsync(mockClient.Object, NewFlow(), new List { "expired-example.com" }, "IdenTrust"); Assert.False(result.AllValidated); mockClient.Verify(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny()), Times.Once); @@ -485,12 +485,12 @@ public async Task EnsureDomainsValidatedAsync_PendingDomain_CallsCheckNotCreate( var mockClient = new Mock(); mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List { - new Domain { Id = "d1", DomainName = "pending.example.com", Status = DomainStatusEnum.Pending } + new Domain { Id = "d1", DomainName = "pending-example.com", Status = DomainStatusEnum.Pending } }); mockClient.Setup(c => c.GetSubmitCheckDomainValidationAsync("d1")) .ReturnsAsync(new Domain { Status = DomainStatusEnum.Validated }); - var result = await plugin.EnsureDomainsValidatedAsync(mockClient.Object, NewFlow(), new List { "pending.example.com" }, "IdenTrust"); + var result = await plugin.EnsureDomainsValidatedAsync(mockClient.Object, NewFlow(), new List { "pending-example.com" }, "IdenTrust"); Assert.True(result.AllValidated); mockClient.Verify(c => c.GetSubmitCheckDomainValidationAsync("d1"), Times.Once); @@ -516,7 +516,7 @@ public async Task EnsureDomainsValidatedAsync_SubdomainOfValidatedParent_SkipsWi } [Fact] - public async Task EnsureDomainsValidatedAsync_SubdomainOfPendingParent_StillCreatesOwnRecord() + public async Task EnsureDomainsValidatedAsync_SubdomainOfPendingParent_RechecksTheParentInsteadOfCreatingItsOwn() { var plugin = new HydrantIdCAPlugin(); var mockClient = new Mock(); @@ -524,14 +524,18 @@ public async Task EnsureDomainsValidatedAsync_SubdomainOfPendingParent_StillCrea { new Domain { Id = "d1", DomainName = "keyfactorhydrantid.com", Status = DomainStatusEnum.Pending } }); - mockClient.Setup(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny())) - .ReturnsAsync(new Domain { Status = DomainStatusEnum.Validated }); + mockClient.Setup(c => c.GetSubmitCheckDomainValidationAsync("d1")) + .ReturnsAsync(new Domain { Id = "d1", Status = DomainStatusEnum.Validated }); var result = await plugin.EnsureDomainsValidatedAsync(mockClient.Object, NewFlow(), new List { "www.keyfactorhydrantid.com" }, "IdenTrust"); + // The base domain carries the organization link, so its in-flight validation is the + // one to finish -- creating a second record for the subdomain would produce another + // record with a null organizationIds. Assert.True(result.AllValidated); - mockClient.Verify(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny()), Times.Once); + mockClient.Verify(c => c.GetSubmitCheckDomainValidationAsync("d1"), Times.Once); + mockClient.Verify(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny()), Times.Never); } [Theory] @@ -578,7 +582,7 @@ public async Task EnsureDomainsValidatedAsync_SoftDeletedPendingRecord_IsIgnored new Domain { Id = "deleted-1", - DomainName = "gone.example.com", + DomainName = "gone-example.com", Status = DomainStatusEnum.Pending, DeletedAt = "2026-09-01T20:23:52.000Z" } @@ -587,7 +591,7 @@ public async Task EnsureDomainsValidatedAsync_SoftDeletedPendingRecord_IsIgnored .ReturnsAsync(new Domain { Id = "fresh-1", Status = DomainStatusEnum.Validated }); var result = await plugin.EnsureDomainsValidatedAsync( - mockClient.Object, NewFlow(), new List { "gone.example.com" }, "IdenTrust"); + mockClient.Object, NewFlow(), new List { "gone-example.com" }, "IdenTrust"); Assert.True(result.AllValidated); mockClient.Verify(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny()), Times.Once); @@ -604,7 +608,7 @@ public async Task EnsureDomainsValidatedAsync_SoftDeletedValidatedRecord_DoesNot new Domain { Id = "deleted-1", - DomainName = "gone.example.com", + DomainName = "gone-example.com", Status = DomainStatusEnum.Validated, DeletedAt = "2026-09-01T20:23:52.000Z" } @@ -613,7 +617,7 @@ public async Task EnsureDomainsValidatedAsync_SoftDeletedValidatedRecord_DoesNot .ReturnsAsync(new Domain { Id = "fresh-1", Status = DomainStatusEnum.Validated }); var result = await plugin.EnsureDomainsValidatedAsync( - mockClient.Object, NewFlow(), new List { "gone.example.com" }, "IdenTrust"); + mockClient.Object, NewFlow(), new List { "gone-example.com" }, "IdenTrust"); Assert.True(result.AllValidated); mockClient.Verify(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny()), Times.Once); @@ -634,19 +638,19 @@ public async Task EnsureDomainsValidatedAsync_MixedPendingAndValidated_Aggregate var mockClient = new Mock(); mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List { - new Domain { Id = "d1", DomainName = "already.example.com", Status = DomainStatusEnum.Validated }, - new Domain { Id = "d2", DomainName = "pending.example.com", Status = DomainStatusEnum.Pending } + new Domain { Id = "d1", DomainName = "already-example.com", Status = DomainStatusEnum.Validated }, + new Domain { Id = "d2", DomainName = "pending-example.com", Status = DomainStatusEnum.Pending } }); mockClient.Setup(c => c.GetSubmitCheckDomainValidationAsync("d2")) .ReturnsAsync(new Domain { Status = DomainStatusEnum.Pending, CodeInstructions = "still waiting" }); var result = await plugin.EnsureDomainsValidatedAsync(mockClient.Object, NewFlow(), - new List { "already.example.com", "pending.example.com" }, "IdenTrust"); + new List { "already-example.com", "pending-example.com" }, "IdenTrust"); Assert.False(result.AllValidated); - Assert.Contains("pending.example.com", result.PendingMessage); + Assert.Contains("pending-example.com", result.PendingMessage); Assert.Contains("still waiting", result.PendingMessage); - Assert.DoesNotContain("already.example.com", result.PendingMessage); + Assert.DoesNotContain("already-example.com", result.PendingMessage); } // --------------------------------------------------------------------- @@ -722,7 +726,7 @@ public async Task EnsureDomainsValidatedAsync_OrgPayloadConfigured_IsIncludedInC .ReturnsAsync(new Domain { Status = DomainStatusEnum.Validated }); plugin.ClientFactory = _ => mockClient.Object; - await plugin.EnsureDomainsValidatedAsync(mockClient.Object, NewFlow(), new List { "new.example.com" }, "IdenTrust"); + await plugin.EnsureDomainsValidatedAsync(mockClient.Object, NewFlow(), new List { "new-example.com" }, "IdenTrust"); Assert.NotNull(capturedPayload.Payload); Assert.IsType(capturedPayload.Payload); @@ -781,6 +785,160 @@ private static Mock StubDnsFactory(IDomainValidator val return factory; } + // --------------------------------------------------------------------- + // Validation target selection (base domain, with FQDN fallback) + // --------------------------------------------------------------------- + + [Theory] + [InlineData("keyfactorluadns.com", "keyfactorluadns.com")] + [InlineData("brian1.keyfactorluadns.com", "keyfactorluadns.com")] + [InlineData("a.b.c.keyfactorluadns.com", "keyfactorluadns.com")] + [InlineData("*.keyfactorluadns.com", "keyfactorluadns.com")] + [InlineData("brian1.keyfactorluadns.com.", "keyfactorluadns.com")] + [InlineData(" brian1.keyfactorluadns.com ", "keyfactorluadns.com")] + [InlineData("WWW.Example.COM", "Example.COM")] + // Multi-label public suffixes must not collapse to something unregistrable. + [InlineData("example.co.uk", "example.co.uk")] + [InlineData("www.example.co.uk", "example.co.uk")] + [InlineData("a.b.example.co.uk", "example.co.uk")] + [InlineData("co.uk", "co.uk")] + [InlineData("example.com.au", "example.com.au")] + [InlineData("host.example.com.au", "example.com.au")] + // Single-label and empty inputs pass through rather than throwing. + [InlineData("localhost", "localhost")] + [InlineData("", null)] + [InlineData(null, null)] + public void GetBaseDomain_ReturnsRegistrableBase(string input, string expected) + { + Assert.Equal(expected, HydrantIdCAPlugin.GetBaseDomain(input)); + } + + [Fact] + public void GetValidationTargets_Subdomain_PrefersBaseDomainThenFqdn() + { + var targets = HydrantIdCAPlugin.GetValidationTargets("brian1.keyfactorluadns.com"); + + Assert.Equal(new[] { "keyfactorluadns.com", "brian1.keyfactorluadns.com" }, targets); + } + + [Fact] + public void GetValidationTargets_AlreadyBaseDomain_ReturnsSingleTarget() + { + var targets = HydrantIdCAPlugin.GetValidationTargets("keyfactorluadns.com"); + + Assert.Equal(new[] { "keyfactorluadns.com" }, targets); + } + + [Fact] + public void GetValidationTargets_EmptyInput_ReturnsNoTargets() + { + Assert.Empty(HydrantIdCAPlugin.GetValidationTargets(null)); + Assert.Empty(HydrantIdCAPlugin.GetValidationTargets(" ")); + } + + [Fact] + public async Task EnsureDomainsValidatedAsync_Subdomain_ValidatesTheBaseDomain() + { + var plugin = new HydrantIdCAPlugin(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List()); + CreateDomainValidationPayload captured = null; + mockClient.Setup(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny())) + .Callback(pl => captured = pl) + .ReturnsAsync(new Domain { Id = "d1", Status = DomainStatusEnum.Validated }); + + var result = await plugin.EnsureDomainsValidatedAsync(mockClient.Object, NewFlow(), + new List { "brian1.keyfactorluadns.com" }, "IdenTrust"); + + // HydrantId links the vetted organization to the base domain only. + Assert.True(result.AllValidated); + Assert.Equal("keyfactorluadns.com", captured.DomainName); + mockClient.Verify(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task EnsureDomainsValidatedAsync_BaseDomainRejected_FallsBackToTheFqdn() + { + var plugin = new HydrantIdCAPlugin(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List()); + var attempted = new List(); + mockClient.Setup(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny())) + .Callback(pl => attempted.Add(pl.DomainName)) + .Returns((CreateDomainValidationPayload pl) => pl.DomainName == "example.invalid" + ? throw new InvalidOperationException("HTTP 400: domain not registrable") + : Task.FromResult(new Domain { Id = "d1", Status = DomainStatusEnum.Validated })); + + var result = await plugin.EnsureDomainsValidatedAsync(mockClient.Object, NewFlow(), + new List { "host.example.invalid" }, "IdenTrust"); + + Assert.True(result.AllValidated); + Assert.Equal(new[] { "example.invalid", "host.example.invalid" }, attempted); + } + + [Fact] + public async Task EnsureDomainsValidatedAsync_AllTargetsRejected_ReportsPendingWithTheError() + { + var plugin = new HydrantIdCAPlugin(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List()); + mockClient.Setup(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("HTTP 400: domain not permitted")); + + var result = await plugin.EnsureDomainsValidatedAsync(mockClient.Object, NewFlow(), + new List { "host.example.invalid" }, "IdenTrust"); + + Assert.False(result.AllValidated); + Assert.Contains("domain not permitted", result.PendingMessage); + // Both candidates attempted before giving up, and the failure is reported rather than thrown. + mockClient.Verify(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task EnsureDomainsValidatedAsync_OneDomainRejected_OtherDomainsStillProgress() + { + var plugin = new HydrantIdCAPlugin(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List()); + mockClient.Setup(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny())) + .Returns((CreateDomainValidationPayload pl) => pl.DomainName.EndsWith(".invalid") + ? throw new InvalidOperationException("HTTP 400: domain not permitted") + : Task.FromResult(new Domain { Id = "d1", Status = DomainStatusEnum.Validated })); + + var result = await plugin.EnsureDomainsValidatedAsync(mockClient.Object, NewFlow(), + new List { "good-example.com", "host.example.invalid" }, "IdenTrust"); + + Assert.False(result.AllValidated); + Assert.Contains("host.example.invalid", result.PendingMessage); + Assert.DoesNotContain("good-example.com", result.PendingMessage); + } + + [Fact] + public async Task EnsureDomainsValidatedAsync_SubdomainStaging_WritesTxtOnTheBaseDomain() + { + var validator = StubDnsValidator(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List()); + mockClient.Setup(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny())) + .ReturnsAsync(new Domain + { + Id = "d1", + Status = DomainStatusEnum.Pending, + Code = "identrust_validate=abc123", + CodeInstructions = "publish TXT" + }); + mockClient.Setup(c => c.GetSubmitCheckDomainValidationAsync("d1")) + .ReturnsAsync(new Domain { Id = "d1", Status = DomainStatusEnum.Validated }); + var plugin = MakePluginWithDnsFactory(mockClient, StubDnsFactory(validator.Object).Object); + + var result = await plugin.EnsureDomainsValidatedAsync(mockClient.Object, NewFlow(), + new List { "brian1.keyfactorluadns.com" }, "IdenTrust"); + + Assert.True(result.AllValidated); + validator.Verify(v => v.StageValidation("keyfactorluadns.com", "identrust_validate=abc123", It.IsAny()), Times.Once); + validator.Verify(v => v.CleanupValidation("keyfactorluadns.com", It.IsAny()), Times.Once); + } + [Fact] public void ResolveDnsValidator_NoFactorySupplied_ReturnsNull() { @@ -846,13 +1004,13 @@ public async Task EnsureDomainsValidatedAsync_StagedRecordValidates_ReturnsAllVa var plugin = MakePluginWithDnsFactory(mockClient, StubDnsFactory(validator.Object).Object); var result = await plugin.EnsureDomainsValidatedAsync( - mockClient.Object, NewFlow(), new List { "auto.example.com" }, "IdenTrust"); + mockClient.Object, NewFlow(), new List { "auto-example.com" }, "IdenTrust"); Assert.True(result.AllValidated); Assert.Null(result.PendingMessage); // Record name is the domain itself, value is HydrantID's whole code string. - validator.Verify(v => v.StageValidation("auto.example.com", "identrust_validate=abc123", It.IsAny()), Times.Once); - validator.Verify(v => v.CleanupValidation("auto.example.com", It.IsAny()), Times.Once); + validator.Verify(v => v.StageValidation("auto-example.com", "identrust_validate=abc123", It.IsAny()), Times.Once); + validator.Verify(v => v.CleanupValidation("auto-example.com", It.IsAny()), Times.Once); } [Fact] @@ -872,7 +1030,7 @@ public async Task EnsureDomainsValidatedAsync_StageFails_FallsBackToManualWithou var plugin = MakePluginWithDnsFactory(mockClient, StubDnsFactory(validator.Object).Object); var result = await plugin.EnsureDomainsValidatedAsync( - mockClient.Object, NewFlow(), new List { "auto.example.com" }, "IdenTrust"); + mockClient.Object, NewFlow(), new List { "auto-example.com" }, "IdenTrust"); Assert.False(result.AllValidated); Assert.Contains("publish TXT", result.PendingMessage); @@ -899,11 +1057,11 @@ public async Task EnsureDomainsValidatedAsync_ValidationNeverCompletes_TimesOutT var plugin = MakePluginWithDnsFactory(mockClient, StubDnsFactory(validator.Object).Object); var result = await plugin.EnsureDomainsValidatedAsync( - mockClient.Object, NewFlow(), new List { "slow.example.com" }, "IdenTrust"); + mockClient.Object, NewFlow(), new List { "slow-example.com" }, "IdenTrust"); Assert.False(result.AllValidated); Assert.Contains("publish TXT", result.PendingMessage); - validator.Verify(v => v.CleanupValidation("slow.example.com", It.IsAny()), Times.Once); + validator.Verify(v => v.CleanupValidation("slow-example.com", It.IsAny()), Times.Once); } [Fact] @@ -925,10 +1083,10 @@ public async Task EnsureDomainsValidatedAsync_StatusCheckThrows_TreatedAsPending var plugin = MakePluginWithDnsFactory(mockClient, StubDnsFactory(validator.Object).Object); var result = await plugin.EnsureDomainsValidatedAsync( - mockClient.Object, NewFlow(), new List { "flaky.example.com" }, "IdenTrust"); + mockClient.Object, NewFlow(), new List { "flaky-example.com" }, "IdenTrust"); Assert.False(result.AllValidated); - validator.Verify(v => v.CleanupValidation("flaky.example.com", It.IsAny()), Times.Once); + validator.Verify(v => v.CleanupValidation("flaky-example.com", It.IsAny()), Times.Once); } [Fact] @@ -952,7 +1110,7 @@ public async Task EnsureDomainsValidatedAsync_CleanupThrows_DoesNotFailAnOtherwi var plugin = MakePluginWithDnsFactory(mockClient, StubDnsFactory(validator.Object).Object); var result = await plugin.EnsureDomainsValidatedAsync( - mockClient.Object, NewFlow(), new List { "auto.example.com" }, "IdenTrust"); + mockClient.Object, NewFlow(), new List { "auto-example.com" }, "IdenTrust"); Assert.True(result.AllValidated); } @@ -968,7 +1126,7 @@ public async Task EnsureDomainsValidatedAsync_NoCodeReturned_FallsBackToManualWi var plugin = MakePluginWithDnsFactory(mockClient, StubDnsFactory(validator.Object).Object); var result = await plugin.EnsureDomainsValidatedAsync( - mockClient.Object, NewFlow(), new List { "nocode.example.com" }, "IdenTrust"); + mockClient.Object, NewFlow(), new List { "nocode-example.com" }, "IdenTrust"); Assert.False(result.AllValidated); Assert.Contains("publish TXT", result.PendingMessage); @@ -982,12 +1140,12 @@ public async Task EnsureDomainsValidatedAsync_DomainAlreadyValidated_NeverResolv var mockClient = new Mock(); mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List { - new Domain { Id = "d1", DomainName = "done.example.com", Status = DomainStatusEnum.Validated } + new Domain { Id = "d1", DomainName = "done-example.com", Status = DomainStatusEnum.Validated } }); var plugin = MakePluginWithDnsFactory(mockClient, factory.Object); var result = await plugin.EnsureDomainsValidatedAsync( - mockClient.Object, NewFlow(), new List { "done.example.com" }, "IdenTrust"); + mockClient.Object, NewFlow(), new List { "done-example.com" }, "IdenTrust"); Assert.True(result.AllValidated); factory.Verify(f => f.ResolveDomainValidator(It.IsAny(), It.IsAny()), Times.Never); diff --git a/HydrantCAProxy/HydrantIdCAPlugin.cs b/HydrantCAProxy/HydrantIdCAPlugin.cs index fa4650d..3bd41a1 100644 --- a/HydrantCAProxy/HydrantIdCAPlugin.cs +++ b/HydrantCAProxy/HydrantIdCAPlugin.cs @@ -903,6 +903,13 @@ internal DomainValidationOrgPayload BuildOrgPayload() /// reports VALIDATED or the configured budget runs out. /// 3. Cleanup -- remove every record this call staged, whatever the outcome. /// + /// Validation targets the registrable base domain rather than the CSR's fully-qualified + /// name, because HydrantID links the vetted organization to the base domain only -- + /// validating a subdomain yields a record with a null organizationIds, and POST /csr then + /// rejects the enrollment with "No valid domains associated with organization". A + /// base-domain validation also covers every subdomain until domainValidUntil. If HydrantID + /// will not accept the base domain, the fully-qualified name is retried as a fallback. + /// /// Domains that could not be automated (no factory, no plugin for the zone, or no code /// returned) fall back to the manual path and appear in the returned pending message, so a /// CA with no DNS plugin deployed behaves exactly as it did before automation existed. @@ -932,64 +939,62 @@ internal DomainValidationOrgPayload BuildOrgPayload() { foreach (var domainName in domainsToValidate) { - var match = existingDomains.FirstOrDefault(d => + var exactMatch = existingDomains.FirstOrDefault(d => string.Equals(d.DomainName, domainName, StringComparison.OrdinalIgnoreCase)); - if (match == null && IsCoveredByValidatedAncestor(domainName, existingDomains, out var coveringDomain)) + if (exactMatch?.Status == DomainStatusEnum.Validated) { - flow.Step("DomainValidation.CoveredByValidatedParent", $"domain='{domainName}', parent='{coveringDomain}'"); + flow.Step("DomainValidation.AlreadyValidated", $"domain='{domainName}'"); continue; } - Domain domain; - if (match == null || match.Status == DomainStatusEnum.Expired) - { - // HydrantID's "regenerate code" action for an expired domain is the same - // POST used to start a validation from scratch -- confirmed idempotent per - // domain name (does not create a duplicate record) against staging. - flow.Step("DomainValidation.CreateOrRegenerate", - $"domain='{domainName}', priorStatus={(match == null ? "(none)" : match.Status.ToString())}"); - var payload = _requestManager.GetCreateDomainValidationRequest(domainName, validatorId, _config?.HydrantIdAccountId, BuildOrgPayload()); - domain = await client.GetSubmitCreateDomainValidationAsync(payload); - } - else if (match.Status != DomainStatusEnum.Validated) + if (exactMatch == null && IsCoveredByValidatedAncestor(domainName, existingDomains, out var coveringDomain)) { - flow.Step("DomainValidation.Recheck", $"domain='{domainName}', status={match.Status}, domainId='{match.Id}'"); - domain = await client.GetSubmitCheckDomainValidationAsync(match.Id); + flow.Step("DomainValidation.CoveredByValidatedParent", $"domain='{domainName}', parent='{coveringDomain}'"); + continue; } - else + + var (domain, target, targetError) = + await ResolveDomainValidationRecordAsync(client, flow, domainName, existingDomains, validatorId); + + if (domain == null) { - flow.Step("DomainValidation.AlreadyValidated", $"domain='{domainName}'"); + // Every candidate was rejected by HydrantID. Report the domain as pending + // with the failure detail rather than throwing, so the rest of the + // certificate's domains still make progress. + pending.Add((domainName, targetError ?? "(no detail returned by HydrantId)")); continue; } - if (domain?.Status == DomainStatusEnum.Validated) + if (domain.Status == DomainStatusEnum.Validated) { - flow.Step("DomainValidation.NowValidated", $"domain='{domainName}'"); + flow.Step("DomainValidation.NowValidated", $"domain='{target}'"); continue; } - flow.Step("DomainValidation.StillPending", $"domain='{domainName}', status={domain?.Status.ToString() ?? "(null response)"}"); - var instructions = domain?.CodeInstructions ?? "(no instructions returned by HydrantId)"; + flow.Step("DomainValidation.StillPending", $"domain='{target}', status={domain.Status?.ToString() ?? "(none)"}"); + var instructions = domain.CodeInstructions ?? "(no instructions returned by HydrantId)"; - var dnsValidator = ResolveDnsValidator(flow, domainName); + // The DNS plugin is resolved on the name the TXT record actually goes on, so a + // base domain in a different zone from the CSR's hostname routes correctly. + var dnsValidator = ResolveDnsValidator(flow, target); if (dnsValidator == null) { - pending.Add((domainName, instructions)); + pending.Add((target, instructions)); continue; } - if (string.IsNullOrWhiteSpace(domain?.Code) || string.IsNullOrWhiteSpace(domain?.Id)) + if (string.IsNullOrWhiteSpace(domain.Code) || string.IsNullOrWhiteSpace(domain.Id)) { - flow.Skip($"DomainValidation.Stage:{domainName}", "HydrantId returned no validation code or domain id to publish"); - pending.Add((domainName, instructions)); + flow.Skip($"DomainValidation.Stage:{target}", "HydrantId returned no validation code or domain id to publish"); + pending.Add((target, instructions)); continue; } - if (await StageDnsRecordAsync(flow, dnsValidator, domainName, domain.Code)) - staged.Add(new StagedValidation(domainName, domain.Id, instructions, dnsValidator)); + if (await StageDnsRecordAsync(flow, dnsValidator, target, domain.Code)) + staged.Add(new StagedValidation(target, domain.Id, instructions, dnsValidator)); else - pending.Add((domainName, instructions)); + pending.Add((target, instructions)); } if (staged.Count > 0) @@ -1019,6 +1024,162 @@ await flow.StepAsync("DomainValidation.AwaitValidation", async () => return (false, message); } + /// + /// Obtains the HydrantID domain record to validate for , trying + /// each candidate from in order: the registrable base + /// domain first, then the fully-qualified name. A candidate HydrantID rejects (for example a + /// bare public suffix that the naive base-domain derivation produced) falls through to the + /// next one instead of failing the enrollment. + /// + /// + /// The domain record and the name it belongs to, or (null, null, error) when every candidate + /// was rejected. + /// + internal async Task<(Domain Domain, string Target, string Error)> ResolveDomainValidationRecordAsync( + IHydrantIdClient client, FlowLogger flow, string domainName, List existingDomains, string validatorId) + { + var targets = GetValidationTargets(domainName); + string lastError = null; + + foreach (var target in targets) + { + var match = existingDomains.FirstOrDefault(d => + string.Equals(d.DomainName, target, StringComparison.OrdinalIgnoreCase)); + + if (match?.Status == DomainStatusEnum.Validated) + { + flow.Step("DomainValidation.AlreadyValidated", $"domain='{target}' (covers '{domainName}')"); + return (match, target, null); + } + + try + { + Domain domain; + if (match == null || match.Status == DomainStatusEnum.Expired) + { + // HydrantID's "regenerate code" action for an expired domain is the same + // POST used to start a validation from scratch -- confirmed idempotent per + // domain name (does not create a duplicate record) against staging. + flow.Step("DomainValidation.CreateOrRegenerate", + $"domain='{target}', for='{domainName}', priorStatus={(match == null ? "(none)" : match.Status.ToString())}"); + var payload = _requestManager.GetCreateDomainValidationRequest(target, validatorId, _config?.HydrantIdAccountId, BuildOrgPayload()); + domain = await client.GetSubmitCreateDomainValidationAsync(payload); + } + else + { + flow.Step("DomainValidation.Recheck", $"domain='{target}', status={match.Status}, domainId='{match.Id}'"); + domain = await client.GetSubmitCheckDomainValidationAsync(match.Id); + } + + return (domain, target, null); + } + catch (Exception ex) + { + lastError = $"HydrantId rejected domain validation for '{target}': {ex.Message}"; + flow.Fail($"DomainValidation.Target:{target}", ex.Message); + _logger.LogWarning(ex, "ResolveDomainValidationRecordAsync: '{Target}' rejected for '{Domain}', trying next candidate: {Message}", + target, domainName, ex.Message); + } + } + + return (null, null, lastError); + } + + /// + /// The names to attempt domain control validation on for , most + /// preferred first: the registrable base domain, then the fully-qualified name itself. The + /// two collapse to one entry when the name is already a base domain. + /// + internal static List GetValidationTargets(string domainName) + { + var normalized = NormalizeDomainName(domainName); + if (string.IsNullOrEmpty(normalized)) + return new List(); + + var targets = new List(); + var baseDomain = GetBaseDomain(domainName); + + if (!string.IsNullOrEmpty(baseDomain) && + !string.Equals(baseDomain, normalized, StringComparison.OrdinalIgnoreCase)) + { + targets.Add(baseDomain); + } + + targets.Add(normalized); + return targets; + } + + /// + /// The registrable base domain of -- the last two labels, + /// or three when the last two form a known multi-label public suffix. Any wildcard prefix + /// and trailing dot are stripped first. + /// + /// This is deliberately not a full public suffix list. Getting it wrong costs one rejected + /// API call, because falls back to the + /// fully-qualified name; carrying a PSL dependency and keeping its data current costs more. + /// + internal static string GetBaseDomain(string domainName) + { + var normalized = NormalizeDomainName(domainName); + if (string.IsNullOrEmpty(normalized)) + return normalized; + + var labels = normalized.Split('.'); + if (labels.Length <= 2) + return normalized; + + var lastTwo = string.Join(".", labels.Skip(labels.Length - 2)); + if (!_multiLabelPublicSuffixes.Contains(lastTwo)) + return lastTwo; + + return labels.Length <= 3 + ? normalized + : string.Join(".", labels.Skip(labels.Length - 3)); + } + + private static string NormalizeDomainName(string domainName) + { + if (string.IsNullOrWhiteSpace(domainName)) + return null; + + var normalized = domainName.Trim().TrimEnd('.'); + if (normalized.StartsWith("*.", StringComparison.Ordinal)) + normalized = normalized.Substring(2); + + return normalized.Length == 0 ? null : normalized; + } + + // Multi-label public suffixes common enough to be worth special-casing, so the base-domain + // derivation does not produce something unregistrable like "co.uk". Not exhaustive by + // design -- see GetBaseDomain. Add entries when a customer's TLD needs them. + private static readonly HashSet _multiLabelPublicSuffixes = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "co.uk", "org.uk", "ac.uk", "gov.uk", "me.uk", "net.uk", "sch.uk", "ltd.uk", "plc.uk", + "com.au", "net.au", "org.au", "edu.au", "gov.au", "asn.au", "id.au", + "co.nz", "net.nz", "org.nz", "govt.nz", "ac.nz", + "co.jp", "or.jp", "ne.jp", "ac.jp", "go.jp", "ad.jp", "ed.jp", "gr.jp", "lg.jp", + "co.kr", "or.kr", "ne.kr", "re.kr", "go.kr", "ac.kr", + "co.in", "net.in", "org.in", "gen.in", "firm.in", "ind.in", "gov.in", "ac.in", + "co.za", "org.za", "net.za", "web.za", "gov.za", "ac.za", + "co.il", "org.il", "net.il", "ac.il", "gov.il", + "com.br", "net.br", "org.br", "gov.br", "edu.br", + "com.mx", "org.mx", "net.mx", "gob.mx", "edu.mx", + "com.ar", "net.ar", "org.ar", "gob.ar", "edu.ar", + "com.co", "net.co", "org.co", "gov.co", "edu.co", + "com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn", "ac.cn", + "com.tw", "net.tw", "org.tw", "gov.tw", "edu.tw", + "com.hk", "net.hk", "org.hk", "gov.hk", "edu.hk", + "com.sg", "net.sg", "org.sg", "gov.sg", "edu.sg", + "com.tr", "net.tr", "org.tr", "gov.tr", "edu.tr", + "com.pl", "net.pl", "org.pl", "gov.pl", "edu.pl", + "com.ua", "net.ua", "org.ua", "gov.ua", "edu.ua", + "com.ru", "net.ru", "org.ru", "edu.ru", + "com.es", "org.es", "nom.es", "gob.es", "edu.es", + "co.id", "or.id", "web.id", "go.id", "ac.id", + "co.th", "or.th", "in.th", "go.th", "ac.th", + "eu.com", "us.com", "uk.com", "uk.co", "gb.com", + }; + /// /// A HydrantID domain validation whose TXT record was written by this enrollment, and which /// must therefore be polled to completion and then cleaned up. diff --git a/docsource/configuration.md b/docsource/configuration.md index a9efa3b..abe1800 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -178,7 +178,9 @@ per domain without any configuration switch. plugin is deployed and configured for the zone that owns the domain, a single enrollment does all of the following without operator involvement: -1. Creates (or re-checks) the HydrantId domain validation record to obtain its TXT code. +1. Creates (or re-checks) the HydrantId domain validation record for the **registrable base + domain** of the requested name — see [Which name gets validated](#which-name-gets-validated) + — to obtain its TXT code. 2. Asks the resolved DNS provider plugin to write that record. The record name is **the domain itself** — not an `_acme-challenge` subdomain, as in ACME — and the value is HydrantId's whole code string, e.g. `identrust_validate=1kiQrHax...`, matching the `codeInstructions` HydrantId @@ -195,6 +197,33 @@ of the following without operator involvement: A cleanup failure is logged but never fails an enrollment that otherwise succeeded — a leftover TXT record cannot block issuance. +#### Which name gets validated + +Domain control validation targets the **registrable base domain**, not the fully-qualified name +from the CSR. Enrolling for `brian1.example.com` validates `example.com`. + +This is required, not merely an optimization. HydrantId links the vetted organization to the base +domain only: a validation created for a subdomain comes back with `organizationIds: null`, and +`POST /api/v2/csr` then rejects the enrollment with: + +``` +No valid domains associated with organization for IdenTrust policy: ! +``` + +Validating the base domain also covers every subdomain at any depth until `domainValidUntil` +(roughly six months for IdenTrust), so subsequent enrollments for other hostnames in the same +zone need no DNS write at all. + +The base domain is derived as the last two labels, or three when the last two form a known +multi-label public suffix (`example.co.uk`, `example.com.au`, and similar). This is intentionally +**not** a full public suffix list. If the derivation produces a name HydrantId will not accept, +the plugin retries with the fully-qualified name, so an unrecognized suffix costs one rejected API +call rather than a failed enrollment. A wildcard prefix (`*.example.com`) is stripped before +deriving the base domain. + +If every candidate is rejected, that domain is reported in the external-validation message with +HydrantId's error and the certificate's other domains still make progress. + **Manual fallback.** Any domain that cannot be automated falls back to the previous behaviour: the enrollment returns an external-validation status carrying the TXT record to publish, and the operator resubmits once it is live. This happens when: @@ -330,6 +359,10 @@ Confirm via the policy list that `details.validator` is unset for the policy und | C10 | DNS provider plugin misconfigured | Configure the DNS provider plugin with a bad credential, then enroll | Enrollment falls back to external validation with TXT instructions; Gateway log records the plugin's staging error; enrollment does not fail outright | | C11 | Mixed zones on one certificate | Enroll for a CN in an automated zone plus a SAN in a zone with no DNS plugin | The automated domain validates; the un-automated one is reported in the external-validation message for manual publication | | C12 | No DNS plugin deployed at all | With no DNS provider plugin configured, repeat C1/C2 | Behaves exactly as C1/C2 did before automation existed | +| C13 | Base domain is what gets validated | Enroll for a subdomain (e.g. `host1.example.com`) never validated before | HydrantId's Domains list shows a record for `example.com`, not `host1.example.com`, and that record has a non-null `organizationIds` | +| C14 | Second hostname in a validated zone | After C13, enroll for a different hostname in the same zone (e.g. `host2.example.com`) | Issues immediately with no DNS write and no new domain validation record — covered by the validated base domain | +| C15 | Multi-label suffix domain | Enroll for a host under a `co.uk`-style domain if one is available | Validates `example.co.uk`, not `co.uk` | +| C16 | Unrecognized multi-label suffix | Enroll for a host under a TLD whose two-label suffix is not in the built-in list | First create is rejected by HydrantId, plugin retries with the fully-qualified name, enrollment proceeds | ### D. Renewal From 6ce6ab6d3d97916233d464b86b14f635de5e765a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 2 Sep 2026 19:29:22 +0000 Subject: [PATCH 22/29] docs: auto-generate README and documentation [skip ci] --- README.md | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 25e5d2a..a1162a9 100644 --- a/README.md +++ b/README.md @@ -224,7 +224,9 @@ The plugin supports the following standard CRL revocation reasons: plugin is deployed and configured for the zone that owns the domain, a single enrollment does all of the following without operator involvement: - 1. Creates (or re-checks) the HydrantId domain validation record to obtain its TXT code. + 1. Creates (or re-checks) the HydrantId domain validation record for the **registrable base + domain** of the requested name — see [Which name gets validated](#which-name-gets-validated) + — to obtain its TXT code. 2. Asks the resolved DNS provider plugin to write that record. The record name is **the domain itself** — not an `_acme-challenge` subdomain, as in ACME — and the value is HydrantId's whole code string, e.g. `identrust_validate=1kiQrHax...`, matching the `codeInstructions` HydrantId @@ -241,6 +243,33 @@ The plugin supports the following standard CRL revocation reasons: A cleanup failure is logged but never fails an enrollment that otherwise succeeded — a leftover TXT record cannot block issuance. + #### Which name gets validated + + Domain control validation targets the **registrable base domain**, not the fully-qualified name + from the CSR. Enrolling for `brian1.example.com` validates `example.com`. + + This is required, not merely an optimization. HydrantId links the vetted organization to the base + domain only: a validation created for a subdomain comes back with `organizationIds: null`, and + `POST /api/v2/csr` then rejects the enrollment with: + + ``` + No valid domains associated with organization for IdenTrust policy: ! + ``` + + Validating the base domain also covers every subdomain at any depth until `domainValidUntil` + (roughly six months for IdenTrust), so subsequent enrollments for other hostnames in the same + zone need no DNS write at all. + + The base domain is derived as the last two labels, or three when the last two form a known + multi-label public suffix (`example.co.uk`, `example.com.au`, and similar). This is intentionally + **not** a full public suffix list. If the derivation produces a name HydrantId will not accept, + the plugin retries with the fully-qualified name, so an unrecognized suffix costs one rejected API + call rather than a failed enrollment. A wildcard prefix (`*.example.com`) is stripped before + deriving the base domain. + + If every candidate is rejected, that domain is reported in the external-validation message with + HydrantId's error and the certificate's other domains still make progress. + **Manual fallback.** Any domain that cannot be automated falls back to the previous behaviour: the enrollment returns an external-validation status carrying the TXT record to publish, and the operator resubmits once it is live. This happens when: @@ -421,6 +450,10 @@ Confirm via the policy list that `details.validator` is unset for the policy und | C10 | DNS provider plugin misconfigured | Configure the DNS provider plugin with a bad credential, then enroll | Enrollment falls back to external validation with TXT instructions; Gateway log records the plugin's staging error; enrollment does not fail outright | | C11 | Mixed zones on one certificate | Enroll for a CN in an automated zone plus a SAN in a zone with no DNS plugin | The automated domain validates; the un-automated one is reported in the external-validation message for manual publication | | C12 | No DNS plugin deployed at all | With no DNS provider plugin configured, repeat C1/C2 | Behaves exactly as C1/C2 did before automation existed | +| C13 | Base domain is what gets validated | Enroll for a subdomain (e.g. `host1.example.com`) never validated before | HydrantId's Domains list shows a record for `example.com`, not `host1.example.com`, and that record has a non-null `organizationIds` | +| C14 | Second hostname in a validated zone | After C13, enroll for a different hostname in the same zone (e.g. `host2.example.com`) | Issues immediately with no DNS write and no new domain validation record — covered by the validated base domain | +| C15 | Multi-label suffix domain | Enroll for a host under a `co.uk`-style domain if one is available | Validates `example.co.uk`, not `co.uk` | +| C16 | Unrecognized multi-label suffix | Enroll for a host under a TLD whose two-label suffix is not in the built-in list | First create is rejected by HydrantId, plugin retries with the fully-qualified name, enrollment proceeds | ### D. Renewal From f80bdc0c5bca5d35c772ccebcf961a7b37a2f0a1 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Wed, 2 Sep 2026 15:50:12 -0400 Subject: [PATCH 23/29] fixed domains --- CHANGELOG.md | 2 + .../HydrantIdCAPluginTests.cs | 96 ++++++++++++++++++- HydrantCAProxy/HydrantIdCAPlugin.cs | 74 +++++++++----- docsource/configuration.md | 35 ++++++- 4 files changed, 174 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e942694..1fd58db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # v1.1.0 * Added automated DNS-01 style domain control validation: when the AnyCA Gateway supplies an IDomainValidatorFactory and a DNS provider plugin is configured for the domain's zone, the plugin now stages HydrantId's validation TXT record, polls until the domain is VALIDATED, removes the record, and issues the certificate within a single enrollment call * Changed domain control validation to target the registrable base domain rather than the CSR's fully-qualified name; HydrantId links the vetted organization to the base domain only, and validating a subdomain produced a record with a null organizationIds that POST /csr rejected with "No valid domains associated with organization". A base-domain validation additionally covers every subdomain until domainValidUntil +* Changed DNS provider plugin resolution to try the base domain and then the requested name, because the Gateway matches a domain validation configuration on exact domain equality; a configuration registered against either name now resolves, and the record is still written on the base domain +* Changed the DNS provider validation type tried first from "dns-01" to "DNS", which is what deployed DNS plugins report to AnyCA Gateway 26.2; the other spelling is still attempted as a fallback * Added a fallback to the fully-qualified name when HydrantId will not accept the derived base domain, so an unrecognized multi-label public suffix costs one rejected API call rather than a failed enrollment * Added per-domain fallback to external validation when automation is unavailable (no factory, no DNS plugin for the zone, staging failure, no validation code, or validation timeout), preserving the previous manual publish-and-resubmit behaviour * Added DnsPropagationDelaySeconds, DomainValidationTimeoutSeconds and DomainValidationPollIntervalSeconds CA connection settings diff --git a/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs b/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs index 575946e..385601f 100644 --- a/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs +++ b/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs @@ -785,6 +785,18 @@ private static Mock StubDnsFactory(IDomainValidator val return factory; } + // A factory that only answers for one exact domain, mirroring the Gateway's + // Domains.Domain = @DomainName equality match. + private static Mock StubDnsFactoryForDomain(IDomainValidator validator, string domain) + { + var factory = new Mock(); + factory.Setup(f => f.ResolveDomainValidator(It.IsAny(), It.IsAny())) + .Returns((IDomainValidator)null); + factory.Setup(f => f.ResolveDomainValidator(domain, HydrantIdCAPlugin.DnsValidationType)) + .Returns(validator); + return factory; + } + // --------------------------------------------------------------------- // Validation target selection (base domain, with FQDN fallback) // --------------------------------------------------------------------- @@ -957,17 +969,17 @@ public void ResolveDnsValidator_NoPluginForZone_ReturnsNullAfterTryingBothValida Assert.Null(plugin.ResolveDnsValidator(NewFlow(), "example.com")); factory.Verify(f => f.ResolveDomainValidator("example.com", HydrantIdCAPlugin.DnsValidationType), Times.Once); - factory.Verify(f => f.ResolveDomainValidator("example.com", HydrantIdCAPlugin.DnsValidationTypeLegacy), Times.Once); + factory.Verify(f => f.ResolveDomainValidator("example.com", HydrantIdCAPlugin.DnsValidationTypeAlternate), Times.Once); } [Fact] - public void ResolveDnsValidator_LegacyValidationType_IsUsedWhenCanonicalMisses() + public void ResolveDnsValidator_AlternateValidationType_IsUsedWhenPrimaryMisses() { var validator = StubDnsValidator().Object; var factory = new Mock(); factory.Setup(f => f.ResolveDomainValidator("example.com", HydrantIdCAPlugin.DnsValidationType)) .Returns((IDomainValidator)null); - factory.Setup(f => f.ResolveDomainValidator("example.com", HydrantIdCAPlugin.DnsValidationTypeLegacy)) + factory.Setup(f => f.ResolveDomainValidator("example.com", HydrantIdCAPlugin.DnsValidationTypeAlternate)) .Returns(validator); var plugin = MakePluginWithDnsFactory(null, factory.Object); @@ -985,6 +997,84 @@ public void ResolveDnsValidator_FactoryThrows_ReturnsNullRatherThanPropagating() Assert.Null(plugin.ResolveDnsValidator(NewFlow(), "example.com")); } + [Fact] + public void ResolveDnsValidator_TriesEachLookupNameInOrder() + { + var validator = StubDnsValidator().Object; + var factory = StubDnsFactoryForDomain(validator, "host.example.com"); + var plugin = MakePluginWithDnsFactory(null, factory.Object); + + // Base domain first, then the requested name -- only the latter is registered. + var resolved = plugin.ResolveDnsValidator(NewFlow(), "example.com", "host.example.com"); + + Assert.Same(validator, resolved); + factory.Verify(f => f.ResolveDomainValidator("example.com", HydrantIdCAPlugin.DnsValidationType), Times.Once); + factory.Verify(f => f.ResolveDomainValidator("host.example.com", HydrantIdCAPlugin.DnsValidationType), Times.Once); + } + + [Fact] + public void ResolveDnsValidator_FirstLookupNameWins_DoesNotQueryTheRest() + { + var validator = StubDnsValidator().Object; + var factory = StubDnsFactoryForDomain(validator, "example.com"); + var plugin = MakePluginWithDnsFactory(null, factory.Object); + + Assert.Same(validator, plugin.ResolveDnsValidator(NewFlow(), "example.com", "host.example.com")); + factory.Verify(f => f.ResolveDomainValidator("host.example.com", It.IsAny()), Times.Never); + } + + [Fact] + public void ResolveDnsValidator_DuplicateAndBlankLookupNames_AreCollapsed() + { + var factory = new Mock(); + factory.Setup(f => f.ResolveDomainValidator(It.IsAny(), It.IsAny())) + .Returns((IDomainValidator)null); + var plugin = MakePluginWithDnsFactory(null, factory.Object); + + Assert.Null(plugin.ResolveDnsValidator(NewFlow(), "example.com", "example.com", null, " ")); + + factory.Verify(f => f.ResolveDomainValidator("example.com", HydrantIdCAPlugin.DnsValidationType), Times.Once); + } + + [Fact] + public void ResolveDnsValidator_NoLookupNames_ReturnsNull() + { + var plugin = MakePluginWithDnsFactory(null, Mock.Of()); + + Assert.Null(plugin.ResolveDnsValidator(NewFlow())); + } + + [Fact] + public async Task EnsureDomainsValidatedAsync_PluginRegisteredOnRequestedNameOnly_StillStagesOnTheBaseDomain() + { + // Regression: base-domain targeting must not break a Gateway domain validation + // configuration that is registered against the requested hostname rather than the + // zone apex. The plugin is found via the hostname; the record still goes on the apex, + // which the DNS plugin's own zone discovery resolves. + var validator = StubDnsValidator(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List()); + mockClient.Setup(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny())) + .ReturnsAsync(new Domain + { + Id = "d1", + Status = DomainStatusEnum.Pending, + Code = "identrust_validate=abc123", + CodeInstructions = "publish TXT" + }); + mockClient.Setup(c => c.GetSubmitCheckDomainValidationAsync("d1")) + .ReturnsAsync(new Domain { Id = "d1", Status = DomainStatusEnum.Validated }); + var factory = StubDnsFactoryForDomain(validator.Object, "www.keyfactorluadns.com"); + var plugin = MakePluginWithDnsFactory(mockClient, factory.Object); + + var result = await plugin.EnsureDomainsValidatedAsync(mockClient.Object, NewFlow(), + new List { "www.keyfactorluadns.com" }, "IdenTrust"); + + Assert.True(result.AllValidated); + validator.Verify(v => v.StageValidation("keyfactorluadns.com", "identrust_validate=abc123", It.IsAny()), Times.Once); + validator.Verify(v => v.CleanupValidation("keyfactorluadns.com", It.IsAny()), Times.Once); + } + [Fact] public async Task EnsureDomainsValidatedAsync_StagedRecordValidates_ReturnsAllValidatedAndCleansUp() { diff --git a/HydrantCAProxy/HydrantIdCAPlugin.cs b/HydrantCAProxy/HydrantIdCAPlugin.cs index 3bd41a1..d13a2a1 100644 --- a/HydrantCAProxy/HydrantIdCAPlugin.cs +++ b/HydrantCAProxy/HydrantIdCAPlugin.cs @@ -37,12 +37,13 @@ public class HydrantIdCAPlugin : IAnyCAPlugin private readonly IDomainValidatorFactory _validatorFactory; - // The validation type DNS provider plugins register themselves under. The ACME CA plugin - // resolves with "dns-01" at runtime, while that repo's DNS plugin documentation describes - // GetValidationType() as returning "DNS" -- so try the canonical value first and fall back - // to the legacy spelling rather than silently missing a plugin that is actually deployed. - internal const string DnsValidationType = "dns-01"; - internal const string DnsValidationTypeLegacy = "DNS"; + // The validation type DNS provider plugins register themselves under. The Gateway stores + // whatever the plugin's GetValidationType() returns in DomainValidatorTypes.ValidationType + // and matches on it exactly, so the spelling has to agree. "DNS" is what the deployed + // LuaDNS plugin reports (confirmed against AnyCA Gateway 26.2); the ACME CA plugin resolves + // with "dns-01". Both are attempted, "DNS" first, so either style of plugin is found. + internal const string DnsValidationType = "DNS"; + internal const string DnsValidationTypeAlternate = "dns-01"; internal const int DefaultDnsPropagationDelaySeconds = 30; internal const int DefaultDomainValidationTimeoutSeconds = 300; @@ -975,9 +976,10 @@ internal DomainValidationOrgPayload BuildOrgPayload() flow.Step("DomainValidation.StillPending", $"domain='{target}', status={domain.Status?.ToString() ?? "(none)"}"); var instructions = domain.CodeInstructions ?? "(no instructions returned by HydrantId)"; - // The DNS plugin is resolved on the name the TXT record actually goes on, so a - // base domain in a different zone from the CSR's hostname routes correctly. - var dnsValidator = ResolveDnsValidator(flow, target); + // Look the plugin up by the record's own name first, then by the name the CSR + // asked for -- the Gateway's domain validation configuration may be registered + // against either. See ResolveDnsValidator. + var dnsValidator = ResolveDnsValidator(flow, target, domainName); if (dnsValidator == null) { pending.Add((target, instructions)); @@ -1201,40 +1203,60 @@ public StagedValidation(string domain, string domainId, string instructions, IDo } /// - /// Resolves the DNS provider plugin that owns 's zone, or null - /// when automation is unavailable for it. Never throws: any failure here degrades to the - /// manual validation path, which is strictly better than failing an enrollment because - /// plugin resolution misbehaved. + /// Resolves the DNS provider plugin to write the validation record with, trying each of + /// in order and returning the first match. + /// + /// The name used to *find* the plugin is deliberately separate from the name the TXT record + /// goes on, the same split the ACME CA plugin makes. The Gateway matches a domain validation + /// configuration on an exact string equality against the domains registered for it + /// (Domains.Domain = @DomainName), so a configuration registered against the requested + /// hostname will not match that hostname's base domain, and vice versa. Passing both means + /// either registration style resolves. Whichever plugin is found then writes the record on + /// the base domain, which its own zone discovery handles. + /// + /// Never throws: any failure here degrades to the manual validation path, which is strictly + /// better than failing an enrollment because plugin resolution misbehaved. /// - internal IDomainValidator ResolveDnsValidator(FlowLogger flow, string domainName) + internal IDomainValidator ResolveDnsValidator(FlowLogger flow, params string[] lookupNames) { + var candidates = (lookupNames ?? new string[0]) + .Where(n => !string.IsNullOrWhiteSpace(n)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var label = candidates.Count > 0 ? candidates[0] : "(none)"; + if (_validatorFactory == null) { - flow.Skip($"DomainValidation.ResolveValidator:{domainName}", + flow.Skip($"DomainValidation.ResolveValidator:{label}", "no IDomainValidatorFactory supplied by the Gateway; manual DNS validation only"); return null; } try { - var validator = _validatorFactory.ResolveDomainValidator(domainName, DnsValidationType) - ?? _validatorFactory.ResolveDomainValidator(domainName, DnsValidationTypeLegacy); - - if (validator == null) + foreach (var candidate in candidates) { - flow.Skip($"DomainValidation.ResolveValidator:{domainName}", - "no DNS provider plugin is configured for this zone"); - return null; + var validator = _validatorFactory.ResolveDomainValidator(candidate, DnsValidationType) + ?? _validatorFactory.ResolveDomainValidator(candidate, DnsValidationTypeAlternate); + + if (validator == null) + continue; + + flow.Step($"DomainValidation.ResolveValidator:{label}", + $"{validator.GetType().Name} (matched on '{candidate}')"); + return validator; } - flow.Step($"DomainValidation.ResolveValidator:{domainName}", validator.GetType().Name); - return validator; + flow.Skip($"DomainValidation.ResolveValidator:{label}", + $"no DNS provider plugin is configured for {string.Join(" or ", candidates)}"); + return null; } catch (Exception ex) { - flow.Fail($"DomainValidation.ResolveValidator:{domainName}", ex.Message); + flow.Fail($"DomainValidation.ResolveValidator:{label}", ex.Message); _logger.LogWarning(ex, "ResolveDnsValidator: could not resolve a DNS provider plugin for '{Domain}', falling back to manual validation: {Message}", - domainName, ex.Message); + label, ex.Message); return null; } } diff --git a/docsource/configuration.md b/docsource/configuration.md index abe1800..95b1c7a 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -240,10 +240,34 @@ others outside it still makes progress on the automated ones. Domains that are already `VALIDATED`, or covered by an already-validated parent domain, skip DCV entirely and never touch a DNS provider plugin. -> **Note on validation type.** DNS provider plugins are resolved with a validation type of -> `dns-01` first, then `DNS`. The reference ACME CA plugin resolves with `dns-01` at runtime while -> that project's DNS plugin documentation describes `GetValidationType()` as returning `DNS`, so -> both spellings are attempted rather than silently missing a plugin that is deployed. +#### Which domain the DNS plugin must be registered against + +The Gateway resolves a domain validation configuration by **exact string match** on the domains +registered for it (`Domains.Domain = @DomainName`) — there is no suffix or wildcard matching. So a +configuration registered for `www.example.com` does not match `example.com`, and vice versa. + +Because validation now targets the base domain, the plugin looks the configuration up under **two** +names, in order: + +1. the base domain the record will be written on (`example.com`) +2. the name the CSR asked for (`www.example.com`) + +The first match wins, and whichever plugin is found writes the record on the base domain — its own +zone discovery locates the containing zone. Either registration style therefore works, but +**registering the zone apex is recommended**: one entry then covers every hostname in the zone, +whereas per-hostname entries need a new one for each name you enroll. + +If neither name matches you will see this in the Gateway log, and the enrollment falls back to +manual validation: + +``` +No configuration found for given domain: 'example.com' and validation type: 'DNS'. +``` + +> **Note on validation type.** Plugins are resolved with a validation type of `DNS` first, then +> `dns-01`. The Gateway stores whatever the plugin's `GetValidationType()` returns and matches on it +> exactly: the LuaDNS plugin reports `DNS`, while the reference ACME CA plugin resolves with +> `dns-01`. Both are attempted so either style of plugin is found. ### Gateway Registration Notes @@ -363,6 +387,9 @@ Confirm via the policy list that `details.validator` is unset for the policy und | C14 | Second hostname in a validated zone | After C13, enroll for a different hostname in the same zone (e.g. `host2.example.com`) | Issues immediately with no DNS write and no new domain validation record — covered by the validated base domain | | C15 | Multi-label suffix domain | Enroll for a host under a `co.uk`-style domain if one is available | Validates `example.co.uk`, not `co.uk` | | C16 | Unrecognized multi-label suffix | Enroll for a host under a TLD whose two-label suffix is not in the built-in list | First create is rejected by HydrantId, plugin retries with the fully-qualified name, enrollment proceeds | +| C17 | DNS plugin registered on the zone apex | Register the DNS provider configuration for `example.com`, enroll for `host.example.com` | Resolves on the first lookup name; log shows `matched on 'example.com'` | +| C18 | DNS plugin registered per hostname | Register the configuration for `host.example.com` only, enroll for `host.example.com` | Still resolves, on the second lookup name; log shows `matched on 'host.example.com'`; TXT is written on `example.com` | +| C19 | DNS plugin registered for an unrelated zone | Register only some other zone, enroll for `host.example.com` | Falls back to external validation; Gateway log shows "No configuration found for given domain" | ### D. Renewal From b2af1842df0db7ad57946a5a4201bb55af2c896a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 2 Sep 2026 19:50:47 +0000 Subject: [PATCH 24/29] docs: auto-generate README and documentation [skip ci] --- README.md | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a1162a9..b835ebd 100644 --- a/README.md +++ b/README.md @@ -286,10 +286,34 @@ The plugin supports the following standard CRL revocation reasons: Domains that are already `VALIDATED`, or covered by an already-validated parent domain, skip DCV entirely and never touch a DNS provider plugin. - > **Note on validation type.** DNS provider plugins are resolved with a validation type of - > `dns-01` first, then `DNS`. The reference ACME CA plugin resolves with `dns-01` at runtime while - > that project's DNS plugin documentation describes `GetValidationType()` as returning `DNS`, so - > both spellings are attempted rather than silently missing a plugin that is deployed. + #### Which domain the DNS plugin must be registered against + + The Gateway resolves a domain validation configuration by **exact string match** on the domains + registered for it (`Domains.Domain = @DomainName`) — there is no suffix or wildcard matching. So a + configuration registered for `www.example.com` does not match `example.com`, and vice versa. + + Because validation now targets the base domain, the plugin looks the configuration up under **two** + names, in order: + + 1. the base domain the record will be written on (`example.com`) + 2. the name the CSR asked for (`www.example.com`) + + The first match wins, and whichever plugin is found writes the record on the base domain — its own + zone discovery locates the containing zone. Either registration style therefore works, but + **registering the zone apex is recommended**: one entry then covers every hostname in the zone, + whereas per-hostname entries need a new one for each name you enroll. + + If neither name matches you will see this in the Gateway log, and the enrollment falls back to + manual validation: + + ``` + No configuration found for given domain: 'example.com' and validation type: 'DNS'. + ``` + + > **Note on validation type.** Plugins are resolved with a validation type of `DNS` first, then + > `dns-01`. The Gateway stores whatever the plugin's `GetValidationType()` returns and matches on it + > exactly: the LuaDNS plugin reports `DNS`, while the reference ACME CA plugin resolves with + > `dns-01`. Both are attempted so either style of plugin is found. ### Gateway Registration Notes @@ -454,6 +478,9 @@ Confirm via the policy list that `details.validator` is unset for the policy und | C14 | Second hostname in a validated zone | After C13, enroll for a different hostname in the same zone (e.g. `host2.example.com`) | Issues immediately with no DNS write and no new domain validation record — covered by the validated base domain | | C15 | Multi-label suffix domain | Enroll for a host under a `co.uk`-style domain if one is available | Validates `example.co.uk`, not `co.uk` | | C16 | Unrecognized multi-label suffix | Enroll for a host under a TLD whose two-label suffix is not in the built-in list | First create is rejected by HydrantId, plugin retries with the fully-qualified name, enrollment proceeds | +| C17 | DNS plugin registered on the zone apex | Register the DNS provider configuration for `example.com`, enroll for `host.example.com` | Resolves on the first lookup name; log shows `matched on 'example.com'` | +| C18 | DNS plugin registered per hostname | Register the configuration for `host.example.com` only, enroll for `host.example.com` | Still resolves, on the second lookup name; log shows `matched on 'host.example.com'`; TXT is written on `example.com` | +| C19 | DNS plugin registered for an unrelated zone | Register only some other zone, enroll for `host.example.com` | Falls back to external validation; Gateway log shows "No configuration found for given domain" | ### D. Renewal From a1480425c26ae4204ca1ade9a27062aa8cae382f Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Wed, 2 Sep 2026 16:04:31 -0400 Subject: [PATCH 25/29] debug --- CHANGELOG.md | 1 + .../HydrantIdCAPluginTests.cs | 51 +++++++++++++++++++ HydrantCAProxy/HydrantIdCAPlugin.cs | 40 +++++++++++++++ 3 files changed, 92 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fd58db..052d6bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Changed DNS provider plugin resolution to try the base domain and then the requested name, because the Gateway matches a domain validation configuration on exact domain equality; a configuration registered against either name now resolves, and the record is still written on the base domain * Changed the DNS provider validation type tried first from "dns-01" to "DNS", which is what deployed DNS plugins report to AnyCA Gateway 26.2; the other spelling is still attempted as a fallback * Added a fallback to the fully-qualified name when HydrantId will not accept the derived base domain, so an unrecognized multi-label public suffix costs one rejected API call rather than a failed enrollment +* Added a diagnostic that reports a validated domain's organizationIds, warning when it is empty, so the opaque "No valid domains associated with organization" failure from POST /csr is visible at the point domain validation completes * Added per-domain fallback to external validation when automation is unavailable (no factory, no DNS plugin for the zone, staging failure, no validation code, or validation timeout), preserving the previous manual publish-and-resubmit behaviour * Added DnsPropagationDelaySeconds, DomainValidationTimeoutSeconds and DomainValidationPollIntervalSeconds CA connection settings * Added domain control validation for policies that declare a validator, including reuse of an already-validated parent domain for subdomains and regeneration of expired validation codes diff --git a/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs b/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs index 385601f..3ea1692 100644 --- a/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs +++ b/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs @@ -951,6 +951,57 @@ public async Task EnsureDomainsValidatedAsync_SubdomainStaging_WritesTxtOnTheBas validator.Verify(v => v.CleanupValidation("keyfactorluadns.com", It.IsAny()), Times.Once); } + // --------------------------------------------------------------------- + // Organization link diagnostic + // --------------------------------------------------------------------- + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("b9bc825f-09d7-4736-8938-fb541822234a")] + public void ReportOrganizationLink_NeverThrows_ForAnyOrganizationIdsValue(string organizationIds) + { + var plugin = new HydrantIdCAPlugin(); + + plugin.ReportOrganizationLink(NewFlow(), + new Domain { DomainName = "example.com", Status = DomainStatusEnum.Validated, OrganizationIds = organizationIds }, + "example.com"); + } + + [Fact] + public void ReportOrganizationLink_NullDomain_IsIgnored() + { + var plugin = new HydrantIdCAPlugin(); + + plugin.ReportOrganizationLink(NewFlow(), null, "example.com"); + } + + [Theory] + [InlineData(null)] + [InlineData("b9bc825f-09d7-4736-8938-fb541822234a")] + public async Task EnsureDomainsValidatedAsync_OrganizationLinkDiagnostic_DoesNotChangeTheOutcome(string organizationIds) + { + // The diagnostic reports what HydrantId returned; whether a policy actually requires an + // organization is HydrantId's call, so a missing link must not fail validation here. + var plugin = new HydrantIdCAPlugin(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List()); + mockClient.Setup(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny())) + .ReturnsAsync(new Domain + { + Id = "d1", + Status = DomainStatusEnum.Validated, + OrganizationIds = organizationIds + }); + + var result = await plugin.EnsureDomainsValidatedAsync(mockClient.Object, NewFlow(), + new List { "orglink-example.com" }, "IdenTrust"); + + Assert.True(result.AllValidated); + Assert.Null(result.PendingMessage); + } + [Fact] public void ResolveDnsValidator_NoFactorySupplied_ReturnsNull() { diff --git a/HydrantCAProxy/HydrantIdCAPlugin.cs b/HydrantCAProxy/HydrantIdCAPlugin.cs index d13a2a1..9365b76 100644 --- a/HydrantCAProxy/HydrantIdCAPlugin.cs +++ b/HydrantCAProxy/HydrantIdCAPlugin.cs @@ -946,6 +946,7 @@ internal DomainValidationOrgPayload BuildOrgPayload() if (exactMatch?.Status == DomainStatusEnum.Validated) { flow.Step("DomainValidation.AlreadyValidated", $"domain='{domainName}'"); + ReportOrganizationLink(flow, exactMatch, domainName); continue; } @@ -970,6 +971,7 @@ internal DomainValidationOrgPayload BuildOrgPayload() if (domain.Status == DomainStatusEnum.Validated) { flow.Step("DomainValidation.NowValidated", $"domain='{target}'"); + ReportOrganizationLink(flow, domain, target); continue; } @@ -1329,9 +1331,14 @@ internal async Task AwaitStagedValidationsAsync( } if (rechecked?.Status == DomainStatusEnum.Validated) + { flow.Step("DomainValidation.NowValidated", $"domain='{entry.Domain}' after {stopwatch.Elapsed.TotalSeconds:F0}s"); + ReportOrganizationLink(flow, rechecked, entry.Domain); + } else + { stillPending.Add(entry); + } } remaining = stillPending; @@ -1353,6 +1360,39 @@ internal async Task AwaitStagedValidationsAsync( } } + /// + /// Records whether a validated HydrantID domain is linked to an organization. + /// + /// An IdenTrust OV policy issues under an organization, and POST /api/v2/csr rejects the + /// enrollment with "No valid domains associated with organization for IdenTrust policy" when + /// the domain it is issuing for has none. That link lives in the domain record's + /// organizationIds and is established by HydrantID, not by anything this plugin sends -- + /// surfacing it at the moment validation completes turns an opaque downstream HTTP 500 into + /// an actionable log line. Purely diagnostic: it never changes the enrollment outcome, + /// because whether a given policy actually requires an organization is HydrantID's call. + /// + internal void ReportOrganizationLink(FlowLogger flow, Domain domain, string target) + { + if (domain == null) + return; + + if (!string.IsNullOrWhiteSpace(domain.OrganizationIds)) + { + flow.Step("DomainValidation.OrganizationLink", + $"domain='{target}', organizationIds='{domain.OrganizationIds}'"); + return; + } + + flow.Step("DomainValidation.NoOrganizationLink", $"domain='{target}' has no organizationIds"); + _logger.LogWarning( + "Domain '{Domain}' is VALIDATED at HydrantId but its organizationIds is empty. A policy that " + + "issues under an organization (e.g. an IdenTrust OV policy) will reject enrollment with " + + "\"No valid domains associated with organization\". This plugin cannot create that link -- " + + "confirm in the HydrantId portal that the domain is associated with a vetted organization, " + + "and that the organization matches the one the policy issues under.", + target); + } + /// /// Removes every TXT record staged by this enrollment. A leftover record cannot break /// issuance, so a cleanup failure is logged and swallowed rather than allowed to fail an From d243c3be4e50c2059d6c0db927b0f31b0570d8cb Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Wed, 2 Sep 2026 16:22:00 -0400 Subject: [PATCH 26/29] fixed organizationid issue --- CHANGELOG.md | 1 + .../HydrantIdCAPluginTests.cs | 110 ++++++++++++++++++ .../Models/CreateDomainValidationPayload.cs | 8 ++ HydrantCAProxy/HydrantIdCAPlugin.cs | 14 ++- .../ICreateDomainValidationPayload.cs | 1 + HydrantCAProxy/RequestManager.cs | 12 +- 6 files changed, 138 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 052d6bb..8b5b1bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # v1.1.0 * Added automated DNS-01 style domain control validation: when the AnyCA Gateway supplies an IDomainValidatorFactory and a DNS provider plugin is configured for the domain's zone, the plugin now stages HydrantId's validation TXT record, polls until the domain is VALIDATED, removes the record, and issues the certificate within a single enrollment call +* Added the enrolling policy's organizationId to the domain validation request as organizationIds; HydrantId policies issue under an organization and POST /csr rejects domains that are not associated with it ("No valid domains associated with organization for IdenTrust policy"), and the plugin previously created every domain with a null organizationIds * Changed domain control validation to target the registrable base domain rather than the CSR's fully-qualified name; HydrantId links the vetted organization to the base domain only, and validating a subdomain produced a record with a null organizationIds that POST /csr rejected with "No valid domains associated with organization". A base-domain validation additionally covers every subdomain until domainValidUntil * Changed DNS provider plugin resolution to try the base domain and then the requested name, because the Gateway matches a domain validation configuration on exact domain equality; a configuration registered against either name now resolves, and the record is still written on the base domain * Changed the DNS provider validation type tried first from "dns-01" to "DNS", which is what deployed DNS plugins report to AnyCA Gateway 26.2; the other spelling is still attempted as a fallback diff --git a/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs b/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs index 3ea1692..a2faaec 100644 --- a/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs +++ b/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs @@ -951,6 +951,116 @@ public async Task EnsureDomainsValidatedAsync_SubdomainStaging_WritesTxtOnTheBas validator.Verify(v => v.CleanupValidation("keyfactorluadns.com", It.IsAny()), Times.Once); } + // --------------------------------------------------------------------- + // Organization association on domain validation creation + // --------------------------------------------------------------------- + + [Fact] + public async Task EnsureDomainsValidatedAsync_OrganizationIdSupplied_IsSentOnCreate() + { + var plugin = new HydrantIdCAPlugin(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List()); + CreateDomainValidationPayload captured = null; + mockClient.Setup(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny())) + .Callback(pl => captured = pl) + .ReturnsAsync(new Domain { Id = "d1", Status = DomainStatusEnum.Validated }); + + await plugin.EnsureDomainsValidatedAsync(mockClient.Object, NewFlow(), + new List { "orgid-example.com" }, "IdenTrust", "b9bc825f-09d7-4736-8938-fb541822234a"); + + Assert.Equal("b9bc825f-09d7-4736-8938-fb541822234a", captured.OrganizationIds); + } + + [Fact] + public async Task EnsureDomainsValidatedAsync_NoOrganizationId_OmitsItFromTheCreate() + { + var plugin = new HydrantIdCAPlugin(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List()); + CreateDomainValidationPayload captured = null; + mockClient.Setup(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny())) + .Callback(pl => captured = pl) + .ReturnsAsync(new Domain { Id = "d1", Status = DomainStatusEnum.Validated }); + + await plugin.EnsureDomainsValidatedAsync(mockClient.Object, NewFlow(), + new List { "orgid-example.com" }, "IdenTrust"); + + // Null rather than empty, so NullValueHandling.Ignore drops it from the JSON entirely. + Assert.Null(captured.OrganizationIds); + } + + [Fact] + public void GetCreateDomainValidationRequest_BlankOrganizationIds_SerializesWithoutTheProperty() + { + var payload = new RequestManager().GetCreateDomainValidationRequest( + "example.com", "IdenTrust", null, null, ""); + + var json = Newtonsoft.Json.JsonConvert.SerializeObject(payload); + + Assert.DoesNotContain("organizationIds", json); + } + + [Fact] + public void GetCreateDomainValidationRequest_OrganizationIds_SerializesAsOrganizationIds() + { + var payload = new RequestManager().GetCreateDomainValidationRequest( + "example.com", "IdenTrust", null, null, "b9bc825f-09d7-4736-8938-fb541822234a"); + + var json = Newtonsoft.Json.JsonConvert.SerializeObject(payload); + + Assert.Contains("\"organizationIds\":\"b9bc825f-09d7-4736-8938-fb541822234a\"", json); + } + + [Fact] + public async Task EnsureDomainsValidatedForPolicyAsync_PassesThePolicysOrganizationIdThrough() + { + var organizationId = Guid.Parse("b9bc825f-09d7-4736-8938-fb541822234a"); + var plugin = new HydrantIdCAPlugin(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List()); + CreateDomainValidationPayload captured = null; + mockClient.Setup(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny())) + .Callback(pl => captured = pl) + .ReturnsAsync(new Domain { Id = "d1", Status = DomainStatusEnum.Validated }); + var policy = new Policy + { + Id = Guid.NewGuid(), + Name = "Keyfactor IdenTrust TLS OV", + OrganizationId = organizationId, + Details = new PolicyDetails { Validator = "IdenTrust" } + }; + + var result = await plugin.EnsureDomainsValidatedForPolicyAsync(mockClient.Object, NewFlow(), policy, SampleCsr, null); + + // The organization the policy issues under is the one the domain must be linked to. + Assert.Null(result); + Assert.Equal(organizationId.ToString(), captured.OrganizationIds); + } + + [Fact] + public async Task EnsureDomainsValidatedForPolicyAsync_PolicyWithoutOrganizationId_SendsNone() + { + var plugin = new HydrantIdCAPlugin(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetDomainListAsync()).ReturnsAsync(new List()); + CreateDomainValidationPayload captured = null; + mockClient.Setup(c => c.GetSubmitCreateDomainValidationAsync(It.IsAny())) + .Callback(pl => captured = pl) + .ReturnsAsync(new Domain { Id = "d1", Status = DomainStatusEnum.Validated }); + var policy = new Policy + { + Id = Guid.NewGuid(), + Name = "P", + OrganizationId = null, + Details = new PolicyDetails { Validator = "PrivateCA" } + }; + + await plugin.EnsureDomainsValidatedForPolicyAsync(mockClient.Object, NewFlow(), policy, SampleCsr, null); + + Assert.Null(captured.OrganizationIds); + } + // --------------------------------------------------------------------- // Organization link diagnostic // --------------------------------------------------------------------- diff --git a/HydrantCAProxy/Client/Models/CreateDomainValidationPayload.cs b/HydrantCAProxy/Client/Models/CreateDomainValidationPayload.cs index f5ec6ea..2f9d7a2 100644 --- a/HydrantCAProxy/Client/Models/CreateDomainValidationPayload.cs +++ b/HydrantCAProxy/Client/Models/CreateDomainValidationPayload.cs @@ -24,6 +24,14 @@ public class CreateDomainValidationPayload : ICreateDomainValidationPayload [JsonProperty("validator", NullValueHandling = NullValueHandling.Ignore)] public string Validator { get;set; } + // The organization the domain should be associated with, taken from the enrolling + // policy's organizationId. HydrantID policies issue under an organization and + // POST /api/v2/csr rejects an enrollment whose domains are not associated with it + // ("No valid domains associated with organization for policy"), so this + // has to be supplied when the validation record is created. + [JsonProperty("organizationIds", NullValueHandling = NullValueHandling.Ignore)] + public string OrganizationIds { get;set; } + [JsonProperty("method", NullValueHandling = NullValueHandling.Ignore)] public ValidationMethod? Method { get;set; } diff --git a/HydrantCAProxy/HydrantIdCAPlugin.cs b/HydrantCAProxy/HydrantIdCAPlugin.cs index 9365b76..be3eab4 100644 --- a/HydrantCAProxy/HydrantIdCAPlugin.cs +++ b/HydrantCAProxy/HydrantIdCAPlugin.cs @@ -846,7 +846,8 @@ internal async Task EnsureDomainsValidatedForPolicyAsync( string pendingMessage = null; await flow.StepAsync("EnsureDomainsValidated", async () => { - (allValidated, pendingMessage) = await EnsureDomainsValidatedAsync(client, flow, domainsToValidate, validatorId); + (allValidated, pendingMessage) = await EnsureDomainsValidatedAsync( + client, flow, domainsToValidate, validatorId, policyId.OrganizationId?.ToString()); }); if (allValidated) @@ -920,7 +921,8 @@ internal DomainValidationOrgPayload BuildOrgPayload() /// previously-started validation's id across Enroll() calls. /// internal async Task<(bool AllValidated, string PendingMessage)> EnsureDomainsValidatedAsync( - IHydrantIdClient client, FlowLogger flow, List domainsToValidate, string validatorId) + IHydrantIdClient client, FlowLogger flow, List domainsToValidate, string validatorId, + string organizationIds = null) { // HydrantID soft-deletes domain records rather than removing them, and it is not // established whether the list endpoint filters them out. A soft-deleted record must @@ -957,7 +959,7 @@ internal DomainValidationOrgPayload BuildOrgPayload() } var (domain, target, targetError) = - await ResolveDomainValidationRecordAsync(client, flow, domainName, existingDomains, validatorId); + await ResolveDomainValidationRecordAsync(client, flow, domainName, existingDomains, validatorId, organizationIds); if (domain == null) { @@ -1040,7 +1042,8 @@ await flow.StepAsync("DomainValidation.AwaitValidation", async () => /// was rejected. /// internal async Task<(Domain Domain, string Target, string Error)> ResolveDomainValidationRecordAsync( - IHydrantIdClient client, FlowLogger flow, string domainName, List existingDomains, string validatorId) + IHydrantIdClient client, FlowLogger flow, string domainName, List existingDomains, string validatorId, + string organizationIds = null) { var targets = GetValidationTargets(domainName); string lastError = null; @@ -1066,7 +1069,8 @@ await flow.StepAsync("DomainValidation.AwaitValidation", async () => // domain name (does not create a duplicate record) against staging. flow.Step("DomainValidation.CreateOrRegenerate", $"domain='{target}', for='{domainName}', priorStatus={(match == null ? "(none)" : match.Status.ToString())}"); - var payload = _requestManager.GetCreateDomainValidationRequest(target, validatorId, _config?.HydrantIdAccountId, BuildOrgPayload()); + var payload = _requestManager.GetCreateDomainValidationRequest( + target, validatorId, _config?.HydrantIdAccountId, BuildOrgPayload(), organizationIds); domain = await client.GetSubmitCreateDomainValidationAsync(payload); } else diff --git a/HydrantCAProxy/Interfaces/ICreateDomainValidationPayload.cs b/HydrantCAProxy/Interfaces/ICreateDomainValidationPayload.cs index 3d39880..348a04d 100644 --- a/HydrantCAProxy/Interfaces/ICreateDomainValidationPayload.cs +++ b/HydrantCAProxy/Interfaces/ICreateDomainValidationPayload.cs @@ -16,6 +16,7 @@ public interface ICreateDomainValidationPayload string AccountId { get;set; } string DomainName { get;set; } string Validator { get;set; } + string OrganizationIds { get;set; } ValidationMethod? Method { get;set; } object Payload { get;set; } } diff --git a/HydrantCAProxy/RequestManager.cs b/HydrantCAProxy/RequestManager.cs index a6c2db4..604883f 100644 --- a/HydrantCAProxy/RequestManager.cs +++ b/HydrantCAProxy/RequestManager.cs @@ -369,13 +369,14 @@ public List GetDomainsToValidate(string csr, Dictionary Date: Thu, 3 Sep 2026 16:51:10 -0400 Subject: [PATCH 27/29] fixed org link up --- CHANGELOG.md | 1 + .../HydrantIdCAPluginTests.cs | 77 ++++++++++++++++-- HydrantCAProxy/Client/HydrantIdClient.cs | 56 +++++++++++++ .../Models/UpdateDomainOrganizationPayload.cs | 20 +++++ HydrantCAProxy/HydrantIdCAPlugin.cs | 78 +++++++++++++------ HydrantCAProxy/Interfaces/IHydrantIdClient.cs | 1 + .../IUpdateDomainOrganizationPayload.cs | 16 ++++ 7 files changed, 218 insertions(+), 31 deletions(-) create mode 100644 HydrantCAProxy/Client/Models/UpdateDomainOrganizationPayload.cs create mode 100644 HydrantCAProxy/Interfaces/IUpdateDomainOrganizationPayload.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b5b1bf..203f39f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ # v1.1.0 +* Fixed "No valid domains associated with organization" on POST /csr for a domain that was already VALIDATED at HydrantId but never linked to the enrolling policy's organization (e.g. validated before organizationIds was sent on creation, or under a different policy). The plugin now calls POST /domains/{id} to link or relink the organization at the moment validation is confirmed, instead of only logging a diagnostic warning * Added automated DNS-01 style domain control validation: when the AnyCA Gateway supplies an IDomainValidatorFactory and a DNS provider plugin is configured for the domain's zone, the plugin now stages HydrantId's validation TXT record, polls until the domain is VALIDATED, removes the record, and issues the certificate within a single enrollment call * Added the enrolling policy's organizationId to the domain validation request as organizationIds; HydrantId policies issue under an organization and POST /csr rejects domains that are not associated with it ("No valid domains associated with organization for IdenTrust policy"), and the plugin previously created every domain with a null organizationIds * Changed domain control validation to target the registrable base domain rather than the CSR's fully-qualified name; HydrantId links the vetted organization to the base domain only, and validating a subdomain produced a record with a null organizationIds that POST /csr rejected with "No valid domains associated with organization". A base-domain validation additionally covers every subdomain until domainValidUntil diff --git a/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs b/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs index a2faaec..578ca03 100644 --- a/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs +++ b/HydrantCAProxy.Tests/HydrantIdCAPluginTests.cs @@ -1062,29 +1062,90 @@ public async Task EnsureDomainsValidatedForPolicyAsync_PolicyWithoutOrganization } // --------------------------------------------------------------------- - // Organization link diagnostic + // Organization link reconciliation // --------------------------------------------------------------------- + [Fact] + public async Task EnsureOrganizationLinkedAsync_NullDomain_IsIgnored() + { + var plugin = new HydrantIdCAPlugin(); + var mockClient = new Mock(MockBehavior.Strict); + + await plugin.EnsureOrganizationLinkedAsync(mockClient.Object, NewFlow(), null, "example.com", "org-1"); + + mockClient.VerifyNoOtherCalls(); + } + + [Fact] + public async Task EnsureOrganizationLinkedAsync_AlreadyLinkedToTheSameOrganization_DoesNotCallUpdate() + { + var plugin = new HydrantIdCAPlugin(); + var mockClient = new Mock(MockBehavior.Strict); + var domain = new Domain { Id = "d1", DomainName = "example.com", Status = DomainStatusEnum.Validated, OrganizationIds = "org-1" }; + + await plugin.EnsureOrganizationLinkedAsync(mockClient.Object, NewFlow(), domain, "example.com", "org-1"); + + mockClient.VerifyNoOtherCalls(); + } + [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] - [InlineData("b9bc825f-09d7-4736-8938-fb541822234a")] - public void ReportOrganizationLink_NeverThrows_ForAnyOrganizationIdsValue(string organizationIds) + public async Task EnsureOrganizationLinkedAsync_NoOrganizationRequired_NeverCallsUpdate(string organizationIds) { + // The matched policy reports no organization -- nothing to link, whatever the domain's + // current organizationIds is. Only ever logs a diagnostic warning. var plugin = new HydrantIdCAPlugin(); + var mockClient = new Mock(MockBehavior.Strict); + var domain = new Domain { Id = "d1", DomainName = "example.com", Status = DomainStatusEnum.Validated, OrganizationIds = null }; + + await plugin.EnsureOrganizationLinkedAsync(mockClient.Object, NewFlow(), domain, "example.com", organizationIds); - plugin.ReportOrganizationLink(NewFlow(), - new Domain { DomainName = "example.com", Status = DomainStatusEnum.Validated, OrganizationIds = organizationIds }, - "example.com"); + mockClient.VerifyNoOtherCalls(); + } + + [Fact] + public async Task EnsureOrganizationLinkedAsync_DomainHasNoOrganization_LinksIt() + { + var plugin = new HydrantIdCAPlugin(); + var mockClient = new Mock(); + var domain = new Domain { Id = "d1", DomainName = "example.com", Status = DomainStatusEnum.Validated, OrganizationIds = null }; + mockClient.Setup(c => c.GetSubmitUpdateDomainOrganizationAsync("d1", "org-1")) + .ReturnsAsync(new Domain { Id = "d1", OrganizationIds = "org-1" }); + + await plugin.EnsureOrganizationLinkedAsync(mockClient.Object, NewFlow(), domain, "example.com", "org-1"); + + mockClient.Verify(c => c.GetSubmitUpdateDomainOrganizationAsync("d1", "org-1"), Times.Once); + Assert.Equal("org-1", domain.OrganizationIds); + } + + [Fact] + public async Task EnsureOrganizationLinkedAsync_DomainLinkedToADifferentOrganization_RelinksIt() + { + var plugin = new HydrantIdCAPlugin(); + var mockClient = new Mock(); + var domain = new Domain { Id = "d1", DomainName = "example.com", Status = DomainStatusEnum.Validated, OrganizationIds = "org-old" }; + mockClient.Setup(c => c.GetSubmitUpdateDomainOrganizationAsync("d1", "org-1")) + .ReturnsAsync(new Domain { Id = "d1", OrganizationIds = "org-1" }); + + await plugin.EnsureOrganizationLinkedAsync(mockClient.Object, NewFlow(), domain, "example.com", "org-1"); + + mockClient.Verify(c => c.GetSubmitUpdateDomainOrganizationAsync("d1", "org-1"), Times.Once); } [Fact] - public void ReportOrganizationLink_NullDomain_IsIgnored() + public async Task EnsureOrganizationLinkedAsync_UpdateThrows_IsSwallowedRatherThanFailingTheEnrollment() { var plugin = new HydrantIdCAPlugin(); + var mockClient = new Mock(); + var domain = new Domain { Id = "d1", DomainName = "example.com", Status = DomainStatusEnum.Validated, OrganizationIds = null }; + mockClient.Setup(c => c.GetSubmitUpdateDomainOrganizationAsync("d1", "org-1")) + .ThrowsAsync(new InvalidOperationException("HTTP 500")); + + await plugin.EnsureOrganizationLinkedAsync(mockClient.Object, NewFlow(), domain, "example.com", "org-1"); - plugin.ReportOrganizationLink(NewFlow(), null, "example.com"); + mockClient.Verify(c => c.GetSubmitUpdateDomainOrganizationAsync("d1", "org-1"), Times.Once); } [Theory] diff --git a/HydrantCAProxy/Client/HydrantIdClient.cs b/HydrantCAProxy/Client/HydrantIdClient.cs index 02e033d..a2691de 100644 --- a/HydrantCAProxy/Client/HydrantIdClient.cs +++ b/HydrantCAProxy/Client/HydrantIdClient.cs @@ -333,6 +333,62 @@ public async Task GetSubmitCreateDomainValidationAsync(CreateDomainValid + // Links an existing domain validation record to an organization after the fact. Needed + // for records created before this plugin started sending organizationIds on creation (or + // linked to the wrong organization), which HydrantId otherwise leaves associated with no + // organization -- POST /api/v2/csr then rejects the enrollment with "No valid domains + // associated with organization". Confirmed against staging: POST to the domain's own + // resource URL (no trailing path segment, unlike creation's /api/v2/domains/) with just + // {"organizationIds": "..."} updates the existing record rather than creating a new one. + public async Task GetSubmitUpdateDomainOrganizationAsync(string domainId, string organizationIds) + { + Log.MethodEntry(); + Log.LogTrace("GetSubmitUpdateDomainOrganizationAsync: domainId='{DomainId}', organizationIds='{OrganizationIds}'", + domainId ?? "(null)", organizationIds ?? "(null)"); + + if (string.IsNullOrEmpty(domainId)) + throw new ArgumentNullException(nameof(domainId), "domainId cannot be null or empty."); + if (string.IsNullOrEmpty(organizationIds)) + throw new ArgumentNullException(nameof(organizationIds), "organizationIds cannot be null or empty."); + + var apiEndpoint = $"/api/v2/domains/{domainId}"; + var fullUrl = BaseUrl + apiEndpoint; + Log.LogTrace("GetSubmitUpdateDomainOrganizationAsync: API Url={Url}", fullUrl); + + var payload = new UpdateDomainOrganizationPayload { OrganizationIds = organizationIds }; + var json = JsonConvert.SerializeObject(payload); + Log.LogTrace("GetSubmitUpdateDomainOrganizationAsync: request JSON: {Json}", json); + + var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; + + try + { + var restClient = ConfigureRestClient("post", fullUrl); + using var resp = await restClient.PostAsync(apiEndpoint, new StringContent(json, Encoding.UTF8, "application/json")); + var responseContent = await resp.Content.ReadAsStringAsync(); + + Log.LogTrace("GetSubmitUpdateDomainOrganizationAsync: HTTP status={StatusCode}, response length={Len}", + resp.StatusCode, responseContent?.Length ?? 0); + + if (!resp.IsSuccessStatusCode) + { + Log.LogError("GetSubmitUpdateDomainOrganizationAsync: request failed with status {StatusCode}: {Response}", resp.StatusCode, responseContent); + throw new HttpRequestException($"GetSubmitUpdateDomainOrganizationAsync failed with HTTP {resp.StatusCode}: {responseContent}"); + } + + var domain = JsonConvert.DeserializeObject(responseContent, settings); + Log.LogTrace("GetSubmitUpdateDomainOrganizationAsync: response JSON: {Json}", JsonConvert.SerializeObject(domain)); + return domain; + } + catch (Exception e) + { + Log.LogError(e, "GetSubmitUpdateDomainOrganizationAsync: exception: {Message}", e.Message); + throw; + } + } + + + public async Task GetSubmitCheckDomainValidationAsync(string domainId) { Log.MethodEntry(); diff --git a/HydrantCAProxy/Client/Models/UpdateDomainOrganizationPayload.cs b/HydrantCAProxy/Client/Models/UpdateDomainOrganizationPayload.cs new file mode 100644 index 0000000..d72013e --- /dev/null +++ b/HydrantCAProxy/Client/Models/UpdateDomainOrganizationPayload.cs @@ -0,0 +1,20 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain a +// copy of the License at http://www.apache.org/licenses/LICENSE-2.0. Unless +// required by applicable law or agreed to in writing, software distributed +// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +// OR CONDITIONS OF ANY KIND, either express or implied. See the License for +// thespecific language governing permissions and limitations under the +// License. +using Keyfactor.HydrantId.Interfaces; +using Newtonsoft.Json; + +namespace Keyfactor.HydrantId.Client.Models +{ + public class UpdateDomainOrganizationPayload : IUpdateDomainOrganizationPayload + { + [JsonProperty("organizationIds", NullValueHandling = NullValueHandling.Ignore)] + public string OrganizationIds { get; set; } + } +} diff --git a/HydrantCAProxy/HydrantIdCAPlugin.cs b/HydrantCAProxy/HydrantIdCAPlugin.cs index be3eab4..c832877 100644 --- a/HydrantCAProxy/HydrantIdCAPlugin.cs +++ b/HydrantCAProxy/HydrantIdCAPlugin.cs @@ -948,7 +948,7 @@ internal DomainValidationOrgPayload BuildOrgPayload() if (exactMatch?.Status == DomainStatusEnum.Validated) { flow.Step("DomainValidation.AlreadyValidated", $"domain='{domainName}'"); - ReportOrganizationLink(flow, exactMatch, domainName); + await EnsureOrganizationLinkedAsync(client, flow, exactMatch, domainName, organizationIds); continue; } @@ -973,7 +973,7 @@ internal DomainValidationOrgPayload BuildOrgPayload() if (domain.Status == DomainStatusEnum.Validated) { flow.Step("DomainValidation.NowValidated", $"domain='{target}'"); - ReportOrganizationLink(flow, domain, target); + await EnsureOrganizationLinkedAsync(client, flow, domain, target, organizationIds); continue; } @@ -1011,7 +1011,7 @@ internal DomainValidationOrgPayload BuildOrgPayload() await flow.StepAsync("DomainValidation.AwaitValidation", async () => { - await AwaitStagedValidationsAsync(client, flow, staged, pending); + await AwaitStagedValidationsAsync(client, flow, staged, pending, organizationIds); }); } } @@ -1309,7 +1309,8 @@ internal async Task StageDnsRecordAsync(FlowLogger flow, IDomainValidator /// can still pick it up. /// internal async Task AwaitStagedValidationsAsync( - IHydrantIdClient client, FlowLogger flow, List staged, List<(string Domain, string Instructions)> pending) + IHydrantIdClient client, FlowLogger flow, List staged, List<(string Domain, string Instructions)> pending, + string organizationIds = null) { var timeout = TimeSpan.FromSeconds(DomainValidationTimeoutSeconds); var interval = TimeSpan.FromSeconds(DomainValidationPollIntervalSeconds); @@ -1337,7 +1338,7 @@ internal async Task AwaitStagedValidationsAsync( if (rechecked?.Status == DomainStatusEnum.Validated) { flow.Step("DomainValidation.NowValidated", $"domain='{entry.Domain}' after {stopwatch.Elapsed.TotalSeconds:F0}s"); - ReportOrganizationLink(flow, rechecked, entry.Domain); + await EnsureOrganizationLinkedAsync(client, flow, rechecked, entry.Domain, organizationIds); } else { @@ -1365,36 +1366,67 @@ internal async Task AwaitStagedValidationsAsync( } /// - /// Records whether a validated HydrantID domain is linked to an organization. + /// Ensures a validated HydrantID domain is linked to the organization the enrolling policy + /// issues under, fixing the link when it is missing or wrong rather than only reporting it. /// /// An IdenTrust OV policy issues under an organization, and POST /api/v2/csr rejects the /// enrollment with "No valid domains associated with organization for IdenTrust policy" when - /// the domain it is issuing for has none. That link lives in the domain record's - /// organizationIds and is established by HydrantID, not by anything this plugin sends -- - /// surfacing it at the moment validation completes turns an opaque downstream HTTP 500 into - /// an actionable log line. Purely diagnostic: it never changes the enrollment outcome, - /// because whether a given policy actually requires an organization is HydrantID's call. + /// the domain it is issuing for has none -- including a domain that was validated before + /// this plugin started sending organizationIds on creation, or one linked to a different + /// organization than the policy now in use. POST /api/v2/domains/{id} with just + /// {"organizationIds": "..."} updates that link on the existing record without disturbing + /// its validation status (confirmed against staging). + /// + /// Does nothing when is blank -- the matched policy + /// reports no organization, so there is nothing to link -- other than warning if the domain + /// also has no link, since a policy that turns out to require one will surface that at + /// enrollment time as "No valid domains associated with organization" instead. /// - internal void ReportOrganizationLink(FlowLogger flow, Domain domain, string target) + internal async Task EnsureOrganizationLinkedAsync( + IHydrantIdClient client, FlowLogger flow, Domain domain, string target, string organizationIds) { if (domain == null) return; - if (!string.IsNullOrWhiteSpace(domain.OrganizationIds)) + if (string.Equals(domain.OrganizationIds, organizationIds, StringComparison.OrdinalIgnoreCase)) { - flow.Step("DomainValidation.OrganizationLink", - $"domain='{target}', organizationIds='{domain.OrganizationIds}'"); + if (!string.IsNullOrWhiteSpace(domain.OrganizationIds)) + { + flow.Step("DomainValidation.OrganizationLink", + $"domain='{target}', organizationIds='{domain.OrganizationIds}'"); + } return; } - flow.Step("DomainValidation.NoOrganizationLink", $"domain='{target}' has no organizationIds"); - _logger.LogWarning( - "Domain '{Domain}' is VALIDATED at HydrantId but its organizationIds is empty. A policy that " + - "issues under an organization (e.g. an IdenTrust OV policy) will reject enrollment with " + - "\"No valid domains associated with organization\". This plugin cannot create that link -- " + - "confirm in the HydrantId portal that the domain is associated with a vetted organization, " + - "and that the organization matches the one the policy issues under.", - target); + if (string.IsNullOrWhiteSpace(organizationIds)) + { + if (string.IsNullOrWhiteSpace(domain.OrganizationIds)) + { + flow.Step("DomainValidation.NoOrganizationLink", $"domain='{target}' has no organizationIds"); + _logger.LogWarning( + "Domain '{Domain}' is VALIDATED at HydrantId but its organizationIds is empty and the matched " + + "policy reports no organization. A policy that issues under an organization (e.g. an IdenTrust " + + "OV policy) will reject enrollment with \"No valid domains associated with organization\".", + target); + } + return; + } + + flow.Step("DomainValidation.LinkOrganization", + $"domain='{target}', organizationIds='{organizationIds}' (was '{domain.OrganizationIds ?? "(none)"}')"); + + try + { + var updated = await client.GetSubmitUpdateDomainOrganizationAsync(domain.Id, organizationIds); + domain.OrganizationIds = updated?.OrganizationIds ?? organizationIds; + } + catch (Exception ex) + { + flow.Fail($"DomainValidation.LinkOrganization:{target}", ex.Message); + _logger.LogWarning(ex, + "EnsureOrganizationLinkedAsync: failed to link domain '{Domain}' to organization '{OrganizationIds}': {Message}", + target, organizationIds, ex.Message); + } } /// diff --git a/HydrantCAProxy/Interfaces/IHydrantIdClient.cs b/HydrantCAProxy/Interfaces/IHydrantIdClient.cs index 1a23838..b7830ef 100644 --- a/HydrantCAProxy/Interfaces/IHydrantIdClient.cs +++ b/HydrantCAProxy/Interfaces/IHydrantIdClient.cs @@ -24,6 +24,7 @@ public interface IHydrantIdClient Task> GetDomainListAsync(); Task GetSubmitCreateDomainValidationAsync(CreateDomainValidationPayload payload); Task GetSubmitCheckDomainValidationAsync(string domainId); + Task GetSubmitUpdateDomainOrganizationAsync(string domainId, string organizationIds); Task GetSubmitGetCertificateAsync(string certificateId); Task GetSubmitGetCertificateByCsrAsync(string requestTrackingId); Task GetSubmitRevokeCertificateAsync(string hydrantId, RevocationReasons revokeReason); diff --git a/HydrantCAProxy/Interfaces/IUpdateDomainOrganizationPayload.cs b/HydrantCAProxy/Interfaces/IUpdateDomainOrganizationPayload.cs new file mode 100644 index 0000000..425f133 --- /dev/null +++ b/HydrantCAProxy/Interfaces/IUpdateDomainOrganizationPayload.cs @@ -0,0 +1,16 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain a +// copy of the License at http://www.apache.org/licenses/LICENSE-2.0. Unless +// required by applicable law or agreed to in writing, software distributed +// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +// OR CONDITIONS OF ANY KIND, either express or implied. See the License for +// thespecific language governing permissions and limitations under the +// License. +namespace Keyfactor.HydrantId.Interfaces +{ + public interface IUpdateDomainOrganizationPayload + { + string OrganizationIds { get; set; } + } +} From e3118c264798fdb4e24396605389b995347dbf0f Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Fri, 4 Sep 2026 09:27:59 -0400 Subject: [PATCH 28/29] Fixed Test Cases --- docsource/configuration.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docsource/configuration.md b/docsource/configuration.md index 95b1c7a..0eef7bf 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -370,11 +370,11 @@ Confirm via the policy list that `details.validator` is unset for the policy und | # | Test Case | Steps | Expected Result | |---|---|---|---| -| C1 | First-time enrollment, domain never validated | CSR Enrollment against a policy with `validator` set, for a domain never validated before | Enrollment returns pending/"external validation" with DNS TXT record instructions in the status message — no cert issued yet | +| C1 | First-time enrollment, domain never validated, no DNS automation | CSR Enrollment against a policy with `validator` set, for a domain never validated before, in a zone with **no** DNS provider plugin configured (or with no `IDomainValidatorFactory` supplied to the plugin) | Enrollment returns pending/"external validation" with DNS TXT record instructions in the status message — no cert issued yet. If the zone has a DNS provider plugin configured, this behaves like C7 (auto-issues) instead — use a zone without one, or see C12 | | C2 | Publish TXT, resubmit | Publish the TXT record from C1 in real DNS, resubmit the same enrollment | Domain validates, certificate issues | | C3 | Re-enroll same domain (already validated) | Submit a second CSR for the same already-validated domain | No new DCV required — issues directly (domain trust is reused while still valid) | | C4 | Enroll for a subdomain of an already-validated domain | Use a subdomain (e.g. `www.example.com`) of a domain already `Validated` in the Domains list, same validator | Issues directly with no new domain validation record created — the plugin treats it as covered by the validated parent | -| C4b | Enroll for a subdomain of a still-pending (not yet validated) parent | Same as C4, but the parent domain's own validation is still `Pending` | Creates its own separate validation record for the subdomain (parent coverage only applies once the parent is actually `Validated`) | +| C4b | Enroll for a subdomain of a still-pending (not yet validated) parent | Same as C4, but the parent domain's own validation is still `Pending` | Rechecks the parent's own in-flight validation record instead of creating a new one for the subdomain (parent coverage via C4 only applies once the parent is actually `Validated`; while pending, the base domain's record is still the one to finish, since a second record for the subdomain would come back with a null `organizationIds`) | | C5 | Never publish the TXT record | Same as C1 but don't publish the record, resubmit later | Stays pending; status message still shows the same/valid instructions, doesn't error | | C6 | Domain validation expires mid-lifecycle (IdenTrust ~200 days) | Not practically testable end-to-end in a short QA pass — mark "not testable this cycle" unless a naturally-expired domain is available in the environment | Enrollment restarts DCV rather than getting stuck on a dead validation record | | C7 | Automated DCV, happy path | Deploy and configure a DNS provider plugin for a zone you control, then enroll for a never-validated domain in that zone | Certificate issues from the single enrollment with no operator step; the TXT record appears in the zone during validation and is gone afterwards | From 5379ce4283199e52e7fbefe3cea70314ab643471 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 4 Sep 2026 13:28:38 +0000 Subject: [PATCH 29/29] docs: auto-generate README and documentation [skip ci] --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b835ebd..ba1f68e 100644 --- a/README.md +++ b/README.md @@ -461,11 +461,11 @@ Confirm via the policy list that `details.validator` is unset for the policy und | # | Test Case | Steps | Expected Result | |---|---|---|---| -| C1 | First-time enrollment, domain never validated | CSR Enrollment against a policy with `validator` set, for a domain never validated before | Enrollment returns pending/"external validation" with DNS TXT record instructions in the status message — no cert issued yet | +| C1 | First-time enrollment, domain never validated, no DNS automation | CSR Enrollment against a policy with `validator` set, for a domain never validated before, in a zone with **no** DNS provider plugin configured (or with no `IDomainValidatorFactory` supplied to the plugin) | Enrollment returns pending/"external validation" with DNS TXT record instructions in the status message — no cert issued yet. If the zone has a DNS provider plugin configured, this behaves like C7 (auto-issues) instead — use a zone without one, or see C12 | | C2 | Publish TXT, resubmit | Publish the TXT record from C1 in real DNS, resubmit the same enrollment | Domain validates, certificate issues | | C3 | Re-enroll same domain (already validated) | Submit a second CSR for the same already-validated domain | No new DCV required — issues directly (domain trust is reused while still valid) | | C4 | Enroll for a subdomain of an already-validated domain | Use a subdomain (e.g. `www.example.com`) of a domain already `Validated` in the Domains list, same validator | Issues directly with no new domain validation record created — the plugin treats it as covered by the validated parent | -| C4b | Enroll for a subdomain of a still-pending (not yet validated) parent | Same as C4, but the parent domain's own validation is still `Pending` | Creates its own separate validation record for the subdomain (parent coverage only applies once the parent is actually `Validated`) | +| C4b | Enroll for a subdomain of a still-pending (not yet validated) parent | Same as C4, but the parent domain's own validation is still `Pending` | Rechecks the parent's own in-flight validation record instead of creating a new one for the subdomain (parent coverage via C4 only applies once the parent is actually `Validated`; while pending, the base domain's record is still the one to finish, since a second record for the subdomain would come back with a null `organizationIds`) | | C5 | Never publish the TXT record | Same as C1 but don't publish the record, resubmit later | Stays pending; status message still shows the same/valid instructions, doesn't error | | C6 | Domain validation expires mid-lifecycle (IdenTrust ~200 days) | Not practically testable end-to-end in a short QA pass — mark "not testable this cycle" unless a naturally-expired domain is available in the environment | Enrollment restarts DCV rather than getting stuck on a dead validation record | | C7 | Automated DCV, happy path | Deploy and configure a DNS provider plugin for a zone you control, then enroll for a never-validated domain in that zone | Certificate issues from the single enrollment with no operator step; the TXT record appears in the zone during validation and is gone afterwards |