From f8e67eb50837cbbc8414ec780cc861537f4a09d1 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Mon, 10 Aug 2026 15:39:35 -0400 Subject: [PATCH] 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