From 19a46f9ea226fd51df00cda399ae0e7493d4847d Mon Sep 17 00:00:00 2001
From: Brian Hill
Date: Mon, 23 Mar 2026 10:05:48 -0400
Subject: [PATCH 01/25] added back slnx
---
SslStoreCaProxy.sln | 34 ----------------------------------
SslStoreCaProxy.slnx | 3 +++
2 files changed, 3 insertions(+), 34 deletions(-)
delete mode 100644 SslStoreCaProxy.sln
create mode 100644 SslStoreCaProxy.slnx
diff --git a/SslStoreCaProxy.sln b/SslStoreCaProxy.sln
deleted file mode 100644
index fde8b96..0000000
--- a/SslStoreCaProxy.sln
+++ /dev/null
@@ -1,34 +0,0 @@
-
-Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 17
-VisualStudioVersion = 17.0.31903.59
-MinimumVisualStudioVersion = 10.0.40219.1
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SslStoreCaProxy", "SslStoreCaProxy\SslStoreCaProxy.csproj", "{DB47A07B-C9DC-4626-9818-783A4717CF1C}"
-EndProject
-Global
- GlobalSection(SolutionConfigurationPlatforms) = preSolution
- Debug|Any CPU = Debug|Any CPU
- Debug|x64 = Debug|x64
- Debug|x86 = Debug|x86
- Release|Any CPU = Release|Any CPU
- Release|x64 = Release|x64
- Release|x86 = Release|x86
- EndGlobalSection
- GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {DB47A07B-C9DC-4626-9818-783A4717CF1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {DB47A07B-C9DC-4626-9818-783A4717CF1C}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {DB47A07B-C9DC-4626-9818-783A4717CF1C}.Debug|x64.ActiveCfg = Debug|Any CPU
- {DB47A07B-C9DC-4626-9818-783A4717CF1C}.Debug|x64.Build.0 = Debug|Any CPU
- {DB47A07B-C9DC-4626-9818-783A4717CF1C}.Debug|x86.ActiveCfg = Debug|Any CPU
- {DB47A07B-C9DC-4626-9818-783A4717CF1C}.Debug|x86.Build.0 = Debug|Any CPU
- {DB47A07B-C9DC-4626-9818-783A4717CF1C}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {DB47A07B-C9DC-4626-9818-783A4717CF1C}.Release|Any CPU.Build.0 = Release|Any CPU
- {DB47A07B-C9DC-4626-9818-783A4717CF1C}.Release|x64.ActiveCfg = Release|Any CPU
- {DB47A07B-C9DC-4626-9818-783A4717CF1C}.Release|x64.Build.0 = Release|Any CPU
- {DB47A07B-C9DC-4626-9818-783A4717CF1C}.Release|x86.ActiveCfg = Release|Any CPU
- {DB47A07B-C9DC-4626-9818-783A4717CF1C}.Release|x86.Build.0 = Release|Any CPU
- EndGlobalSection
- GlobalSection(SolutionProperties) = preSolution
- HideSolutionNode = FALSE
- EndGlobalSection
-EndGlobal
diff --git a/SslStoreCaProxy.slnx b/SslStoreCaProxy.slnx
new file mode 100644
index 0000000..ba17d13
--- /dev/null
+++ b/SslStoreCaProxy.slnx
@@ -0,0 +1,3 @@
+
+
+
From 53548b44b4e44a7948b3888ea56d155da172655a Mon Sep 17 00:00:00 2001
From: Brian Hill
Date: Mon, 23 Mar 2026 10:17:16 -0400
Subject: [PATCH 02/25] removed old file
---
SampleConfig.json | 1084 ---------------------------------------------
1 file changed, 1084 deletions(-)
delete mode 100644 SampleConfig.json
diff --git a/SampleConfig.json b/SampleConfig.json
deleted file mode 100644
index dcb3f97..0000000
--- a/SampleConfig.json
+++ /dev/null
@@ -1,1084 +0,0 @@
-{
- "Security": {
- "KEYFACTOR\\administrator": {
- "READ": "Allow",
- "ENROLL": "Allow",
- "OFFICER": "Allow",
- "ADMINISTRATOR": "Allow"
- },
- "KEYFACTOR\\SVC_AppPool": {
- "READ": "Allow",
- "ENROLL": "Allow",
- "OFFICER": "Allow",
- "ADMINISTRATOR": "Allow"
- },
- "KEYFACTOR\\SVC_TimerService": {
- "READ": "Allow",
- "ENROLL": "Allow",
- "OFFICER": "Allow",
- "ADMINISTRATOR": "Allow"
- }
- },
- "CAConnection": {
- "SSLStoreURL": "https://sandbox-wbapi.thesslstore.com",
- "PartnerCode": "SomePartnerCodeFromSSLStore",
- "AuthToken": "SomeAuthTokenFromSSLStore",
- "KeyfactorApiUrl": "https://kftrain.keyfactor.lab/KeyfactorAPI",
- "KeyfactorApiUserId": "SomeKeyfactorAPIUser",
- "KeyfactorApiPassword": "SomeKeyfactorAPIPassword",
- "PageSize": "25",
- "SampleRequest": {
- "AuthRequest": {
- "PartnerCode": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "None"
- }
- },
- "AuthToken": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "None"
- }
- }
- },
- "ProductCode": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "None"
- }
- },
- "TSSOrganizationId": {
- "FieldData": {
- "RequiredForProducts": [
- "None"
- ],
- "EnrollmentFieldMapping": "Organization ID"
- }
- },
- "OrganizationInfo": {
- "OrganizationName": {
- "FieldData": {
- "RequiredForProducts": [
- "digi_plus_ssl",
- "digi_wc_ssl",
- "digi_md_ssl",
- "digi_securesite_md",
- "digi_csc",
- "digi_securesite",
- "digi_securesite_pro",
- "digi_securesite_wc",
- "digi_securesite_pro_ev",
- "digi_csc_ev",
- "digi_ev_md_ssl",
- "digi_securesite_ev_md",
- "digi_securesite_ev",
- "digi_ssl_ev_basic",
- "digi_securesite_ev_flex",
- "digi_securesite_pro_ev_flex",
- "digi_securesite_pro_flex",
- "digi_securesite_pro_ev_flex",
- "digi_ssl_basic",
- "digi_securesite_flex",
- "digi_plus_ev_ssl",
- "digi_doc_signing_org_2000",
- "digi_doc_signing_org_5000",
- "digi_truebizid_ev",
- "digi_truebizid",
- "digi_truebizid_ev_md",
- "digi_truebizid_wc",
- "digi_truebizid_md",
- "digi_truebizid_md_wc",
- "digi_truebizid_flex",
- "digi_truebizid_ev_flex",
- "digi_sslwebserver_ev",
- "digi_sslwebserver",
- "digi_sslwebserver_wc",
- "digi_sslwebserver_md_wc",
- "digi_sslwebserver_flex",
- "digi_sslwebserver_ev_flex",
- "truebizid",
- "truebusinessidev",
- "truebusinessidevmd",
- "truebusinessidwildcard",
- "truebizidmdwc",
- "truebizidmd",
- "malwarescan",
- "sslwebserverwildcard",
- "thawtecsc",
- "sslwebserver",
- "sslwebserverev",
- "sectigocsc",
- "sectigoevssl",
- "sectigoevcsc",
- "sectigoevmdc",
- "sectigoovssl",
- "sectigomdc",
- "sectigomdcwildcard",
- "sectigoovwildcard",
- "comodocsc",
- "positiveevssl",
- "comodoevssl",
- "comodoevcsc",
- "enterpriseproev",
- "enterpriseproevmdc",
- "positiveevmdc",
- "comodoevmdc",
- "comodomdc",
- "instantssl",
- "instantsslpro",
- "comodopremiumssl",
- "comodomdcwildcard",
- "comodopremiumwildcard",
- "comodouccwildcard",
- "comodoucc",
- "elitessl",
- "comodopciscan",
- "enterprisessl",
- "enterprisepro",
- "enterpriseprowc",
- "pacenterprise",
- "hackerprooftm",
- "hgpcicontrolscan"
- ],
- "EnrollmentFieldMapping": "Organization Name"
- }
- },
- "RegistrationNumber": {
- "FieldData": {
- "RequiredForProducts": [
- "certum"
- ],
- "EnrollmentFieldMapping": "Organization Registration Number"
- }
- },
- "JurisdictionCountry": {
- "FieldData": {
- "RequiredForProducts": [
- "comodoevssl",
- "comodoevcsc",
- "comodoevmdc",
- "sectigoevssl",
- "sectigoevcsc",
- "sectigoevmdc",
- "enterpriseproev",
- "enterpriseproevmdc",
- "positiveevmdc",
- "positiveevssl"
- ],
- "EnrollmentFieldMapping": "Organization Jurisdiction Country"
- }
- },
- "OrganizationAddress": {
- "AddressLine1": {
- "FieldData": {
- "RequiredForProducts": [
- "digi_plus_ssl",
- "digi_wc_ssl",
- "digi_md_ssl",
- "digi_securesite_md",
- "digi_csc",
- "digi_securesite",
- "digi_securesite_pro",
- "digi_securesite_wc",
- "digi_securesite_pro_ev",
- "digi_csc_ev",
- "digi_ev_md_ssl",
- "digi_securesite_ev_md",
- "digi_securesite_ev",
- "digi_ssl_ev_basic",
- "digi_securesite_ev_flex",
- "digi_securesite_pro_ev_flex",
- "digi_securesite_pro_flex",
- "digi_securesite_pro_ev_flex",
- "digi_ssl_basic",
- "digi_securesite_flex",
- "digi_plus_ev_ssl",
- "digi_doc_signing_org_2000",
- "digi_doc_signing_org_5000",
- "digi_truebizid_ev",
- "digi_truebizid",
- "digi_truebizid_ev_md",
- "digi_truebizid_wc",
- "digi_truebizid_md",
- "digi_truebizid_md_wc",
- "digi_truebizid_flex",
- "digi_truebizid_ev_flex",
- "digi_sslwebserver_ev",
- "digi_sslwebserver",
- "digi_sslwebserver_wc",
- "digi_sslwebserver_md_wc",
- "digi_sslwebserver_flex",
- "digi_sslwebserver_ev_flex",
- "truebizid",
- "truebusinessidev",
- "truebusinessidevmd",
- "truebusinessidwildcard",
- "truebizidmdwc",
- "truebizidmd",
- "malwarescan",
- "sslwebserverwildcard",
- "thawtecsc",
- "sslwebserver",
- "sslwebserverev",
- "sectigocsc",
- "sectigoevssl",
- "sectigoevcsc",
- "sectigoevmdc",
- "sectigoovssl",
- "sectigomdc",
- "sectigomdcwildcard",
- "sectigoovwildcard",
- "comodocsc",
- "positiveevssl",
- "comodoevssl",
- "comodoevcsc",
- "enterpriseproev",
- "enterpriseproevmdc",
- "positiveevmdc",
- "comodoevmdc",
- "comodomdc",
- "instantssl",
- "instantsslpro",
- "comodopremiumssl",
- "comodomdcwildcard",
- "comodopremiumwildcard",
- "comodouccwildcard",
- "comodoucc",
- "elitessl",
- "comodopciscan",
- "enterprisessl",
- "enterprisepro",
- "enterpriseprowc",
- "pacenterprise",
- "hackerprooftm",
- "hgpcicontrolscan"
- ],
- "EnrollmentFieldMapping": "Organization Address"
- }
- },
- "Region": {
- "FieldData": {
- "RequiredForProducts": [
- "digi_plus_ssl",
- "digi_wc_ssl",
- "digi_md_ssl",
- "digi_securesite_md",
- "digi_csc",
- "digi_securesite",
- "digi_securesite_pro",
- "digi_securesite_wc",
- "digi_securesite_pro_ev",
- "digi_csc_ev",
- "digi_ev_md_ssl",
- "digi_securesite_ev_md",
- "digi_securesite_ev",
- "digi_ssl_ev_basic",
- "digi_securesite_ev_flex",
- "digi_securesite_pro_ev_flex",
- "digi_securesite_pro_flex",
- "digi_securesite_pro_ev_flex",
- "digi_ssl_basic",
- "digi_securesite_flex",
- "digi_plus_ev_ssl",
- "digi_doc_signing_org_2000",
- "digi_doc_signing_org_5000",
- "digi_truebizid_ev",
- "digi_truebizid",
- "digi_truebizid_ev_md",
- "digi_truebizid_wc",
- "digi_truebizid_md",
- "digi_truebizid_md_wc",
- "digi_truebizid_flex",
- "digi_truebizid_ev_flex",
- "digi_sslwebserver_ev",
- "digi_sslwebserver",
- "digi_sslwebserver_wc",
- "digi_sslwebserver_md_wc",
- "digi_sslwebserver_flex",
- "digi_sslwebserver_ev_flex",
- "truebizid",
- "truebusinessidev",
- "truebusinessidevmd",
- "truebusinessidwildcard",
- "truebizidmdwc",
- "truebizidmd",
- "malwarescan",
- "sslwebserverwildcard",
- "thawtecsc",
- "sslwebserver",
- "sslwebserverev",
- "sectigocsc",
- "sectigoevssl",
- "sectigoevcsc",
- "sectigoevmdc",
- "sectigoovssl",
- "sectigomdc",
- "sectigomdcwildcard",
- "sectigoovwildcard",
- "comodocsc",
- "positiveevssl",
- "comodoevssl",
- "comodoevcsc",
- "enterpriseproev",
- "enterpriseproevmdc",
- "positiveevmdc",
- "comodoevmdc",
- "comodomdc",
- "instantssl",
- "instantsslpro",
- "comodopremiumssl",
- "comodomdcwildcard",
- "comodopremiumwildcard",
- "comodouccwildcard",
- "comodoucc",
- "elitessl",
- "comodopciscan",
- "enterprisessl",
- "enterprisepro",
- "enterpriseprowc",
- "pacenterprise",
- "hackerprooftm",
- "hgpcicontrolscan"
- ],
- "EnrollmentFieldMapping": "Organization State/Province"
- }
- },
- "PostalCode": {
- "FieldData": {
- "RequiredForProducts": [
- "digi_plus_ssl",
- "digi_wc_ssl",
- "digi_md_ssl",
- "digi_securesite_md",
- "digi_csc",
- "digi_securesite",
- "digi_securesite_pro",
- "digi_securesite_wc",
- "digi_securesite_pro_ev",
- "digi_csc_ev",
- "digi_ev_md_ssl",
- "digi_securesite_ev_md",
- "digi_securesite_ev",
- "digi_ssl_ev_basic",
- "digi_securesite_ev_flex",
- "digi_securesite_pro_ev_flex",
- "digi_securesite_pro_flex",
- "digi_securesite_pro_ev_flex",
- "digi_ssl_basic",
- "digi_securesite_flex",
- "digi_plus_ev_ssl",
- "digi_doc_signing_org_2000",
- "digi_doc_signing_org_5000",
- "digi_truebizid_ev",
- "digi_truebizid",
- "digi_truebizid_ev_md",
- "digi_truebizid_wc",
- "digi_truebizid_md",
- "digi_truebizid_md_wc",
- "digi_truebizid_flex",
- "digi_truebizid_ev_flex",
- "digi_sslwebserver_ev",
- "digi_sslwebserver",
- "digi_sslwebserver_wc",
- "digi_sslwebserver_md_wc",
- "digi_sslwebserver_flex",
- "digi_sslwebserver_ev_flex",
- "truebizid",
- "truebusinessidev",
- "truebusinessidevmd",
- "truebusinessidwildcard",
- "truebizidmdwc",
- "truebizidmd",
- "malwarescan",
- "sslwebserverwildcard",
- "thawtecsc",
- "sslwebserver",
- "sslwebserverev",
- "sectigocsc",
- "sectigoevssl",
- "sectigoevcsc",
- "sectigoevmdc",
- "sectigoovssl",
- "sectigomdc",
- "sectigomdcwildcard",
- "sectigoovwildcard",
- "comodocsc",
- "positiveevssl",
- "comodoevssl",
- "comodoevcsc",
- "enterpriseproev",
- "enterpriseproevmdc",
- "positiveevmdc",
- "comodoevmdc",
- "comodomdc",
- "instantssl",
- "instantsslpro",
- "comodopremiumssl",
- "comodomdcwildcard",
- "comodopremiumwildcard",
- "comodouccwildcard",
- "comodoucc",
- "elitessl",
- "comodopciscan",
- "enterprisessl",
- "enterprisepro",
- "enterpriseprowc",
- "pacenterprise",
- "hackerprooftm",
- "hgpcicontrolscan"
- ],
- "EnrollmentFieldMapping": "Organization Postal Code"
- }
- },
- "LocalityName": {
- "FieldData": {
- "RequiredForProducts": [
- "comodocsc",
- "comodoevssl",
- "comodoevcsc",
- "comodoevmdc",
- "comodomdc",
- "comodomdcwildcard",
- "comodouccwildcard",
- "comodoucc",
- "pacenterprise"
- ],
- "EnrollmentFieldMapping": "Organization Locality Name"
- }
- },
- "Country": {
- "FieldData": {
- "RequiredForProducts": [
- "truebizidmd",
- "digi_ev_md_ssl",
- "digi_md_ssl",
- "digi_plus_ev_ssl",
- "digi_plus_ssl",
- "digi_securesite",
- "digi_securesite_wc",
- "digi_securesite_pro_ev",
- "digi_securesite_ev_md",
- "digi_securesite_ev",
- "digi_ssl_ev_basic",
- "digi_securesite_ev_flex",
- "digi_securesite_pro_ev_flex",
- "digi_securesite_pro_flex",
- "digi_ssl_basic",
- "digi_truebizid_ev_flex",
- "digi_sslwebserver_ev_flex",
- "digi_securesite_flex",
- "digi_truebizid_ev",
- "digi_truebizid",
- "digi_truebizid_ev_md",
- "digi_truebizid_wc",
- "digi_truebizid_md",
- "digi_truebizid_md_wc",
- "digi_sslwebserver_ev",
- "digi_sslwebserver",
- "digi_sslwebserver_wc",
- "digi_sslwebserver_md_wc",
- "digi_sslwebserver_flex"
- ],
- "EnrollmentFieldMapping": "Organization Country"
- }
- },
- "Phone": {
- "FieldData": {
- "RequiredForProducts": [
- "truebizidmd",
- "digi_ev_md_ssl",
- "digi_md_ssl",
- "digi_plus_ev_ssl",
- "digi_plus_ssl",
- "digi_securesite",
- "digi_securesite_wc",
- "digi_securesite_pro_ev",
- "digi_securesite_ev_md",
- "digi_securesite_ev",
- "digi_ssl_ev_basic",
- "digi_securesite_ev_flex",
- "digi_securesite_pro_ev_flex",
- "digi_securesite_pro_flex",
- "digi_ssl_basic",
- "digi_sslwebserver_ev_flex",
- "digi_truebizid_ev_flex",
- "digi_securesite_flex",
- "digi_truebizid_ev",
- "digi_truebizid",
- "digi_truebizid_ev_md",
- "digi_truebizid_wc",
- "digi_truebizid_md",
- "digi_truebizid_md_wc",
- "digi_sslwebserver_ev",
- "digi_sslwebserver",
- "digi_sslwebserver_wc",
- "digi_sslwebserver_md_wc",
- "digi_sslwebserver_flex"
- ],
- "EnrollmentFieldMapping": "Organization Phone"
- }
- }
- }
- },
- "ValidityPeriod": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "Validity Period (In Months)"
- }
- },
- "ServerCount": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "Server Count"
- }
- },
- "CSR": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "None"
- }
- },
- "DomainName": {
- "FieldData": {
- "RequiredForProducts": [
- "Certum"
- ],
- "EnrollmentFieldMapping": "Domain Name"
- }
- },
- "WebServerType": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "Web Server Type"
- }
- },
- "DNSNames": {
- "FieldData": {
- "RequiredForProducts": [
- "digi_ssl_ev_basic",
- "digi_ssl_ev_basic-EO",
- "digi_securesite_ev_flex",
- "digi_securesite_ev_flex-EO",
- "digi_securesite_pro_ev_flex",
- "digi_securesite_pro_ev_flex-EO",
- "digi_securesite_pro_flex",
- "digi_securesite_pro_flex-EO",
- "digi_ssl_basic",
- "digi_ssl_basic-EO",
- "digi_securesite_flex",
- "digi_securesite_flex-EO",
- "digi_truebizid_flex",
- "digi_truebizid_flex-EO",
- "digi_truebizid_ev_flex",
- "digi_truebizid_ev_flex-EO",
- "digi_ssl_dv_geotrust_flex",
- "digi_rapidssl",
- "digi_rapidssl_wc",
- "digi_ssl123_flex",
- "digi_sslwebserver_flex",
- "digi_sslwebserver_flex-EO",
- "digi_sslwebserver_ev_flex",
- "digi_sslwebserver_ev_flex-EO",
- "positivemdcssl",
- "positivemdcwildcard",
- "sectigodvucc",
- "sectigouccwildcard",
- "sectigoevmdc",
- "sectigomdcwildcard",
- "sectigomdc"
- ],
- "EnrollmentFieldMapping": "DNS Names Comma Separated",
- "Array": true
- }
- },
- "isCUOrder": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "Is CU Order?"
- }
- },
- "isRenewalOrder": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "Is Renewal Order?"
- }
- },
- "isTrialOrder": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "Is Trial Order?"
- }
- },
- "AdminContact": {
- "FirstName": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "Admin Contact - First Name"
- }
- },
- "LastName": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "Admin Contact - Last Name"
- }
- },
- "Phone": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "Admin Contact - Phone"
- }
- },
- "Email": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "Admin Contact - Email"
- }
- },
- "Title": {
- "FieldData": {
- "RequiredForProducts": [
- "symantec",
- "digi_ssl_ev_basic",
- "digi_sslwebserver_ev_flex",
- "digi_truebizid_ev_flex",
- "digi_securesite_pro_ev_flex",
- "digi_securesite_ev_flex"
- ],
- "EnrollmentFieldMapping": "Admin Contact - Title"
- }
- },
- "OrganizationName": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "Admin Contact - Organization Name"
- }
- },
- "AddressLine1": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "Admin Contact - Address"
- }
- },
- "City": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "Admin Contact - City"
- }
- },
- "Region": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "Admin Contact - Region"
- }
- },
- "PostalCode": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "Admin Contact - Postal Code"
- }
- },
- "Country": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "Admin Contact - Country"
- }
- }
- },
- "TechnicalContact": {
- "FirstName": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "Technical Contact - First Name"
- }
- },
- "LastName": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "Technical Contact - Last Name"
- }
- },
- "SubjectFirstName": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "Technical Contact - Subject First Name"
- }
- },
- "SubjectLastName": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "Technical Contact - Subject Last Name"
- }
- },
- "Phone": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "Technical Contact - Phone"
- }
- },
- "Email": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "Technical Contact - Email"
- }
- },
- "Title": {
- "FieldData": {
- "RequiredForProducts": [
- "symantec",
- "digi_ssl_ev_basic",
- "digi_securesite_ev_flex",
- "digi_truebizid_ev_flex",
- "digi_sslwebserver_ev_flex"
- ],
- "EnrollmentFieldMapping": "Technical Contact - Title"
- }
- },
- "AddressLine1": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "Technical Contact - Address"
- }
- },
- "City": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "Technical Contact - City"
- }
- },
- "Region": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "Technical Contact - Region"
- }
- },
- "PostalCode": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "Technical Contact - Postal Code"
- }
- },
- "Country": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "Technical Contact - Country"
- }
- }
- },
- "ApproverEmail": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "Approver Email"
- }
- },
- "AutoWWW": {
- "FieldData": {
- "RequiredForProducts": [
- "positivessl"
- ],
- "EnrollmentFieldMapping": "AutoWWW"
- }
- },
- "FileAuthDVIndicator": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "File Auth Domain Validation"
- }
- },
- "CNAMEAuthDVIndicator": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "CName Auth Domain Validation"
- }
- },
- "SignatureHashAlgorithm": {
- "FieldData": {
- "RequiredForProducts": [
- "All"
- ],
- "EnrollmentFieldMapping": "Signature Hash Algorithm"
- }
- }
- }
- },
- "Templates": {
- "positivessl": {
- "ProductID": "positivessl",
- "Parameters": {
- "Parameters": {
- "AutoWWW": "True"
- }
- }
- },
- "positiveevssl": {
- "ProductID": "positiveevssl",
- "Parameters": {}
- },
- "enterpriseproev": {
- "ProductID": "enterpriseproev",
- "Parameters": {}
- },
- "enterpriseproevmdc": {
- "ProductID": "enterpriseproevmdc",
- "Parameters": {}
- },
- "positivesslwildcard": {
- "ProductID": "positivesslwildcard",
- "Parameters": {}
- },
- "positivemdcssl": {
- "ProductID": "positivemdcssl",
- "Parameters": {}
- },
- "positivemdcwildcard": {
- "ProductID": "positivemdcwildcard",
- "Parameters": {}
- },
- "positiveevmdc": {
- "ProductID": "positiveevmdc",
- "Parameters": {}
- },
- "instantssl": {
- "ProductID": "instantssl",
- "Parameters": {}
- },
- "instantsslpro": {
- "ProductID": "instantsslpro",
- "Parameters": {}
- },
- "comodopremiumssl": {
- "ProductID": "comodopremiumssl",
- "Parameters": {}
- },
- "comodopremiumwildcard": {
- "ProductID": "comodopremiumwildcard",
- "Parameters": {}
- },
- "enterprisepro": {
- "ProductID": "enterprisepro",
- "Parameters": {}
- },
- "enterpriseprowc": {
- "ProductID": "enterpriseprowc",
- "Parameters": {}
- },
- "sectigossl": {
- "ProductID": "sectigossl",
- "Parameters": {}
- },
- "sectigodvucc": {
- "ProductID": "sectigodvucc",
- "Parameters": {}
- },
- "sectigoevssl": {
- "ProductID": "sectigoevssl",
- "Parameters": {}
- },
- "sectigoevmdc": {
- "ProductID": "sectigoevmdc",
- "Parameters": {}
- },
- "sectigoovssl": {
- "ProductID": "sectigoovssl",
- "Parameters": {}
- },
- "sectigomdc": {
- "ProductID": "sectigomdc",
- "Parameters": {}
- },
- "sectigomdcwildcard": {
- "ProductID": "sectigomdcwildcard",
- "Parameters": {}
- },
- "sectigoovwildcard": {
- "ProductID": "sectigoovwildcard",
- "Parameters": {}
- },
- "sectigowildcard": {
- "ProductID": "sectigowildcard",
- "Parameters": {}
- },
- "sectigouccwildcard": {
- "ProductID": "sectigouccwildcard",
- "Parameters": {}
- },
- "digi_ssl_ev_basic": {
- "ProductID": "digi_ssl_ev_basic",
- "Parameters": {}
- },
- "digi_ssl_ev_basic-EO": {
- "ProductID": "digi_ssl_ev_basic-EO",
- "Parameters": {}
- },
- "digi_securesite_ev_flex": {
- "ProductID": "digi_securesite_ev_flex",
- "Parameters": {}
- },
- "digi_securesite_ev_flex-EO": {
- "ProductID": "digi_securesite_ev_flex-EO",
- "Parameters": {}
- },
- "digi_securesite_pro_ev_flex": {
- "ProductID": "digi_securesite_pro_ev_flex",
- "Parameters": {}
- },
- "digi_securesite_pro_ev_flex-EO": {
- "ProductID": "digi_securesite_pro_ev_flex-EO",
- "Parameters": {}
- },
- "digi_securesite_pro_flex": {
- "ProductID": "digi_securesite_pro_flex",
- "Parameters": {}
- },
- "digi_securesite_pro_flex-EO": {
- "ProductID": "digi_securesite_pro_flex-EO",
- "Parameters": {}
- },
- "digi_ssl_basic": {
- "ProductID": "digi_ssl_basic",
- "Parameters": {}
- },
- "digi_ssl_basic-EO": {
- "ProductID": "digi_ssl_basic-EO",
- "Parameters": {}
- },
- "digi_securesite_flex": {
- "ProductID": "digi_securesite_flex",
- "Parameters": {}
- },
- "digi_securesite_flex-EO": {
- "ProductID": "digi_securesite_flex-EO",
- "Parameters": {}
- },
- "digi_truebizid_flex": {
- "ProductID": "digi_truebizid_flex",
- "Parameters": {}
- },
- "digi_truebizid_flex-EO": {
- "ProductID": "digi_truebizid_flex-EO",
- "Parameters": {}
- },
- "digi_truebizid_ev_flex": {
- "ProductID": "digi_truebizid_ev_flex",
- "Parameters": {}
- },
- "digi_truebizid_ev_flex-EO": {
- "ProductID": "digi_truebizid_ev_flex-EO",
- "Parameters": {}
- },
- "digi_ssl_dv_geotrust_flex": {
- "ProductID": "digi_ssl_dv_geotrust_flex",
- "Parameters": {}
- },
- "digi_rapidssl": {
- "ProductID": "digi_rapidssl",
- "Parameters": {}
- },
- "digi_rapidssl_wc": {
- "ProductID": "digi_rapidssl_wc",
- "Parameters": {}
- },
- "digi_ssl123_flex": {
- "ProductID": "digi_ssl123_flex",
- "Parameters": {}
- },
- "digi_sslwebserver_flex": {
- "ProductID": "digi_sslwebserver_flex",
- "Parameters": {}
- },
- "digi_sslwebserver_flex-EO": {
- "ProductID": "digi_sslwebserver_flex-EO",
- "Parameters": {}
- },
- "digi_sslwebserver_ev_flex": {
- "ProductID": "digi_sslwebserver_ev_flex",
- "Parameters": {}
- },
- "digi_sslwebserver_ev_flex-EO": {
- "ProductID": "digi_sslwebserver_ev_flex-EO",
- "Parameters": {}
- }
- },
- "CertificateManagers": null,
- "GatewayRegistration": {
- "LogicalName": "SSLStore",
- "GatewayCertificate": {
- "StoreName": "CA",
- "StoreLocation": "LocalMachine",
- "Thumbprint": "339cdd57cfd5b141169b615ff31428782d1da639"
- }
- },
- "ServiceSettings": {
- "ViewIdleMinutes": 1,
- "FullScanPeriodHours": 1,
- "PartialScanPeriodMinutes": 1
- }
-}
\ No newline at end of file
From 7e27ab6cbda5aadfc9bd220cfc5499f2e3cc6e1f Mon Sep 17 00:00:00 2001
From: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com>
Date: Fri, 27 Mar 2026 12:04:13 -0400
Subject: [PATCH 03/25] Update integration-manifest.json
---
integration-manifest.json | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/integration-manifest.json b/integration-manifest.json
index 4f22c79..94ed669 100644
--- a/integration-manifest.json
+++ b/integration-manifest.json
@@ -7,8 +7,8 @@
"status": "production",
"integration_type": "anyca-plugin",
"support_level": "kf-supported",
- "link_github": true,
- "update_catalog": true,
+ "link_github": false,
+ "update_catalog": false,
"gateway_framework": "25.5",
"about": {
"carest": {
@@ -285,4 +285,4 @@
]
}
}
-}
\ No newline at end of file
+}
From fe5b7e10d0d50967d399dc264ad0c65db7119a40 Mon Sep 17 00:00:00 2001
From: Keyfactor
Date: Fri, 27 Mar 2026 16:06:06 +0000
Subject: [PATCH 04/25] Update generated docs
---
integration-manifest.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/integration-manifest.json b/integration-manifest.json
index 94ed669..86be3f5 100644
--- a/integration-manifest.json
+++ b/integration-manifest.json
@@ -285,4 +285,4 @@
]
}
}
-}
+}
\ No newline at end of file
From 8a13feb1e3ae3d84bcc83f35ffd7d0579dff0811 Mon Sep 17 00:00:00 2001
From: Brian Hill
Date: Mon, 30 Mar 2026 13:12:15 -0400
Subject: [PATCH 05/25] fixed default status
---
SslStoreCaProxy/RequestManager.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/SslStoreCaProxy/RequestManager.cs b/SslStoreCaProxy/RequestManager.cs
index fbbdbe4..e4694e1 100644
--- a/SslStoreCaProxy/RequestManager.cs
+++ b/SslStoreCaProxy/RequestManager.cs
@@ -198,7 +198,7 @@ public int MapReturnStatus(string sslStoreStatus)
case "Cancelled":
return (int)EndEntityStatus.REVOKED;
default:
- return (int)EndEntityStatus.FAILED;
+ return (int)EndEntityStatus.NEW;
}
}
From 9be985e679d5dd57f5b6d43d5e1864cacf1b87b5 Mon Sep 17 00:00:00 2001
From: Brian Hill
Date: Tue, 31 Mar 2026 10:13:08 -0400
Subject: [PATCH 06/25] fixed reissue issue
---
SslStoreCaProxy/RequestManager.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/SslStoreCaProxy/RequestManager.cs b/SslStoreCaProxy/RequestManager.cs
index e4694e1..bac8730 100644
--- a/SslStoreCaProxy/RequestManager.cs
+++ b/SslStoreCaProxy/RequestManager.cs
@@ -86,7 +86,7 @@ public ReIssueRequest GetReIssueRequest(INewOrderResponse orderData, string csr,
{
AuthRequest = GetAuthRequest(),
TheSslStoreOrderId = orderData.TheSslStoreOrderId,
- CustomOrderId = Guid.NewGuid().ToString(),
+ CustomOrderId = orderData.CustomOrderId,
Csr = csr,
IsRenewalOrder = isRenewal,
IsWildCard = orderData.ProductCode.Contains("wc") || orderData.ProductCode.Contains("wildcard"),
From fe3d4459400c6d7f1ae76bd42705afcc2ec2e19d Mon Sep 17 00:00:00 2001
From: Brian Hill
Date: Tue, 31 Mar 2026 13:32:05 -0400
Subject: [PATCH 07/25] undo fix
---
SslStoreCaProxy/RequestManager.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/SslStoreCaProxy/RequestManager.cs b/SslStoreCaProxy/RequestManager.cs
index bac8730..e4694e1 100644
--- a/SslStoreCaProxy/RequestManager.cs
+++ b/SslStoreCaProxy/RequestManager.cs
@@ -86,7 +86,7 @@ public ReIssueRequest GetReIssueRequest(INewOrderResponse orderData, string csr,
{
AuthRequest = GetAuthRequest(),
TheSslStoreOrderId = orderData.TheSslStoreOrderId,
- CustomOrderId = orderData.CustomOrderId,
+ CustomOrderId = Guid.NewGuid().ToString(),
Csr = csr,
IsRenewalOrder = isRenewal,
IsWildCard = orderData.ProductCode.Contains("wc") || orderData.ProductCode.Contains("wildcard"),
From 9133420a552206103a7a9fc4ae0cbb09810621dc Mon Sep 17 00:00:00 2001
From: Brian Hill
Date: Tue, 31 Mar 2026 13:42:28 -0400
Subject: [PATCH 08/25] fixe custom order id
---
SslStoreCaProxy/SslStoreCaProxy.cs | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/SslStoreCaProxy/SslStoreCaProxy.cs b/SslStoreCaProxy/SslStoreCaProxy.cs
index dce2725..869edb9 100644
--- a/SslStoreCaProxy/SslStoreCaProxy.cs
+++ b/SslStoreCaProxy/SslStoreCaProxy.cs
@@ -279,6 +279,10 @@ public async Task Enroll(string csr, string subject, Dictionar
enrollmentResponse = await client.SubmitReIssueRequestAsync(reIssueRequest);
_logger.LogTrace($"reissue enrollmentResponse JSON {JsonConvert.SerializeObject(enrollmentResponse)}");
+
+ // SSL Store API ignores the CustomOrderId we send on reissue and returns the original order's ID.
+ // Override it with our generated ID so the framework can create a new DB record.
+ enrollmentResponse.CustomOrderId = reIssueRequest.CustomOrderId;
}
}
From 0bbe7e984d404ba88120e104d42d94f298adea8a Mon Sep 17 00:00:00 2001
From: Brian Hill
Date: Tue, 31 Mar 2026 14:05:41 -0400
Subject: [PATCH 09/25] fixed order id
---
SslStoreCaProxy/RequestManager.cs | 18 ++++++
SslStoreCaProxy/SslStoreCaProxy.cs | 99 ++++++++++++++++++++++++------
2 files changed, 97 insertions(+), 20 deletions(-)
diff --git a/SslStoreCaProxy/RequestManager.cs b/SslStoreCaProxy/RequestManager.cs
index e4694e1..30718f8 100644
--- a/SslStoreCaProxy/RequestManager.cs
+++ b/SslStoreCaProxy/RequestManager.cs
@@ -142,6 +142,15 @@ public DownloadCertificateRequest GetCertificateRequest(string customOrderId)
};
}
+ public DownloadCertificateRequest GetCertificateRequestBySslStoreId(string theSslStoreOrderId)
+ {
+ return new DownloadCertificateRequest
+ {
+ AuthRequest = GetAuthRequest(),
+ TheSslStoreOrderId = theSslStoreOrderId
+ };
+ }
+
public RevokeOrderRequest GetRevokeOrderRequest(string customOrderId)
{
return new RevokeOrderRequest
@@ -151,6 +160,15 @@ public RevokeOrderRequest GetRevokeOrderRequest(string customOrderId)
};
}
+ public RevokeOrderRequest GetRevokeOrderRequestBySslStoreId(string theSslStoreOrderId)
+ {
+ return new RevokeOrderRequest
+ {
+ AuthRequest = GetAuthRequest(),
+ TheSslStoreOrderId = theSslStoreOrderId
+ };
+ }
+
public int GetClientPageSize(IAnyCAPluginConfigProvider config)
{
if (config.CAConnectionData.ContainsKey(Constants.PageSize))
diff --git a/SslStoreCaProxy/SslStoreCaProxy.cs b/SslStoreCaProxy/SslStoreCaProxy.cs
index 869edb9..04bd232 100644
--- a/SslStoreCaProxy/SslStoreCaProxy.cs
+++ b/SslStoreCaProxy/SslStoreCaProxy.cs
@@ -129,7 +129,16 @@ public Dictionary GetTemplateParameterAnnotations()
public async Task Revoke(string caRequestId, string hexSerialNumber, uint revocationReason)
{
_logger.MethodEntry();
- var revokeOrderRequest = _requestManager.GetRevokeOrderRequest(caRequestId);
+ var sslStoreOrderId = ParseSslStoreOrderId(caRequestId);
+ RevokeOrderRequest revokeOrderRequest;
+ if (sslStoreOrderId != null)
+ {
+ revokeOrderRequest = _requestManager.GetRevokeOrderRequestBySslStoreId(sslStoreOrderId);
+ }
+ else
+ {
+ revokeOrderRequest = _requestManager.GetRevokeOrderRequest(caRequestId);
+ }
_logger.LogTrace($"Revoke Request JSON {JsonConvert.SerializeObject(revokeOrderRequest)}");
try
{
@@ -240,9 +249,20 @@ public async Task Enroll(string csr, string subject, Dictionar
_logger.LogTrace($"Prior Cert Serial Number: {sn}");
var caRequestId = await _certDataReader.GetRequestIDBySerialNumber(sn);
- _logger.LogTrace($"Prior CA Request ID (CustomOrderId): {caRequestId}");
+ _logger.LogTrace($"Prior CA Request ID: {caRequestId}");
- var orderStatusRequest = _requestManager.GetOrderStatusRequest(caRequestId);
+ var priorSslStoreOrderId = ParseSslStoreOrderId(caRequestId);
+ OrderStatusRequest orderStatusRequest;
+ if (priorSslStoreOrderId != null)
+ {
+ _logger.LogTrace($"Parsed TheSSLStoreOrderID: {priorSslStoreOrderId}");
+ orderStatusRequest = _requestManager.GetOrderStatusRequestBySslStoreId(priorSslStoreOrderId);
+ }
+ else
+ {
+ _logger.LogTrace($"Legacy GUID format, querying by CustomOrderId: {caRequestId}");
+ orderStatusRequest = _requestManager.GetOrderStatusRequest(caRequestId);
+ }
_logger.LogTrace($"orderStatusRequest JSON {JsonConvert.SerializeObject(orderStatusRequest)}");
var orderStatusResponse = await client.SubmitOrderStatusRequestAsync(orderStatusRequest);
@@ -279,10 +299,6 @@ public async Task Enroll(string csr, string subject, Dictionar
enrollmentResponse = await client.SubmitReIssueRequestAsync(reIssueRequest);
_logger.LogTrace($"reissue enrollmentResponse JSON {JsonConvert.SerializeObject(enrollmentResponse)}");
-
- // SSL Store API ignores the CustomOrderId we send on reissue and returns the original order's ID.
- // Override it with our generated ID so the framework can create a new DB record.
- enrollmentResponse.CustomOrderId = reIssueRequest.CustomOrderId;
}
}
@@ -294,6 +310,36 @@ public async Task Enroll(string csr, string subject, Dictionar
}
}
+ ///
+ /// Builds a composite CARequestID in the format "{TheSSLStoreOrderID}-{PartnerOrderID}".
+ /// This ensures uniqueness across reissues (same order, different PartnerOrderID).
+ ///
+ private static string BuildCompositeRequestId(string theSslStoreOrderId, string partnerOrderId)
+ {
+ return $"{theSslStoreOrderId}-{partnerOrderId}";
+ }
+
+ ///
+ /// Parses the TheSSLStoreOrderID from a CARequestID. Supports both composite format
+ /// ("{TheSSLStoreOrderID}-{PartnerOrderID}") and legacy GUID format (falls back to
+ /// treating the whole string as a CustomOrderId for backward compatibility).
+ ///
+ private static string ParseSslStoreOrderId(string caRequestId)
+ {
+ if (string.IsNullOrEmpty(caRequestId)) return caRequestId;
+
+ var dashIndex = caRequestId.IndexOf('-');
+ // Composite IDs have a numeric TheSSLStoreOrderID before the first dash.
+ // Legacy GUIDs have hex chars before the first dash so we check for digits only.
+ if (dashIndex > 0 && caRequestId.Substring(0, dashIndex).All(char.IsDigit))
+ {
+ return caRequestId.Substring(0, dashIndex);
+ }
+
+ // Legacy GUID format — return as-is for backward compatibility
+ return null;
+ }
+
private EnrollmentResult GetEnrollmentResult(INewOrderResponse newOrderResponse)
{
if (newOrderResponse != null && newOrderResponse.AuthResponse.IsError)
@@ -308,16 +354,16 @@ private EnrollmentResult GetEnrollmentResult(INewOrderResponse newOrderResponse)
var majorStatus = newOrderResponse?.OrderStatus?.MajorStatus;
var status = _requestManager.MapReturnStatus(majorStatus);
- var orderId = newOrderResponse?.CustomOrderId;
+ var compositeId = BuildCompositeRequestId(newOrderResponse?.TheSslStoreOrderId, newOrderResponse?.PartnerOrderId);
- _logger.LogTrace($"Order {orderId} status: {majorStatus} -> mapped to {status}");
+ _logger.LogTrace($"Order {compositeId} (SSLStoreOrderId: {newOrderResponse?.TheSslStoreOrderId}, PartnerOrderId: {newOrderResponse?.PartnerOrderId}) status: {majorStatus} -> mapped to {status}");
_logger.MethodExit();
return new EnrollmentResult
{
- CARequestID = orderId,
+ CARequestID = compositeId,
Status = status,
- StatusMessage = $"Order Successfully Created With Order Number {orderId}"
+ StatusMessage = $"Order Successfully Created With Order Number {compositeId}"
};
}
@@ -326,8 +372,19 @@ public async Task GetSingleRecord(string caRequestId)
_logger.MethodEntry();
var client = new SslStoreClient(Config);
- var orderStatusRequest = _requestManager.GetOrderStatusRequest(caRequestId);
- _logger.LogTrace($"orderStatusRequest JSON {JsonConvert.SerializeObject(orderStatusRequest)}");
+ var sslStoreOrderId = ParseSslStoreOrderId(caRequestId);
+
+ OrderStatusRequest orderStatusRequest;
+ if (sslStoreOrderId != null)
+ {
+ _logger.LogTrace($"Parsed TheSSLStoreOrderID: {sslStoreOrderId} from CARequestID: {caRequestId}");
+ orderStatusRequest = _requestManager.GetOrderStatusRequestBySslStoreId(sslStoreOrderId);
+ }
+ else
+ {
+ _logger.LogTrace($"Legacy GUID format, querying by CustomOrderId: {caRequestId}");
+ orderStatusRequest = _requestManager.GetOrderStatusRequest(caRequestId);
+ }
var orderStatusResponse = await client.SubmitOrderStatusRequestAsync(orderStatusRequest);
_logger.LogTrace($"orderStatusResponse JSON {JsonConvert.SerializeObject(orderStatusResponse)}");
@@ -337,7 +394,7 @@ public async Task GetSingleRecord(string caRequestId)
if (certStatus == (int)EndEntityStatus.GENERATED)
{
- var downloadCertificateRequest = _requestManager.GetCertificateRequest(caRequestId);
+ var downloadCertificateRequest = _requestManager.GetCertificateRequestBySslStoreId(sslStoreOrderId ?? orderStatusResponse.TheSslStoreOrderId);
var certResponse = await client.SubmitDownloadCertificateAsync(downloadCertificateRequest);
if (!certResponse.AuthResponse.IsError)
{
@@ -383,19 +440,21 @@ public async Task Synchronize(BlockingCollection blockin
var orderStatusRequest = _requestManager.GetOrderStatusRequestBySslStoreId(currentResponseItem?.TheSslStoreOrderId);
var orderStatusResponse = await client.SubmitOrderStatusRequestAsync(orderStatusRequest);
- var customOrderId = orderStatusResponse.CustomOrderId;
- if (string.IsNullOrEmpty(customOrderId))
+ var theSslStoreOrderId = orderStatusResponse.TheSslStoreOrderId;
+ var partnerOrderId = orderStatusResponse.PartnerOrderId;
+ if (string.IsNullOrEmpty(theSslStoreOrderId) || string.IsNullOrEmpty(partnerOrderId))
{
- _logger.LogTrace($"Order {currentResponseItem?.TheSslStoreOrderId} has no CustomOrderId, skipping");
+ _logger.LogTrace($"Order {currentResponseItem?.TheSslStoreOrderId} missing required IDs, skipping");
continue;
}
+ var compositeId = BuildCompositeRequestId(theSslStoreOrderId, partnerOrderId);
var fileContent = "";
var certStatus = _requestManager.MapReturnStatus(orderStatusResponse.OrderStatus.MajorStatus);
if (certStatus == (int)EndEntityStatus.GENERATED)
{
- var downloadCertificateRequest = _requestManager.GetCertificateRequest(customOrderId);
+ var downloadCertificateRequest = _requestManager.GetCertificateRequestBySslStoreId(theSslStoreOrderId);
var certResponse = await client.SubmitDownloadCertificateAsync(downloadCertificateRequest);
if (!certResponse.AuthResponse.IsError)
{
@@ -404,13 +463,13 @@ public async Task Synchronize(BlockingCollection blockin
fileContent = Convert.ToBase64String(endEntityCert.RawData);
}
}
-
+
if ((certStatus == (int)EndEntityStatus.GENERATED && fileContent.Length > 0) ||
certStatus == (int)EndEntityStatus.REVOKED)
{
blockingBuffer.Add(new AnyCAPluginCertificate
{
- CARequestID = customOrderId,
+ CARequestID = compositeId,
Certificate = fileContent,
Status = certStatus,
ProductID = $"{orderStatusResponse.ProductCode}"
From 306f460f78be405d689e9d718ed1080e2a890e52 Mon Sep 17 00:00:00 2001
From: Brian Hill
Date: Tue, 14 Jul 2026 14:25:04 -0400
Subject: [PATCH 10/25] dnsplugin updates
---
CHANGELOG.md | 10 +
.../Clients/DNS/DnsVerificationHelper.cs | 192 +++++++++++++++
SslStoreCaProxy/Interfaces/IRequestManager.cs | 3 +-
SslStoreCaProxy/RequestManager.cs | 9 +-
SslStoreCaProxy/SslStoreCAPluginConfig.cs | 33 +++
SslStoreCaProxy/SslStoreCaProxy.cs | 233 ++++++++++++++++--
SslStoreCaProxy/SslStoreCaProxy.csproj | 3 +-
docsource/configuration.md | 35 +++
8 files changed, 486 insertions(+), 32 deletions(-)
create mode 100644 SslStoreCaProxy/Clients/DNS/DnsVerificationHelper.cs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1470e43..431ad56 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,13 @@
+# v2.1.0
+* Added support for the generic DNS provider plugin framework (Keyfactor.AnyGateway.IAnyCAPlugin 3.3.0)
+* Automated DNS (CNAME) domain control validation: when enabled, the plugin requests CNAME-based
+ validation from SSL Store and publishes the returned record via the DNS provider plugin resolved
+ by the AnyCA Gateway (Azure, Route53, Cloudflare, Google, NS1, Infoblox, RFC2136, etc.)
+* Verifies public DNS propagation of the validation record before returning
+* Best-effort cleanup of DNS validation records once an order is issued (GetSingleRecord/Synchronize)
+* New CA-connection settings: `DnsValidationEnabled`, `DnsValidationType`, `DnsVerificationServer`
+* Email approver validation remains the default when DNS validation is disabled
+
# v2.0.0
* Converted from AnyCA Gateway (DB) to AnyCA Gateway REST plugin architecture
* Migrated from CAProxy.AnyGateway (BaseCAConnector) to IAnyCAPlugin interface
diff --git a/SslStoreCaProxy/Clients/DNS/DnsVerificationHelper.cs b/SslStoreCaProxy/Clients/DNS/DnsVerificationHelper.cs
new file mode 100644
index 0000000..3023673
--- /dev/null
+++ b/SslStoreCaProxy/Clients/DNS/DnsVerificationHelper.cs
@@ -0,0 +1,192 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Threading.Tasks;
+using DnsClient;
+using Keyfactor.Logging;
+using Microsoft.Extensions.Logging;
+
+namespace Keyfactor.AnyGateway.SslStore.Clients.DNS
+{
+ ///
+ /// Verifies DNS record propagation before relying on a Certificate Authority to poll for
+ /// Domain Control Validation. Supports both TXT and CNAME records so it works with the
+ /// generic DNS provider plugin framework regardless of which record type a CA requires.
+ ///
+ public class DnsVerificationHelper
+ {
+ private static readonly ILogger _logger = LogHandler.GetClassLogger();
+ private readonly List _dnsServers;
+ private readonly bool _usePrivateDns;
+ private const int MaxVerificationAttempts = 3;
+ private const int VerificationDelaySeconds = 10;
+
+ ///
+ /// Creates a DNS verification helper.
+ ///
+ /// Optional DNS server IP for verification. For
+ /// private/internal zones, specify your authoritative DNS server. Leave null/empty to
+ /// use public DNS servers.
+ public DnsVerificationHelper(string verificationServer = null)
+ {
+ _dnsServers = new List();
+
+ if (!string.IsNullOrWhiteSpace(verificationServer) && IPAddress.TryParse(verificationServer, out var privateServer))
+ {
+ _usePrivateDns = true;
+ _dnsServers.Add(privateServer);
+ _logger.LogInformation("DNS verification will use private DNS server: {Server}", verificationServer);
+ }
+ else
+ {
+ _usePrivateDns = false;
+ _dnsServers = new List
+ {
+ IPAddress.Parse("8.8.8.8"), // Google Primary
+ IPAddress.Parse("8.8.4.4"), // Google Secondary
+ IPAddress.Parse("1.1.1.1"), // Cloudflare Primary
+ IPAddress.Parse("1.0.0.1"), // Cloudflare Secondary
+ IPAddress.Parse("208.67.222.222"), // OpenDNS
+ IPAddress.Parse("9.9.9.9") // Quad9
+ };
+ }
+ }
+
+ ///
+ /// Waits for a DNS record to propagate across multiple DNS servers.
+ ///
+ /// DNS record name (e.g. _abc123.example.com)
+ /// Expected record value (TXT text or CNAME target)
+ /// Record type to query (TXT or CNAME)
+ /// Minimum number of public DNS servers that must see the record (ignored for private DNS)
+ /// True if the record propagated to enough servers
+ public async Task WaitForDnsPropagationAsync(
+ string recordName,
+ string expectedValue,
+ QueryType recordType = QueryType.CNAME,
+ int minimumServers = 3)
+ {
+ _logger.LogInformation("Waiting for DNS propagation of {RecordType} record {RecordName}", recordType, recordName);
+
+ var requiredServers = _usePrivateDns ? 1 : minimumServers;
+
+ for (int attempt = 1; attempt <= MaxVerificationAttempts; attempt++)
+ {
+ var successCount = 0;
+ var results = new List();
+
+ foreach (var dnsServer in _dnsServers)
+ {
+ try
+ {
+ var hasRecord = await CheckDnsRecordAsync(recordName, expectedValue, recordType, dnsServer);
+ results.Add(hasRecord ? $"OK {dnsServer}" : $"-- {dnsServer}");
+ if (hasRecord) successCount++;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning("DNS query failed for server {Server}: {Error}", dnsServer, ex.Message);
+ results.Add($"?? {dnsServer} (error)");
+ }
+ }
+
+ _logger.LogDebug("DNS verification attempt {Attempt}/{MaxAttempts}: {SuccessCount}/{TotalServers} servers confirmed record. Results: {Results}",
+ attempt, MaxVerificationAttempts, successCount, _dnsServers.Count, string.Join(", ", results));
+
+ if (successCount >= requiredServers)
+ {
+ _logger.LogInformation("DNS record propagated successfully! {SuccessCount}/{TotalServers} servers confirmed record after {Attempt} attempt(s)",
+ successCount, _dnsServers.Count, attempt);
+ return true;
+ }
+
+ if (attempt < MaxVerificationAttempts)
+ {
+ _logger.LogDebug("Waiting {Delay} seconds before next DNS verification attempt...", VerificationDelaySeconds);
+ await Task.Delay(TimeSpan.FromSeconds(VerificationDelaySeconds));
+ }
+ }
+
+ _logger.LogWarning("DNS record did not propagate within {MaxAttempts} attempts (~{TotalMinutes} minute(s))",
+ MaxVerificationAttempts, MaxVerificationAttempts * VerificationDelaySeconds / 60);
+ return false;
+ }
+
+ ///
+ /// Checks whether a specific DNS server returns the expected TXT or CNAME record.
+ ///
+ private async Task CheckDnsRecordAsync(string recordName, string expectedValue, QueryType recordType, IPAddress dnsServer)
+ {
+ var client = new LookupClient(dnsServer);
+ var result = await client.QueryAsync(recordName, recordType);
+
+ if (result.Answers?.Any() != true)
+ {
+ return false;
+ }
+
+ if (recordType == QueryType.CNAME)
+ {
+ var expected = NormalizeDnsName(expectedValue);
+ var cnames = result.Answers
+ .OfType()
+ .Select(r => NormalizeDnsName(r.CanonicalName.Value))
+ .ToList();
+
+ var hasExpected = cnames.Any(c => string.Equals(c, expected, StringComparison.OrdinalIgnoreCase));
+ _logger.LogTrace("DNS server {Server} returned {Count} CNAME record(s) for {RecordName}. Expected: {Expected}, Found: {HasExpected}",
+ dnsServer, cnames.Count, recordName, expected, hasExpected);
+ return hasExpected;
+ }
+
+ var txtRecords = result.Answers
+ .OfType()
+ .SelectMany(r => r.Text)
+ .ToList();
+
+ var hasValue = txtRecords.Any(txt => string.Equals(txt, expectedValue, StringComparison.OrdinalIgnoreCase));
+ _logger.LogTrace("DNS server {Server} returned {Count} TXT record(s) for {RecordName}. Expected: {Expected}, Found: {HasExpected}",
+ dnsServer, txtRecords.Count, recordName, expectedValue, hasValue);
+ return hasValue;
+ }
+
+ ///
+ /// Gets the authoritative DNS servers for a domain (best-effort, for diagnostics).
+ ///
+ public async Task> GetAuthoritativeDnsServersAsync(string domain)
+ {
+ var authServers = new List();
+
+ try
+ {
+ var client = new LookupClient();
+ var result = await client.QueryAsync(domain, QueryType.NS);
+
+ foreach (var nsRecord in result.Answers.OfType())
+ {
+ try
+ {
+ var nsResult = await client.QueryAsync(nsRecord.NSDName, QueryType.A);
+ authServers.AddRange(nsResult.Answers.OfType().Select(a => a.Address));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning("Failed to resolve NS record {NSName}: {Error}", nsRecord.NSDName, ex.Message);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning("Failed to get authoritative DNS servers for {Domain}: {Error}", domain, ex.Message);
+ }
+
+ return authServers.Distinct().ToList();
+ }
+
+ private static string NormalizeDnsName(string name)
+ {
+ return string.IsNullOrEmpty(name) ? name : name.TrimEnd('.');
+ }
+ }
+}
diff --git a/SslStoreCaProxy/Interfaces/IRequestManager.cs b/SslStoreCaProxy/Interfaces/IRequestManager.cs
index a4bb581..3a9224e 100644
--- a/SslStoreCaProxy/Interfaces/IRequestManager.cs
+++ b/SslStoreCaProxy/Interfaces/IRequestManager.cs
@@ -7,7 +7,8 @@ namespace Keyfactor.AnyGateway.SslStore.Interfaces
public interface IRequestManager
{
NewOrderRequest GetEnrollmentRequest(string csr, string subject, Dictionary san,
- EnrollmentProductInfo productInfo, IAnyCAPluginConfigProvider configProvider, bool isRenewalOrder);
+ EnrollmentProductInfo productInfo, IAnyCAPluginConfigProvider configProvider, bool isRenewalOrder,
+ bool useDnsValidation = false);
AuthRequest GetAuthRequest();
ReIssueRequest GetReIssueRequest(INewOrderResponse orderData, string csr, bool isRenewal);
diff --git a/SslStoreCaProxy/RequestManager.cs b/SslStoreCaProxy/RequestManager.cs
index 30718f8..2d4952d 100644
--- a/SslStoreCaProxy/RequestManager.cs
+++ b/SslStoreCaProxy/RequestManager.cs
@@ -25,10 +25,11 @@ public RequestManager(SslStoreCaProxy sslStoreCaProxy)
}
public NewOrderRequest GetEnrollmentRequest(string csr, string subject, Dictionary san,
- EnrollmentProductInfo productInfo, IAnyCAPluginConfigProvider configProvider, bool isRenewalOrder)
+ EnrollmentProductInfo productInfo, IAnyCAPluginConfigProvider configProvider, bool isRenewalOrder,
+ bool useDnsValidation = false)
{
var pemCsr = ConvertCsrToPem(csr);
- return BuildNewOrderRequest(productInfo, pemCsr, subject, san, isRenewalOrder);
+ return BuildNewOrderRequest(productInfo, pemCsr, subject, san, isRenewalOrder, useDnsValidation);
}
private string ConvertCsrToPem(string csr)
@@ -265,7 +266,7 @@ public TechnicalContact GetTechnicalContact(INewOrderResponse productInfo)
}
private NewOrderRequest BuildNewOrderRequest(EnrollmentProductInfo productInfo,
- string csr, string subject, Dictionary san, bool isRenewal)
+ string csr, string subject, Dictionary san, bool isRenewal, bool useDnsValidation = false)
{
var p = productInfo.ProductParameters;
@@ -338,7 +339,7 @@ private NewOrderRequest BuildNewOrderRequest(EnrollmentProductInfo productInfo,
},
ApproverEmail = GetParam(p, "Approver Email"),
FileAuthDvIndicator = false,
- CnameAuthDvIndicator = false,
+ CnameAuthDvIndicator = useDnsValidation,
SignatureHashAlgorithm = GetParam(p, "Signature Hash Algorithm") ?? "PREFER_SHA2"
};
}
diff --git a/SslStoreCaProxy/SslStoreCAPluginConfig.cs b/SslStoreCaProxy/SslStoreCAPluginConfig.cs
index 2c375a9..7ceddb1 100644
--- a/SslStoreCaProxy/SslStoreCAPluginConfig.cs
+++ b/SslStoreCaProxy/SslStoreCAPluginConfig.cs
@@ -15,6 +15,9 @@ public class ConfigConstants
public static string PageSize = "PageSize";
public static string Enabled = "Enabled";
public static string RenewalWindow = "RenewalWindow";
+ public static string DnsValidationEnabled = "DnsValidationEnabled";
+ public static string DnsValidationType = "DnsValidationType";
+ public static string DnsVerificationServer = "DnsVerificationServer";
}
public class Config
@@ -25,6 +28,9 @@ public class Config
public int PageSize { get; set; } = DefaultPageSize;
public bool Enabled { get; set; }
public int RenewalWindow { get; set; } = 30;
+ public bool DnsValidationEnabled { get; set; }
+ public string DnsValidationType { get; set; } = "dns-01";
+ public string DnsVerificationServer { get; set; }
}
public static Dictionary GetPluginAnnotations()
@@ -72,6 +78,33 @@ public static Dictionary GetPluginAnnotations()
Hidden = false,
DefaultValue = 30,
Type = "Number"
+ },
+ [ConfigConstants.DnsValidationEnabled] = new PropertyConfigInfo()
+ {
+ Comments = "Enable automated DNS (CNAME) domain control validation. When enabled, the plugin " +
+ "requests CNAME-based validation from SSL Store and publishes the returned record via the " +
+ "DNS provider plugin resolved by the AnyCA Gateway. Requires a DNS provider plugin (e.g. Azure, " +
+ "Route53, Cloudflare) to be deployed and configured on the gateway. When disabled, email approver validation is used.",
+ Hidden = false,
+ DefaultValue = false,
+ Type = "Bool"
+ },
+ [ConfigConstants.DnsValidationType] = new PropertyConfigInfo()
+ {
+ Comments = "The validation type passed to the DNS provider plugin framework when resolving a domain " +
+ "validator. Must match the validation type advertised by your deployed DNS provider plugin " +
+ "(GetValidationType). Defaults to 'dns-01'.",
+ Hidden = false,
+ DefaultValue = "dns-01",
+ Type = "String"
+ },
+ [ConfigConstants.DnsVerificationServer] = new PropertyConfigInfo()
+ {
+ Comments = "Optional. IP address of an authoritative/internal DNS server to use when verifying record " +
+ "propagation. Leave empty to verify against public DNS resolvers (Google, Cloudflare, OpenDNS, Quad9).",
+ Hidden = false,
+ DefaultValue = "",
+ Type = "String"
}
};
}
diff --git a/SslStoreCaProxy/SslStoreCaProxy.cs b/SslStoreCaProxy/SslStoreCaProxy.cs
index 04bd232..7ed594a 100644
--- a/SslStoreCaProxy/SslStoreCaProxy.cs
+++ b/SslStoreCaProxy/SslStoreCaProxy.cs
@@ -15,6 +15,8 @@
using Newtonsoft.Json;
using System.Linq;
using Keyfactor.PKI.X509;
+using Keyfactor.AnyGateway.SslStore.Clients.DNS;
+using DnsClient;
namespace Keyfactor.AnyGateway.SslStore
@@ -26,12 +28,23 @@ public class SslStoreCaProxy : IAnyCAPlugin
private IAnyCAPluginConfigProvider Config { get; set; }
private ICertificateDataReader _certDataReader;
private SslStoreCAPluginConfig.Config _config;
+ private readonly IDomainValidatorFactory _validatorFactory;
public string PartnerCode { get; set; }
public string AuthenticationToken { get; set; }
public int PageSize { get; set; }
public int RenewalWindow { get; set; }
+ ///
+ /// Constructor. The AnyCA Gateway platform injects an
+ /// used to resolve DNS provider plugins for automated (CNAME) domain control validation.
+ /// The factory is only required when DNS validation is enabled in the CA configuration.
+ ///
+ public SslStoreCaProxy(IDomainValidatorFactory validatorFactory)
+ {
+ _validatorFactory = validatorFactory;
+ }
+
public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDataReader certificateDataReader)
{
_logger.MethodEntry();
@@ -191,46 +204,65 @@ public async Task Enroll(string csr, string subject, Dictionar
var dnsNames = san != null && san.ContainsKey("dns") ? san["dns"] : Array.Empty();
_logger.LogTrace($"DNS Names from SAN: {string.Join(",", dnsNames)}");
- string[] arrayApproverEmails = Array.Empty();
- if (productInfo.ProductParameters.ContainsKey("Approver Email"))
+ var useDnsValidation = ResolveUseDnsValidation(productInfo);
+ _logger.LogTrace($"DNS domain control validation enabled: {useDnsValidation}");
+
+ if (!useDnsValidation)
{
- _logger.LogTrace($"Approver Email {productInfo.ProductParameters["Approver Email"]}");
- arrayApproverEmails = productInfo.ProductParameters["Approver Email"].Split(new char[] { ',' });
- }
+ string[] arrayApproverEmails = Array.Empty();
+ if (productInfo.ProductParameters.ContainsKey("Approver Email"))
+ {
+ _logger.LogTrace($"Approver Email {productInfo.ProductParameters["Approver Email"]}");
+ arrayApproverEmails = productInfo.ProductParameters["Approver Email"].Split(new char[] { ',' });
+ }
- // Validate approver emails against all domains (CN + SANs)
- var allDomains = new List();
- if (!string.IsNullOrEmpty(domainName)) allDomains.Add(domainName);
- allDomains.AddRange(dnsNames.Where(d => !string.Equals(d, domainName, StringComparison.OrdinalIgnoreCase)));
+ // Validate approver emails against all domains (CN + SANs)
+ var allDomains = new List();
+ if (!string.IsNullOrEmpty(domainName)) allDomains.Add(domainName);
+ allDomains.AddRange(dnsNames.Where(d => !string.Equals(d, domainName, StringComparison.OrdinalIgnoreCase)));
- var count = 1;
- foreach (var domain in allDomains)
- {
- var emailApproverRequest = _requestManager.GetEmailApproverListRequest(productInfo.ProductID, domain);
- _logger.LogTrace($"Email Approver Request JSON {JsonConvert.SerializeObject(emailApproverRequest)}");
+ var count = 1;
+ foreach (var domain in allDomains)
+ {
+ var emailApproverRequest = _requestManager.GetEmailApproverListRequest(productInfo.ProductID, domain);
+ _logger.LogTrace($"Email Approver Request JSON {JsonConvert.SerializeObject(emailApproverRequest)}");
- var emailApproverResponse = await client.SubmitEmailApproverRequestAsync(emailApproverRequest);
- _logger.LogTrace($"Email Approver Response JSON {JsonConvert.SerializeObject(emailApproverResponse)}");
+ var emailApproverResponse = await client.SubmitEmailApproverRequestAsync(emailApproverRequest);
+ _logger.LogTrace($"Email Approver Response JSON {JsonConvert.SerializeObject(emailApproverResponse)}");
- var emailValidation = ValidateEmails(emailApproverResponse, arrayApproverEmails, productInfo, count);
- _logger.LogTrace($"Email Validation Result {emailValidation}");
+ var emailValidation = ValidateEmails(emailApproverResponse, arrayApproverEmails, productInfo, count);
+ _logger.LogTrace($"Email Validation Result {emailValidation}");
- if (emailValidation.Length > 0)
- {
- return new EnrollmentResult
+ if (emailValidation.Length > 0)
{
- Status = (int)EndEntityStatus.FAILED,
- StatusMessage = emailValidation
- };
+ return new EnrollmentResult
+ {
+ Status = (int)EndEntityStatus.FAILED,
+ StatusMessage = emailValidation
+ };
+ }
+ count++;
}
- count++;
}
- var enrollmentRequest = _requestManager.GetEnrollmentRequest(csr, subject, san, productInfo, Config, false);
+ var enrollmentRequest = _requestManager.GetEnrollmentRequest(csr, subject, san, productInfo, Config, false, useDnsValidation);
_logger.LogTrace($"enrollmentRequest JSON {JsonConvert.SerializeObject(enrollmentRequest)}");
enrollmentResponse = await client.SubmitNewOrderRequestAsync(enrollmentRequest);
_logger.LogTrace($"enrollmentResponse JSON {JsonConvert.SerializeObject(enrollmentResponse)}");
+
+ if (useDnsValidation && enrollmentResponse != null && !(enrollmentResponse.AuthResponse?.IsError ?? false))
+ {
+ var dnsError = await StageDnsValidationAsync(enrollmentResponse, domainName);
+ if (!string.IsNullOrEmpty(dnsError))
+ {
+ return new EnrollmentResult
+ {
+ Status = (int)EndEntityStatus.FAILED,
+ StatusMessage = dnsError
+ };
+ }
+ }
}
else
{
@@ -402,6 +434,9 @@ public async Task GetSingleRecord(string caRequestId)
var endEntityCert = X509Utilities.ExtractEndEntityCertificateContents(fullChain, null);
certificate = Convert.ToBase64String(endEntityCert.RawData);
}
+
+ // Order is issued - best-effort cleanup of any DNS validation records we published.
+ await CleanupDnsValidationAsync(orderStatusResponse);
}
_logger.MethodExit();
@@ -462,6 +497,9 @@ public async Task Synchronize(BlockingCollection blockin
var endEntityCert = X509Utilities.ExtractEndEntityCertificateContents(fullChain, null);
fileContent = Convert.ToBase64String(endEntityCert.RawData);
}
+
+ // Order is issued - best-effort cleanup of any DNS validation records we published.
+ await CleanupDnsValidationAsync(orderStatusResponse);
}
if ((certStatus == (int)EndEntityStatus.GENERATED && fileContent.Length > 0) ||
@@ -517,5 +555,148 @@ private string ValidateEmails(EmailApproverResponse validEmails, string[] arrayA
return "";
}
+
+ ///
+ /// Determines whether automated DNS (CNAME) domain control validation should be used for
+ /// this enrollment. Enabled by the CA-connection DnsValidationEnabled flag, or
+ /// per-template via the "CName Auth Domain Validation" parameter.
+ ///
+ private bool ResolveUseDnsValidation(EnrollmentProductInfo productInfo)
+ {
+ if (_config.DnsValidationEnabled) return true;
+
+ if (productInfo?.ProductParameters != null &&
+ productInfo.ProductParameters.TryGetValue("CName Auth Domain Validation", out var flag))
+ {
+ return string.Equals(flag, "True", StringComparison.OrdinalIgnoreCase);
+ }
+
+ return false;
+ }
+
+ ///
+ /// Publishes the CNAME validation record(s) SSL Store returned for the order using the DNS
+ /// provider plugin resolved by the AnyCA Gateway, then verifies public DNS propagation.
+ /// Returns an empty string on success or an error message on failure. Propagation is
+ /// best-effort: SSL Store polls DNS on its own schedule, so a not-yet-propagated record is
+ /// logged as a warning rather than failing the enrollment.
+ ///
+ private async Task StageDnsValidationAsync(INewOrderResponse response, string cnDomain)
+ {
+ if (_validatorFactory == null)
+ {
+ return "DNS domain control validation is enabled but the AnyCA Gateway did not provide an " +
+ "IDomainValidatorFactory. Ensure the gateway version supports DNS provider plugins.";
+ }
+
+ var records = CollectDnsRecords(response, cnDomain);
+ if (records.Count == 0)
+ {
+ _logger.LogWarning("DNS validation enabled but SSL Store returned no CNAME validation records to publish.");
+ return "";
+ }
+
+ var verifier = new DnsVerificationHelper(_config.DnsVerificationServer);
+
+ foreach (var (domain, recordName, recordValue) in records)
+ {
+ _logger.LogInformation($"Staging CNAME validation record for {domain}: {recordName} -> {recordValue}");
+
+ IDomainValidator validator;
+ try
+ {
+ validator = _validatorFactory.ResolveDomainValidator(domain, _config.DnsValidationType);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError($"Failed to resolve DNS provider plugin for '{domain}': {ex.Message}");
+ return $"Failed to resolve DNS provider plugin for '{domain}' (validation type '{_config.DnsValidationType}'): {ex.Message}";
+ }
+
+ if (validator == null)
+ {
+ return $"No DNS provider plugin resolved for '{domain}' (validation type '{_config.DnsValidationType}'). " +
+ "Ensure a DNS provider plugin is deployed and configured for the zone that hosts this domain.";
+ }
+
+ var result = await validator.StageValidation(recordName, recordValue, CancellationToken.None);
+ if (result == null || !result.Success)
+ {
+ var msg = result?.ErrorMessage ?? "unknown error";
+ _logger.LogError($"Failed to publish DNS validation record {recordName} for '{domain}': {msg}");
+ return $"Failed to publish DNS validation record for '{domain}': {msg}";
+ }
+
+ var propagated = await verifier.WaitForDnsPropagationAsync(recordName, recordValue, QueryType.CNAME, 3);
+ if (!propagated)
+ {
+ _logger.LogWarning($"CNAME record {recordName} for '{domain}' was not yet confirmed across public resolvers. " +
+ "SSL Store will re-check on its own schedule.");
+ }
+ }
+
+ return "";
+ }
+
+ ///
+ /// Best-effort removal of the DNS validation record(s) once an order has been issued.
+ /// Called during GetSingleRecord/Synchronize when the order first reports Active. Any failure
+ /// is logged and swallowed so it never affects sync or record retrieval.
+ ///
+ private async Task CleanupDnsValidationAsync(INewOrderResponse response)
+ {
+ if (_validatorFactory == null || !_config.DnsValidationEnabled || response == null) return;
+
+ var records = CollectDnsRecords(response, response.CommonName ?? "");
+ foreach (var (domain, recordName, _) in records)
+ {
+ try
+ {
+ var validator = _validatorFactory.ResolveDomainValidator(domain, _config.DnsValidationType);
+ if (validator == null) continue;
+
+ var result = await validator.CleanupValidation(recordName, CancellationToken.None);
+ if (result != null && !result.Success)
+ _logger.LogWarning($"Cleanup of DNS validation record {recordName} for '{domain}' reported failure: {result.ErrorMessage}");
+ else
+ _logger.LogTrace($"Cleaned up DNS validation record {recordName} for '{domain}'");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning($"Best-effort cleanup of DNS validation record {recordName} for '{domain}' failed: {ex.Message}");
+ }
+ }
+ }
+
+ ///
+ /// Collects the CNAME validation records SSL Store expects to be published for an order:
+ /// the order-level record (CNAMEAuthName/CNAMEAuthValue) plus any per-domain
+ /// records carried in the order's DomainAuthVettingStatus. De-duplicated by record name.
+ ///
+ private static List<(string domain, string recordName, string recordValue)> CollectDnsRecords(INewOrderResponse response, string cnDomain)
+ {
+ var records = new List<(string, string, string)>();
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ if (!string.IsNullOrEmpty(response?.CnameAuthName) && !string.IsNullOrEmpty(response.CnameAuthValue) &&
+ seen.Add(response.CnameAuthName))
+ {
+ records.Add((cnDomain, response.CnameAuthName, response.CnameAuthValue));
+ }
+
+ var vetting = response?.OrderStatus?.DomainAuthVettingStatus;
+ if (vetting != null)
+ {
+ foreach (var v in vetting)
+ {
+ if (!string.IsNullOrEmpty(v.DnsName) && !string.IsNullOrEmpty(v.DnsEntry) && seen.Add(v.DnsName))
+ {
+ records.Add((string.IsNullOrEmpty(v.Domain) ? cnDomain : v.Domain, v.DnsName, v.DnsEntry));
+ }
+ }
+ }
+
+ return records;
+ }
}
}
diff --git a/SslStoreCaProxy/SslStoreCaProxy.csproj b/SslStoreCaProxy/SslStoreCaProxy.csproj
index 303fdcd..b88b318 100644
--- a/SslStoreCaProxy/SslStoreCaProxy.csproj
+++ b/SslStoreCaProxy/SslStoreCaProxy.csproj
@@ -9,7 +9,8 @@
-
+
+
diff --git a/docsource/configuration.md b/docsource/configuration.md
index d3592a4..1570c25 100644
--- a/docsource/configuration.md
+++ b/docsource/configuration.md
@@ -13,6 +13,7 @@ The SSL Store AnyCA Gateway REST plugin extends the capabilities of the SSL Stor
* Support for DV, OV, and EV certificate products
* Multi-domain (MDC/SAN) and wildcard certificate support
* Automatic domain validation with approver email verification
+ * Automated DNS (CNAME) domain control validation via the AnyCA Gateway DNS provider plugin framework
* 80+ pre-configured certificate products across DigiCert and Sectigo families
* **Certificate Revocation**:
* Request revocation of previously issued certificates via SSL Store refund request API
@@ -205,6 +206,9 @@ When registering the SSL Store CA in the AnyCA Gateway, you'll need to provide t
| **PageSize** | Number of records per page during synchronization | No | `100` |
| **Enabled** | Flag to Enable or Disable the CA connector | No | `true` |
| **RenewalWindow** | Days before order expiry to trigger renewal vs. reissue | No | `30` |
+| **DnsValidationEnabled** | Enable automated DNS (CNAME) domain control validation instead of email approvers | No | `false` |
+| **DnsValidationType** | Validation type passed to the DNS provider plugin framework | No | `dns-01` |
+| **DnsVerificationServer** | Optional authoritative/internal DNS server IP used to verify record propagation | No | (empty) |
### Gateway Registration Notes
@@ -237,6 +241,32 @@ Populate using the configuration fields collected in the [requirements](#require
* **PageSize** - Number of records to retrieve per page during certificate synchronization. Default is 100.
* **Enabled** - Flag to enable or disable the CA connector. Set to `true` to enable.
* **RenewalWindow** - Number of days before an order's expiration date to trigger a renewal (new order) instead of a reissue (same order). Default is 30 days.
+* **DnsValidationEnabled** - When `true`, the plugin requests CNAME-based domain control validation from SSL Store and automatically publishes the returned validation record using the DNS provider plugin resolved by the AnyCA Gateway. When `false` (default), the email approver validation flow is used.
+* **DnsValidationType** - The validation type string passed to the DNS provider plugin framework when resolving a domain validator. This must match the validation type advertised by your deployed DNS provider plugin (its `GetValidationType()`). Default is `dns-01`.
+* **DnsVerificationServer** - Optional. IP address of an authoritative or internal DNS server used to confirm record propagation. Leave empty to verify against public resolvers (Google, Cloudflare, OpenDNS, Quad9).
+
+### Automated DNS (CNAME) Domain Validation
+
+The plugin integrates with the AnyCA Gateway **generic DNS provider plugin framework**
+(`Keyfactor.AnyGateway.IAnyCAPlugin` 3.3.0+). DNS provider plugins (Azure DNS, AWS Route53,
+Cloudflare, Google Cloud DNS, NS1, Infoblox, RFC2136, etc.) are deployed and configured
+**separately** on the gateway; this CA plugin does not bundle any DNS provider SDKs. The
+gateway injects an `IDomainValidatorFactory` that resolves the correct provider for each
+domain at enrollment time.
+
+When `DnsValidationEnabled` is set (or the `CName Auth Domain Validation` template parameter
+is `True`), enrollment proceeds as follows:
+
+1. The order is submitted to SSL Store with the CNAME DCV indicator set.
+2. SSL Store returns the CNAME validation record (`CNAMEAuthName` → `CNAMEAuthValue`).
+3. The plugin resolves a DNS provider plugin for the domain and publishes the CNAME record.
+4. The plugin verifies the record has propagated to public (or the configured) DNS resolvers.
+5. Enrollment returns as pending external validation; SSL Store completes validation on its
+ own schedule and the certificate is retrieved on the next sync / status check.
+6. Once the order is issued, the plugin makes a best-effort attempt to remove the CNAME record.
+
+A DNS provider plugin for the relevant zone must be deployed and configured on the gateway;
+otherwise enrollment fails with a "no DNS provider plugin resolved" error.
## Certificate Template Creation Step
@@ -329,6 +359,11 @@ The plugin validates approver emails against SSL Store's approved list for each
- **Sectigo/Comodo products**: At least one approver email must be from the approved list
- Emails are validated per-domain for multi-domain certificates
+> **Note:** Approver email validation is skipped when DNS (CNAME) domain control validation is
+> enabled — see [Automated DNS (CNAME) Domain Validation](#automated-dns-cname-domain-validation).
+> DNS validation can be enabled globally via the `DnsValidationEnabled` CA-connection field or
+> per-template via the `CName Auth Domain Validation` parameter.
+
### Important Notes
- Product IDs are automatically registered from the plugin's built-in product registry
From 7cb96d2615882db11c8f8244acf15068b8a57924 Mon Sep 17 00:00:00 2001
From: Keyfactor
Date: Tue, 14 Jul 2026 18:27:19 +0000
Subject: [PATCH 11/25] Update generated docs
---
README.md | 38 ++++++++++++++++++++++++++++++++++++++
integration-manifest.json | 12 ++++++++++++
2 files changed, 50 insertions(+)
diff --git a/README.md b/README.md
index e4e1873..b395923 100644
--- a/README.md
+++ b/README.md
@@ -47,6 +47,7 @@ The SSL Store AnyCA Gateway REST plugin extends the capabilities of the SSL Stor
* Support for DV, OV, and EV certificate products
* Multi-domain (MDC/SAN) and wildcard certificate support
* Automatic domain validation with approver email verification
+ * Automated DNS (CNAME) domain control validation via the AnyCA Gateway DNS provider plugin framework
* 80+ pre-configured certificate products across DigiCert and Sectigo families
* **Certificate Revocation**:
* Request revocation of previously issued certificates via SSL Store refund request API
@@ -273,6 +274,9 @@ The plugin uses a configurable **Renewal Window** (default: 30 days) to determin
| **PageSize** | Number of records per page during synchronization | No | `100` |
| **Enabled** | Flag to Enable or Disable the CA connector | No | `true` |
| **RenewalWindow** | Days before order expiry to trigger renewal vs. reissue | No | `30` |
+ | **DnsValidationEnabled** | Enable automated DNS (CNAME) domain control validation instead of email approvers | No | `false` |
+ | **DnsValidationType** | Validation type passed to the DNS provider plugin framework | No | `dns-01` |
+ | **DnsVerificationServer** | Optional authoritative/internal DNS server IP used to verify record propagation | No | (empty) |
### Gateway Registration Notes
@@ -305,6 +309,32 @@ The plugin uses a configurable **Renewal Window** (default: 30 days) to determin
* **PageSize** - Number of records to retrieve per page during certificate synchronization. Default is 100.
* **Enabled** - Flag to enable or disable the CA connector. Set to `true` to enable.
* **RenewalWindow** - Number of days before an order's expiration date to trigger a renewal (new order) instead of a reissue (same order). Default is 30 days.
+ * **DnsValidationEnabled** - When `true`, the plugin requests CNAME-based domain control validation from SSL Store and automatically publishes the returned validation record using the DNS provider plugin resolved by the AnyCA Gateway. When `false` (default), the email approver validation flow is used.
+ * **DnsValidationType** - The validation type string passed to the DNS provider plugin framework when resolving a domain validator. This must match the validation type advertised by your deployed DNS provider plugin (its `GetValidationType()`). Default is `dns-01`.
+ * **DnsVerificationServer** - Optional. IP address of an authoritative or internal DNS server used to confirm record propagation. Leave empty to verify against public resolvers (Google, Cloudflare, OpenDNS, Quad9).
+
+ ### Automated DNS (CNAME) Domain Validation
+
+ The plugin integrates with the AnyCA Gateway **generic DNS provider plugin framework**
+ (`Keyfactor.AnyGateway.IAnyCAPlugin` 3.3.0+). DNS provider plugins (Azure DNS, AWS Route53,
+ Cloudflare, Google Cloud DNS, NS1, Infoblox, RFC2136, etc.) are deployed and configured
+ **separately** on the gateway; this CA plugin does not bundle any DNS provider SDKs. The
+ gateway injects an `IDomainValidatorFactory` that resolves the correct provider for each
+ domain at enrollment time.
+
+ When `DnsValidationEnabled` is set (or the `CName Auth Domain Validation` template parameter
+ is `True`), enrollment proceeds as follows:
+
+ 1. The order is submitted to SSL Store with the CNAME DCV indicator set.
+ 2. SSL Store returns the CNAME validation record (`CNAMEAuthName` → `CNAMEAuthValue`).
+ 3. The plugin resolves a DNS provider plugin for the domain and publishes the CNAME record.
+ 4. The plugin verifies the record has propagated to public (or the configured) DNS resolvers.
+ 5. Enrollment returns as pending external validation; SSL Store completes validation on its
+ own schedule and the certificate is retrieved on the next sync / status check.
+ 6. Once the order is issued, the plugin makes a best-effort attempt to remove the CNAME record.
+
+ A DNS provider plugin for the relevant zone must be deployed and configured on the gateway;
+ otherwise enrollment fails with a "no DNS provider plugin resolved" error.
* **CA Connection**
@@ -316,6 +346,9 @@ The plugin uses a configurable **Renewal Window** (default: 30 days) to determin
* **PageSize** - The number of records to return per page during synchronization.
* **Enabled** - Flag to Enable or Disable the CA connector.
* **RenewalWindow** - Number of days before order expiry to trigger a renewal instead of a reissue.
+ * **DnsValidationEnabled** - Enable automated DNS (CNAME) domain control validation. When enabled, the plugin requests CNAME-based validation from SSL Store and publishes the returned record via the DNS provider plugin resolved by the AnyCA Gateway. Requires a DNS provider plugin (e.g. Azure, Route53, Cloudflare) to be deployed and configured on the gateway. When disabled, email approver validation is used.
+ * **DnsValidationType** - The validation type passed to the DNS provider plugin framework when resolving a domain validator. Must match the validation type advertised by your deployed DNS provider plugin (GetValidationType). Defaults to 'dns-01'.
+ * **DnsVerificationServer** - Optional. IP address of an authoritative/internal DNS server to use when verifying record propagation. Leave empty to verify against public DNS resolvers (Google, Cloudflare, OpenDNS, Quad9).
2. ### Template (Product) Configuration
@@ -406,6 +439,11 @@ The plugin uses a configurable **Renewal Window** (default: 30 days) to determin
- **Sectigo/Comodo products**: At least one approver email must be from the approved list
- Emails are validated per-domain for multi-domain certificates
+ > **Note:** Approver email validation is skipped when DNS (CNAME) domain control validation is
+ > enabled — see [Automated DNS (CNAME) Domain Validation](#automated-dns-cname-domain-validation).
+ > DNS validation can be enabled globally via the `DnsValidationEnabled` CA-connection field or
+ > per-template via the `CName Auth Domain Validation` parameter.
+
### Important Notes
- Product IDs are automatically registered from the plugin's built-in product registry
diff --git a/integration-manifest.json b/integration-manifest.json
index 86be3f5..be05043 100644
--- a/integration-manifest.json
+++ b/integration-manifest.json
@@ -36,6 +36,18 @@
{
"name": "RenewalWindow",
"description": "Number of days before order expiry to trigger a renewal instead of a reissue."
+ },
+ {
+ "name": "DnsValidationEnabled",
+ "description": "Enable automated DNS (CNAME) domain control validation. When enabled, the plugin requests CNAME-based validation from SSL Store and publishes the returned record via the DNS provider plugin resolved by the AnyCA Gateway. Requires a DNS provider plugin (e.g. Azure, Route53, Cloudflare) to be deployed and configured on the gateway. When disabled, email approver validation is used."
+ },
+ {
+ "name": "DnsValidationType",
+ "description": "The validation type passed to the DNS provider plugin framework when resolving a domain validator. Must match the validation type advertised by your deployed DNS provider plugin (GetValidationType). Defaults to 'dns-01'."
+ },
+ {
+ "name": "DnsVerificationServer",
+ "description": "Optional. IP address of an authoritative/internal DNS server to use when verifying record propagation. Leave empty to verify against public DNS resolvers (Google, Cloudflare, OpenDNS, Quad9)."
}
],
"enrollment_config": [
From e668f8d1c1449790b03b65c4bd93912174796b32 Mon Sep 17 00:00:00 2001
From: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com>
Date: Tue, 14 Jul 2026 15:21:30 -0400
Subject: [PATCH 12/25] Update keyfactor-starter-workflow.yml
---
.github/workflows/keyfactor-starter-workflow.yml | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/keyfactor-starter-workflow.yml b/.github/workflows/keyfactor-starter-workflow.yml
index 7b909a3..0f3d3ae 100644
--- a/.github/workflows/keyfactor-starter-workflow.yml
+++ b/.github/workflows/keyfactor-starter-workflow.yml
@@ -11,9 +11,17 @@ on:
jobs:
call-starter-workflow:
- uses: keyfactor/actions/.github/workflows/starter.yml@v4
+ uses: keyfactor/actions/.github/workflows/starter.yml@v5
+ with:
+ command_token_url: ${{ vars.COMMAND_TOKEN_URL }}
+ command_hostname: ${{ vars.COMMAND_HOSTNAME }}
+ command_base_api_path: ${{ vars.COMMAND_API_PATH }}
secrets:
token: ${{ secrets.V2BUILDTOKEN}}
gpg_key: ${{ secrets.KF_GPG_PRIVATE_KEY }}
gpg_pass: ${{ secrets.KF_GPG_PASSPHRASE }}
scan_token: ${{ secrets.SAST_TOKEN }}
+ entra_username: ${{ secrets.DOCTOOL_ENTRA_USERNAME }}
+ entra_password: ${{ secrets.DOCTOOL_ENTRA_PASSWD }}
+ command_client_id: ${{ secrets.COMMAND_CLIENT_ID }}
+ command_client_secret: ${{ secrets.COMMAND_CLIENT_SECRET }}
From ff4d32b4bc123543d24b84b0c4cfc33e6c2952b9 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Tue, 14 Jul 2026 19:22:03 +0000
Subject: [PATCH 13/25] docs: auto-generate README and documentation [skip ci]
---
README.md | 286 +++++++++++++++++++++++++++---------------------------
1 file changed, 142 insertions(+), 144 deletions(-)
diff --git a/README.md b/README.md
index b395923..c9108dd 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
Support
-
+
·
Requirements
@@ -33,7 +33,6 @@
-
The SSL Store AnyCA Gateway REST plugin extends the capabilities of the SSL Store Certificate Authority Service to Keyfactor Command via the Keyfactor AnyCA Gateway. SSL Store is a certificate reseller providing access to 80+ certificate products from vendors including DigiCert, Sectigo, RapidSSL, GeoTrust, and Comodo through a single REST API. The plugin represents a fully featured AnyCA Plugin with the following capabilities:
* **CA Sync**:
@@ -57,7 +56,7 @@ The SSL Store AnyCA Gateway REST plugin extends the capabilities of the SSL Stor
The SSL Store AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 25.5 and later.
## Support
-The SSL Store AnyCA Gateway REST plugin is supported by Keyfactor for Keyfactor customers. If you have a support issue, please open a support ticket with your Keyfactor representative. If you have a support issue, please open a support ticket via the Keyfactor Support Portal at https://support.keyfactor.com.
+The SSL Store AnyCA Gateway REST plugin is supported by Keyfactor for Keyfactor customers. If you have a support issue, please open a support ticket via the Keyfactor Support Portal at https://support.keyfactor.com.
> To report a problem or suggest a new feature, use the **[Issues](../../issues)** tab. If you want to contribute actual bug fixes or proposed enhancements, use the **[Pull requests](../../pulls)** tab.
@@ -241,16 +240,17 @@ The plugin uses a configurable **Renewal Window** (default: 30 days) to determin
2. On the server hosting the AnyCA Gateway REST, download and unzip the latest [SSL Store AnyCA Gateway REST plugin](https://github.com/Keyfactor/sslstore-caplugin/releases/latest) from GitHub.
-3. Copy the unzipped directory (usually called `net6.0` or `net8.0`) to the Extensions directory:
+3. Copy the unzipped directory (usually called `net6.0` or `net8.0` or `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 SSL Store AnyCA Gateway REST plugin DLLs (`net6.0` or `net8.0`) can be named anything, as long as it is unique within the `Extensions` directory.
+ > The directory containing the SSL Store 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.
4. Restart the AnyCA Gateway REST service.
@@ -263,9 +263,9 @@ The plugin uses a configurable **Renewal Window** (default: 30 days) to determin
* **Gateway Registration**
### CA Connection Configuration
-
+
When registering the SSL Store CA in the AnyCA Gateway, you'll need to provide the following configuration parameters:
-
+
| Parameter | Description | Required | Default |
|-----------|-------------|----------|---------|
| **SSLStoreURL** | Full URL to the SSL Store API endpoint | Yes | `https://sandbox-wbapi.thesslstore.com` |
@@ -277,9 +277,9 @@ The plugin uses a configurable **Renewal Window** (default: 30 days) to determin
| **DnsValidationEnabled** | Enable automated DNS (CNAME) domain control validation instead of email approvers | No | `false` |
| **DnsValidationType** | Validation type passed to the DNS provider plugin framework | No | `dns-01` |
| **DnsVerificationServer** | Optional authoritative/internal DNS server IP used to verify record propagation | No | (empty) |
-
+
### Gateway Registration Notes
-
+
- Each defined Certificate Authority in the AnyCA Gateway REST can support one SSL Store API endpoint
- If you have multiple SSL Store environments (production/sandbox), define separate Certificate Authorities for each
- Each CA configuration will manifest in Command as a separate CA entry
@@ -289,20 +289,20 @@ The plugin uses a configurable **Renewal Window** (default: 30 days) to determin
- Certificate status mapping (Active, Pending, Cancelled)
- End-entity certificate extraction from certificate chains
- Paginated order synchronization with retry logic
-
+
### Security Considerations
-
+
1. **Credential Storage**: The AuthToken field is configured as a secret/hidden field and should be stored securely
2. **Network Security**: Ensure TLS/SSL is properly configured for all API communications
3. **Least Privilege**: Request API credentials with minimal required permissions
4. **Audit Logging**: Enable comprehensive logging in both the Gateway and SSL Store for security monitoring
5. **Credential Rotation**: Regularly rotate API credentials according to your security policy
6. **Sandbox Testing**: Use the sandbox endpoint (`https://sandbox-wbapi.thesslstore.com`) for initial configuration and testing before switching to production
-
+
### CA Connection Fields
-
+
Populate using the configuration fields collected in the [requirements](#requirements) section.
-
+
* **SSLStoreURL** - The base URL for the SSL Store API endpoint. Use `https://wbapi.thesslstore.com` for production or `https://sandbox-wbapi.thesslstore.com` for testing.
* **PartnerCode** - The Partner Code obtained from your SSL Store partner account.
* **AuthToken** - The Authentication Token obtained from your SSL Store partner account.
@@ -312,19 +312,19 @@ The plugin uses a configurable **Renewal Window** (default: 30 days) to determin
* **DnsValidationEnabled** - When `true`, the plugin requests CNAME-based domain control validation from SSL Store and automatically publishes the returned validation record using the DNS provider plugin resolved by the AnyCA Gateway. When `false` (default), the email approver validation flow is used.
* **DnsValidationType** - The validation type string passed to the DNS provider plugin framework when resolving a domain validator. This must match the validation type advertised by your deployed DNS provider plugin (its `GetValidationType()`). Default is `dns-01`.
* **DnsVerificationServer** - Optional. IP address of an authoritative or internal DNS server used to confirm record propagation. Leave empty to verify against public resolvers (Google, Cloudflare, OpenDNS, Quad9).
-
+
### Automated DNS (CNAME) Domain Validation
-
+
The plugin integrates with the AnyCA Gateway **generic DNS provider plugin framework**
(`Keyfactor.AnyGateway.IAnyCAPlugin` 3.3.0+). DNS provider plugins (Azure DNS, AWS Route53,
Cloudflare, Google Cloud DNS, NS1, Infoblox, RFC2136, etc.) are deployed and configured
**separately** on the gateway; this CA plugin does not bundle any DNS provider SDKs. The
gateway injects an `IDomainValidatorFactory` that resolves the correct provider for each
domain at enrollment time.
-
+
When `DnsValidationEnabled` is set (or the `CName Auth Domain Validation` template parameter
is `True`), enrollment proceeds as follows:
-
+
1. The order is submitted to SSL Store with the CNAME DCV indicator set.
2. SSL Store returns the CNAME validation record (`CNAMEAuthName` → `CNAMEAuthValue`).
3. The plugin resolves a DNS provider plugin for the domain and publishes the CNAME record.
@@ -332,7 +332,7 @@ The plugin uses a configurable **Renewal Window** (default: 30 days) to determin
5. Enrollment returns as pending external validation; SSL Store completes validation on its
own schedule and the certificate is retrieved on the next sync / status check.
6. Once the order is issued, the plugin makes a best-effort attempt to remove the CNAME record.
-
+
A DNS provider plugin for the relevant zone must be deployed and configured on the gateway;
otherwise enrollment fails with a "no DNS provider plugin resolved" error.
@@ -340,165 +340,163 @@ The plugin uses a configurable **Renewal Window** (default: 30 days) to determin
Populate using the configuration fields collected in the [requirements](#requirements) section.
- * **SSLStoreURL** - The Base URL for the SSL Store API endpoint (e.g. https://sandbox-wbapi.thesslstore.com).
- * **PartnerCode** - The Partner Code obtained from SSL Store.
- * **AuthToken** - The Authentication Token obtained from SSL Store.
- * **PageSize** - The number of records to return per page during synchronization.
- * **Enabled** - Flag to Enable or Disable the CA connector.
- * **RenewalWindow** - Number of days before order expiry to trigger a renewal instead of a reissue.
- * **DnsValidationEnabled** - Enable automated DNS (CNAME) domain control validation. When enabled, the plugin requests CNAME-based validation from SSL Store and publishes the returned record via the DNS provider plugin resolved by the AnyCA Gateway. Requires a DNS provider plugin (e.g. Azure, Route53, Cloudflare) to be deployed and configured on the gateway. When disabled, email approver validation is used.
- * **DnsValidationType** - The validation type passed to the DNS provider plugin framework when resolving a domain validator. Must match the validation type advertised by your deployed DNS provider plugin (GetValidationType). Defaults to 'dns-01'.
- * **DnsVerificationServer** - Optional. IP address of an authoritative/internal DNS server to use when verifying record propagation. Leave empty to verify against public DNS resolvers (Google, Cloudflare, OpenDNS, Quad9).
+ * **SSLStoreURL** - The Base URL for the SSL Store API endpoint (e.g. https://sandbox-wbapi.thesslstore.com).
+ * **PartnerCode** - The Partner Code obtained from SSL Store.
+ * **AuthToken** - The Authentication Token obtained from SSL Store.
+ * **PageSize** - The number of records to return per page during synchronization.
+ * **Enabled** - Flag to Enable or Disable the CA connector.
+ * **RenewalWindow** - Number of days before order expiry to trigger a renewal instead of a reissue.
+ * **DnsValidationEnabled** - Enable automated DNS (CNAME) domain control validation. When enabled, the plugin requests CNAME-based validation from SSL Store and publishes the returned record via the DNS provider plugin resolved by the AnyCA Gateway. Requires a DNS provider plugin (e.g. Azure, Route53, Cloudflare) to be deployed and configured on the gateway. When disabled, email approver validation is used.
+ * **DnsValidationType** - The validation type passed to the DNS provider plugin framework when resolving a domain validator. Must match the validation type advertised by your deployed DNS provider plugin (GetValidationType). Defaults to 'dns-01'.
+ * **DnsVerificationServer** - Optional. IP address of an authoritative/internal DNS server to use when verifying record propagation. Leave empty to verify against public DNS resolvers (Google, Cloudflare, OpenDNS, Quad9).
2. ### Template (Product) Configuration
- After adding the CA to the Gateway, certificate templates are automatically discovered from the plugin's built-in product registry. Each template may require different enrollment fields depending on the product type and validation level.
+After adding the CA to the Gateway, certificate templates are automatically discovered from the plugin's built-in product registry. Each template may require different enrollment fields depending on the product type and validation level.
- **Enrollment fields vary by product type. The following categories exist:**
+**Enrollment fields vary by product type. The following categories exist:**
- #### DV Products (Minimal Fields)
+#### DV Products (Minimal Fields)
- Products like `positivessl`, `sectigossl`, `sectigowildcard`:
+Products like `positivessl`, `sectigossl`, `sectigowildcard`:
- | Parameter | Description | Required |
- |-----------|-------------|----------|
- | **Admin Contact - Email** | Administrative contact email | Yes |
- | **Approver Email** | Domain validation approver email | Yes |
- | **Validity Period (In Days)** | Certificate validity in days | Yes |
+| Parameter | Description | Required |
+|-----------|-------------|----------|
+| **Admin Contact - Email** | Administrative contact email | Yes |
+| **Approver Email** | Domain validation approver email | Yes |
+| **Validity Period (In Days)** | Certificate validity in days | Yes |
- #### OV Products (Organization Fields)
+#### OV Products (Organization Fields)
- Products like `sectigoovssl`, `comodopremiumssl`, `instantssl`:
+Products like `sectigoovssl`, `comodopremiumssl`, `instantssl`:
- | Parameter | Description | Required |
- |-----------|-------------|----------|
- | **Admin Contact - Email** | Administrative contact email | Yes |
- | **Approver Email** | Domain validation approver email | Yes |
- | **Validity Period (In Days)** | Certificate validity in days | Yes |
- | **Organization Name** | Organization name | Yes |
- | **Organization Address** | Organization street address | Yes |
- | **Organization State/Province** | Organization state or province | Yes |
- | **Organization Postal Code** | Organization postal/zip code | Yes |
- | **Organization Country** | Two-letter country code (e.g. US) | Yes |
- | **Organization Phone** | Organization phone number | Yes |
+| Parameter | Description | Required |
+|-----------|-------------|----------|
+| **Admin Contact - Email** | Administrative contact email | Yes |
+| **Approver Email** | Domain validation approver email | Yes |
+| **Validity Period (In Days)** | Certificate validity in days | Yes |
+| **Organization Name** | Organization name | Yes |
+| **Organization Address** | Organization street address | Yes |
+| **Organization State/Province** | Organization state or province | Yes |
+| **Organization Postal Code** | Organization postal/zip code | Yes |
+| **Organization Country** | Two-letter country code (e.g. US) | Yes |
+| **Organization Phone** | Organization phone number | Yes |
- #### DigiCert OV Flex Products
+#### DigiCert OV Flex Products
- Products like `digi_securesite_flex`, `digi_sslwebserver_flex`, `digi_truebizid_flex`:
+Products like `digi_securesite_flex`, `digi_sslwebserver_flex`, `digi_truebizid_flex`:
- | Parameter | Description | Required |
- |-----------|-------------|----------|
- | **Admin Contact - First Name** | Administrative contact first name | Yes |
- | **Admin Contact - Last Name** | Administrative contact last name | Yes |
- | **Admin Contact - Phone** | Administrative contact phone | Yes |
- | **Admin Contact - Email** | Administrative contact email | Yes |
- | **Approver Email** | Domain validation approver email | Yes |
- | **Validity Period (In Days)** | Certificate validity in days | Yes |
- | **Organization Name** | Organization name | Yes |
- | **Organization Address** | Organization street address | Yes |
- | **Organization City** | Organization city | Yes |
- | **Organization State/Province** | Organization state or province | Yes |
- | **Organization Postal Code** | Organization postal/zip code | Yes |
- | **Organization Country** | Two-letter country code | Yes |
- | **Organization Phone** | Organization phone number | Yes |
+| Parameter | Description | Required |
+|-----------|-------------|----------|
+| **Admin Contact - First Name** | Administrative contact first name | Yes |
+| **Admin Contact - Last Name** | Administrative contact last name | Yes |
+| **Admin Contact - Phone** | Administrative contact phone | Yes |
+| **Admin Contact - Email** | Administrative contact email | Yes |
+| **Approver Email** | Domain validation approver email | Yes |
+| **Validity Period (In Days)** | Certificate validity in days | Yes |
+| **Organization Name** | Organization name | Yes |
+| **Organization Address** | Organization street address | Yes |
+| **Organization City** | Organization city | Yes |
+| **Organization State/Province** | Organization state or province | Yes |
+| **Organization Postal Code** | Organization postal/zip code | Yes |
+| **Organization Country** | Two-letter country code | Yes |
+| **Organization Phone** | Organization phone number | Yes |
- #### DigiCert EV Flex Products
+#### DigiCert EV Flex Products
- Products like `digi_securesite_ev_flex`, `digi_ssl_ev_basic`, `digi_truebizid_ev_flex`:
+Products like `digi_securesite_ev_flex`, `digi_ssl_ev_basic`, `digi_truebizid_ev_flex`:
- Same as DigiCert OV Flex, plus:
+Same as DigiCert OV Flex, plus:
- | Parameter | Description | Required |
- |-----------|-------------|----------|
- | **Admin Contact - Title** | Administrative contact job title | Yes |
+| Parameter | Description | Required |
+|-----------|-------------|----------|
+| **Admin Contact - Title** | Administrative contact job title | Yes |
- #### Enterprise Organization (-EO) Products
+#### Enterprise Organization (-EO) Products
- Products like `digi_securesite_flex-EO`, `digi_sslwebserver_ev_flex-EO`:
+Products like `digi_securesite_flex-EO`, `digi_sslwebserver_ev_flex-EO`:
- | Parameter | Description | Required |
- |-----------|-------------|----------|
- | **Validity Period (In Days)** | Certificate validity in days | Yes |
- | **Organization ID** | DigiCert Organization ID | Yes |
+| Parameter | Description | Required |
+|-----------|-------------|----------|
+| **Validity Period (In Days)** | Certificate validity in days | Yes |
+| **Organization ID** | DigiCert Organization ID | Yes |
- #### EV Products with Jurisdiction
+#### EV Products with Jurisdiction
- Products like `enterpriseproev`, `positiveevssl`, `positiveevmdc`:
+Products like `enterpriseproev`, `positiveevssl`, `positiveevmdc`:
- Same as OV Products, plus:
+Same as OV Products, plus:
- | Parameter | Description | Required |
- |-----------|-------------|----------|
- | **Organization Jurisdiction Country** | Jurisdiction country code for EV validation | Yes |
+| Parameter | Description | Required |
+|-----------|-------------|----------|
+| **Organization Jurisdiction Country** | Jurisdiction country code for EV validation | Yes |
- ### Domain Validation - Approver Emails
+### Domain Validation - Approver Emails
- The plugin validates approver emails against SSL Store's approved list for each domain before enrollment:
+The plugin validates approver emails against SSL Store's approved list for each domain before enrollment:
- - **DigiCert products**: Exactly one approver email is required and must be from the approved list
- - **Sectigo/Comodo products**: At least one approver email must be from the approved list
- - Emails are validated per-domain for multi-domain certificates
+- **DigiCert products**: Exactly one approver email is required and must be from the approved list
+- **Sectigo/Comodo products**: At least one approver email must be from the approved list
+- Emails are validated per-domain for multi-domain certificates
- > **Note:** Approver email validation is skipped when DNS (CNAME) domain control validation is
- > enabled — see [Automated DNS (CNAME) Domain Validation](#automated-dns-cname-domain-validation).
- > DNS validation can be enabled globally via the `DnsValidationEnabled` CA-connection field or
- > per-template via the `CName Auth Domain Validation` parameter.
+> **Note:** Approver email validation is skipped when DNS (CNAME) domain control validation is
+> enabled — see [Automated DNS (CNAME) Domain Validation](#automated-dns-cname-domain-validation).
+> DNS validation can be enabled globally via the `DnsValidationEnabled` CA-connection field or
+> per-template via the `CName Auth Domain Validation` parameter.
- ### Important Notes
+### Important Notes
- - Product IDs are automatically registered from the plugin's built-in product registry
- - The `Validity Period (In Days)` is automatically converted to months for the SSL Store API
- - For `-EO` (Enterprise Organization) products, the Organization ID dropdown is populated from your DigiCert account's active organizations
- - DNS names (SANs) are extracted from the Keyfactor enrollment request; they do not need to be provided as a separate enrollment field
- - The Common Name (CN) is extracted from the CSR subject
+- Product IDs are automatically registered from the plugin's built-in product registry
+- The `Validity Period (In Days)` is automatically converted to months for the SSL Store API
+- For `-EO` (Enterprise Organization) products, the Organization ID dropdown is populated from your DigiCert account's active organizations
+- DNS names (SANs) are extracted from the Keyfactor enrollment request; they do not need to be provided as a separate enrollment field
+- The Common Name (CN) is extracted from the CSR subject
3. Follow the [official Keyfactor documentation](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCA-Keyfactor.htm) to add each defined Certificate Authority to Keyfactor Command and import the newly defined Certificate Templates.
4. In Keyfactor Command (v12.3+), for each imported Certificate Template, follow the [official documentation](https://software.keyfactor.com/Core-OnPrem/Current/Content/ReferenceGuide/Configuring%20Template%20Options.htm) to define enrollment fields for each of the following parameters:
- * **Approver Email** - Comma-separated approver email address(es) for domain validation.
- * **Validity Period (In Days)** - Certificate validity period in days (e.g. 90, 365, 730).
- * **Admin Contact - First Name** - Administrative contact first name.
- * **Admin Contact - Last Name** - Administrative contact last name.
- * **Admin Contact - Phone** - Administrative contact phone number.
- * **Admin Contact - Email** - Administrative contact email address.
- * **Admin Contact - Title** - Administrative contact job title.
- * **Admin Contact - Organization Name** - Administrative contact organization name.
- * **Admin Contact - Address** - Administrative contact street address.
- * **Admin Contact - City** - Administrative contact city.
- * **Admin Contact - Region** - Administrative contact state/province/region.
- * **Admin Contact - Postal Code** - Administrative contact postal/zip code.
- * **Admin Contact - Country** - Administrative contact two-letter country code (e.g. US).
- * **Technical Contact - First Name** - Technical contact first name.
- * **Technical Contact - Last Name** - Technical contact last name.
- * **Technical Contact - Phone** - Technical contact phone number.
- * **Technical Contact - Email** - Technical contact email address.
- * **Technical Contact - Organization Name** - Technical contact organization name.
- * **Technical Contact - Address** - Technical contact street address.
- * **Technical Contact - City** - Technical contact city.
- * **Technical Contact - Region** - Technical contact state/province/region.
- * **Technical Contact - Postal Code** - Technical contact postal/zip code.
- * **Technical Contact - Country** - Technical contact two-letter country code (e.g. US).
- * **Organization Name** - Organization name for the certificate.
- * **Organization Address** - Organization street address.
- * **Organization City** - Organization city.
- * **Organization Region** - Organization state/province/region.
- * **Organization State/Province** - Organization state or province.
- * **Organization Postal Code** - Organization postal/zip code.
- * **Organization Country** - Organization two-letter country code (e.g. US).
- * **Organization Phone** - Organization phone number.
- * **Organization Jurisdiction Country** - Jurisdiction country code for EV certificates.
- * **Organization ID** - DigiCert organization ID for EO (Enterprise Organization) products.
- * **Server Count** - Number of server licenses for the certificate.
- * **Web Server Type** - Web server type (e.g. apacheopenssl, iis, tomcat, Other).
- * **Signature Hash Algorithm** - Signature hash algorithm (PREFER_SHA2, REQUIRE_SHA2, PREFER_SHA1).
- * **File Auth Domain Validation** - Use file-based domain validation (True/False).
- * **CName Auth Domain Validation** - Use CNAME-based domain validation (True/False).
- * **Is CU Order?** - Is this a CU (Customer) order (True/False).
- * **Is Renewal Order?** - Is this a renewal order (True/False).
- * **Is Trial Order?** - Is this a trial order (True/False).
-
-
+ * **Approver Email** - Comma-separated approver email address(es) for domain validation.
+ * **Validity Period (In Days)** - Certificate validity period in days (e.g. 90, 365, 730).
+ * **Admin Contact - First Name** - Administrative contact first name.
+ * **Admin Contact - Last Name** - Administrative contact last name.
+ * **Admin Contact - Phone** - Administrative contact phone number.
+ * **Admin Contact - Email** - Administrative contact email address.
+ * **Admin Contact - Title** - Administrative contact job title.
+ * **Admin Contact - Organization Name** - Administrative contact organization name.
+ * **Admin Contact - Address** - Administrative contact street address.
+ * **Admin Contact - City** - Administrative contact city.
+ * **Admin Contact - Region** - Administrative contact state/province/region.
+ * **Admin Contact - Postal Code** - Administrative contact postal/zip code.
+ * **Admin Contact - Country** - Administrative contact two-letter country code (e.g. US).
+ * **Technical Contact - First Name** - Technical contact first name.
+ * **Technical Contact - Last Name** - Technical contact last name.
+ * **Technical Contact - Phone** - Technical contact phone number.
+ * **Technical Contact - Email** - Technical contact email address.
+ * **Technical Contact - Organization Name** - Technical contact organization name.
+ * **Technical Contact - Address** - Technical contact street address.
+ * **Technical Contact - City** - Technical contact city.
+ * **Technical Contact - Region** - Technical contact state/province/region.
+ * **Technical Contact - Postal Code** - Technical contact postal/zip code.
+ * **Technical Contact - Country** - Technical contact two-letter country code (e.g. US).
+ * **Organization Name** - Organization name for the certificate.
+ * **Organization Address** - Organization street address.
+ * **Organization City** - Organization city.
+ * **Organization Region** - Organization state/province/region.
+ * **Organization State/Province** - Organization state or province.
+ * **Organization Postal Code** - Organization postal/zip code.
+ * **Organization Country** - Organization two-letter country code (e.g. US).
+ * **Organization Phone** - Organization phone number.
+ * **Organization Jurisdiction Country** - Jurisdiction country code for EV certificates.
+ * **Organization ID** - DigiCert organization ID for EO (Enterprise Organization) products.
+ * **Server Count** - Number of server licenses for the certificate.
+ * **Web Server Type** - Web server type (e.g. apacheopenssl, iis, tomcat, Other).
+ * **Signature Hash Algorithm** - Signature hash algorithm (PREFER_SHA2, REQUIRE_SHA2, PREFER_SHA1).
+ * **File Auth Domain Validation** - Use file-based domain validation (True/False).
+ * **CName Auth Domain Validation** - Use CNAME-based domain validation (True/False).
+ * **Is CU Order?** - Is this a CU (Customer) order (True/False).
+ * **Is Renewal Order?** - Is this a renewal order (True/False).
+ * **Is Trial Order?** - Is this a trial order (True/False).
## License
@@ -506,4 +504,4 @@ Apache License 2.0, see [LICENSE](LICENSE).
## Related Integrations
-See all [Keyfactor Any CA Gateways (REST)](https://github.com/orgs/Keyfactor/repositories?q=anycagateway).
\ No newline at end of file
+See all [Keyfactor Any CA Gateways (REST)](https://github.com/orgs/Keyfactor/repositories?q=anycagateway).
From a5d78736dc53dc87d1e4a92d6acbd65b939606ea Mon Sep 17 00:00:00 2001
From: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com>
Date: Tue, 14 Jul 2026 15:22:29 -0400
Subject: [PATCH 14/25] Update integration-manifest.json
---
integration-manifest.json | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/integration-manifest.json b/integration-manifest.json
index be05043..4ee3c93 100644
--- a/integration-manifest.json
+++ b/integration-manifest.json
@@ -9,7 +9,7 @@
"support_level": "kf-supported",
"link_github": false,
"update_catalog": false,
- "gateway_framework": "25.5",
+ "gateway_framework": "26.2",
"about": {
"carest": {
"ca_plugin_config": [
@@ -297,4 +297,4 @@
]
}
}
-}
\ No newline at end of file
+}
From f70fbf92c9b4761ee6a9c0ce82e87416b3e6de20 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Tue, 14 Jul 2026 19:23:03 +0000
Subject: [PATCH 15/25] docs: auto-generate README and documentation [skip ci]
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index c9108dd..42a38d6 100644
--- a/README.md
+++ b/README.md
@@ -53,7 +53,7 @@ The SSL Store AnyCA Gateway REST plugin extends the capabilities of the SSL Stor
## Compatibility
-The SSL Store AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 25.5 and later.
+The SSL Store AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 26.2 and later.
## Support
The SSL Store AnyCA Gateway REST plugin is supported by Keyfactor for Keyfactor customers. If you have a support issue, please open a support ticket via the Keyfactor Support Portal at https://support.keyfactor.com.
From 409f807dac16244436d827c391a80715c9aca8b0 Mon Sep 17 00:00:00 2001
From: Brian Hill
Date: Wed, 15 Jul 2026 11:26:14 -0400
Subject: [PATCH 16/25] fixed cname validator
---
README.md | 12 ++++++--
.../Clients/DNS/DnsVerificationHelper.cs | 29 +++++++++++-------
SslStoreCaProxy/SslStoreCAPluginConfig.cs | 30 +++++++++++++++++--
SslStoreCaProxy/SslStoreCaProxy.cs | 2 +-
docsource/configuration.md | 4 ++-
integration-manifest.json | 10 ++++++-
6 files changed, 68 insertions(+), 19 deletions(-)
diff --git a/README.md b/README.md
index 42a38d6..c4714d9 100644
--- a/README.md
+++ b/README.md
@@ -275,8 +275,10 @@ The plugin uses a configurable **Renewal Window** (default: 30 days) to determin
| **Enabled** | Flag to Enable or Disable the CA connector | No | `true` |
| **RenewalWindow** | Days before order expiry to trigger renewal vs. reissue | No | `30` |
| **DnsValidationEnabled** | Enable automated DNS (CNAME) domain control validation instead of email approvers | No | `false` |
- | **DnsValidationType** | Validation type passed to the DNS provider plugin framework | No | `dns-01` |
+ | **DnsValidationType** | Validation type passed to the DNS provider plugin framework. SSL Store DCV is CNAME-based, so this must resolve a CNAME validator (e.g. `Ns1CnameDomainValidator`) | No | `cname` |
| **DnsVerificationServer** | Optional authoritative/internal DNS server IP used to verify record propagation | No | (empty) |
+ | **DnsPropagationMaxAttempts** | Number of times to poll DNS for the validation record before giving up during enrollment | No | `3` |
+ | **DnsPropagationDelaySeconds** | Seconds to wait between DNS propagation polling attempts (enrollment blocks for this duration) | No | `10` |
### Gateway Registration Notes
@@ -310,8 +312,10 @@ The plugin uses a configurable **Renewal Window** (default: 30 days) to determin
* **Enabled** - Flag to enable or disable the CA connector. Set to `true` to enable.
* **RenewalWindow** - Number of days before an order's expiration date to trigger a renewal (new order) instead of a reissue (same order). Default is 30 days.
* **DnsValidationEnabled** - When `true`, the plugin requests CNAME-based domain control validation from SSL Store and automatically publishes the returned validation record using the DNS provider plugin resolved by the AnyCA Gateway. When `false` (default), the email approver validation flow is used.
- * **DnsValidationType** - The validation type string passed to the DNS provider plugin framework when resolving a domain validator. This must match the validation type advertised by your deployed DNS provider plugin (its `GetValidationType()`). Default is `dns-01`.
+ * **DnsValidationType** - The validation type string passed to the DNS provider plugin framework when resolving a domain validator. This must match the validation type advertised by your deployed DNS provider plugin (its `GetValidationType()`). SSL Store DCV is CNAME-based, so this must resolve a **CNAME** validator (e.g. `Ns1CnameDomainValidator`, `CloudflareCnameDomainValidator`) that publishes a CNAME record — not the `dns-01`/TXT variant. Default is `cname`.
* **DnsVerificationServer** - Optional. IP address of an authoritative or internal DNS server used to confirm record propagation. Leave empty to verify against public resolvers (Google, Cloudflare, OpenDNS, Quad9).
+ * **DnsPropagationMaxAttempts** - Number of times to poll DNS for the validation record before giving up during enrollment. Total wait is roughly `(attempts - 1) × delay` seconds. Default is 3.
+ * **DnsPropagationDelaySeconds** - Seconds to wait between DNS propagation polling attempts. Enrollment blocks for this duration, so keep the combined wait reasonable — propagation is best-effort and SSL Store re-checks on its own schedule. Default is 10.
### Automated DNS (CNAME) Domain Validation
@@ -347,8 +351,10 @@ The plugin uses a configurable **Renewal Window** (default: 30 days) to determin
* **Enabled** - Flag to Enable or Disable the CA connector.
* **RenewalWindow** - Number of days before order expiry to trigger a renewal instead of a reissue.
* **DnsValidationEnabled** - Enable automated DNS (CNAME) domain control validation. When enabled, the plugin requests CNAME-based validation from SSL Store and publishes the returned record via the DNS provider plugin resolved by the AnyCA Gateway. Requires a DNS provider plugin (e.g. Azure, Route53, Cloudflare) to be deployed and configured on the gateway. When disabled, email approver validation is used.
- * **DnsValidationType** - The validation type passed to the DNS provider plugin framework when resolving a domain validator. Must match the validation type advertised by your deployed DNS provider plugin (GetValidationType). Defaults to 'dns-01'.
+ * **DnsValidationType** - The validation type passed to the DNS provider plugin framework when resolving a domain validator. Must match the validation type advertised by your deployed DNS provider plugin (GetValidationType). SSL Store domain control validation is CNAME-based, so this must resolve a CNAME validator (e.g. Ns1CnameDomainValidator, CloudflareCnameDomainValidator) that publishes a CNAME record. Defaults to 'cname'.
* **DnsVerificationServer** - Optional. IP address of an authoritative/internal DNS server to use when verifying record propagation. Leave empty to verify against public DNS resolvers (Google, Cloudflare, OpenDNS, Quad9).
+ * **DnsPropagationMaxAttempts** - Number of times to poll DNS for the validation record before giving up during enrollment. Total wait is roughly (attempts - 1) x delay seconds. Increase this (and/or the delay) if records routinely need longer to propagate. Defaults to 3.
+ * **DnsPropagationDelaySeconds** - Seconds to wait between DNS propagation polling attempts during enrollment. Total wait is roughly (attempts - 1) x delay seconds. Defaults to 10. Note: enrollment blocks for this duration, so keep the combined wait reasonable — propagation is best-effort and SSL Store re-checks on its own schedule.
2. ### Template (Product) Configuration
diff --git a/SslStoreCaProxy/Clients/DNS/DnsVerificationHelper.cs b/SslStoreCaProxy/Clients/DNS/DnsVerificationHelper.cs
index 3023673..14e2e99 100644
--- a/SslStoreCaProxy/Clients/DNS/DnsVerificationHelper.cs
+++ b/SslStoreCaProxy/Clients/DNS/DnsVerificationHelper.cs
@@ -19,8 +19,10 @@ public class DnsVerificationHelper
private static readonly ILogger _logger = LogHandler.GetClassLogger();
private readonly List _dnsServers;
private readonly bool _usePrivateDns;
- private const int MaxVerificationAttempts = 3;
- private const int VerificationDelaySeconds = 10;
+ private const int DefaultMaxVerificationAttempts = 3;
+ private const int DefaultVerificationDelaySeconds = 10;
+ private readonly int _maxVerificationAttempts;
+ private readonly int _verificationDelaySeconds;
///
/// Creates a DNS verification helper.
@@ -28,8 +30,14 @@ public class DnsVerificationHelper
/// Optional DNS server IP for verification. For
/// private/internal zones, specify your authoritative DNS server. Leave null/empty to
/// use public DNS servers.
- public DnsVerificationHelper(string verificationServer = null)
+ /// Number of times to poll DNS for the record before
+ /// giving up. Values below 1 fall back to the default.
+ /// Seconds to wait between polling attempts. Values
+ /// below 1 fall back to the default.
+ public DnsVerificationHelper(string verificationServer = null, int maxVerificationAttempts = DefaultMaxVerificationAttempts, int verificationDelaySeconds = DefaultVerificationDelaySeconds)
{
+ _maxVerificationAttempts = maxVerificationAttempts > 0 ? maxVerificationAttempts : DefaultMaxVerificationAttempts;
+ _verificationDelaySeconds = verificationDelaySeconds > 0 ? verificationDelaySeconds : DefaultVerificationDelaySeconds;
_dnsServers = new List();
if (!string.IsNullOrWhiteSpace(verificationServer) && IPAddress.TryParse(verificationServer, out var privateServer))
@@ -71,7 +79,7 @@ public async Task WaitForDnsPropagationAsync(
var requiredServers = _usePrivateDns ? 1 : minimumServers;
- for (int attempt = 1; attempt <= MaxVerificationAttempts; attempt++)
+ for (int attempt = 1; attempt <= _maxVerificationAttempts; attempt++)
{
var successCount = 0;
var results = new List();
@@ -92,7 +100,7 @@ public async Task WaitForDnsPropagationAsync(
}
_logger.LogDebug("DNS verification attempt {Attempt}/{MaxAttempts}: {SuccessCount}/{TotalServers} servers confirmed record. Results: {Results}",
- attempt, MaxVerificationAttempts, successCount, _dnsServers.Count, string.Join(", ", results));
+ attempt, _maxVerificationAttempts, successCount, _dnsServers.Count, string.Join(", ", results));
if (successCount >= requiredServers)
{
@@ -101,15 +109,16 @@ public async Task WaitForDnsPropagationAsync(
return true;
}
- if (attempt < MaxVerificationAttempts)
+ if (attempt < _maxVerificationAttempts)
{
- _logger.LogDebug("Waiting {Delay} seconds before next DNS verification attempt...", VerificationDelaySeconds);
- await Task.Delay(TimeSpan.FromSeconds(VerificationDelaySeconds));
+ _logger.LogDebug("Waiting {Delay} seconds before next DNS verification attempt...", _verificationDelaySeconds);
+ await Task.Delay(TimeSpan.FromSeconds(_verificationDelaySeconds));
}
}
- _logger.LogWarning("DNS record did not propagate within {MaxAttempts} attempts (~{TotalMinutes} minute(s))",
- MaxVerificationAttempts, MaxVerificationAttempts * VerificationDelaySeconds / 60);
+ var totalWaitSeconds = (_maxVerificationAttempts - 1) * _verificationDelaySeconds;
+ _logger.LogWarning("DNS record did not propagate within {MaxAttempts} attempts (~{TotalSeconds} second(s))",
+ _maxVerificationAttempts, totalWaitSeconds);
return false;
}
diff --git a/SslStoreCaProxy/SslStoreCAPluginConfig.cs b/SslStoreCaProxy/SslStoreCAPluginConfig.cs
index 7ceddb1..febfffa 100644
--- a/SslStoreCaProxy/SslStoreCAPluginConfig.cs
+++ b/SslStoreCaProxy/SslStoreCAPluginConfig.cs
@@ -18,6 +18,8 @@ public class ConfigConstants
public static string DnsValidationEnabled = "DnsValidationEnabled";
public static string DnsValidationType = "DnsValidationType";
public static string DnsVerificationServer = "DnsVerificationServer";
+ public static string DnsPropagationMaxAttempts = "DnsPropagationMaxAttempts";
+ public static string DnsPropagationDelaySeconds = "DnsPropagationDelaySeconds";
}
public class Config
@@ -29,8 +31,10 @@ public class Config
public bool Enabled { get; set; }
public int RenewalWindow { get; set; } = 30;
public bool DnsValidationEnabled { get; set; }
- public string DnsValidationType { get; set; } = "dns-01";
+ public string DnsValidationType { get; set; } = "cname";
public string DnsVerificationServer { get; set; }
+ public int DnsPropagationMaxAttempts { get; set; } = 3;
+ public int DnsPropagationDelaySeconds { get; set; } = 10;
}
public static Dictionary GetPluginAnnotations()
@@ -93,9 +97,11 @@ public static Dictionary GetPluginAnnotations()
{
Comments = "The validation type passed to the DNS provider plugin framework when resolving a domain " +
"validator. Must match the validation type advertised by your deployed DNS provider plugin " +
- "(GetValidationType). Defaults to 'dns-01'.",
+ "(GetValidationType). SSL Store domain control validation is CNAME-based, so this must resolve " +
+ "a CNAME validator (e.g. Ns1CnameDomainValidator, CloudflareCnameDomainValidator) that publishes " +
+ "a CNAME record. Defaults to 'cname'.",
Hidden = false,
- DefaultValue = "dns-01",
+ DefaultValue = "cname",
Type = "String"
},
[ConfigConstants.DnsVerificationServer] = new PropertyConfigInfo()
@@ -105,6 +111,24 @@ public static Dictionary GetPluginAnnotations()
Hidden = false,
DefaultValue = "",
Type = "String"
+ },
+ [ConfigConstants.DnsPropagationMaxAttempts] = new PropertyConfigInfo()
+ {
+ Comments = "Number of times to poll DNS for the validation record before giving up during enrollment. " +
+ "Total wait is roughly (attempts - 1) x delay seconds. Increase this (and/or the delay) if records " +
+ "routinely need longer to propagate. Defaults to 3.",
+ Hidden = false,
+ DefaultValue = 3,
+ Type = "Number"
+ },
+ [ConfigConstants.DnsPropagationDelaySeconds] = new PropertyConfigInfo()
+ {
+ Comments = "Seconds to wait between DNS propagation polling attempts during enrollment. Total wait is roughly " +
+ "(attempts - 1) x delay seconds. Defaults to 10. Note: enrollment blocks for this duration, so keep " +
+ "the combined wait reasonable — propagation is best-effort and SSL Store re-checks on its own schedule.",
+ Hidden = false,
+ DefaultValue = 10,
+ Type = "Number"
}
};
}
diff --git a/SslStoreCaProxy/SslStoreCaProxy.cs b/SslStoreCaProxy/SslStoreCaProxy.cs
index 7ed594a..6845f9a 100644
--- a/SslStoreCaProxy/SslStoreCaProxy.cs
+++ b/SslStoreCaProxy/SslStoreCaProxy.cs
@@ -596,7 +596,7 @@ private async Task StageDnsValidationAsync(INewOrderResponse response, s
return "";
}
- var verifier = new DnsVerificationHelper(_config.DnsVerificationServer);
+ var verifier = new DnsVerificationHelper(_config.DnsVerificationServer, _config.DnsPropagationMaxAttempts, _config.DnsPropagationDelaySeconds);
foreach (var (domain, recordName, recordValue) in records)
{
diff --git a/docsource/configuration.md b/docsource/configuration.md
index 1570c25..14ac700 100644
--- a/docsource/configuration.md
+++ b/docsource/configuration.md
@@ -242,8 +242,10 @@ Populate using the configuration fields collected in the [requirements](#require
* **Enabled** - Flag to enable or disable the CA connector. Set to `true` to enable.
* **RenewalWindow** - Number of days before an order's expiration date to trigger a renewal (new order) instead of a reissue (same order). Default is 30 days.
* **DnsValidationEnabled** - When `true`, the plugin requests CNAME-based domain control validation from SSL Store and automatically publishes the returned validation record using the DNS provider plugin resolved by the AnyCA Gateway. When `false` (default), the email approver validation flow is used.
-* **DnsValidationType** - The validation type string passed to the DNS provider plugin framework when resolving a domain validator. This must match the validation type advertised by your deployed DNS provider plugin (its `GetValidationType()`). Default is `dns-01`.
+* **DnsValidationType** - The validation type string passed to the DNS provider plugin framework when resolving a domain validator. This must match the validation type advertised by your deployed DNS provider plugin (its `GetValidationType()`). SSL Store DCV is CNAME-based, so this must resolve a **CNAME** validator (e.g. `Ns1CnameDomainValidator`, `CloudflareCnameDomainValidator`) that publishes a CNAME record — not the `dns-01`/TXT variant. Default is `cname`.
* **DnsVerificationServer** - Optional. IP address of an authoritative or internal DNS server used to confirm record propagation. Leave empty to verify against public resolvers (Google, Cloudflare, OpenDNS, Quad9).
+* **DnsPropagationMaxAttempts** - Number of times to poll DNS for the validation record before giving up during enrollment. Total wait is roughly `(attempts - 1) × delay` seconds. Default is 3.
+* **DnsPropagationDelaySeconds** - Seconds to wait between DNS propagation polling attempts. Enrollment blocks for this duration, so keep the combined wait reasonable — propagation is best-effort and SSL Store re-checks on its own schedule. Default is 10.
### Automated DNS (CNAME) Domain Validation
diff --git a/integration-manifest.json b/integration-manifest.json
index 4ee3c93..8264301 100644
--- a/integration-manifest.json
+++ b/integration-manifest.json
@@ -43,11 +43,19 @@
},
{
"name": "DnsValidationType",
- "description": "The validation type passed to the DNS provider plugin framework when resolving a domain validator. Must match the validation type advertised by your deployed DNS provider plugin (GetValidationType). Defaults to 'dns-01'."
+ "description": "The validation type passed to the DNS provider plugin framework when resolving a domain validator. Must match the validation type advertised by your deployed DNS provider plugin (GetValidationType). SSL Store domain control validation is CNAME-based, so this must resolve a CNAME validator (e.g. Ns1CnameDomainValidator, CloudflareCnameDomainValidator) that publishes a CNAME record. Defaults to 'cname'."
},
{
"name": "DnsVerificationServer",
"description": "Optional. IP address of an authoritative/internal DNS server to use when verifying record propagation. Leave empty to verify against public DNS resolvers (Google, Cloudflare, OpenDNS, Quad9)."
+ },
+ {
+ "name": "DnsPropagationMaxAttempts",
+ "description": "Number of times to poll DNS for the validation record before giving up during enrollment. Total wait is roughly (attempts - 1) x delay seconds. Increase this (and/or the delay) if records routinely need longer to propagate. Defaults to 3."
+ },
+ {
+ "name": "DnsPropagationDelaySeconds",
+ "description": "Seconds to wait between DNS propagation polling attempts during enrollment. Total wait is roughly (attempts - 1) x delay seconds. Defaults to 10. Note: enrollment blocks for this duration, so keep the combined wait reasonable — propagation is best-effort and SSL Store re-checks on its own schedule."
}
],
"enrollment_config": [
From 3492b91ada8588e9784e9ee35b9ce834a94ffeab Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Wed, 15 Jul 2026 15:26:51 +0000
Subject: [PATCH 17/25] docs: auto-generate README and documentation [skip ci]
---
README.md | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/README.md b/README.md
index c4714d9..9d2bd93 100644
--- a/README.md
+++ b/README.md
@@ -275,10 +275,8 @@ The plugin uses a configurable **Renewal Window** (default: 30 days) to determin
| **Enabled** | Flag to Enable or Disable the CA connector | No | `true` |
| **RenewalWindow** | Days before order expiry to trigger renewal vs. reissue | No | `30` |
| **DnsValidationEnabled** | Enable automated DNS (CNAME) domain control validation instead of email approvers | No | `false` |
- | **DnsValidationType** | Validation type passed to the DNS provider plugin framework. SSL Store DCV is CNAME-based, so this must resolve a CNAME validator (e.g. `Ns1CnameDomainValidator`) | No | `cname` |
+ | **DnsValidationType** | Validation type passed to the DNS provider plugin framework | No | `dns-01` |
| **DnsVerificationServer** | Optional authoritative/internal DNS server IP used to verify record propagation | No | (empty) |
- | **DnsPropagationMaxAttempts** | Number of times to poll DNS for the validation record before giving up during enrollment | No | `3` |
- | **DnsPropagationDelaySeconds** | Seconds to wait between DNS propagation polling attempts (enrollment blocks for this duration) | No | `10` |
### Gateway Registration Notes
From 424e8ab031d76f6fda003adbf96f6a89e474cb62 Mon Sep 17 00:00:00 2001
From: Brian Hill
Date: Wed, 15 Jul 2026 11:34:44 -0400
Subject: [PATCH 18/25] added more comprehensive logging
---
.../Clients/DNS/DnsVerificationHelper.cs | 6 +-
SslStoreCaProxy/FlowLogger.cs | 245 ++++++++++++++
SslStoreCaProxy/SslStoreCaProxy.cs | 311 ++++++++++++++----
3 files changed, 497 insertions(+), 65 deletions(-)
create mode 100644 SslStoreCaProxy/FlowLogger.cs
diff --git a/SslStoreCaProxy/Clients/DNS/DnsVerificationHelper.cs b/SslStoreCaProxy/Clients/DNS/DnsVerificationHelper.cs
index 14e2e99..a11666a 100644
--- a/SslStoreCaProxy/Clients/DNS/DnsVerificationHelper.cs
+++ b/SslStoreCaProxy/Clients/DNS/DnsVerificationHelper.cs
@@ -99,8 +99,8 @@ public async Task WaitForDnsPropagationAsync(
}
}
- _logger.LogDebug("DNS verification attempt {Attempt}/{MaxAttempts}: {SuccessCount}/{TotalServers} servers confirmed record. Results: {Results}",
- attempt, _maxVerificationAttempts, successCount, _dnsServers.Count, string.Join(", ", results));
+ _logger.LogInformation("DNS verification attempt {Attempt}/{MaxAttempts} for {RecordType} {RecordName}: {SuccessCount}/{TotalServers} resolver(s) confirmed (need {Required}). Results: {Results}",
+ attempt, _maxVerificationAttempts, recordType, recordName, successCount, _dnsServers.Count, requiredServers, string.Join(", ", results));
if (successCount >= requiredServers)
{
@@ -111,7 +111,7 @@ public async Task WaitForDnsPropagationAsync(
if (attempt < _maxVerificationAttempts)
{
- _logger.LogDebug("Waiting {Delay} seconds before next DNS verification attempt...", _verificationDelaySeconds);
+ _logger.LogInformation("Waiting {Delay} seconds before next DNS verification attempt...", _verificationDelaySeconds);
await Task.Delay(TimeSpan.FromSeconds(_verificationDelaySeconds));
}
}
diff --git a/SslStoreCaProxy/FlowLogger.cs b/SslStoreCaProxy/FlowLogger.cs
new file mode 100644
index 0000000..8139639
--- /dev/null
+++ b/SslStoreCaProxy/FlowLogger.cs
@@ -0,0 +1,245 @@
+// 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 the specific language governing permissions
+// and limitations under the License.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+
+namespace Keyfactor.AnyGateway.SslStore
+{
+ public enum FlowStepStatus
+ {
+ Success,
+ Failed,
+ Skipped,
+ InProgress
+ }
+
+ public class FlowStep
+ {
+ public string Name { get; set; }
+ public FlowStepStatus Status { get; set; }
+ public string Detail { get; set; }
+ public long ElapsedMs { get; set; }
+ public List Children { get; } = new List();
+ }
+
+ ///
+ /// Tracks high-level operation flow and renders a visual step diagram to Trace logs.
+ /// Usage:
+ /// using var flow = new FlowLogger(logger, "Enroll-New");
+ /// flow.Step("ParseCSR");
+ /// flow.Step("ValidateCSR", () => { ... });
+ /// flow.Fail("CreateOrder", "API returned 400");
+ /// // flow renders automatically on Dispose
+ ///
+ public sealed class FlowLogger : IDisposable
+ {
+ private readonly ILogger _logger;
+ private readonly string _flowName;
+ private readonly Stopwatch _totalTimer;
+ private readonly List _steps = new List();
+ private FlowStep _currentParent;
+ private bool _disposed;
+
+ public FlowLogger(ILogger logger, string flowName)
+ {
+ _logger = logger;
+ _flowName = flowName;
+ _totalTimer = Stopwatch.StartNew();
+ _logger.LogTrace("===== FLOW START: {FlowName} =====", _flowName);
+ }
+
+ /// Record a completed step.
+ public FlowLogger Step(string name, string detail = null)
+ {
+ var step = new FlowStep { Name = name, Status = FlowStepStatus.Success, Detail = detail };
+ AddStep(step);
+ _logger.LogTrace(" [{FlowName}] {StepName} ... OK{Detail}",
+ _flowName, name, detail != null ? $" ({detail})" : "");
+ return this;
+ }
+
+ /// Record a step that executes an action and times it.
+ public FlowLogger Step(string name, Action action, string detail = null)
+ {
+ var sw = Stopwatch.StartNew();
+ var step = new FlowStep { Name = name, Detail = detail };
+ try
+ {
+ _logger.LogTrace(" [{FlowName}] {StepName} ...", _flowName, name);
+ action();
+ sw.Stop();
+ step.Status = FlowStepStatus.Success;
+ step.ElapsedMs = sw.ElapsedMilliseconds;
+ AddStep(step);
+ _logger.LogTrace(" [{FlowName}] {StepName} ... OK ({Elapsed}ms){Detail}",
+ _flowName, name, sw.ElapsedMilliseconds, detail != null ? $" {detail}" : "");
+ }
+ catch (Exception ex)
+ {
+ sw.Stop();
+ step.Status = FlowStepStatus.Failed;
+ step.ElapsedMs = sw.ElapsedMilliseconds;
+ step.Detail = ex.Message;
+ AddStep(step);
+ _logger.LogTrace(" [{FlowName}] {StepName} ... FAILED ({Elapsed}ms): {Error}",
+ _flowName, name, sw.ElapsedMilliseconds, ex.Message);
+ throw;
+ }
+ return this;
+ }
+
+ /// Record an async step that executes and times it.
+ public async Task StepAsync(string name, Func action, string detail = null)
+ {
+ var sw = Stopwatch.StartNew();
+ var step = new FlowStep { Name = name, Detail = detail };
+ try
+ {
+ _logger.LogTrace(" [{FlowName}] {StepName} ...", _flowName, name);
+ await action();
+ sw.Stop();
+ step.Status = FlowStepStatus.Success;
+ step.ElapsedMs = sw.ElapsedMilliseconds;
+ AddStep(step);
+ _logger.LogTrace(" [{FlowName}] {StepName} ... OK ({Elapsed}ms){Detail}",
+ _flowName, name, sw.ElapsedMilliseconds, detail != null ? $" {detail}" : "");
+ }
+ catch (Exception ex)
+ {
+ sw.Stop();
+ step.Status = FlowStepStatus.Failed;
+ step.ElapsedMs = sw.ElapsedMilliseconds;
+ step.Detail = ex.Message;
+ AddStep(step);
+ _logger.LogTrace(" [{FlowName}] {StepName} ... FAILED ({Elapsed}ms): {Error}",
+ _flowName, name, sw.ElapsedMilliseconds, ex.Message);
+ throw;
+ }
+ return this;
+ }
+
+ /// Record a failed step without throwing.
+ public FlowLogger Fail(string name, string reason = null)
+ {
+ var step = new FlowStep { Name = name, Status = FlowStepStatus.Failed, Detail = reason };
+ AddStep(step);
+ _logger.LogTrace(" [{FlowName}] {StepName} ... FAILED{Reason}",
+ _flowName, name, reason != null ? $": {reason}" : "");
+ return this;
+ }
+
+ /// Record a skipped step.
+ public FlowLogger Skip(string name, string reason = null)
+ {
+ var step = new FlowStep { Name = name, Status = FlowStepStatus.Skipped, Detail = reason };
+ AddStep(step);
+ _logger.LogTrace(" [{FlowName}] {StepName} ... SKIPPED{Reason}",
+ _flowName, name, reason != null ? $": {reason}" : "");
+ return this;
+ }
+
+ /// Start a branch (group of child steps).
+ public FlowLogger Branch(string name)
+ {
+ var step = new FlowStep { Name = name, Status = FlowStepStatus.InProgress };
+ AddStep(step);
+ _currentParent = step;
+ _logger.LogTrace(" [{FlowName}] >> Branch: {BranchName}", _flowName, name);
+ return this;
+ }
+
+ /// End the current branch.
+ public FlowLogger EndBranch()
+ {
+ _currentParent = null;
+ return this;
+ }
+
+ private void AddStep(FlowStep step)
+ {
+ if (_currentParent != null)
+ _currentParent.Children.Add(step);
+ else
+ _steps.Add(step);
+ }
+
+ /// Render the visual flow diagram to Trace log.
+ private string RenderFlow()
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine();
+ sb.AppendLine($" ===== FLOW: {_flowName} ({_totalTimer.ElapsedMilliseconds}ms total) =====");
+ sb.AppendLine();
+
+ for (var i = 0; i < _steps.Count; i++)
+ {
+ var step = _steps[i];
+ var icon = GetStatusIcon(step.Status);
+ var elapsed = step.ElapsedMs > 0 ? $" ({step.ElapsedMs}ms)" : "";
+ var detail = !string.IsNullOrEmpty(step.Detail) ? $" [{step.Detail}]" : "";
+
+ sb.AppendLine($" {icon} {step.Name}{elapsed}{detail}");
+
+ // Render children (branch)
+ if (step.Children.Count > 0)
+ {
+ for (var j = 0; j < step.Children.Count; j++)
+ {
+ var child = step.Children[j];
+ var childIcon = GetStatusIcon(child.Status);
+ var childElapsed = child.ElapsedMs > 0 ? $" ({child.ElapsedMs}ms)" : "";
+ var childDetail = !string.IsNullOrEmpty(child.Detail) ? $" [{child.Detail}]" : "";
+ sb.AppendLine($" |");
+ sb.AppendLine($" +-- {childIcon} {child.Name}{childElapsed}{childDetail}");
+ }
+ }
+
+ // Connector between top-level steps
+ if (i < _steps.Count - 1)
+ {
+ sb.AppendLine(" |");
+ sb.AppendLine(" v");
+ }
+ }
+
+ sb.AppendLine();
+
+ // Final status line
+ var finalStatus = _steps.Count > 0 && _steps.Last().Status == FlowStepStatus.Failed
+ ? "FAILED" : _steps.Any(s => s.Status == FlowStepStatus.Failed) ? "PARTIAL FAILURE" : "SUCCESS";
+ sb.AppendLine($" ===== FLOW RESULT: {finalStatus} =====");
+
+ return sb.ToString();
+ }
+
+ private static string GetStatusIcon(FlowStepStatus status)
+ {
+ switch (status)
+ {
+ case FlowStepStatus.Success: return "[OK]";
+ case FlowStepStatus.Failed: return "[FAIL]";
+ case FlowStepStatus.Skipped: return "[SKIP]";
+ case FlowStepStatus.InProgress: return "[...]";
+ default: return "[?]";
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ _totalTimer.Stop();
+ _logger.LogTrace(RenderFlow());
+ }
+ }
+}
diff --git a/SslStoreCaProxy/SslStoreCaProxy.cs b/SslStoreCaProxy/SslStoreCaProxy.cs
index 6845f9a..76f9bc0 100644
--- a/SslStoreCaProxy/SslStoreCaProxy.cs
+++ b/SslStoreCaProxy/SslStoreCaProxy.cs
@@ -48,27 +48,48 @@ public SslStoreCaProxy(IDomainValidatorFactory validatorFactory)
public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDataReader certificateDataReader)
{
_logger.MethodEntry();
+ using var flow = new FlowLogger(_logger, "Initialize");
try
{
- _certDataReader = certificateDataReader;
- Config = configProvider;
- var rawData = JsonConvert.SerializeObject(configProvider.CAConnectionData);
- _config = JsonConvert.DeserializeObject(rawData);
-
- PartnerCode = _config.PartnerCode;
- AuthenticationToken = _config.AuthToken;
- PageSize = _config.PageSize > 0 ? _config.PageSize : SslStoreCAPluginConfig.DefaultPageSize;
- RenewalWindow = _config.RenewalWindow > 0 ? _config.RenewalWindow : 30;
+ flow.Step("ReadConfigProvider", () =>
+ {
+ _certDataReader = certificateDataReader;
+ Config = configProvider;
+ });
- _requestManager = new RequestManager(this);
+ flow.Step("DeserializeConnectionData", () =>
+ {
+ var rawData = JsonConvert.SerializeObject(configProvider.CAConnectionData);
+ _config = JsonConvert.DeserializeObject(rawData);
+ });
- _logger.LogTrace($"Initialize - Enabled: {_config.Enabled}");
+ flow.Step("ApplyConfig", () =>
+ {
+ PartnerCode = _config.PartnerCode;
+ AuthenticationToken = _config.AuthToken;
+ PageSize = _config.PageSize > 0 ? _config.PageSize : SslStoreCAPluginConfig.DefaultPageSize;
+ RenewalWindow = _config.RenewalWindow > 0 ? _config.RenewalWindow : 30;
+ }, $"PageSize={PageSize}, RenewalWindow={RenewalWindow}");
+
+ flow.Step("CreateRequestManager", () => _requestManager = new RequestManager(this));
+
+ _logger.LogInformation(
+ "SslStore CAPlugin initialized. Enabled={Enabled}, SSLStoreURL={Url}, PartnerCode set={HasPartner}, AuthToken set={HasToken}, PageSize={PageSize}, RenewalWindow={RenewalWindow}, DnsValidationEnabled={DnsEnabled}, DnsValidationType={DnsType}, DnsVerificationServer={DnsServer}, DnsPropagationMaxAttempts={DnsAttempts}, DnsPropagationDelaySeconds={DnsDelay}, DomainValidatorFactory available={HasFactory}",
+ _config.Enabled, _config.SSLStoreURL, !string.IsNullOrEmpty(_config.PartnerCode), !string.IsNullOrEmpty(_config.AuthToken),
+ PageSize, RenewalWindow, _config.DnsValidationEnabled, _config.DnsValidationType,
+ string.IsNullOrEmpty(_config.DnsVerificationServer) ? "(public resolvers)" : _config.DnsVerificationServer,
+ _config.DnsPropagationMaxAttempts, _config.DnsPropagationDelaySeconds, _validatorFactory != null);
}
catch (Exception ex)
{
- _logger.LogError($"Failed to initialize SslStore CAPlugin: {ex}");
+ flow.Fail("Initialize", ex.Message);
+ _logger.LogError(ex, "Failed to initialize SslStore CAPlugin");
throw;
}
+ finally
+ {
+ _logger.MethodExit();
+ }
}
public async Task Ping()
@@ -142,45 +163,65 @@ public Dictionary GetTemplateParameterAnnotations()
public async Task Revoke(string caRequestId, string hexSerialNumber, uint revocationReason)
{
_logger.MethodEntry();
+ _logger.LogInformation("Revoke requested for CARequestID '{CaRequestId}', serial '{Serial}', reason {Reason}",
+ caRequestId ?? "(null)", hexSerialNumber ?? "(null)", revocationReason);
+ using var flow = new FlowLogger(_logger, $"Revoke({caRequestId ?? "null"})");
+
var sslStoreOrderId = ParseSslStoreOrderId(caRequestId);
RevokeOrderRequest revokeOrderRequest;
if (sslStoreOrderId != null)
{
+ flow.Step("BuildRevokeRequest", $"by SSLStoreOrderId={sslStoreOrderId}");
revokeOrderRequest = _requestManager.GetRevokeOrderRequestBySslStoreId(sslStoreOrderId);
}
else
{
+ flow.Step("BuildRevokeRequest", $"legacy CustomOrderId={caRequestId}");
revokeOrderRequest = _requestManager.GetRevokeOrderRequest(caRequestId);
}
_logger.LogTrace($"Revoke Request JSON {JsonConvert.SerializeObject(revokeOrderRequest)}");
try
{
var client = new SslStoreClient(Config);
- var requestResponse = await client.SubmitRevokeCertificateAsync(revokeOrderRequest);
+ IOrderStatusResponse requestResponse = null;
+ await flow.StepAsync("SubmitRevokeToSslStore",
+ async () => requestResponse = await client.SubmitRevokeCertificateAsync(revokeOrderRequest));
_logger.LogTrace($"Revoke Response JSON {JsonConvert.SerializeObject(requestResponse)}");
if (requestResponse.AuthResponse.IsError)
{
- _logger.LogError("Revoke Error Occurred");
- _logger.MethodExit();
+ var msg = requestResponse.AuthResponse.Message != null ? string.Join("; ", requestResponse.AuthResponse.Message) : "(no message)";
+ flow.Fail("RevokeResult", msg);
+ _logger.LogError("Revoke error for CARequestID '{CaRequestId}': {Message}", caRequestId, msg);
return (int)EndEntityStatus.FAILED;
}
- _logger.MethodExit();
+ flow.Step("RevokeResult", "REVOKED");
+ _logger.LogInformation("Revoke succeeded for CARequestID '{CaRequestId}'", caRequestId);
return (int)EndEntityStatus.REVOKED;
}
catch (Exception e)
{
- _logger.LogError($"An Error has occurred during the revoke process {e.Message}");
+ flow.Fail("UNHANDLED", e.Message);
+ _logger.LogError(e, "An error has occurred during the revoke process for CARequestID '{CaRequestId}'", caRequestId);
return (int)EndEntityStatus.FAILED;
}
+ finally
+ {
+ _logger.MethodExit();
+ }
}
public async Task Enroll(string csr, string subject, Dictionary san,
EnrollmentProductInfo productInfo, RequestFormat requestFormat, EnrollmentType enrollmentType)
{
_logger.MethodEntry();
+ _logger.LogInformation(
+ "Enroll requested. Type={EnrollmentType}, ProductID={ProductId}, Subject='{Subject}', SAN dns count={SanCount}, RequestFormat={RequestFormat}",
+ enrollmentType, productInfo?.ProductID, subject,
+ san != null && san.ContainsKey("dns") ? san["dns"].Length : 0, requestFormat);
+ using var flow = new FlowLogger(_logger, $"Enroll-{enrollmentType}");
var client = new SslStoreClient(Config);
try
@@ -189,6 +230,7 @@ public async Task Enroll(string csr, string subject, Dictionar
if (enrollmentType == EnrollmentType.New)
{
+ flow.Branch("NewEnrollment");
_logger.LogTrace("Entering New Enrollment");
if (!productInfo.ProductParameters.ContainsKey("PriorCertSN"))
@@ -205,10 +247,13 @@ public async Task Enroll(string csr, string subject, Dictionar
_logger.LogTrace($"DNS Names from SAN: {string.Join(",", dnsNames)}");
var useDnsValidation = ResolveUseDnsValidation(productInfo);
- _logger.LogTrace($"DNS domain control validation enabled: {useDnsValidation}");
+ flow.Step("ResolveValidationMethod", useDnsValidation ? "CNAME/DNS" : "Email approver");
+ _logger.LogInformation("Enroll new order for CN '{Domain}' with {SanCount} SAN(s); DNS DCV enabled={UseDns}",
+ domainName, dnsNames.Length, useDnsValidation);
if (!useDnsValidation)
{
+ flow.Branch("EmailApproverValidation");
string[] arrayApproverEmails = Array.Empty();
if (productInfo.ProductParameters.ContainsKey("Approver Email"))
{
@@ -220,6 +265,7 @@ public async Task Enroll(string csr, string subject, Dictionar
var allDomains = new List();
if (!string.IsNullOrEmpty(domainName)) allDomains.Add(domainName);
allDomains.AddRange(dnsNames.Where(d => !string.Equals(d, domainName, StringComparison.OrdinalIgnoreCase)));
+ _logger.LogTrace($"Validating approver emails against {allDomains.Count} domain(s): {string.Join(", ", allDomains)}");
var count = 1;
foreach (var domain in allDomains)
@@ -227,7 +273,9 @@ public async Task Enroll(string csr, string subject, Dictionar
var emailApproverRequest = _requestManager.GetEmailApproverListRequest(productInfo.ProductID, domain);
_logger.LogTrace($"Email Approver Request JSON {JsonConvert.SerializeObject(emailApproverRequest)}");
- var emailApproverResponse = await client.SubmitEmailApproverRequestAsync(emailApproverRequest);
+ EmailApproverResponse emailApproverResponse = null;
+ await flow.StepAsync($"FetchApproverEmails[{domain}]",
+ async () => emailApproverResponse = await client.SubmitEmailApproverRequestAsync(emailApproverRequest));
_logger.LogTrace($"Email Approver Response JSON {JsonConvert.SerializeObject(emailApproverResponse)}");
var emailValidation = ValidateEmails(emailApproverResponse, arrayApproverEmails, productInfo, count);
@@ -235,27 +283,37 @@ public async Task Enroll(string csr, string subject, Dictionar
if (emailValidation.Length > 0)
{
+ flow.Fail($"ValidateApproverEmail[{domain}]", emailValidation);
+ _logger.LogError("Approver email validation failed for '{Domain}': {Message}", domain, emailValidation);
return new EnrollmentResult
{
Status = (int)EndEntityStatus.FAILED,
StatusMessage = emailValidation
};
}
+ flow.Step($"ValidateApproverEmail[{domain}]", "OK");
count++;
}
+ flow.EndBranch();
}
var enrollmentRequest = _requestManager.GetEnrollmentRequest(csr, subject, san, productInfo, Config, false, useDnsValidation);
_logger.LogTrace($"enrollmentRequest JSON {JsonConvert.SerializeObject(enrollmentRequest)}");
- enrollmentResponse = await client.SubmitNewOrderRequestAsync(enrollmentRequest);
+ await flow.StepAsync("SubmitNewOrderToSslStore",
+ async () => enrollmentResponse = await client.SubmitNewOrderRequestAsync(enrollmentRequest));
_logger.LogTrace($"enrollmentResponse JSON {JsonConvert.SerializeObject(enrollmentResponse)}");
+ _logger.LogInformation("New order submitted. SSLStoreOrderId={OrderId}, PartnerOrderId={PartnerId}, IsError={IsError}",
+ enrollmentResponse?.TheSslStoreOrderId, enrollmentResponse?.PartnerOrderId, enrollmentResponse?.AuthResponse?.IsError);
if (useDnsValidation && enrollmentResponse != null && !(enrollmentResponse.AuthResponse?.IsError ?? false))
{
- var dnsError = await StageDnsValidationAsync(enrollmentResponse, domainName);
+ string dnsError = null;
+ await flow.StepAsync("StageDnsValidation",
+ async () => dnsError = await StageDnsValidationAsync(enrollmentResponse, domainName));
if (!string.IsNullOrEmpty(dnsError))
{
+ flow.Fail("StageDnsValidation", dnsError);
return new EnrollmentResult
{
Status = (int)EndEntityStatus.FAILED,
@@ -263,9 +321,12 @@ public async Task Enroll(string csr, string subject, Dictionar
};
}
}
+ flow.EndBranch();
}
else
{
+ flow.Fail("RejectExpiredRenew", "PriorCertSN present on New enrollment");
+ _logger.LogWarning("Rejecting New enrollment: PriorCertSN present (expired cert cannot be renewed via New).");
return new EnrollmentResult
{
Status = (int)EndEntityStatus.FAILED,
@@ -275,12 +336,14 @@ public async Task Enroll(string csr, string subject, Dictionar
}
else if (enrollmentType == EnrollmentType.RenewOrReissue)
{
+ flow.Branch("RenewOrReissue");
_logger.LogTrace("Entering Renew/Reissue Logic...");
var sn = productInfo.ProductParameters["PriorCertSN"];
_logger.LogTrace($"Prior Cert Serial Number: {sn}");
var caRequestId = await _certDataReader.GetRequestIDBySerialNumber(sn);
+ flow.Step("LookupPriorRequestId", $"SN={sn} -> {caRequestId}");
_logger.LogTrace($"Prior CA Request ID: {caRequestId}");
var priorSslStoreOrderId = ParseSslStoreOrderId(caRequestId);
@@ -297,7 +360,9 @@ public async Task Enroll(string csr, string subject, Dictionar
}
_logger.LogTrace($"orderStatusRequest JSON {JsonConvert.SerializeObject(orderStatusRequest)}");
- var orderStatusResponse = await client.SubmitOrderStatusRequestAsync(orderStatusRequest);
+ INewOrderResponse orderStatusResponse = null;
+ await flow.StepAsync("FetchPriorOrderStatus",
+ async () => orderStatusResponse = await client.SubmitOrderStatusRequestAsync(orderStatusRequest));
_logger.LogTrace($"orderStatusResponse JSON {JsonConvert.SerializeObject(orderStatusResponse)}");
// Determine renewal vs reissue based on order expiry and RenewalWindow
@@ -307,34 +372,47 @@ public async Task Enroll(string csr, string subject, Dictionar
var daysUntilOrderExpiry = (orderExpiry - DateTime.UtcNow).TotalDays;
_logger.LogTrace($"Order expiry: {orderExpiry:u}, days remaining: {daysUntilOrderExpiry:F0}, renewal window: {RenewalWindow} days");
shouldRenew = daysUntilOrderExpiry <= RenewalWindow;
+ flow.Step("EvaluateRenewalWindow", $"daysRemaining={daysUntilOrderExpiry:F0}, window={RenewalWindow} -> {(shouldRenew ? "renew" : "reissue")}");
}
else
{
_logger.LogWarning($"Could not parse OrderExpiryDateInUTC '{orderStatusResponse.OrderExpiryDateInUtc}', defaulting to renewal");
+ flow.Step("EvaluateRenewalWindow", "unparseable expiry -> defaulting to renew");
shouldRenew = true;
}
if (shouldRenew)
{
- _logger.LogTrace("Order is within renewal window, performing renewal (new order)...");
+ _logger.LogInformation("Order is within renewal window, performing renewal (new order).");
var renewRequest = _requestManager.GetRenewalRequest(orderStatusResponse, csr);
_logger.LogTrace($"renewRequest JSON {JsonConvert.SerializeObject(renewRequest)}");
- enrollmentResponse = await client.SubmitRenewRequestAsync(renewRequest);
+ await flow.StepAsync("SubmitRenewalToSslStore",
+ async () => enrollmentResponse = await client.SubmitRenewRequestAsync(renewRequest));
_logger.LogTrace($"enrollmentResponse JSON {JsonConvert.SerializeObject(enrollmentResponse)}");
}
else
{
- _logger.LogTrace("Order has life remaining, performing reissue (same order)...");
+ _logger.LogInformation("Order has life remaining, performing reissue (same order).");
var reIssueRequest = _requestManager.GetReIssueRequest(orderStatusResponse, csr, false);
_logger.LogTrace($"reIssueRequest JSON {JsonConvert.SerializeObject(reIssueRequest)}");
- enrollmentResponse = await client.SubmitReIssueRequestAsync(reIssueRequest);
+ await flow.StepAsync("SubmitReissueToSslStore",
+ async () => enrollmentResponse = await client.SubmitReIssueRequestAsync(reIssueRequest));
_logger.LogTrace($"reissue enrollmentResponse JSON {JsonConvert.SerializeObject(enrollmentResponse)}");
}
+ flow.EndBranch();
}
- return GetEnrollmentResult(enrollmentResponse);
+ var result = GetEnrollmentResult(enrollmentResponse);
+ flow.Step("MapEnrollmentResult", $"Status={result.Status}, CARequestID={result.CARequestID ?? "(null)"}");
+ return result;
+ }
+ catch (Exception ex)
+ {
+ flow.Fail("UNHANDLED", ex.Message);
+ _logger.LogError(ex, "Unhandled error during Enroll (Type={EnrollmentType}, ProductID={ProductId})", enrollmentType, productInfo?.ProductID);
+ throw;
}
finally
{
@@ -402,6 +480,8 @@ private EnrollmentResult GetEnrollmentResult(INewOrderResponse newOrderResponse)
public async Task GetSingleRecord(string caRequestId)
{
_logger.MethodEntry();
+ _logger.LogInformation("GetSingleRecord requested for CARequestID '{CaRequestId}'", caRequestId ?? "(null)");
+ using var flow = new FlowLogger(_logger, $"GetSingleRecord({caRequestId ?? "null"})");
var client = new SslStoreClient(Config);
var sslStoreOrderId = ParseSslStoreOrderId(caRequestId);
@@ -409,66 +489,109 @@ public async Task GetSingleRecord(string caRequestId)
OrderStatusRequest orderStatusRequest;
if (sslStoreOrderId != null)
{
+ flow.Step("BuildOrderStatusRequest", $"by SSLStoreOrderId={sslStoreOrderId}");
_logger.LogTrace($"Parsed TheSSLStoreOrderID: {sslStoreOrderId} from CARequestID: {caRequestId}");
orderStatusRequest = _requestManager.GetOrderStatusRequestBySslStoreId(sslStoreOrderId);
}
else
{
+ flow.Step("BuildOrderStatusRequest", $"legacy CustomOrderId={caRequestId}");
_logger.LogTrace($"Legacy GUID format, querying by CustomOrderId: {caRequestId}");
orderStatusRequest = _requestManager.GetOrderStatusRequest(caRequestId);
}
- var orderStatusResponse = await client.SubmitOrderStatusRequestAsync(orderStatusRequest);
- _logger.LogTrace($"orderStatusResponse JSON {JsonConvert.SerializeObject(orderStatusResponse)}");
+ try
+ {
+ INewOrderResponse orderStatusResponse = null;
+ await flow.StepAsync("FetchOrderStatus",
+ async () => orderStatusResponse = await client.SubmitOrderStatusRequestAsync(orderStatusRequest));
+ _logger.LogTrace($"orderStatusResponse JSON {JsonConvert.SerializeObject(orderStatusResponse)}");
- var certStatus = _requestManager.MapReturnStatus(orderStatusResponse?.OrderStatus.MajorStatus);
- var certificate = string.Empty;
+ var certStatus = _requestManager.MapReturnStatus(orderStatusResponse?.OrderStatus.MajorStatus);
+ flow.Step("MapStatus", $"{orderStatusResponse?.OrderStatus?.MajorStatus} -> {certStatus}");
+ var certificate = string.Empty;
- if (certStatus == (int)EndEntityStatus.GENERATED)
- {
- var downloadCertificateRequest = _requestManager.GetCertificateRequestBySslStoreId(sslStoreOrderId ?? orderStatusResponse.TheSslStoreOrderId);
- var certResponse = await client.SubmitDownloadCertificateAsync(downloadCertificateRequest);
- if (!certResponse.AuthResponse.IsError)
+ if (certStatus == (int)EndEntityStatus.GENERATED)
{
- var fullChain = string.Join("\n", certResponse.Certificates.Select(c => c.FileContent));
- var endEntityCert = X509Utilities.ExtractEndEntityCertificateContents(fullChain, null);
- certificate = Convert.ToBase64String(endEntityCert.RawData);
+ flow.Branch("DownloadIssuedCert");
+ var downloadCertificateRequest = _requestManager.GetCertificateRequestBySslStoreId(sslStoreOrderId ?? orderStatusResponse.TheSslStoreOrderId);
+ IDownloadCertificateResponse certResponse = null;
+ await flow.StepAsync("DownloadCertificate",
+ async () => certResponse = await client.SubmitDownloadCertificateAsync(downloadCertificateRequest));
+ if (!certResponse.AuthResponse.IsError)
+ {
+ flow.Step("ExtractLeafCert", () =>
+ {
+ var fullChain = string.Join("\n", certResponse.Certificates.Select(c => c.FileContent));
+ var endEntityCert = X509Utilities.ExtractEndEntityCertificateContents(fullChain, null);
+ certificate = Convert.ToBase64String(endEntityCert.RawData);
+ });
+ }
+ else
+ {
+ flow.Fail("DownloadCertificate", "AuthResponse.IsError");
+ _logger.LogWarning("Certificate download reported an error for CARequestID '{CaRequestId}'", caRequestId);
+ }
+
+ // Order is issued - best-effort cleanup of any DNS validation records we published.
+ await flow.StepAsync("CleanupDnsValidation",
+ async () => await CleanupDnsValidationAsync(orderStatusResponse));
+ flow.EndBranch();
}
- // Order is issued - best-effort cleanup of any DNS validation records we published.
- await CleanupDnsValidationAsync(orderStatusResponse);
- }
+ _logger.LogInformation("GetSingleRecord result for '{CaRequestId}': Status={Status}, certReturned={HasCert}",
+ caRequestId, certStatus, !string.IsNullOrEmpty(certificate));
- _logger.MethodExit();
- return new AnyCAPluginCertificate
+ return new AnyCAPluginCertificate
+ {
+ CARequestID = caRequestId,
+ Certificate = certificate,
+ Status = certStatus
+ };
+ }
+ catch (Exception ex)
{
- CARequestID = caRequestId,
- Certificate = certificate,
- Status = certStatus
- };
+ flow.Fail("UNHANDLED", ex.Message);
+ _logger.LogError(ex, "Unhandled error in GetSingleRecord for CARequestID '{CaRequestId}'", caRequestId);
+ throw;
+ }
+ finally
+ {
+ _logger.MethodExit();
+ }
}
public async Task Synchronize(BlockingCollection blockingBuffer,
DateTime? lastSync, bool fullSync, CancellationToken cancelToken)
{
_logger.MethodEntry();
+ _logger.LogInformation("Synchronize started. fullSync={FullSync}, lastSync={LastSync}",
+ fullSync, lastSync.HasValue ? lastSync.Value.ToString("u") : "(none)");
+ using var flow = new FlowLogger(_logger, $"Synchronize-{(fullSync ? "Full" : "Incremental")}");
+
+ var processed = 0;
+ var added = 0;
+ var skipped = 0;
try
{
var client = new SslStoreClient(Config);
var certs = new BlockingCollection(100);
+ flow.Step("StartQueryOrders");
_ = client.SubmitQueryOrderRequestAsync(certs, cancelToken, _requestManager);
foreach (var currentResponseItem in certs.GetConsumingEnumerable(cancelToken))
{
if (cancelToken.IsCancellationRequested)
{
- _logger.LogError("Synchronize was canceled.");
+ _logger.LogWarning("Synchronize was canceled after processing {Processed} order(s).", processed);
+ flow.Fail("Cancelled", $"after {processed} processed");
break;
}
try
{
+ processed++;
_logger.LogTrace($"Took Certificate ID {currentResponseItem?.TheSslStoreOrderId} (CustomOrderId: {currentResponseItem?.CustomOrderId}) from Queue");
// Use TheSslStoreOrderId for sync lookups since that's what the query returns
@@ -479,6 +602,7 @@ public async Task Synchronize(BlockingCollection blockin
var partnerOrderId = orderStatusResponse.PartnerOrderId;
if (string.IsNullOrEmpty(theSslStoreOrderId) || string.IsNullOrEmpty(partnerOrderId))
{
+ skipped++;
_logger.LogTrace($"Order {currentResponseItem?.TheSslStoreOrderId} missing required IDs, skipping");
continue;
}
@@ -486,6 +610,8 @@ public async Task Synchronize(BlockingCollection blockin
var compositeId = BuildCompositeRequestId(theSslStoreOrderId, partnerOrderId);
var fileContent = "";
var certStatus = _requestManager.MapReturnStatus(orderStatusResponse.OrderStatus.MajorStatus);
+ _logger.LogTrace("Sync order {CompositeId}: MajorStatus={MajorStatus} -> {CertStatus}",
+ compositeId, orderStatusResponse.OrderStatus.MajorStatus, certStatus);
if (certStatus == (int)EndEntityStatus.GENERATED)
{
@@ -497,6 +623,10 @@ public async Task Synchronize(BlockingCollection blockin
var endEntityCert = X509Utilities.ExtractEndEntityCertificateContents(fullChain, null);
fileContent = Convert.ToBase64String(endEntityCert.RawData);
}
+ else
+ {
+ _logger.LogWarning("Certificate download error during sync for order {CompositeId}", compositeId);
+ }
// Order is issued - best-effort cleanup of any DNS validation records we published.
await CleanupDnsValidationAsync(orderStatusResponse);
@@ -505,6 +635,7 @@ public async Task Synchronize(BlockingCollection blockin
if ((certStatus == (int)EndEntityStatus.GENERATED && fileContent.Length > 0) ||
certStatus == (int)EndEntityStatus.REVOKED)
{
+ added++;
blockingBuffer.Add(new AnyCAPluginCertificate
{
CARequestID = compositeId,
@@ -513,21 +644,33 @@ public async Task Synchronize(BlockingCollection blockin
ProductID = $"{orderStatusResponse.ProductCode}"
}, cancelToken);
}
+ else
+ {
+ skipped++;
+ }
}
catch (OperationCanceledException)
{
- _logger.LogError("Synchronize was canceled.");
+ _logger.LogWarning("Synchronize was canceled after processing {Processed} order(s).", processed);
+ flow.Fail("Cancelled", $"after {processed} processed");
break;
}
}
+
+ flow.Step("SyncSummary", $"processed={processed}, added={added}, skipped={skipped}");
+ _logger.LogInformation("Synchronize finished. Processed={Processed}, added={Added}, skipped={Skipped}",
+ processed, added, skipped);
}
- catch (AggregateException)
+ catch (AggregateException ae)
{
- _logger.LogError("SslStore Synchronize Task failed!");
+ flow.Fail("SyncError", ae.Message);
+ _logger.LogError(ae, "SslStore Synchronize Task failed after processing {Processed} order(s)!", processed);
throw;
}
-
- _logger.MethodExit();
+ finally
+ {
+ _logger.MethodExit();
+ }
}
private string ValidateEmails(EmailApproverResponse validEmails, string[] arrayApproverEmails, EnrollmentProductInfo productInfo, int count)
@@ -583,19 +726,31 @@ private bool ResolveUseDnsValidation(EnrollmentProductInfo productInfo)
///
private async Task StageDnsValidationAsync(INewOrderResponse response, string cnDomain)
{
+ using var flow = new FlowLogger(_logger, "StageDnsValidation");
+
if (_validatorFactory == null)
{
+ flow.Fail("CheckValidatorFactory", "IDomainValidatorFactory not provided by gateway");
+ _logger.LogError("DNS validation enabled but the AnyCA Gateway did not inject an IDomainValidatorFactory.");
return "DNS domain control validation is enabled but the AnyCA Gateway did not provide an " +
"IDomainValidatorFactory. Ensure the gateway version supports DNS provider plugins.";
}
var records = CollectDnsRecords(response, cnDomain);
+ flow.Step("CollectDnsRecords", $"{records.Count} record(s)");
if (records.Count == 0)
{
+ flow.Skip("PublishRecords", "no CNAME records returned by SSL Store");
_logger.LogWarning("DNS validation enabled but SSL Store returned no CNAME validation records to publish.");
return "";
}
+ _logger.LogInformation(
+ "Staging {Count} DNS validation record(s) via validation type '{ValidationType}' (verification server: {Server}, {Attempts} attempt(s) x {Delay}s).",
+ records.Count, _config.DnsValidationType,
+ string.IsNullOrEmpty(_config.DnsVerificationServer) ? "public resolvers" : _config.DnsVerificationServer,
+ _config.DnsPropagationMaxAttempts, _config.DnsPropagationDelaySeconds);
+
var verifier = new DnsVerificationHelper(_config.DnsVerificationServer, _config.DnsPropagationMaxAttempts, _config.DnsPropagationDelaySeconds);
foreach (var (domain, recordName, recordValue) in records)
@@ -606,33 +761,49 @@ private async Task StageDnsValidationAsync(INewOrderResponse response, s
try
{
validator = _validatorFactory.ResolveDomainValidator(domain, _config.DnsValidationType);
+ flow.Step($"ResolveValidator[{domain}]", validator != null ? validator.GetType().Name : "null");
}
catch (Exception ex)
{
- _logger.LogError($"Failed to resolve DNS provider plugin for '{domain}': {ex.Message}");
+ flow.Fail($"ResolveValidator[{domain}]", ex.Message);
+ _logger.LogError(ex, "Failed to resolve DNS provider plugin for '{Domain}' (validation type '{ValidationType}')", domain, _config.DnsValidationType);
return $"Failed to resolve DNS provider plugin for '{domain}' (validation type '{_config.DnsValidationType}'): {ex.Message}";
}
if (validator == null)
{
+ flow.Fail($"ResolveValidator[{domain}]", "no validator resolved");
+ _logger.LogError("No DNS provider plugin resolved for '{Domain}' (validation type '{ValidationType}').", domain, _config.DnsValidationType);
return $"No DNS provider plugin resolved for '{domain}' (validation type '{_config.DnsValidationType}'). " +
"Ensure a DNS provider plugin is deployed and configured for the zone that hosts this domain.";
}
- var result = await validator.StageValidation(recordName, recordValue, CancellationToken.None);
+ DomainValidationResult result = null;
+ await flow.StepAsync($"PublishRecord[{recordName}]",
+ async () => result = await validator.StageValidation(recordName, recordValue, CancellationToken.None));
if (result == null || !result.Success)
{
var msg = result?.ErrorMessage ?? "unknown error";
- _logger.LogError($"Failed to publish DNS validation record {recordName} for '{domain}': {msg}");
+ flow.Fail($"PublishRecord[{recordName}]", msg);
+ _logger.LogError("Failed to publish DNS validation record {RecordName} for '{Domain}': {Message}", recordName, domain, msg);
return $"Failed to publish DNS validation record for '{domain}': {msg}";
}
+ _logger.LogInformation("Published DNS validation record {RecordName} for '{Domain}' via {Validator}.",
+ recordName, domain, validator.GetType().Name);
- var propagated = await verifier.WaitForDnsPropagationAsync(recordName, recordValue, QueryType.CNAME, 3);
+ var propagated = false;
+ await flow.StepAsync($"VerifyPropagation[{recordName}]",
+ async () => propagated = await verifier.WaitForDnsPropagationAsync(recordName, recordValue, QueryType.CNAME, 3));
if (!propagated)
{
+ flow.Skip($"VerifyPropagation[{recordName}]", "not yet confirmed (best-effort)");
_logger.LogWarning($"CNAME record {recordName} for '{domain}' was not yet confirmed across public resolvers. " +
"SSL Store will re-check on its own schedule.");
}
+ else
+ {
+ _logger.LogInformation("CNAME record {RecordName} for '{Domain}' confirmed propagated.", recordName, domain);
+ }
}
return "";
@@ -645,25 +816,41 @@ private async Task StageDnsValidationAsync(INewOrderResponse response, s
///
private async Task CleanupDnsValidationAsync(INewOrderResponse response)
{
- if (_validatorFactory == null || !_config.DnsValidationEnabled || response == null) return;
+ if (_validatorFactory == null || !_config.DnsValidationEnabled || response == null)
+ {
+ _logger.LogTrace("Skipping DNS validation cleanup (factory available={HasFactory}, DnsValidationEnabled={Enabled}, response null={NullResponse}).",
+ _validatorFactory != null, _config.DnsValidationEnabled, response == null);
+ return;
+ }
var records = CollectDnsRecords(response, response.CommonName ?? "");
+ if (records.Count == 0)
+ {
+ _logger.LogTrace("No DNS validation records to clean up for this order.");
+ return;
+ }
+
+ _logger.LogInformation("Best-effort cleanup of {Count} DNS validation record(s).", records.Count);
foreach (var (domain, recordName, _) in records)
{
try
{
var validator = _validatorFactory.ResolveDomainValidator(domain, _config.DnsValidationType);
- if (validator == null) continue;
+ if (validator == null)
+ {
+ _logger.LogTrace("No validator resolved for '{Domain}' during cleanup; skipping record {RecordName}.", domain, recordName);
+ continue;
+ }
var result = await validator.CleanupValidation(recordName, CancellationToken.None);
if (result != null && !result.Success)
_logger.LogWarning($"Cleanup of DNS validation record {recordName} for '{domain}' reported failure: {result.ErrorMessage}");
else
- _logger.LogTrace($"Cleaned up DNS validation record {recordName} for '{domain}'");
+ _logger.LogInformation("Cleaned up DNS validation record {RecordName} for '{Domain}'.", recordName, domain);
}
catch (Exception ex)
{
- _logger.LogWarning($"Best-effort cleanup of DNS validation record {recordName} for '{domain}' failed: {ex.Message}");
+ _logger.LogWarning(ex, "Best-effort cleanup of DNS validation record {RecordName} for '{Domain}' failed.", recordName, domain);
}
}
}
From 49bb24a5239826808cf5d334d149edc681511079 Mon Sep 17 00:00:00 2001
From: Brian Hill
Date: Wed, 15 Jul 2026 12:58:26 -0400
Subject: [PATCH 19/25] fixed issues
---
SslStoreCaProxy/SslStoreCaProxy.cs | 98 +++++++++++++++++++-----------
1 file changed, 61 insertions(+), 37 deletions(-)
diff --git a/SslStoreCaProxy/SslStoreCaProxy.cs b/SslStoreCaProxy/SslStoreCaProxy.cs
index 76f9bc0..fd13f40 100644
--- a/SslStoreCaProxy/SslStoreCaProxy.cs
+++ b/SslStoreCaProxy/SslStoreCaProxy.cs
@@ -511,31 +511,28 @@ await flow.StepAsync("FetchOrderStatus",
flow.Step("MapStatus", $"{orderStatusResponse?.OrderStatus?.MajorStatus} -> {certStatus}");
var certificate = string.Empty;
- if (certStatus == (int)EndEntityStatus.GENERATED)
+ var isIssued = certStatus == (int)EndEntityStatus.GENERATED;
+ var isRevoked = certStatus == (int)EndEntityStatus.REVOKED;
+
+ if (isIssued || isRevoked)
{
- flow.Branch("DownloadIssuedCert");
- var downloadCertificateRequest = _requestManager.GetCertificateRequestBySslStoreId(sslStoreOrderId ?? orderStatusResponse.TheSslStoreOrderId);
- IDownloadCertificateResponse certResponse = null;
+ // Download the certificate for both issued and revoked orders so a valid cert is
+ // always returned when one exists. A revoked order that was never issued yields no
+ // content (empty) rather than a corrupt/empty certificate handle.
+ flow.Branch(isIssued ? "DownloadIssuedCert" : "DownloadRevokedCert");
await flow.StepAsync("DownloadCertificate",
- async () => certResponse = await client.SubmitDownloadCertificateAsync(downloadCertificateRequest));
- if (!certResponse.AuthResponse.IsError)
- {
- flow.Step("ExtractLeafCert", () =>
- {
- var fullChain = string.Join("\n", certResponse.Certificates.Select(c => c.FileContent));
- var endEntityCert = X509Utilities.ExtractEndEntityCertificateContents(fullChain, null);
- certificate = Convert.ToBase64String(endEntityCert.RawData);
- });
- }
- else
+ async () => certificate = await DownloadLeafCertificateAsync(
+ client, sslStoreOrderId ?? orderStatusResponse.TheSslStoreOrderId, caRequestId));
+
+ if (isRevoked && string.IsNullOrEmpty(certificate))
+ _logger.LogWarning("Revoked order '{CaRequestId}' has no downloadable certificate.", caRequestId);
+
+ if (isIssued)
{
- flow.Fail("DownloadCertificate", "AuthResponse.IsError");
- _logger.LogWarning("Certificate download reported an error for CARequestID '{CaRequestId}'", caRequestId);
+ // Order is issued - best-effort cleanup of any DNS validation records we published.
+ await flow.StepAsync("CleanupDnsValidation",
+ async () => await CleanupDnsValidationAsync(orderStatusResponse));
}
-
- // Order is issued - best-effort cleanup of any DNS validation records we published.
- await flow.StepAsync("CleanupDnsValidation",
- async () => await CleanupDnsValidationAsync(orderStatusResponse));
flow.EndBranch();
}
@@ -613,27 +610,25 @@ public async Task Synchronize(BlockingCollection blockin
_logger.LogTrace("Sync order {CompositeId}: MajorStatus={MajorStatus} -> {CertStatus}",
compositeId, orderStatusResponse.OrderStatus.MajorStatus, certStatus);
- if (certStatus == (int)EndEntityStatus.GENERATED)
+ var isIssued = certStatus == (int)EndEntityStatus.GENERATED;
+ var isRevoked = certStatus == (int)EndEntityStatus.REVOKED;
+
+ if (isIssued || isRevoked)
{
- var downloadCertificateRequest = _requestManager.GetCertificateRequestBySslStoreId(theSslStoreOrderId);
- var certResponse = await client.SubmitDownloadCertificateAsync(downloadCertificateRequest);
- if (!certResponse.AuthResponse.IsError)
- {
- var fullChain = string.Join("\n", certResponse.Certificates.Select(c => c.FileContent));
- var endEntityCert = X509Utilities.ExtractEndEntityCertificateContents(fullChain, null);
- fileContent = Convert.ToBase64String(endEntityCert.RawData);
- }
- else
+ // Download the certificate for both issued and revoked (Cancelled) orders so
+ // the gateway always stores valid DER. Revoked orders that were never issued
+ // return no content and are skipped below — storing an empty certificate makes
+ // the gateway's certificate search fail with "m_safeCertContext is an invalid handle".
+ fileContent = await DownloadLeafCertificateAsync(client, theSslStoreOrderId, compositeId);
+
+ if (isIssued)
{
- _logger.LogWarning("Certificate download error during sync for order {CompositeId}", compositeId);
+ // Order is issued - best-effort cleanup of any DNS validation records we published.
+ await CleanupDnsValidationAsync(orderStatusResponse);
}
-
- // Order is issued - best-effort cleanup of any DNS validation records we published.
- await CleanupDnsValidationAsync(orderStatusResponse);
}
- if ((certStatus == (int)EndEntityStatus.GENERATED && fileContent.Length > 0) ||
- certStatus == (int)EndEntityStatus.REVOKED)
+ if ((isIssued || isRevoked) && fileContent.Length > 0)
{
added++;
blockingBuffer.Add(new AnyCAPluginCertificate
@@ -646,6 +641,8 @@ public async Task Synchronize(BlockingCollection blockin
}
else
{
+ if (isRevoked)
+ _logger.LogWarning("Skipping revoked order {CompositeId} with no downloadable certificate (nothing to store).", compositeId);
skipped++;
}
}
@@ -673,6 +670,33 @@ public async Task Synchronize(BlockingCollection blockin
}
}
+ ///
+ /// Downloads the issued certificate for an order and returns the base64-encoded end-entity
+ /// certificate, or an empty string if the download reported an error or returned no content.
+ /// Used for both issued and revoked orders so the gateway always stores valid certificate
+ /// bytes; callers must treat an empty result as "no certificate available".
+ ///
+ private async Task DownloadLeafCertificateAsync(SslStoreClient client, string theSslStoreOrderId, string compositeId)
+ {
+ var downloadCertificateRequest = _requestManager.GetCertificateRequestBySslStoreId(theSslStoreOrderId);
+ var certResponse = await client.SubmitDownloadCertificateAsync(downloadCertificateRequest);
+ if (certResponse == null || certResponse.AuthResponse.IsError)
+ {
+ _logger.LogWarning("Certificate download reported an error for order {CompositeId}.", compositeId);
+ return string.Empty;
+ }
+
+ var fullChain = string.Join("\n", certResponse.Certificates.Select(c => c.FileContent));
+ if (string.IsNullOrWhiteSpace(fullChain))
+ {
+ _logger.LogWarning("Certificate download returned no content for order {CompositeId}.", compositeId);
+ return string.Empty;
+ }
+
+ var endEntityCert = X509Utilities.ExtractEndEntityCertificateContents(fullChain, null);
+ return Convert.ToBase64String(endEntityCert.RawData);
+ }
+
private string ValidateEmails(EmailApproverResponse validEmails, string[] arrayApproverEmails, EnrollmentProductInfo productInfo, int count)
{
if (arrayApproverEmails.Length > 1 && productInfo.ProductID.Contains("digi"))
From 346e1e99256efccfd083a0bbde014cfa55ca3b90 Mon Sep 17 00:00:00 2001
From: Brian Hill
Date: Wed, 15 Jul 2026 13:45:14 -0400
Subject: [PATCH 20/25] fixed validation methods
---
CHANGELOG.md | 10 +++++++--
README.md | 18 ++++++++++-----
SslStoreCaProxy/SslStoreCAPluginConfig.cs | 13 -----------
SslStoreCaProxy/SslStoreCaProxy.cs | 27 +++++++++++++++--------
docsource/configuration.md | 17 +++++++++-----
integration-manifest.json | 4 ----
6 files changed, 50 insertions(+), 39 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 431ad56..d8d1a4d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,10 +3,16 @@
* Automated DNS (CNAME) domain control validation: when enabled, the plugin requests CNAME-based
validation from SSL Store and publishes the returned record via the DNS provider plugin resolved
by the AnyCA Gateway (Azure, Route53, Cloudflare, Google, NS1, Infoblox, RFC2136, etc.)
-* Verifies public DNS propagation of the validation record before returning
+* Verifies public DNS propagation of the validation record before returning (configurable attempts/delay)
* Best-effort cleanup of DNS validation records once an order is issued (GetSingleRecord/Synchronize)
-* New CA-connection settings: `DnsValidationEnabled`, `DnsValidationType`, `DnsVerificationServer`
+* New CA-connection settings: `DnsValidationEnabled`, `DnsVerificationServer`, `DnsPropagationMaxAttempts`, `DnsPropagationDelaySeconds`
+* CNAME is the intrinsic DCV method for SSL Store; the DNS record type is determined by the CNAME
+ validator you map to each domain in the gateway's Domain Validation configuration (no CA-connection knob)
* Email approver validation remains the default when DNS validation is disabled
+* Revoked (Cancelled) orders now download and store the actual certificate; orders with no
+ downloadable certificate are skipped instead of storing empty cert bytes (which previously
+ crashed the gateway certificate search with "m_safeCertContext is an invalid handle")
+* Added FlowLogger step tracing and expanded operational logging across all plugin operations
# v2.0.0
* Converted from AnyCA Gateway (DB) to AnyCA Gateway REST plugin architecture
diff --git a/README.md b/README.md
index 9d2bd93..6ff3c8e 100644
--- a/README.md
+++ b/README.md
@@ -275,7 +275,6 @@ The plugin uses a configurable **Renewal Window** (default: 30 days) to determin
| **Enabled** | Flag to Enable or Disable the CA connector | No | `true` |
| **RenewalWindow** | Days before order expiry to trigger renewal vs. reissue | No | `30` |
| **DnsValidationEnabled** | Enable automated DNS (CNAME) domain control validation instead of email approvers | No | `false` |
- | **DnsValidationType** | Validation type passed to the DNS provider plugin framework | No | `dns-01` |
| **DnsVerificationServer** | Optional authoritative/internal DNS server IP used to verify record propagation | No | (empty) |
### Gateway Registration Notes
@@ -310,7 +309,6 @@ The plugin uses a configurable **Renewal Window** (default: 30 days) to determin
* **Enabled** - Flag to enable or disable the CA connector. Set to `true` to enable.
* **RenewalWindow** - Number of days before an order's expiration date to trigger a renewal (new order) instead of a reissue (same order). Default is 30 days.
* **DnsValidationEnabled** - When `true`, the plugin requests CNAME-based domain control validation from SSL Store and automatically publishes the returned validation record using the DNS provider plugin resolved by the AnyCA Gateway. When `false` (default), the email approver validation flow is used.
- * **DnsValidationType** - The validation type string passed to the DNS provider plugin framework when resolving a domain validator. This must match the validation type advertised by your deployed DNS provider plugin (its `GetValidationType()`). SSL Store DCV is CNAME-based, so this must resolve a **CNAME** validator (e.g. `Ns1CnameDomainValidator`, `CloudflareCnameDomainValidator`) that publishes a CNAME record — not the `dns-01`/TXT variant. Default is `cname`.
* **DnsVerificationServer** - Optional. IP address of an authoritative or internal DNS server used to confirm record propagation. Leave empty to verify against public resolvers (Google, Cloudflare, OpenDNS, Quad9).
* **DnsPropagationMaxAttempts** - Number of times to poll DNS for the validation record before giving up during enrollment. Total wait is roughly `(attempts - 1) × delay` seconds. Default is 3.
* **DnsPropagationDelaySeconds** - Seconds to wait between DNS propagation polling attempts. Enrollment blocks for this duration, so keep the combined wait reasonable — propagation is best-effort and SSL Store re-checks on its own schedule. Default is 10.
@@ -324,19 +322,28 @@ The plugin uses a configurable **Renewal Window** (default: 30 days) to determin
gateway injects an `IDomainValidatorFactory` that resolves the correct provider for each
domain at enrollment time.
+ SSL Store domain control validation is always **CNAME-based**, so the plugin always requests
+ the `cname` validation type from the framework — this is intrinsic to the CA and is **not** a
+ configurable setting. What you configure is the **domain-to-validator mapping** in the
+ gateway's **Domain Validation** section: for each domain (e.g. `*.example.com`) you select a
+ **CNAME** validator (e.g. `Ns1CnameDomainValidator`, `CloudflareCnameDomainValidator`) that
+ publishes a CNAME record. Do **not** select the `dns-01`/TXT variant (e.g. `Ns1DomainValidator`) —
+ it publishes a TXT record, which SSL Store's CNAME DCV will never satisfy.
+
When `DnsValidationEnabled` is set (or the `CName Auth Domain Validation` template parameter
is `True`), enrollment proceeds as follows:
1. The order is submitted to SSL Store with the CNAME DCV indicator set.
2. SSL Store returns the CNAME validation record (`CNAMEAuthName` → `CNAMEAuthValue`).
- 3. The plugin resolves a DNS provider plugin for the domain and publishes the CNAME record.
+ 3. The plugin resolves the CNAME validator mapped to the domain and publishes the CNAME record.
4. The plugin verifies the record has propagated to public (or the configured) DNS resolvers.
5. Enrollment returns as pending external validation; SSL Store completes validation on its
own schedule and the certificate is retrieved on the next sync / status check.
6. Once the order is issued, the plugin makes a best-effort attempt to remove the CNAME record.
- A DNS provider plugin for the relevant zone must be deployed and configured on the gateway;
- otherwise enrollment fails with a "no DNS provider plugin resolved" error.
+ A CNAME DNS validator for the relevant zone must be deployed and mapped to the domain in the
+ gateway's Domain Validation configuration; otherwise enrollment fails with a "no DNS provider
+ plugin resolved" error.
* **CA Connection**
@@ -349,7 +356,6 @@ The plugin uses a configurable **Renewal Window** (default: 30 days) to determin
* **Enabled** - Flag to Enable or Disable the CA connector.
* **RenewalWindow** - Number of days before order expiry to trigger a renewal instead of a reissue.
* **DnsValidationEnabled** - Enable automated DNS (CNAME) domain control validation. When enabled, the plugin requests CNAME-based validation from SSL Store and publishes the returned record via the DNS provider plugin resolved by the AnyCA Gateway. Requires a DNS provider plugin (e.g. Azure, Route53, Cloudflare) to be deployed and configured on the gateway. When disabled, email approver validation is used.
- * **DnsValidationType** - The validation type passed to the DNS provider plugin framework when resolving a domain validator. Must match the validation type advertised by your deployed DNS provider plugin (GetValidationType). SSL Store domain control validation is CNAME-based, so this must resolve a CNAME validator (e.g. Ns1CnameDomainValidator, CloudflareCnameDomainValidator) that publishes a CNAME record. Defaults to 'cname'.
* **DnsVerificationServer** - Optional. IP address of an authoritative/internal DNS server to use when verifying record propagation. Leave empty to verify against public DNS resolvers (Google, Cloudflare, OpenDNS, Quad9).
* **DnsPropagationMaxAttempts** - Number of times to poll DNS for the validation record before giving up during enrollment. Total wait is roughly (attempts - 1) x delay seconds. Increase this (and/or the delay) if records routinely need longer to propagate. Defaults to 3.
* **DnsPropagationDelaySeconds** - Seconds to wait between DNS propagation polling attempts during enrollment. Total wait is roughly (attempts - 1) x delay seconds. Defaults to 10. Note: enrollment blocks for this duration, so keep the combined wait reasonable — propagation is best-effort and SSL Store re-checks on its own schedule.
diff --git a/SslStoreCaProxy/SslStoreCAPluginConfig.cs b/SslStoreCaProxy/SslStoreCAPluginConfig.cs
index febfffa..a310084 100644
--- a/SslStoreCaProxy/SslStoreCAPluginConfig.cs
+++ b/SslStoreCaProxy/SslStoreCAPluginConfig.cs
@@ -16,7 +16,6 @@ public class ConfigConstants
public static string Enabled = "Enabled";
public static string RenewalWindow = "RenewalWindow";
public static string DnsValidationEnabled = "DnsValidationEnabled";
- public static string DnsValidationType = "DnsValidationType";
public static string DnsVerificationServer = "DnsVerificationServer";
public static string DnsPropagationMaxAttempts = "DnsPropagationMaxAttempts";
public static string DnsPropagationDelaySeconds = "DnsPropagationDelaySeconds";
@@ -31,7 +30,6 @@ public class Config
public bool Enabled { get; set; }
public int RenewalWindow { get; set; } = 30;
public bool DnsValidationEnabled { get; set; }
- public string DnsValidationType { get; set; } = "cname";
public string DnsVerificationServer { get; set; }
public int DnsPropagationMaxAttempts { get; set; } = 3;
public int DnsPropagationDelaySeconds { get; set; } = 10;
@@ -93,17 +91,6 @@ public static Dictionary GetPluginAnnotations()
DefaultValue = false,
Type = "Bool"
},
- [ConfigConstants.DnsValidationType] = new PropertyConfigInfo()
- {
- Comments = "The validation type passed to the DNS provider plugin framework when resolving a domain " +
- "validator. Must match the validation type advertised by your deployed DNS provider plugin " +
- "(GetValidationType). SSL Store domain control validation is CNAME-based, so this must resolve " +
- "a CNAME validator (e.g. Ns1CnameDomainValidator, CloudflareCnameDomainValidator) that publishes " +
- "a CNAME record. Defaults to 'cname'.",
- Hidden = false,
- DefaultValue = "cname",
- Type = "String"
- },
[ConfigConstants.DnsVerificationServer] = new PropertyConfigInfo()
{
Comments = "Optional. IP address of an authoritative/internal DNS server to use when verifying record " +
diff --git a/SslStoreCaProxy/SslStoreCaProxy.cs b/SslStoreCaProxy/SslStoreCaProxy.cs
index fd13f40..ff4defd 100644
--- a/SslStoreCaProxy/SslStoreCaProxy.cs
+++ b/SslStoreCaProxy/SslStoreCaProxy.cs
@@ -24,6 +24,15 @@ namespace Keyfactor.AnyGateway.SslStore
public class SslStoreCaProxy : IAnyCAPlugin
{
private static readonly ILogger _logger = LogHandler.GetClassLogger();
+
+ ///
+ /// Validation type string passed to .
+ /// SSL Store domain control validation always publishes a CNAME record, so we resolve a DNS
+ /// provider that advertises the "cname" validation type (e.g. Ns1CnameDomainValidator). This is
+ /// intrinsic to the CA — it is not operator-configurable. Which validator actually handles a
+ /// given domain is chosen via the AnyCA Gateway's Domain Validation mapping, not here.
+ ///
+ private const string DnsValidationType = "cname";
private RequestManager _requestManager;
private IAnyCAPluginConfigProvider Config { get; set; }
private ICertificateDataReader _certDataReader;
@@ -76,7 +85,7 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa
_logger.LogInformation(
"SslStore CAPlugin initialized. Enabled={Enabled}, SSLStoreURL={Url}, PartnerCode set={HasPartner}, AuthToken set={HasToken}, PageSize={PageSize}, RenewalWindow={RenewalWindow}, DnsValidationEnabled={DnsEnabled}, DnsValidationType={DnsType}, DnsVerificationServer={DnsServer}, DnsPropagationMaxAttempts={DnsAttempts}, DnsPropagationDelaySeconds={DnsDelay}, DomainValidatorFactory available={HasFactory}",
_config.Enabled, _config.SSLStoreURL, !string.IsNullOrEmpty(_config.PartnerCode), !string.IsNullOrEmpty(_config.AuthToken),
- PageSize, RenewalWindow, _config.DnsValidationEnabled, _config.DnsValidationType,
+ PageSize, RenewalWindow, _config.DnsValidationEnabled, DnsValidationType,
string.IsNullOrEmpty(_config.DnsVerificationServer) ? "(public resolvers)" : _config.DnsVerificationServer,
_config.DnsPropagationMaxAttempts, _config.DnsPropagationDelaySeconds, _validatorFactory != null);
}
@@ -771,7 +780,7 @@ private async Task StageDnsValidationAsync(INewOrderResponse response, s
_logger.LogInformation(
"Staging {Count} DNS validation record(s) via validation type '{ValidationType}' (verification server: {Server}, {Attempts} attempt(s) x {Delay}s).",
- records.Count, _config.DnsValidationType,
+ records.Count, DnsValidationType,
string.IsNullOrEmpty(_config.DnsVerificationServer) ? "public resolvers" : _config.DnsVerificationServer,
_config.DnsPropagationMaxAttempts, _config.DnsPropagationDelaySeconds);
@@ -784,22 +793,22 @@ private async Task StageDnsValidationAsync(INewOrderResponse response, s
IDomainValidator validator;
try
{
- validator = _validatorFactory.ResolveDomainValidator(domain, _config.DnsValidationType);
+ validator = _validatorFactory.ResolveDomainValidator(domain, DnsValidationType);
flow.Step($"ResolveValidator[{domain}]", validator != null ? validator.GetType().Name : "null");
}
catch (Exception ex)
{
flow.Fail($"ResolveValidator[{domain}]", ex.Message);
- _logger.LogError(ex, "Failed to resolve DNS provider plugin for '{Domain}' (validation type '{ValidationType}')", domain, _config.DnsValidationType);
- return $"Failed to resolve DNS provider plugin for '{domain}' (validation type '{_config.DnsValidationType}'): {ex.Message}";
+ _logger.LogError(ex, "Failed to resolve DNS provider plugin for '{Domain}' (validation type '{ValidationType}')", domain, DnsValidationType);
+ return $"Failed to resolve DNS provider plugin for '{domain}' (validation type '{DnsValidationType}'): {ex.Message}";
}
if (validator == null)
{
flow.Fail($"ResolveValidator[{domain}]", "no validator resolved");
- _logger.LogError("No DNS provider plugin resolved for '{Domain}' (validation type '{ValidationType}').", domain, _config.DnsValidationType);
- return $"No DNS provider plugin resolved for '{domain}' (validation type '{_config.DnsValidationType}'). " +
- "Ensure a DNS provider plugin is deployed and configured for the zone that hosts this domain.";
+ _logger.LogError("No DNS provider plugin resolved for '{Domain}' (validation type '{ValidationType}').", domain, DnsValidationType);
+ return $"No DNS provider plugin resolved for '{domain}' (validation type '{DnsValidationType}'). " +
+ "Ensure a DNS provider plugin is deployed and mapped to this domain in the gateway's Domain Validation configuration.";
}
DomainValidationResult result = null;
@@ -859,7 +868,7 @@ private async Task CleanupDnsValidationAsync(INewOrderResponse response)
{
try
{
- var validator = _validatorFactory.ResolveDomainValidator(domain, _config.DnsValidationType);
+ var validator = _validatorFactory.ResolveDomainValidator(domain, DnsValidationType);
if (validator == null)
{
_logger.LogTrace("No validator resolved for '{Domain}' during cleanup; skipping record {RecordName}.", domain, recordName);
diff --git a/docsource/configuration.md b/docsource/configuration.md
index 14ac700..1b43e04 100644
--- a/docsource/configuration.md
+++ b/docsource/configuration.md
@@ -207,7 +207,6 @@ When registering the SSL Store CA in the AnyCA Gateway, you'll need to provide t
| **Enabled** | Flag to Enable or Disable the CA connector | No | `true` |
| **RenewalWindow** | Days before order expiry to trigger renewal vs. reissue | No | `30` |
| **DnsValidationEnabled** | Enable automated DNS (CNAME) domain control validation instead of email approvers | No | `false` |
-| **DnsValidationType** | Validation type passed to the DNS provider plugin framework | No | `dns-01` |
| **DnsVerificationServer** | Optional authoritative/internal DNS server IP used to verify record propagation | No | (empty) |
### Gateway Registration Notes
@@ -242,7 +241,6 @@ Populate using the configuration fields collected in the [requirements](#require
* **Enabled** - Flag to enable or disable the CA connector. Set to `true` to enable.
* **RenewalWindow** - Number of days before an order's expiration date to trigger a renewal (new order) instead of a reissue (same order). Default is 30 days.
* **DnsValidationEnabled** - When `true`, the plugin requests CNAME-based domain control validation from SSL Store and automatically publishes the returned validation record using the DNS provider plugin resolved by the AnyCA Gateway. When `false` (default), the email approver validation flow is used.
-* **DnsValidationType** - The validation type string passed to the DNS provider plugin framework when resolving a domain validator. This must match the validation type advertised by your deployed DNS provider plugin (its `GetValidationType()`). SSL Store DCV is CNAME-based, so this must resolve a **CNAME** validator (e.g. `Ns1CnameDomainValidator`, `CloudflareCnameDomainValidator`) that publishes a CNAME record — not the `dns-01`/TXT variant. Default is `cname`.
* **DnsVerificationServer** - Optional. IP address of an authoritative or internal DNS server used to confirm record propagation. Leave empty to verify against public resolvers (Google, Cloudflare, OpenDNS, Quad9).
* **DnsPropagationMaxAttempts** - Number of times to poll DNS for the validation record before giving up during enrollment. Total wait is roughly `(attempts - 1) × delay` seconds. Default is 3.
* **DnsPropagationDelaySeconds** - Seconds to wait between DNS propagation polling attempts. Enrollment blocks for this duration, so keep the combined wait reasonable — propagation is best-effort and SSL Store re-checks on its own schedule. Default is 10.
@@ -256,19 +254,28 @@ Cloudflare, Google Cloud DNS, NS1, Infoblox, RFC2136, etc.) are deployed and con
gateway injects an `IDomainValidatorFactory` that resolves the correct provider for each
domain at enrollment time.
+SSL Store domain control validation is always **CNAME-based**, so the plugin always requests
+the `cname` validation type from the framework — this is intrinsic to the CA and is **not** a
+configurable setting. What you configure is the **domain-to-validator mapping** in the
+gateway's **Domain Validation** section: for each domain (e.g. `*.example.com`) you select a
+**CNAME** validator (e.g. `Ns1CnameDomainValidator`, `CloudflareCnameDomainValidator`) that
+publishes a CNAME record. Do **not** select the `dns-01`/TXT variant (e.g. `Ns1DomainValidator`) —
+it publishes a TXT record, which SSL Store's CNAME DCV will never satisfy.
+
When `DnsValidationEnabled` is set (or the `CName Auth Domain Validation` template parameter
is `True`), enrollment proceeds as follows:
1. The order is submitted to SSL Store with the CNAME DCV indicator set.
2. SSL Store returns the CNAME validation record (`CNAMEAuthName` → `CNAMEAuthValue`).
-3. The plugin resolves a DNS provider plugin for the domain and publishes the CNAME record.
+3. The plugin resolves the CNAME validator mapped to the domain and publishes the CNAME record.
4. The plugin verifies the record has propagated to public (or the configured) DNS resolvers.
5. Enrollment returns as pending external validation; SSL Store completes validation on its
own schedule and the certificate is retrieved on the next sync / status check.
6. Once the order is issued, the plugin makes a best-effort attempt to remove the CNAME record.
-A DNS provider plugin for the relevant zone must be deployed and configured on the gateway;
-otherwise enrollment fails with a "no DNS provider plugin resolved" error.
+A CNAME DNS validator for the relevant zone must be deployed and mapped to the domain in the
+gateway's Domain Validation configuration; otherwise enrollment fails with a "no DNS provider
+plugin resolved" error.
## Certificate Template Creation Step
diff --git a/integration-manifest.json b/integration-manifest.json
index 8264301..1fa6845 100644
--- a/integration-manifest.json
+++ b/integration-manifest.json
@@ -41,10 +41,6 @@
"name": "DnsValidationEnabled",
"description": "Enable automated DNS (CNAME) domain control validation. When enabled, the plugin requests CNAME-based validation from SSL Store and publishes the returned record via the DNS provider plugin resolved by the AnyCA Gateway. Requires a DNS provider plugin (e.g. Azure, Route53, Cloudflare) to be deployed and configured on the gateway. When disabled, email approver validation is used."
},
- {
- "name": "DnsValidationType",
- "description": "The validation type passed to the DNS provider plugin framework when resolving a domain validator. Must match the validation type advertised by your deployed DNS provider plugin (GetValidationType). SSL Store domain control validation is CNAME-based, so this must resolve a CNAME validator (e.g. Ns1CnameDomainValidator, CloudflareCnameDomainValidator) that publishes a CNAME record. Defaults to 'cname'."
- },
{
"name": "DnsVerificationServer",
"description": "Optional. IP address of an authoritative/internal DNS server to use when verifying record propagation. Leave empty to verify against public DNS resolvers (Google, Cloudflare, OpenDNS, Quad9)."
From f6c4625b0890c3feb0d98667efa34c319dbf9d15 Mon Sep 17 00:00:00 2001
From: Brian Hill
Date: Wed, 15 Jul 2026 14:13:47 -0400
Subject: [PATCH 21/25] poll for issuance
---
CHANGELOG.md | 3 +
README.md | 10 ++-
SslStoreCaProxy/SslStoreCAPluginConfig.cs | 13 ++++
SslStoreCaProxy/SslStoreCaProxy.cs | 89 ++++++++++++++++++++++-
docsource/configuration.md | 12 ++-
integration-manifest.json | 4 +
6 files changed, 123 insertions(+), 8 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d8d1a4d..897676a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,9 @@
validation from SSL Store and publishes the returned record via the DNS provider plugin resolved
by the AnyCA Gateway (Azure, Route53, Cloudflare, Google, NS1, Infoblox, RFC2136, etc.)
* Verifies public DNS propagation of the validation record before returning (configurable attempts/delay)
+* After publishing the CNAME, polls SSL Store for issuance up to `DcvPollTimeoutSeconds` and returns the
+ issued certificate directly from the enrollment call when it issues in time (ACME-style); otherwise
+ returns pending and the certificate is retrieved on the next CA sync
* Best-effort cleanup of DNS validation records once an order is issued (GetSingleRecord/Synchronize)
* New CA-connection settings: `DnsValidationEnabled`, `DnsVerificationServer`, `DnsPropagationMaxAttempts`, `DnsPropagationDelaySeconds`
* CNAME is the intrinsic DCV method for SSL Store; the DNS record type is determined by the CNAME
diff --git a/README.md b/README.md
index 6ff3c8e..15b208c 100644
--- a/README.md
+++ b/README.md
@@ -312,6 +312,7 @@ The plugin uses a configurable **Renewal Window** (default: 30 days) to determin
* **DnsVerificationServer** - Optional. IP address of an authoritative or internal DNS server used to confirm record propagation. Leave empty to verify against public resolvers (Google, Cloudflare, OpenDNS, Quad9).
* **DnsPropagationMaxAttempts** - Number of times to poll DNS for the validation record before giving up during enrollment. Total wait is roughly `(attempts - 1) × delay` seconds. Default is 3.
* **DnsPropagationDelaySeconds** - Seconds to wait between DNS propagation polling attempts. Enrollment blocks for this duration, so keep the combined wait reasonable — propagation is best-effort and SSL Store re-checks on its own schedule. Default is 10.
+ * **DcvPollTimeoutSeconds** - After a DNS-validated enrollment publishes its CNAME record, the plugin polls SSL Store for up to this many seconds for the certificate to be issued and, if issued in time, returns it directly from the enrollment call (like ACME). If the window expires, enrollment returns pending and the certificate is retrieved on the next CA sync. Enrollment blocks for up to this duration; SSL Store DCV is asynchronous and may take longer. Set to `0` to disable polling. Default is 90.
### Automated DNS (CNAME) Domain Validation
@@ -337,9 +338,11 @@ The plugin uses a configurable **Renewal Window** (default: 30 days) to determin
2. SSL Store returns the CNAME validation record (`CNAMEAuthName` → `CNAMEAuthValue`).
3. The plugin resolves the CNAME validator mapped to the domain and publishes the CNAME record.
4. The plugin verifies the record has propagated to public (or the configured) DNS resolvers.
- 5. Enrollment returns as pending external validation; SSL Store completes validation on its
- own schedule and the certificate is retrieved on the next sync / status check.
- 6. Once the order is issued, the plugin makes a best-effort attempt to remove the CNAME record.
+ 5. The plugin polls SSL Store for issuance for up to `DcvPollTimeoutSeconds`. If the certificate
+ is issued within that window, it is downloaded and returned directly from the enrollment call.
+ 6. Otherwise enrollment returns as pending external validation; SSL Store completes validation on
+ its own schedule and the certificate is retrieved on the next sync / status check.
+ 7. Once the order is issued, the plugin makes a best-effort attempt to remove the CNAME record.
A CNAME DNS validator for the relevant zone must be deployed and mapped to the domain in the
gateway's Domain Validation configuration; otherwise enrollment fails with a "no DNS provider
@@ -359,6 +362,7 @@ The plugin uses a configurable **Renewal Window** (default: 30 days) to determin
* **DnsVerificationServer** - Optional. IP address of an authoritative/internal DNS server to use when verifying record propagation. Leave empty to verify against public DNS resolvers (Google, Cloudflare, OpenDNS, Quad9).
* **DnsPropagationMaxAttempts** - Number of times to poll DNS for the validation record before giving up during enrollment. Total wait is roughly (attempts - 1) x delay seconds. Increase this (and/or the delay) if records routinely need longer to propagate. Defaults to 3.
* **DnsPropagationDelaySeconds** - Seconds to wait between DNS propagation polling attempts during enrollment. Total wait is roughly (attempts - 1) x delay seconds. Defaults to 10. Note: enrollment blocks for this duration, so keep the combined wait reasonable — propagation is best-effort and SSL Store re-checks on its own schedule.
+ * **DcvPollTimeoutSeconds** - After a DNS-validated enrollment publishes its CNAME record, the plugin polls SSL Store for up to this many seconds for the certificate to be issued and, if issued in time, returns it directly from the enrollment call (like ACME). If the window expires, enrollment returns pending and the certificate is retrieved on the next CA sync. Enrollment blocks for up to this duration. Set to 0 to disable polling. Defaults to 90.
2. ### Template (Product) Configuration
diff --git a/SslStoreCaProxy/SslStoreCAPluginConfig.cs b/SslStoreCaProxy/SslStoreCAPluginConfig.cs
index a310084..4ab1abe 100644
--- a/SslStoreCaProxy/SslStoreCAPluginConfig.cs
+++ b/SslStoreCaProxy/SslStoreCAPluginConfig.cs
@@ -19,6 +19,7 @@ public class ConfigConstants
public static string DnsVerificationServer = "DnsVerificationServer";
public static string DnsPropagationMaxAttempts = "DnsPropagationMaxAttempts";
public static string DnsPropagationDelaySeconds = "DnsPropagationDelaySeconds";
+ public static string DcvPollTimeoutSeconds = "DcvPollTimeoutSeconds";
}
public class Config
@@ -33,6 +34,7 @@ public class Config
public string DnsVerificationServer { get; set; }
public int DnsPropagationMaxAttempts { get; set; } = 3;
public int DnsPropagationDelaySeconds { get; set; } = 10;
+ public int DcvPollTimeoutSeconds { get; set; } = 90;
}
public static Dictionary GetPluginAnnotations()
@@ -116,6 +118,17 @@ public static Dictionary GetPluginAnnotations()
Hidden = false,
DefaultValue = 10,
Type = "Number"
+ },
+ [ConfigConstants.DcvPollTimeoutSeconds] = new PropertyConfigInfo()
+ {
+ Comments = "After a DNS-validated enrollment publishes its CNAME record, the plugin polls SSL Store for up " +
+ "to this many seconds for the certificate to be issued and, if issued in time, returns it directly " +
+ "from the enrollment call (like ACME). If the window expires the enrollment returns pending and the " +
+ "certificate is retrieved on the next CA sync. Enrollment blocks for up to this duration, so keep it " +
+ "reasonable; SSL Store DCV is asynchronous and may take longer. Set to 0 to disable polling. Defaults to 90.",
+ Hidden = false,
+ DefaultValue = 90,
+ Type = "Number"
}
};
}
diff --git a/SslStoreCaProxy/SslStoreCaProxy.cs b/SslStoreCaProxy/SslStoreCaProxy.cs
index ff4defd..e39ba8d 100644
--- a/SslStoreCaProxy/SslStoreCaProxy.cs
+++ b/SslStoreCaProxy/SslStoreCaProxy.cs
@@ -33,6 +33,9 @@ public class SslStoreCaProxy : IAnyCAPlugin
/// given domain is chosen via the AnyCA Gateway's Domain Validation mapping, not here.
///
private const string DnsValidationType = "cname";
+
+ /// Seconds between polls while waiting for SSL Store to issue a DNS-validated order.
+ private const int DcvPollIntervalSeconds = 15;
private RequestManager _requestManager;
private IAnyCAPluginConfigProvider Config { get; set; }
private ICertificateDataReader _certDataReader;
@@ -83,11 +86,11 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa
flow.Step("CreateRequestManager", () => _requestManager = new RequestManager(this));
_logger.LogInformation(
- "SslStore CAPlugin initialized. Enabled={Enabled}, SSLStoreURL={Url}, PartnerCode set={HasPartner}, AuthToken set={HasToken}, PageSize={PageSize}, RenewalWindow={RenewalWindow}, DnsValidationEnabled={DnsEnabled}, DnsValidationType={DnsType}, DnsVerificationServer={DnsServer}, DnsPropagationMaxAttempts={DnsAttempts}, DnsPropagationDelaySeconds={DnsDelay}, DomainValidatorFactory available={HasFactory}",
+ "SslStore CAPlugin initialized. Enabled={Enabled}, SSLStoreURL={Url}, PartnerCode set={HasPartner}, AuthToken set={HasToken}, PageSize={PageSize}, RenewalWindow={RenewalWindow}, DnsValidationEnabled={DnsEnabled}, DnsValidationType={DnsType}, DnsVerificationServer={DnsServer}, DnsPropagationMaxAttempts={DnsAttempts}, DnsPropagationDelaySeconds={DnsDelay}, DcvPollTimeoutSeconds={DcvPoll}, DomainValidatorFactory available={HasFactory}",
_config.Enabled, _config.SSLStoreURL, !string.IsNullOrEmpty(_config.PartnerCode), !string.IsNullOrEmpty(_config.AuthToken),
PageSize, RenewalWindow, _config.DnsValidationEnabled, DnsValidationType,
string.IsNullOrEmpty(_config.DnsVerificationServer) ? "(public resolvers)" : _config.DnsVerificationServer,
- _config.DnsPropagationMaxAttempts, _config.DnsPropagationDelaySeconds, _validatorFactory != null);
+ _config.DnsPropagationMaxAttempts, _config.DnsPropagationDelaySeconds, _config.DcvPollTimeoutSeconds, _validatorFactory != null);
}
catch (Exception ex)
{
@@ -329,6 +332,21 @@ await flow.StepAsync("StageDnsValidation",
StatusMessage = dnsError
};
}
+
+ // The CNAME is published; poll SSL Store for issuance and, if it issues within the
+ // configured window, return the certificate directly from this enrollment call
+ // (ACME-style). Otherwise fall through to a pending result and let CA sync finish it.
+ var compositeId = BuildCompositeRequestId(enrollmentResponse.TheSslStoreOrderId, enrollmentResponse.PartnerOrderId);
+ EnrollmentResult issuedResult = null;
+ await flow.StepAsync("PollForIssuance",
+ async () => issuedResult = await TryPollForIssuedCertAsync(compositeId));
+ if (issuedResult != null)
+ {
+ flow.Step("PollResult", "issued during poll window");
+ flow.EndBranch();
+ return issuedResult;
+ }
+ flow.Skip("PollResult", "not issued within poll window; returning pending");
}
flow.EndBranch();
}
@@ -706,6 +724,73 @@ private async Task DownloadLeafCertificateAsync(SslStoreClient client, s
return Convert.ToBase64String(endEntityCert.RawData);
}
+ ///
+ /// After a DNS-validated order's CNAME is published, polls SSL Store (via )
+ /// for up to DcvPollTimeoutSeconds for the certificate to be issued. Returns a GENERATED
+ /// carrying the issued leaf certificate if it issues within the window,
+ /// or null if the window expires (caller then returns its pending/EXTERNALVALIDATION result).
+ /// No-op (returns null) when polling is disabled or the request ID is missing.
+ ///
+ private async Task TryPollForIssuedCertAsync(string caRequestId)
+ {
+ if (_config.DcvPollTimeoutSeconds <= 0)
+ {
+ _logger.LogTrace("Issuance polling disabled (DcvPollTimeoutSeconds=0); returning pending.");
+ return null;
+ }
+
+ if (string.IsNullOrEmpty(caRequestId))
+ {
+ _logger.LogWarning("No CARequestID available to poll for issuance; returning pending.");
+ return null;
+ }
+
+ var deadline = DateTime.UtcNow.AddSeconds(_config.DcvPollTimeoutSeconds);
+ var interval = TimeSpan.FromSeconds(DcvPollIntervalSeconds);
+ _logger.LogInformation("Polling SSL Store for issuance of '{CaRequestId}' for up to {Seconds}s (interval {Interval}s).",
+ caRequestId, _config.DcvPollTimeoutSeconds, DcvPollIntervalSeconds);
+
+ var attempt = 0;
+ while (DateTime.UtcNow < deadline)
+ {
+ attempt++;
+ AnyCAPluginCertificate record = null;
+ try
+ {
+ record = await GetSingleRecord(caRequestId);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Issuance poll attempt {Attempt} for '{CaRequestId}' threw; will retry.", attempt, caRequestId);
+ }
+
+ if (record != null && record.Status == (int)EndEntityStatus.GENERATED && !string.IsNullOrEmpty(record.Certificate))
+ {
+ _logger.LogInformation("Order '{CaRequestId}' issued after {Attempt} poll(s); returning certificate directly.", caRequestId, attempt);
+ return new EnrollmentResult
+ {
+ Status = (int)EndEntityStatus.GENERATED,
+ CARequestID = caRequestId,
+ Certificate = record.Certificate,
+ StatusMessage = $"Certificate issued and retrieved for order {caRequestId}."
+ };
+ }
+
+ _logger.LogTrace("Issuance poll attempt {Attempt} for '{CaRequestId}': status={Status}, cert={CertState}.",
+ attempt, caRequestId, record?.Status, string.IsNullOrEmpty(record?.Certificate) ? "empty" : "present");
+
+ // Don't sleep past the deadline.
+ if (DateTime.UtcNow.Add(interval) >= deadline)
+ break;
+
+ await Task.Delay(interval);
+ }
+
+ _logger.LogInformation("Order '{CaRequestId}' not issued within {Seconds}s after {Attempts} attempt(s); returning pending.",
+ caRequestId, _config.DcvPollTimeoutSeconds, attempt);
+ return null;
+ }
+
private string ValidateEmails(EmailApproverResponse validEmails, string[] arrayApproverEmails, EnrollmentProductInfo productInfo, int count)
{
if (arrayApproverEmails.Length > 1 && productInfo.ProductID.Contains("digi"))
diff --git a/docsource/configuration.md b/docsource/configuration.md
index 1b43e04..b56cd0c 100644
--- a/docsource/configuration.md
+++ b/docsource/configuration.md
@@ -208,6 +208,9 @@ When registering the SSL Store CA in the AnyCA Gateway, you'll need to provide t
| **RenewalWindow** | Days before order expiry to trigger renewal vs. reissue | No | `30` |
| **DnsValidationEnabled** | Enable automated DNS (CNAME) domain control validation instead of email approvers | No | `false` |
| **DnsVerificationServer** | Optional authoritative/internal DNS server IP used to verify record propagation | No | (empty) |
+| **DnsPropagationMaxAttempts** | Times to poll DNS for the validation record before giving up during enrollment | No | `3` |
+| **DnsPropagationDelaySeconds** | Seconds between DNS propagation polling attempts (enrollment blocks for this) | No | `10` |
+| **DcvPollTimeoutSeconds** | Seconds to poll SSL Store for issuance after publishing the CNAME; returns the cert inline if issued in time (`0` disables) | No | `90` |
### Gateway Registration Notes
@@ -244,6 +247,7 @@ Populate using the configuration fields collected in the [requirements](#require
* **DnsVerificationServer** - Optional. IP address of an authoritative or internal DNS server used to confirm record propagation. Leave empty to verify against public resolvers (Google, Cloudflare, OpenDNS, Quad9).
* **DnsPropagationMaxAttempts** - Number of times to poll DNS for the validation record before giving up during enrollment. Total wait is roughly `(attempts - 1) × delay` seconds. Default is 3.
* **DnsPropagationDelaySeconds** - Seconds to wait between DNS propagation polling attempts. Enrollment blocks for this duration, so keep the combined wait reasonable — propagation is best-effort and SSL Store re-checks on its own schedule. Default is 10.
+* **DcvPollTimeoutSeconds** - After a DNS-validated enrollment publishes its CNAME record, the plugin polls SSL Store for up to this many seconds for the certificate to be issued and, if issued in time, returns it directly from the enrollment call (like ACME). If the window expires, enrollment returns pending and the certificate is retrieved on the next CA sync. Enrollment blocks for up to this duration; SSL Store DCV is asynchronous and may take longer. Set to `0` to disable polling. Default is 90.
### Automated DNS (CNAME) Domain Validation
@@ -269,9 +273,11 @@ is `True`), enrollment proceeds as follows:
2. SSL Store returns the CNAME validation record (`CNAMEAuthName` → `CNAMEAuthValue`).
3. The plugin resolves the CNAME validator mapped to the domain and publishes the CNAME record.
4. The plugin verifies the record has propagated to public (or the configured) DNS resolvers.
-5. Enrollment returns as pending external validation; SSL Store completes validation on its
- own schedule and the certificate is retrieved on the next sync / status check.
-6. Once the order is issued, the plugin makes a best-effort attempt to remove the CNAME record.
+5. The plugin polls SSL Store for issuance for up to `DcvPollTimeoutSeconds`. If the certificate
+ is issued within that window, it is downloaded and returned directly from the enrollment call.
+6. Otherwise enrollment returns as pending external validation; SSL Store completes validation on
+ its own schedule and the certificate is retrieved on the next sync / status check.
+7. Once the order is issued, the plugin makes a best-effort attempt to remove the CNAME record.
A CNAME DNS validator for the relevant zone must be deployed and mapped to the domain in the
gateway's Domain Validation configuration; otherwise enrollment fails with a "no DNS provider
diff --git a/integration-manifest.json b/integration-manifest.json
index 1fa6845..a343037 100644
--- a/integration-manifest.json
+++ b/integration-manifest.json
@@ -52,6 +52,10 @@
{
"name": "DnsPropagationDelaySeconds",
"description": "Seconds to wait between DNS propagation polling attempts during enrollment. Total wait is roughly (attempts - 1) x delay seconds. Defaults to 10. Note: enrollment blocks for this duration, so keep the combined wait reasonable — propagation is best-effort and SSL Store re-checks on its own schedule."
+ },
+ {
+ "name": "DcvPollTimeoutSeconds",
+ "description": "After a DNS-validated enrollment publishes its CNAME record, the plugin polls SSL Store for up to this many seconds for the certificate to be issued and, if issued in time, returns it directly from the enrollment call (like ACME). If the window expires the enrollment returns pending and the certificate is retrieved on the next CA sync. Enrollment blocks for up to this duration. Set to 0 to disable polling. Defaults to 90."
}
],
"enrollment_config": [
From c421b258e7d889e3eef147057d7a9de9f9a1e429 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Wed, 15 Jul 2026 18:14:29 +0000
Subject: [PATCH 22/25] docs: auto-generate README and documentation [skip ci]
---
README.md | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 15b208c..39fdfc1 100644
--- a/README.md
+++ b/README.md
@@ -276,6 +276,9 @@ The plugin uses a configurable **Renewal Window** (default: 30 days) to determin
| **RenewalWindow** | Days before order expiry to trigger renewal vs. reissue | No | `30` |
| **DnsValidationEnabled** | Enable automated DNS (CNAME) domain control validation instead of email approvers | No | `false` |
| **DnsVerificationServer** | Optional authoritative/internal DNS server IP used to verify record propagation | No | (empty) |
+ | **DnsPropagationMaxAttempts** | Times to poll DNS for the validation record before giving up during enrollment | No | `3` |
+ | **DnsPropagationDelaySeconds** | Seconds between DNS propagation polling attempts (enrollment blocks for this) | No | `10` |
+ | **DcvPollTimeoutSeconds** | Seconds to poll SSL Store for issuance after publishing the CNAME; returns the cert inline if issued in time (`0` disables) | No | `90` |
### Gateway Registration Notes
@@ -362,7 +365,7 @@ The plugin uses a configurable **Renewal Window** (default: 30 days) to determin
* **DnsVerificationServer** - Optional. IP address of an authoritative/internal DNS server to use when verifying record propagation. Leave empty to verify against public DNS resolvers (Google, Cloudflare, OpenDNS, Quad9).
* **DnsPropagationMaxAttempts** - Number of times to poll DNS for the validation record before giving up during enrollment. Total wait is roughly (attempts - 1) x delay seconds. Increase this (and/or the delay) if records routinely need longer to propagate. Defaults to 3.
* **DnsPropagationDelaySeconds** - Seconds to wait between DNS propagation polling attempts during enrollment. Total wait is roughly (attempts - 1) x delay seconds. Defaults to 10. Note: enrollment blocks for this duration, so keep the combined wait reasonable — propagation is best-effort and SSL Store re-checks on its own schedule.
- * **DcvPollTimeoutSeconds** - After a DNS-validated enrollment publishes its CNAME record, the plugin polls SSL Store for up to this many seconds for the certificate to be issued and, if issued in time, returns it directly from the enrollment call (like ACME). If the window expires, enrollment returns pending and the certificate is retrieved on the next CA sync. Enrollment blocks for up to this duration. Set to 0 to disable polling. Defaults to 90.
+ * **DcvPollTimeoutSeconds** - After a DNS-validated enrollment publishes its CNAME record, the plugin polls SSL Store for up to this many seconds for the certificate to be issued and, if issued in time, returns it directly from the enrollment call (like ACME). If the window expires the enrollment returns pending and the certificate is retrieved on the next CA sync. Enrollment blocks for up to this duration. Set to 0 to disable polling. Defaults to 90.
2. ### Template (Product) Configuration
From c7514f2cfa3283962a3066d9c449c05b3ec3e93b Mon Sep 17 00:00:00 2001
From: Brian Hill
Date: Wed, 15 Jul 2026 14:42:14 -0400
Subject: [PATCH 23/25] harden code
---
SslStoreCaProxy.slnx | 8 +
.../Clients/DNS/DnsVerificationHelper.cs | 9 +-
SslStoreCaProxy/SslStoreCaProxy.cs | 104 ++++++-
docs/enrollment-flows.md | 153 +++++++++
postman/README.md | 70 +++++
.../SslStoreCaGateway.postman_collection.json | 290 ++++++++++++++++++
...SslStoreCaGateway.postman_environment.json | 19 ++
7 files changed, 635 insertions(+), 18 deletions(-)
create mode 100644 docs/enrollment-flows.md
create mode 100644 postman/README.md
create mode 100644 postman/SslStoreCaGateway.postman_collection.json
create mode 100644 postman/SslStoreCaGateway.postman_environment.json
diff --git a/SslStoreCaProxy.slnx b/SslStoreCaProxy.slnx
index ba17d13..59c97bb 100644
--- a/SslStoreCaProxy.slnx
+++ b/SslStoreCaProxy.slnx
@@ -1,3 +1,11 @@
+
+
+
+
+
+
+
+
diff --git a/SslStoreCaProxy/Clients/DNS/DnsVerificationHelper.cs b/SslStoreCaProxy/Clients/DNS/DnsVerificationHelper.cs
index a11666a..332a332 100644
--- a/SslStoreCaProxy/Clients/DNS/DnsVerificationHelper.cs
+++ b/SslStoreCaProxy/Clients/DNS/DnsVerificationHelper.cs
@@ -75,9 +75,16 @@ public async Task WaitForDnsPropagationAsync(
QueryType recordType = QueryType.CNAME,
int minimumServers = 3)
{
+ if (string.IsNullOrWhiteSpace(recordName) || string.IsNullOrWhiteSpace(expectedValue))
+ {
+ _logger.LogWarning("DNS propagation check skipped: recordName or expectedValue was empty (record '{RecordName}').", recordName);
+ return false;
+ }
+
_logger.LogInformation("Waiting for DNS propagation of {RecordType} record {RecordName}", recordType, recordName);
- var requiredServers = _usePrivateDns ? 1 : minimumServers;
+ // Never require more confirmations than we have resolvers to query, or it can never succeed.
+ var requiredServers = _usePrivateDns ? 1 : Math.Min(Math.Max(1, minimumServers), _dnsServers.Count);
for (int attempt = 1; attempt <= _maxVerificationAttempts; attempt++)
{
diff --git a/SslStoreCaProxy/SslStoreCaProxy.cs b/SslStoreCaProxy/SslStoreCaProxy.cs
index e39ba8d..184b24d 100644
--- a/SslStoreCaProxy/SslStoreCaProxy.cs
+++ b/SslStoreCaProxy/SslStoreCaProxy.cs
@@ -36,6 +36,14 @@ public class SslStoreCaProxy : IAnyCAPlugin
/// Seconds between polls while waiting for SSL Store to issue a DNS-validated order.
private const int DcvPollIntervalSeconds = 15;
+
+ // Guardrails so a mis-typed CA-connection value cannot make enrollment block for an
+ // unreasonable time or spin uselessly. Values outside these bounds are clamped at Initialize.
+ private const int MinRenewalWindowDays = 1;
+ private const int MaxRenewalWindowDays = 3650;
+ private const int MaxDnsPropagationMaxAttempts = 30;
+ private const int MaxDnsPropagationDelaySeconds = 120;
+ private const int MaxDcvPollTimeoutSeconds = 600;
private RequestManager _requestManager;
private IAnyCAPluginConfigProvider Config { get; set; }
private ICertificateDataReader _certDataReader;
@@ -77,10 +85,18 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa
flow.Step("ApplyConfig", () =>
{
+ if (_config == null)
+ throw new InvalidOperationException("CA connection data could not be deserialized into a configuration object.");
+
PartnerCode = _config.PartnerCode;
AuthenticationToken = _config.AuthToken;
PageSize = _config.PageSize > 0 ? _config.PageSize : SslStoreCAPluginConfig.DefaultPageSize;
- RenewalWindow = _config.RenewalWindow > 0 ? _config.RenewalWindow : 30;
+ RenewalWindow = Clamp(_config.RenewalWindow > 0 ? _config.RenewalWindow : 30, MinRenewalWindowDays, MaxRenewalWindowDays, "RenewalWindow");
+
+ // Clamp DNS/DCV timing so a bad value can't make enrollment block forever or spin.
+ _config.DnsPropagationMaxAttempts = Clamp(_config.DnsPropagationMaxAttempts > 0 ? _config.DnsPropagationMaxAttempts : 3, 1, MaxDnsPropagationMaxAttempts, "DnsPropagationMaxAttempts");
+ _config.DnsPropagationDelaySeconds = Clamp(_config.DnsPropagationDelaySeconds > 0 ? _config.DnsPropagationDelaySeconds : 10, 1, MaxDnsPropagationDelaySeconds, "DnsPropagationDelaySeconds");
+ _config.DcvPollTimeoutSeconds = Clamp(_config.DcvPollTimeoutSeconds, 0, MaxDcvPollTimeoutSeconds, "DcvPollTimeoutSeconds");
}, $"PageSize={PageSize}, RenewalWindow={RenewalWindow}");
flow.Step("CreateRequestManager", () => _requestManager = new RequestManager(this));
@@ -234,6 +250,16 @@ public async Task Enroll(string csr, string subject, Dictionar
enrollmentType, productInfo?.ProductID, subject,
san != null && san.ContainsKey("dns") ? san["dns"].Length : 0, requestFormat);
using var flow = new FlowLogger(_logger, $"Enroll-{enrollmentType}");
+
+ if (productInfo == null)
+ {
+ flow.Fail("ValidateInputs", "productInfo is null");
+ _logger.LogError("Enroll called with null productInfo.");
+ return new EnrollmentResult { Status = (int)EndEntityStatus.FAILED, StatusMessage = "Enrollment product information was not provided." };
+ }
+ // ProductParameters is dereferenced throughout; guarantee a non-null map.
+ var productParameters = productInfo.ProductParameters ?? new Dictionary();
+
var client = new SslStoreClient(Config);
try
@@ -245,7 +271,7 @@ public async Task Enroll(string csr, string subject, Dictionar
flow.Branch("NewEnrollment");
_logger.LogTrace("Entering New Enrollment");
- if (!productInfo.ProductParameters.ContainsKey("PriorCertSN"))
+ if (!productParameters.ContainsKey("PriorCertSN"))
{
// Extract domain name from CSR subject and SANs from the Keyfactor san parameter
var domainName = subject?.Split(',')
@@ -267,10 +293,10 @@ public async Task Enroll(string csr, string subject, Dictionar
{
flow.Branch("EmailApproverValidation");
string[] arrayApproverEmails = Array.Empty();
- if (productInfo.ProductParameters.ContainsKey("Approver Email"))
+ if (productParameters.ContainsKey("Approver Email"))
{
- _logger.LogTrace($"Approver Email {productInfo.ProductParameters["Approver Email"]}");
- arrayApproverEmails = productInfo.ProductParameters["Approver Email"].Split(new char[] { ',' });
+ _logger.LogTrace($"Approver Email {productParameters["Approver Email"]}");
+ arrayApproverEmails = productParameters["Approver Email"].Split(new char[] { ',' });
}
// Validate approver emails against all domains (CN + SANs)
@@ -336,17 +362,25 @@ await flow.StepAsync("StageDnsValidation",
// The CNAME is published; poll SSL Store for issuance and, if it issues within the
// configured window, return the certificate directly from this enrollment call
// (ACME-style). Otherwise fall through to a pending result and let CA sync finish it.
- var compositeId = BuildCompositeRequestId(enrollmentResponse.TheSslStoreOrderId, enrollmentResponse.PartnerOrderId);
- EnrollmentResult issuedResult = null;
- await flow.StepAsync("PollForIssuance",
- async () => issuedResult = await TryPollForIssuedCertAsync(compositeId));
- if (issuedResult != null)
+ if (!string.IsNullOrEmpty(enrollmentResponse.TheSslStoreOrderId) && !string.IsNullOrEmpty(enrollmentResponse.PartnerOrderId))
{
- flow.Step("PollResult", "issued during poll window");
- flow.EndBranch();
- return issuedResult;
+ var compositeId = BuildCompositeRequestId(enrollmentResponse.TheSslStoreOrderId, enrollmentResponse.PartnerOrderId);
+ EnrollmentResult issuedResult = null;
+ await flow.StepAsync("PollForIssuance",
+ async () => issuedResult = await TryPollForIssuedCertAsync(compositeId));
+ if (issuedResult != null)
+ {
+ flow.Step("PollResult", "issued during poll window");
+ flow.EndBranch();
+ return issuedResult;
+ }
+ flow.Skip("PollResult", "not issued within poll window; returning pending");
+ }
+ else
+ {
+ flow.Skip("PollForIssuance", "SSL Store did not return both order IDs; cannot poll");
+ _logger.LogWarning("Cannot poll for issuance: SSL Store response missing TheSslStoreOrderId/PartnerOrderId.");
}
- flow.Skip("PollResult", "not issued within poll window; returning pending");
}
flow.EndBranch();
}
@@ -366,7 +400,12 @@ await flow.StepAsync("PollForIssuance",
flow.Branch("RenewOrReissue");
_logger.LogTrace("Entering Renew/Reissue Logic...");
- var sn = productInfo.ProductParameters["PriorCertSN"];
+ if (!productParameters.TryGetValue("PriorCertSN", out var sn) || string.IsNullOrEmpty(sn))
+ {
+ flow.Fail("ValidatePriorSN", "PriorCertSN missing");
+ _logger.LogError("Renew/Reissue enrollment is missing the required PriorCertSN parameter.");
+ return new EnrollmentResult { Status = (int)EndEntityStatus.FAILED, StatusMessage = "Renewal/reissue requires the prior certificate serial number (PriorCertSN)." };
+ }
_logger.LogTrace($"Prior Cert Serial Number: {sn}");
var caRequestId = await _certDataReader.GetRequestIDBySerialNumber(sn);
@@ -456,6 +495,25 @@ private static string BuildCompositeRequestId(string theSslStoreOrderId, string
return $"{theSslStoreOrderId}-{partnerOrderId}";
}
+ ///
+ /// Clamps a configuration value into [min, max], logging a warning when the supplied value
+ /// was out of range so operators can see their setting was overridden.
+ ///
+ private static int Clamp(int value, int min, int max, string name)
+ {
+ if (value < min)
+ {
+ _logger.LogWarning("Config value {Name}={Value} is below the minimum {Min}; using {Min}.", name, value, min);
+ return min;
+ }
+ if (value > max)
+ {
+ _logger.LogWarning("Config value {Name}={Value} is above the maximum {Max}; using {Max}.", name, value, max);
+ return max;
+ }
+ return value;
+ }
+
///
/// Parses the TheSSLStoreOrderID from a CARequestID. Supports both composite format
/// ("{TheSSLStoreOrderID}-{PartnerOrderID}") and legacy GUID format (falls back to
@@ -909,9 +967,21 @@ await flow.StepAsync($"PublishRecord[{recordName}]",
_logger.LogInformation("Published DNS validation record {RecordName} for '{Domain}' via {Validator}.",
recordName, domain, validator.GetType().Name);
+ // Propagation verification is best-effort: the record is already published, and SSL Store
+ // re-checks DNS on its own schedule. A verification failure (or an unexpected error in the
+ // DNS client) must never fail an enrollment whose record was successfully published.
var propagated = false;
- await flow.StepAsync($"VerifyPropagation[{recordName}]",
- async () => propagated = await verifier.WaitForDnsPropagationAsync(recordName, recordValue, QueryType.CNAME, 3));
+ try
+ {
+ await flow.StepAsync($"VerifyPropagation[{recordName}]",
+ async () => propagated = await verifier.WaitForDnsPropagationAsync(recordName, recordValue, QueryType.CNAME, 3));
+ }
+ catch (Exception ex)
+ {
+ flow.Skip($"VerifyPropagation[{recordName}]", $"verification error (best-effort): {ex.Message}");
+ _logger.LogWarning(ex, "DNS propagation verification for {RecordName} ('{Domain}') threw; continuing (best-effort).", recordName, domain);
+ }
+
if (!propagated)
{
flow.Skip($"VerifyPropagation[{recordName}]", "not yet confirmed (best-effort)");
diff --git a/docs/enrollment-flows.md b/docs/enrollment-flows.md
new file mode 100644
index 0000000..1bd3280
--- /dev/null
+++ b/docs/enrollment-flows.md
@@ -0,0 +1,153 @@
+# SSL Store AnyCA Gateway — Operation Flows
+
+This document describes what the plugin does for each operation, the calls involved, the
+status mapping, and how to read the `FlowLogger` traces. It complements the configuration
+reference in [`docsource/configuration.md`](../docsource/configuration.md).
+
+There are **two API layers** in play:
+
+1. **AnyCA Gateway REST API** — the EJBCA-compatible REST surface Keyfactor Command calls
+ (e.g. `POST /ejbca/ejbca-rest-api/v1/certificate/certificaterequest`). These are the calls
+ in the Postman collection under [`postman/`](../postman).
+2. **SSL Store WBAPI** — the vendor API the plugin calls internally
+ (`/rest/order/neworder`, `/rest/order/status`, `/rest/order/download`, etc.). You do **not**
+ call these directly; they are shown here so the traces make sense.
+
+The plugin never bundles a DNS provider. Automated DNS validation uses the gateway's
+**DNS provider plugin framework**: the plugin asks `IDomainValidatorFactory.ResolveDomainValidator(domain, "cname")`
+and the operator maps each domain to a **CNAME** validator (e.g. `Ns1CnameDomainValidator`) in the
+gateway's **Domain Validation** configuration. `"cname"` is intrinsic to SSL Store DCV and is not a setting.
+
+---
+
+## Status mapping
+
+`RequestManager.MapReturnStatus` maps SSL Store order status to the gateway's `EndEntityStatus`:
+
+| SSL Store `MajorStatus` | Gateway status | Meaning |
+|-------------------------|----------------|---------|
+| `Active` | `GENERATED` (40) | Issued; certificate downloadable |
+| `Initial`, `Pending` | `EXTERNALVALIDATION` | Awaiting DCV / issuance |
+| `Cancelled` | `REVOKED` (50) | Cancelled / refunded |
+| anything else | `NEW` | Unknown / not yet actionable |
+
+When `Enroll` returns `EXTERNALVALIDATION`, the gateway surfaces
+*"The enrollment requires external validation before a certificate can be issued."* to the
+synchronous enroll caller (HTTP 400). This is **expected** for asynchronous DCV — the certificate
+is retrieved later by CA Sync (or returned inline if it issues within the poll window; see below).
+
+---
+
+## Flow 1 — New enrollment with email approver validation
+
+Used when DNS validation is **not** enabled and no `PriorCertSN` is present.
+
+1. Extract CN from the subject and SANs from the `dns` set.
+2. For each domain, `POST /rest/order/approverlist` (SSL Store) and validate the configured
+ `Approver Email` against the returned list (`ValidateEmails`). Invalid → `FAILED`.
+3. `POST /rest/order/neworder` (SSL Store) → order created, returns `TheSSLStoreOrderId` + `PartnerOrderId`.
+4. Return an `EnrollmentResult`:
+ - order `Active` → `GENERATED` (rare on first call);
+ - otherwise `EXTERNALVALIDATION` (pending; the approver must click the email).
+
+**Gateway REST calls:** `POST /v1/endentity` → `POST /v1/certificate/certificaterequest`.
+
+**FlowLogger:** `Enroll-New` › `EmailApproverValidation` › `SubmitNewOrderToSslStore` › `MapEnrollmentResult`.
+
+## Flow 2 — New enrollment with automated DNS (CNAME) validation
+
+Used when `DnsValidationEnabled=true` **or** the `CName Auth Domain Validation` template parameter is `True`.
+
+1. `POST /rest/order/neworder` with the CNAME DCV indicator set.
+2. SSL Store returns the CNAME record(s) (`CNAMEAuthName` → `CNAMEAuthValue`, plus any per-domain
+ records in `DomainAuthVettingStatus`). `CollectDnsRecords` de-duplicates by record name.
+3. For each record, resolve the domain's CNAME validator and **publish** the CNAME
+ (`IDomainValidator.StageValidation`). Publish failure → `FAILED`.
+4. **Verify propagation** across public resolvers (or `DnsVerificationServer`) —
+ `DnsPropagationMaxAttempts` × `DnsPropagationDelaySeconds`. Best-effort: failure/exception only warns.
+5. **Poll for issuance** up to `DcvPollTimeoutSeconds` (via `GetSingleRecord`, interval 15s):
+ - issued in time → download the leaf cert and return `GENERATED` **with the certificate inline**;
+ - window expires → return `EXTERNALVALIDATION` (pending); CA Sync retrieves it later.
+
+**Gateway REST calls:** `POST /v1/endentity` → `POST /v1/certificate/certificaterequest`
+(→ optionally `POST /v2/certificate/search` to observe issuance).
+
+**FlowLogger:** `Enroll-New` › `SubmitNewOrderToSslStore` › `StageDnsValidation`
+(`ResolveValidator` › `PublishRecord` › `VerifyPropagation`) › `PollForIssuance` › `PollResult`.
+
+## Flow 3 — Renewal vs. reissue
+
+Triggered by `EnrollmentType.RenewOrReissue`; requires `PriorCertSN`.
+
+1. Resolve the prior order via `_certDataReader.GetRequestIDBySerialNumber(PriorCertSN)` then
+ `POST /rest/order/status` (SSL Store).
+2. Compare order expiry to `RenewalWindow`:
+ - within the window (or expiry unparseable) → **renewal** (`POST /rest/order/neworder` with `IsRenewalOrder`);
+ - otherwise → **reissue** (`POST /rest/order/reissue`, same order).
+3. Return the mapped `EnrollmentResult`.
+
+**FlowLogger:** `Enroll-RenewOrReissue` › `LookupPriorRequestId` › `FetchPriorOrderStatus`
+› `EvaluateRenewalWindow` › `SubmitRenewalToSslStore` | `SubmitReissueToSslStore`.
+
+## Flow 4 — Get single record (status check / poll)
+
+1. Parse the composite `CARequestID` (`{TheSSLStoreOrderId}-{PartnerOrderId}`) or fall back to legacy CustomOrderId.
+2. `POST /rest/order/status` (SSL Store) → map status.
+3. If `GENERATED` **or** `REVOKED`, `POST /rest/order/download` and extract the leaf cert.
+ A revoked order that was never issued yields no content (returned empty rather than crashing).
+4. On `GENERATED`, best-effort DNS cleanup (`CleanupValidation`) removes the CNAME.
+
+**Gateway REST calls:** `POST /v2/certificate/search`.
+
+## Flow 5 — Synchronize
+
+1. `POST /rest/order/query` (SSL Store, paginated) streams orders into a bounded queue.
+2. For each order: `POST /rest/order/status`; if `GENERATED`/`REVOKED`, download the leaf cert.
+ **Rows are only written when actual certificate bytes exist** — a revoked order with no
+ downloadable certificate is skipped (writing empty bytes crashes the gateway's certificate search).
+3. Issued rows trigger best-effort DNS cleanup.
+4. Emits a summary: `processed / added / skipped`.
+
+**Gateway REST calls:** `POST /v2/certificate/search`, `POST /v1/endentity/search` (Command drives sync).
+
+## Flow 6 — Revocation
+
+1. Parse the SSL Store order id from the `CARequestID`.
+2. `POST /rest/order/refundrequest` (SSL Store).
+3. `IsError` → `FAILED`; otherwise `REVOKED`.
+
+**Gateway REST call:** `PUT /v1/certificate/{issuer_dn}/{serial}/revoke`.
+
+**FlowLogger:** `Revoke(...)` › `BuildRevokeRequest` › `SubmitRevokeToSslStore` › `RevokeResult`.
+
+---
+
+## Reading FlowLogger output
+
+Each operation renders a step diagram to **Trace** logs on completion:
+
+```
+ ===== FLOW: Enroll-New (24211ms total) =====
+
+ [OK] SubmitNewOrderToSslStore (312ms)
+ |
+ v
+ [...] NewEnrollment
+ |
+ +-- [OK] StageDnsValidation (20120ms)
+ +-- [SKIP] PollResult [not issued within poll window; returning pending]
+ ===== FLOW RESULT: SUCCESS =====
+```
+
+Operational summaries are logged at **Information**; the full step diagram is at **Trace**. Icons:
+`[OK]` success, `[FAIL]` failure, `[SKIP]` skipped/best-effort, `[...]` branch.
+
+## Hardening / guardrails
+
+- CA-connection timing values are clamped at `Initialize`: `RenewalWindow` 1–3650 days,
+ `DnsPropagationMaxAttempts` 1–30, `DnsPropagationDelaySeconds` 1–120, `DcvPollTimeoutSeconds` 0–600.
+ Out-of-range values are logged and overridden.
+- Propagation verification never fails an enrollment whose record was published (best-effort, exception-guarded).
+- Issuance polling is skipped when SSL Store did not return both order IDs, and disabled entirely with `DcvPollTimeoutSeconds=0`.
+- `Enroll` rejects null `productInfo`, tolerates a null `ProductParameters`, and requires `PriorCertSN` for renew/reissue.
+- Synchronize/GetSingleRecord never store empty certificate bytes for revoked orders.
diff --git a/postman/README.md b/postman/README.md
new file mode 100644
index 0000000..35e45ce
--- /dev/null
+++ b/postman/README.md
@@ -0,0 +1,70 @@
+# Postman collection — SSL Store AnyCA Gateway REST
+
+Exercises the **AnyCA Gateway REST** (EJBCA-compatible) API that fronts the SSL Store CA plugin,
+grouped by operation flow. Use it to smoke-test the gateway, drive enrollments, watch issuance,
+search inventory, and revoke — without going through Keyfactor Command.
+
+## Files
+
+| File | Purpose |
+|------|---------|
+| `SslStoreCaGateway.postman_collection.json` | The requests, grouped into flow folders |
+| `SslStoreCaGateway.postman_environment.json` | Environment variables (base URL, CA/profile names, CSR, etc.) |
+
+## Import
+
+1. Postman → **Import** → select both JSON files.
+2. Select the **SSL Store Gateway** environment (top-right).
+3. Fill in the environment values (see below).
+
+## Authentication (important)
+
+The gateway REST API authenticates with a **client certificate (mutual TLS)** — there is no
+API key or bearer token. In **Postman → Settings → Certificates**:
+
+- Add a client certificate for the gateway host (e.g. `20.7.36.37:8475`) with your PFX (or PEM + key).
+- The certificate must be one the gateway trusts/has enrolled (the gateway logs its thumbprint via
+ `AuthCertificateValidationCache`).
+- If the gateway presents a self-signed TLS server cert, turn **SSL certificate verification** off
+ (Settings → General) or add its CA to Postman.
+
+## Variables
+
+| Variable | Example | Notes |
+|----------|---------|-------|
+| `baseUrl` | `https://20.7.36.37:8475` | Gateway REST base URL |
+| `caName` | `SslStoreGw` | Logical CA name |
+| `endEntityProfile` | `AnyCA` | End entity profile |
+| `certificateProfile` | `PositiveSsl-RSA` | Maps to an SSL Store product |
+| `username` | `Keyfactor_POSTMAN_TEST` | End entity username |
+| `password` | | End entity enrollment code (if required) |
+| `commonName` | `www.example.com` | CN / primary SAN |
+| `csrPem` | `-----BEGIN CERTIFICATE REQUEST-----\n...` | Single-line PEM with `\n`, or paste multi-line in the env editor |
+| `issuerDn` | `CN=SslStoreGw` | For revocation |
+| `serialNumber` | | Certificate serial (revocation) |
+| `priorCertSN` | | Prior cert serial (renew/reissue) |
+| `revocationReason` | `CESSATION_OF_OPERATION` | RFC 5280 reason name |
+
+## Flows (folders)
+
+1. **Discovery & Health** — `GET ca/status`, `GET ca`, authorized/EE/cert profiles. Confirms auth + plugin are up.
+2. **Enroll - New (Email DCV)** — create end entity (with `Approver Email`) → certificate request. Returns pending until the approver validates; cert arrives via sync.
+3. **Enroll - New (DNS CNAME DCV)** — create end entity (with `CName Auth Domain Validation=True`) → certificate request (blocks while the plugin publishes the CNAME, verifies propagation, and polls for issuance) → poll search.
+4. **Renew / Reissue** — certificate request carrying `PriorCertSN`; the plugin picks renew vs reissue from expiry vs `RenewalWindow`.
+5. **Sync / Search** — `certificate/search` and `endentity/search` used during synchronization/inventory.
+6. **Revocation** — `PUT certificate/{issuerDn}/{serial}/revoke` → SSL Store refund request.
+
+## Expected "errors" that are actually normal
+
+- **HTTP 400 "The enrollment requires external validation before a certificate can be issued."** —
+ the order is pending DCV. Expected for email DCV, and for DNS DCV when the cert isn't issued within
+ `DcvPollTimeoutSeconds`. The cert is retrieved on the next CA sync.
+
+## Caveat
+
+The AnyCA Gateway REST surface mirrors the EJBCA REST contract, so request bodies here follow that
+shape. Product/template parameters (Approver Email, Validity Period, CName Auth Domain Validation,
+PriorCertSN, …) are normally supplied by Command from the certificate profile's enrollment fields;
+in this collection they appear as `extension_data` entries and may need tailoring to match your
+profile's field names. See [`../docs/enrollment-flows.md`](../docs/enrollment-flows.md) for the full
+per-flow behavior and the internal SSL Store WBAPI calls each flow triggers.
diff --git a/postman/SslStoreCaGateway.postman_collection.json b/postman/SslStoreCaGateway.postman_collection.json
new file mode 100644
index 0000000..61d246d
--- /dev/null
+++ b/postman/SslStoreCaGateway.postman_collection.json
@@ -0,0 +1,290 @@
+{
+ "info": {
+ "_postman_id": "b3f1a2c4-0000-4a00-9000-sslstorecagw01",
+ "name": "SSL Store AnyCA Gateway REST",
+ "description": "Exercises the AnyCA Gateway REST (EJBCA-compatible) API that fronts the SSL Store CA plugin, grouped by operation flow: Discovery, Enrollment (email DCV), Enrollment (DNS CNAME DCV), Renew/Reissue, Sync/Search, and Revocation.\n\n## Authentication\nThe gateway REST API uses **mutual TLS (client certificate)** auth, not headers or a bearer token. Configure your client certificate in **Postman → Settings → Certificates**, adding an entry for the gateway host (e.g. `20.7.36.37:8475`) with your PFX/PEM + key. The certificate must be trusted/enrolled by the gateway (the gateway logs the thumbprint via `AuthCertificateValidationCache`). If your gateway uses a self-signed TLS cert, turn **SSL certificate verification** off in Settings.\n\n## Variables\nSet these in the `SSL Store Gateway` environment (see `SslStoreCaGateway.postman_environment.json`): `baseUrl`, `caName`, `endEntityProfile`, `certificateProfile`, `username`, `password`, `commonName`, `csrPem`, `issuerDn`, `serialNumber`, `priorCertSN`, `revocationReason`.\n\n## Notes\n- The AnyCA Gateway REST surface mirrors the EJBCA REST contract; bodies below follow that shape. Product/template parameters (Approver Email, Validity Period, CName Auth Domain Validation, etc.) are normally supplied by Command from the certificate profile's enrollment fields — where a flow needs them they are shown as `extension_data` placeholders and may require tailoring to your profile.\n- `csrPem` must be a single-line PEM with `\\n` escapes, or use Postman's environment editor multi-line value.",
+ "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
+ },
+ "variable": [
+ { "key": "baseUrl", "value": "https://20.7.36.37:8475" },
+ { "key": "caName", "value": "SslStoreGw" },
+ { "key": "endEntityProfile", "value": "AnyCA" },
+ { "key": "certificateProfile", "value": "PositiveSsl-RSA" },
+ { "key": "username", "value": "Keyfactor_POSTMAN_TEST" },
+ { "key": "password", "value": "" },
+ { "key": "commonName", "value": "www.example.com" },
+ { "key": "csrPem", "value": "-----BEGIN CERTIFICATE REQUEST-----\n...replace with your CSR...\n-----END CERTIFICATE REQUEST-----" },
+ { "key": "issuerDn", "value": "CN=SslStoreGw" },
+ { "key": "serialNumber", "value": "" },
+ { "key": "priorCertSN", "value": "" },
+ { "key": "revocationReason", "value": "CESSATION_OF_OPERATION" }
+ ],
+ "item": [
+ {
+ "name": "1. Discovery & Health",
+ "description": "Read-only calls Command makes when connecting to the CA. Good smoke tests that auth and the plugin are up.",
+ "item": [
+ {
+ "name": "CA management status",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{baseUrl}}/ejbca/ejbca-rest-api/v1/ca/status",
+ "host": ["{{baseUrl}}"],
+ "path": ["ejbca", "ejbca-rest-api", "v1", "ca", "status"]
+ },
+ "description": "Plugin/CA management status. First call to confirm the gateway and plugin are reachable."
+ }
+ },
+ {
+ "name": "List CAs",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{baseUrl}}/ejbca/ejbca-rest-api/v1/ca",
+ "host": ["{{baseUrl}}"],
+ "path": ["ejbca", "ejbca-rest-api", "v1", "ca"]
+ },
+ "description": "Lists configured CAs (expect your SslStoreGw CA)."
+ }
+ },
+ {
+ "name": "Authorized end entity profiles",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{baseUrl}}/ejbca/ejbca-rest-api/v2/endentity/profiles/authorized",
+ "host": ["{{baseUrl}}"],
+ "path": ["ejbca", "ejbca-rest-api", "v2", "endentity", "profiles", "authorized"]
+ }
+ }
+ },
+ {
+ "name": "Get end entity profile",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{baseUrl}}/ejbca/ejbca-rest-api/v2/endentity/profile/{{endEntityProfile}}",
+ "host": ["{{baseUrl}}"],
+ "path": ["ejbca", "ejbca-rest-api", "v2", "endentity", "profile", "{{endEntityProfile}}"]
+ }
+ }
+ },
+ {
+ "name": "Get certificate profile",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{baseUrl}}/ejbca/ejbca-rest-api/v2/certificate/profile/{{certificateProfile}}",
+ "host": ["{{baseUrl}}"],
+ "path": ["ejbca", "ejbca-rest-api", "v2", "certificate", "profile", "{{certificateProfile}}"]
+ },
+ "description": "Certificate profile maps to an SSL Store product (e.g. PositiveSsl-RSA)."
+ }
+ }
+ ]
+ },
+ {
+ "name": "2. Enroll - New (Email DCV)",
+ "description": "New enrollment validated by approver email. Order returns pending until the approver clicks the SSL Store email; Command retrieves the cert on the next sync.",
+ "item": [
+ {
+ "name": "Create end entity",
+ "request": {
+ "method": "POST",
+ "header": [{ "key": "Content-Type", "value": "application/json" }],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"username\": \"{{username}}\",\n \"password\": \"{{password}}\",\n \"subject_dn\": \"CN={{commonName}}\",\n \"subject_alt_name\": \"dNSName={{commonName}}\",\n \"ca_name\": \"{{caName}}\",\n \"certificate_profile_name\": \"{{certificateProfile}}\",\n \"end_entity_profile_name\": \"{{endEntityProfile}}\",\n \"token\": \"USERGENERATED\",\n \"extension_data\": [\n { \"name\": \"Approver Email\", \"value\": \"admin@example.com\" },\n { \"name\": \"Validity Period (In Days)\", \"value\": \"365\" }\n ]\n}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/ejbca/ejbca-rest-api/v1/endentity",
+ "host": ["{{baseUrl}}"],
+ "path": ["ejbca", "ejbca-rest-api", "v1", "endentity"]
+ },
+ "description": "Registers the end entity + product/template parameters. `extension_data` names must match the certificate profile's enrollment fields."
+ }
+ },
+ {
+ "name": "Enroll (certificate request)",
+ "request": {
+ "method": "POST",
+ "header": [{ "key": "Content-Type", "value": "application/json" }],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"certificate_request\": \"{{csrPem}}\",\n \"certificate_profile_name\": \"{{certificateProfile}}\",\n \"end_entity_profile_name\": \"{{endEntityProfile}}\",\n \"certificate_authority_name\": \"{{caName}}\",\n \"username\": \"{{username}}\",\n \"password\": \"{{password}}\",\n \"include_chain\": true\n}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/ejbca/ejbca-rest-api/v1/certificate/certificaterequest",
+ "host": ["{{baseUrl}}"],
+ "path": ["ejbca", "ejbca-rest-api", "v1", "certificate", "certificaterequest"]
+ },
+ "description": "Submits the CSR. Expect HTTP 400 'requires external validation' while the order is pending — this is normal; the cert arrives via sync once the approver validates."
+ }
+ }
+ ]
+ },
+ {
+ "name": "3. Enroll - New (DNS CNAME DCV)",
+ "description": "New enrollment validated by CNAME. The plugin publishes the CNAME via the mapped CNAME validator, verifies propagation, then polls for issuance up to DcvPollTimeoutSeconds. If issued in time the cert is returned inline; otherwise it returns pending and sync finishes it.",
+ "item": [
+ {
+ "name": "Create end entity (CNAME DCV)",
+ "request": {
+ "method": "POST",
+ "header": [{ "key": "Content-Type", "value": "application/json" }],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"username\": \"{{username}}\",\n \"password\": \"{{password}}\",\n \"subject_dn\": \"CN={{commonName}}\",\n \"subject_alt_name\": \"dNSName={{commonName}}\",\n \"ca_name\": \"{{caName}}\",\n \"certificate_profile_name\": \"{{certificateProfile}}\",\n \"end_entity_profile_name\": \"{{endEntityProfile}}\",\n \"token\": \"USERGENERATED\",\n \"extension_data\": [\n { \"name\": \"CName Auth Domain Validation\", \"value\": \"True\" },\n { \"name\": \"Validity Period (In Days)\", \"value\": \"365\" }\n ]\n}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/ejbca/ejbca-rest-api/v1/endentity",
+ "host": ["{{baseUrl}}"],
+ "path": ["ejbca", "ejbca-rest-api", "v1", "endentity"]
+ },
+ "description": "Set `CName Auth Domain Validation=True` (or enable DnsValidationEnabled on the CA connection). A CNAME validator (e.g. Ns1CnameDomainValidator) must be mapped to the domain in the gateway's Domain Validation configuration."
+ }
+ },
+ {
+ "name": "Enroll (certificate request)",
+ "request": {
+ "method": "POST",
+ "header": [{ "key": "Content-Type", "value": "application/json" }],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"certificate_request\": \"{{csrPem}}\",\n \"certificate_profile_name\": \"{{certificateProfile}}\",\n \"end_entity_profile_name\": \"{{endEntityProfile}}\",\n \"certificate_authority_name\": \"{{caName}}\",\n \"username\": \"{{username}}\",\n \"password\": \"{{password}}\",\n \"include_chain\": true\n}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/ejbca/ejbca-rest-api/v1/certificate/certificaterequest",
+ "host": ["{{baseUrl}}"],
+ "path": ["ejbca", "ejbca-rest-api", "v1", "certificate", "certificaterequest"]
+ },
+ "description": "This call blocks while the plugin publishes the CNAME, verifies propagation, and polls for issuance (up to DcvPollTimeoutSeconds). Returns the cert inline if issued in time, else HTTP 400 'requires external validation' (pending)."
+ }
+ },
+ {
+ "name": "Poll issuance (search by CN)",
+ "request": {
+ "method": "POST",
+ "header": [{ "key": "Content-Type", "value": "application/json" }],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"max_number_of_results\": 10,\n \"criteria\": [\n { \"property\": \"QUERY\", \"value\": \"{{commonName}}\", \"operation\": \"LIKE\" }\n ]\n}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/ejbca/ejbca-rest-api/v2/certificate/search",
+ "host": ["{{baseUrl}}"],
+ "path": ["ejbca", "ejbca-rest-api", "v2", "certificate", "search"]
+ },
+ "description": "Re-run after enrollment to watch the order flip to issued once Sectigo validates the CNAME on its own schedule."
+ }
+ }
+ ]
+ },
+ {
+ "name": "4. Renew / Reissue",
+ "description": "Renew (new order) vs reissue (same order) is chosen by the plugin from the prior order's expiry vs RenewalWindow. Requires the prior certificate serial number.",
+ "item": [
+ {
+ "name": "Renew/Reissue (certificate request)",
+ "request": {
+ "method": "POST",
+ "header": [{ "key": "Content-Type", "value": "application/json" }],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"certificate_request\": \"{{csrPem}}\",\n \"certificate_profile_name\": \"{{certificateProfile}}\",\n \"end_entity_profile_name\": \"{{endEntityProfile}}\",\n \"certificate_authority_name\": \"{{caName}}\",\n \"username\": \"{{username}}\",\n \"password\": \"{{password}}\",\n \"include_chain\": true,\n \"extension_data\": [\n { \"name\": \"PriorCertSN\", \"value\": \"{{priorCertSN}}\" }\n ]\n}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/ejbca/ejbca-rest-api/v1/certificate/certificaterequest",
+ "host": ["{{baseUrl}}"],
+ "path": ["ejbca", "ejbca-rest-api", "v1", "certificate", "certificaterequest"]
+ },
+ "description": "In Command this is a renew/reissue enrollment carrying the prior cert serial. `PriorCertSN` must be set to the existing certificate's serial number."
+ }
+ }
+ ]
+ },
+ {
+ "name": "5. Sync / Search",
+ "description": "Certificate/end-entity search endpoints Command uses during synchronization and inventory. Useful for verifying stored records and statuses.",
+ "item": [
+ {
+ "name": "Certificate search - all",
+ "request": {
+ "method": "POST",
+ "header": [{ "key": "Content-Type", "value": "application/json" }],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"max_number_of_results\": 100,\n \"criteria\": []\n}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/ejbca/ejbca-rest-api/v2/certificate/search",
+ "host": ["{{baseUrl}}"],
+ "path": ["ejbca", "ejbca-rest-api", "v2", "certificate", "search"]
+ },
+ "description": "Returns stored certificates. If this ever 500s with 'm_safeCertContext is an invalid handle', a stored row has empty certificate bytes (fixed for revoked orders in the plugin; run a full sync to repair legacy rows)."
+ }
+ },
+ {
+ "name": "Certificate search - by CN",
+ "request": {
+ "method": "POST",
+ "header": [{ "key": "Content-Type", "value": "application/json" }],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"max_number_of_results\": 20,\n \"criteria\": [\n { \"property\": \"QUERY\", \"value\": \"{{commonName}}\", \"operation\": \"LIKE\" }\n ]\n}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/ejbca/ejbca-rest-api/v2/certificate/search",
+ "host": ["{{baseUrl}}"],
+ "path": ["ejbca", "ejbca-rest-api", "v2", "certificate", "search"]
+ }
+ }
+ },
+ {
+ "name": "End entity search - by username",
+ "request": {
+ "method": "POST",
+ "header": [{ "key": "Content-Type", "value": "application/json" }],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"max_number_of_results\": 10,\n \"criteria\": [\n { \"property\": \"USERNAME\", \"value\": \"{{username}}\", \"operation\": \"EQUAL\" }\n ]\n}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/ejbca/ejbca-rest-api/v1/endentity/search",
+ "host": ["{{baseUrl}}"],
+ "path": ["ejbca", "ejbca-rest-api", "v1", "endentity", "search"]
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "6. Revocation",
+ "description": "Revocation maps to SSL Store's refund request. Provide the issuer DN and the certificate serial number.",
+ "item": [
+ {
+ "name": "Revoke certificate",
+ "request": {
+ "method": "PUT",
+ "header": [],
+ "url": {
+ "raw": "{{baseUrl}}/ejbca/ejbca-rest-api/v1/certificate/{{issuerDn}}/{{serialNumber}}/revoke?reason={{revocationReason}}",
+ "host": ["{{baseUrl}}"],
+ "path": ["ejbca", "ejbca-rest-api", "v1", "certificate", "{{issuerDn}}", "{{serialNumber}}", "revoke"],
+ "query": [
+ { "key": "reason", "value": "{{revocationReason}}" }
+ ]
+ },
+ "description": "Revoke by issuer DN + serial. `reason` uses RFC 5280 names (e.g. CESSATION_OF_OPERATION, KEY_COMPROMISE, UNSPECIFIED)."
+ }
+ }
+ ]
+ }
+ ]
+}
diff --git a/postman/SslStoreCaGateway.postman_environment.json b/postman/SslStoreCaGateway.postman_environment.json
new file mode 100644
index 0000000..abf6850
--- /dev/null
+++ b/postman/SslStoreCaGateway.postman_environment.json
@@ -0,0 +1,19 @@
+{
+ "id": "b3f1a2c4-0000-4e00-9000-sslstorecaenv1",
+ "name": "SSL Store Gateway",
+ "values": [
+ { "key": "baseUrl", "value": "https://20.7.36.37:8475", "type": "default", "enabled": true },
+ { "key": "caName", "value": "SslStoreGw", "type": "default", "enabled": true },
+ { "key": "endEntityProfile", "value": "AnyCA", "type": "default", "enabled": true },
+ { "key": "certificateProfile", "value": "PositiveSsl-RSA", "type": "default", "enabled": true },
+ { "key": "username", "value": "Keyfactor_POSTMAN_TEST", "type": "default", "enabled": true },
+ { "key": "password", "value": "", "type": "secret", "enabled": true },
+ { "key": "commonName", "value": "www.example.com", "type": "default", "enabled": true },
+ { "key": "csrPem", "value": "-----BEGIN CERTIFICATE REQUEST-----\n...replace with your CSR...\n-----END CERTIFICATE REQUEST-----", "type": "default", "enabled": true },
+ { "key": "issuerDn", "value": "CN=SslStoreGw", "type": "default", "enabled": true },
+ { "key": "serialNumber", "value": "", "type": "default", "enabled": true },
+ { "key": "priorCertSN", "value": "", "type": "default", "enabled": true },
+ { "key": "revocationReason", "value": "CESSATION_OF_OPERATION", "type": "default", "enabled": true }
+ ],
+ "_postman_variable_scope": "environment"
+}
From 12fa8743f6ed321deb3f2bc47233c7582fbbe2ba Mon Sep 17 00:00:00 2001
From: Brian Hill
Date: Wed, 15 Jul 2026 14:55:53 -0400
Subject: [PATCH 24/25] fixed postman collection
---
SslStoreCaProxy.slnx | 4 +-
docs/enrollment-flows.md | 12 +-
postman/README.md | 109 +++----
postman/SslStoreApi.postman_collection.json | 257 ++++++++++++++++
postman/SslStoreApi.postman_environment.json | 18 ++
.../SslStoreCaGateway.postman_collection.json | 290 ------------------
...SslStoreCaGateway.postman_environment.json | 19 --
7 files changed, 340 insertions(+), 369 deletions(-)
create mode 100644 postman/SslStoreApi.postman_collection.json
create mode 100644 postman/SslStoreApi.postman_environment.json
delete mode 100644 postman/SslStoreCaGateway.postman_collection.json
delete mode 100644 postman/SslStoreCaGateway.postman_environment.json
diff --git a/SslStoreCaProxy.slnx b/SslStoreCaProxy.slnx
index 59c97bb..3888fcd 100644
--- a/SslStoreCaProxy.slnx
+++ b/SslStoreCaProxy.slnx
@@ -1,8 +1,8 @@
-
-
+
+
diff --git a/docs/enrollment-flows.md b/docs/enrollment-flows.md
index 1bd3280..8d743dc 100644
--- a/docs/enrollment-flows.md
+++ b/docs/enrollment-flows.md
@@ -6,12 +6,12 @@ reference in [`docsource/configuration.md`](../docsource/configuration.md).
There are **two API layers** in play:
-1. **AnyCA Gateway REST API** — the EJBCA-compatible REST surface Keyfactor Command calls
- (e.g. `POST /ejbca/ejbca-rest-api/v1/certificate/certificaterequest`). These are the calls
- in the Postman collection under [`postman/`](../postman).
-2. **SSL Store WBAPI** — the vendor API the plugin calls internally
- (`/rest/order/neworder`, `/rest/order/status`, `/rest/order/download`, etc.). You do **not**
- call these directly; they are shown here so the traces make sense.
+1. **SSL Store WBAPI** — the vendor API the plugin calls
+ (`/rest/order/neworder`, `/rest/order/status`, `/rest/order/download`, etc.). These are the
+ calls in the Postman collection under [`postman/`](../postman).
+2. **AnyCA Gateway REST API** — the EJBCA-compatible REST surface Keyfactor Command calls into the
+ gateway (e.g. `POST /ejbca/ejbca-rest-api/v1/certificate/certificaterequest`). Command drives
+ these; the plugin translates each into the SSL Store calls above.
The plugin never bundles a DNS provider. Automated DNS validation uses the gateway's
**DNS provider plugin framework**: the plugin asks `IDomainValidatorFactory.ResolveDomainValidator(domain, "cname")`
diff --git a/postman/README.md b/postman/README.md
index 35e45ce..bd07815 100644
--- a/postman/README.md
+++ b/postman/README.md
@@ -1,70 +1,75 @@
-# Postman collection — SSL Store AnyCA Gateway REST
+# Postman collection — SSL Store WBAPI
-Exercises the **AnyCA Gateway REST** (EJBCA-compatible) API that fronts the SSL Store CA plugin,
-grouped by operation flow. Use it to smoke-test the gateway, drive enrollments, watch issuance,
-search inventory, and revoke — without going through Keyfactor Command.
+The actual **SSL Store Web-Based API (WBAPI)** calls the SSL Store CA plugin makes, grouped by
+operation flow. Use it to reproduce and debug what the plugin does against SSL Store directly
+(the same endpoints `SslStoreClient` calls).
## Files
| File | Purpose |
|------|---------|
-| `SslStoreCaGateway.postman_collection.json` | The requests, grouped into flow folders |
-| `SslStoreCaGateway.postman_environment.json` | Environment variables (base URL, CA/profile names, CSR, etc.) |
+| `SslStoreApi.postman_collection.json` | The SSL Store requests, grouped into flow folders |
+| `SslStoreApi.postman_environment.json` | Environment variables (base URL, credentials, CSR, order id, …) |
## Import
1. Postman → **Import** → select both JSON files.
-2. Select the **SSL Store Gateway** environment (top-right).
-3. Fill in the environment values (see below).
+2. Select the **SSL Store WBAPI** environment (top-right).
+3. Fill in `partnerCode`, `authToken`, and `csrPem` (at minimum).
-## Authentication (important)
+## Base URL
-The gateway REST API authenticates with a **client certificate (mutual TLS)** — there is no
-API key or bearer token. In **Postman → Settings → Certificates**:
+- Sandbox: `https://sandbox-wbapi.thesslstore.com` (default)
+- Production: `https://wbapi.thesslstore.com`
-- Add a client certificate for the gateway host (e.g. `20.7.36.37:8475`) with your PFX (or PEM + key).
-- The certificate must be one the gateway trusts/has enrolled (the gateway logs its thumbprint via
- `AuthCertificateValidationCache`).
-- If the gateway presents a self-signed TLS server cert, turn **SSL certificate verification** off
- (Settings → General) or add its CA to Postman.
+## Authentication
+
+SSL Store authenticates **in the request body**, not with headers. Every request includes:
+
+```json
+"AuthRequest": { "PartnerCode": "{{partnerCode}}", "AuthToken": "{{authToken}}" }
+```
+
+All calls are `POST` with `Content-Type: application/json`.
## Variables
| Variable | Example | Notes |
|----------|---------|-------|
-| `baseUrl` | `https://20.7.36.37:8475` | Gateway REST base URL |
-| `caName` | `SslStoreGw` | Logical CA name |
-| `endEntityProfile` | `AnyCA` | End entity profile |
-| `certificateProfile` | `PositiveSsl-RSA` | Maps to an SSL Store product |
-| `username` | `Keyfactor_POSTMAN_TEST` | End entity username |
-| `password` | | End entity enrollment code (if required) |
-| `commonName` | `www.example.com` | CN / primary SAN |
-| `csrPem` | `-----BEGIN CERTIFICATE REQUEST-----\n...` | Single-line PEM with `\n`, or paste multi-line in the env editor |
-| `issuerDn` | `CN=SslStoreGw` | For revocation |
-| `serialNumber` | | Certificate serial (revocation) |
-| `priorCertSN` | | Prior cert serial (renew/reissue) |
-| `revocationReason` | `CESSATION_OF_OPERATION` | RFC 5280 reason name |
-
-## Flows (folders)
-
-1. **Discovery & Health** — `GET ca/status`, `GET ca`, authorized/EE/cert profiles. Confirms auth + plugin are up.
-2. **Enroll - New (Email DCV)** — create end entity (with `Approver Email`) → certificate request. Returns pending until the approver validates; cert arrives via sync.
-3. **Enroll - New (DNS CNAME DCV)** — create end entity (with `CName Auth Domain Validation=True`) → certificate request (blocks while the plugin publishes the CNAME, verifies propagation, and polls for issuance) → poll search.
-4. **Renew / Reissue** — certificate request carrying `PriorCertSN`; the plugin picks renew vs reissue from expiry vs `RenewalWindow`.
-5. **Sync / Search** — `certificate/search` and `endentity/search` used during synchronization/inventory.
-6. **Revocation** — `PUT certificate/{issuerDn}/{serial}/revoke` → SSL Store refund request.
-
-## Expected "errors" that are actually normal
-
-- **HTTP 400 "The enrollment requires external validation before a certificate can be issued."** —
- the order is pending DCV. Expected for email DCV, and for DNS DCV when the cert isn't issued within
- `DcvPollTimeoutSeconds`. The cert is retrieved on the next CA sync.
-
-## Caveat
-
-The AnyCA Gateway REST surface mirrors the EJBCA REST contract, so request bodies here follow that
-shape. Product/template parameters (Approver Email, Validity Period, CName Auth Domain Validation,
-PriorCertSN, …) are normally supplied by Command from the certificate profile's enrollment fields;
-in this collection they appear as `extension_data` entries and may need tailoring to match your
-profile's field names. See [`../docs/enrollment-flows.md`](../docs/enrollment-flows.md) for the full
-per-flow behavior and the internal SSL Store WBAPI calls each flow triggers.
+| `baseUrl` | `https://sandbox-wbapi.thesslstore.com` | Sandbox or production |
+| `partnerCode` | | SSL Store Partner Code |
+| `authToken` | | SSL Store Auth Token |
+| `productCode` | `positivessl` | SSL Store product code |
+| `commonName` | `www.example.com` | CN / domain |
+| `csrPem` | `-----BEGIN CERTIFICATE REQUEST-----\n...` | PEM CSR (single-line with `\n`, or paste multi-line in env editor) |
+| `approverEmail` | `admin@example.com` | Approver / contact email |
+| `validityDays` | `365` | Validity period |
+| `webServerType` | `Other` | Web server type |
+| `customOrderId` | `postman-{{$guid}}` | Your tracking id |
+| `theSslStoreOrderId` | | Set automatically from the neworder response by a test script |
+
+## Flows (folders) and their calls
+
+| Flow | Calls (in order) |
+|------|------------------|
+| **1. Enroll - New (Email DCV)** | `POST /rest/order/approverlist` → `POST /rest/order/neworder` |
+| **2. Enroll - New (DNS CNAME DCV)** | `POST /rest/order/neworder` (`CNAMEAuthDVIndicator=true`) |
+| **3. Renew / Reissue** | `POST /rest/order/status` → `POST /rest/order/neworder` (renew) **or** `POST /rest/order/reissue` |
+| **4. Status & Download** | `POST /rest/order/status` → `POST /rest/order/download` |
+| **5. Synchronize** | `POST /rest/order/query` (paged) → status → download per order |
+| **6. Revoke** | `POST /rest/order/refundrequest` |
+
+The **New order** requests include a test script that captures `TheSSLStoreOrderID` from the
+response into the `theSslStoreOrderId` variable, so the status/download/revoke calls target the
+order you just created.
+
+## Notes
+
+- **DNS CNAME DCV:** the `neworder` response returns the CNAME record (`CNAMEAuthName` →
+ `CNAMEAuthValue`, plus per-domain entries under `OrderStatus.DomainAuthVettingStatus`). In the
+ plugin, that record is published via the CNAME DNS validator mapped to the domain; with raw
+ Postman you would create the CNAME yourself, then poll `status`.
+- **Contacts:** `AdminContact`/`TechnicalContact` are required for OV/EV products; DV products
+ (e.g. `positivessl`) generally ignore them. Example values are inlined in the bodies.
+- See [`../docs/enrollment-flows.md`](../docs/enrollment-flows.md) for how each SSL Store call maps
+ to the plugin's gateway operations and status codes.
diff --git a/postman/SslStoreApi.postman_collection.json b/postman/SslStoreApi.postman_collection.json
new file mode 100644
index 0000000..6c7f002
--- /dev/null
+++ b/postman/SslStoreApi.postman_collection.json
@@ -0,0 +1,257 @@
+{
+ "info": {
+ "_postman_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
+ "name": "SSL Store WBAPI (SSL Store CA plugin)",
+ "description": "The actual SSL Store Web-Based API (WBAPI) calls the SSL Store CA plugin makes, grouped by operation flow. Use it to reproduce/debug what the plugin does against SSL Store directly.\n\n## Base URL\n- Sandbox: `https://sandbox-wbapi.thesslstore.com`\n- Production: `https://wbapi.thesslstore.com`\n\n## Authentication\nSSL Store authenticates **in the request body**, not via headers. Every request includes an `AuthRequest` object with your `PartnerCode` and `AuthToken`. Set these in the `SSL Store WBAPI` environment. All calls are `POST` with `Content-Type: application/json`.\n\n## Order identifiers\nSSL Store returns `TheSSLStoreOrderID` (and `PartnerOrderID`) on `neworder`. Most follow-up calls take either `TheSSLStoreOrderID` or the `CustomOrderID` you supplied. Save `TheSSLStoreOrderID` from the neworder response into the `theSslStoreOrderId` variable and the follow-up calls will use it.\n\nSee [`../docs/enrollment-flows.md`](../docs/enrollment-flows.md) for how these map to the plugin operations.",
+ "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
+ },
+ "variable": [
+ { "key": "baseUrl", "value": "https://sandbox-wbapi.thesslstore.com" },
+ { "key": "partnerCode", "value": "" },
+ { "key": "authToken", "value": "" },
+ { "key": "productCode", "value": "positivessl" },
+ { "key": "commonName", "value": "www.example.com" },
+ { "key": "csrPem", "value": "-----BEGIN CERTIFICATE REQUEST-----\n...replace with your CSR...\n-----END CERTIFICATE REQUEST-----" },
+ { "key": "approverEmail", "value": "admin@example.com" },
+ { "key": "validityDays", "value": "365" },
+ { "key": "webServerType", "value": "Other" },
+ { "key": "customOrderId", "value": "postman-{{$guid}}" },
+ { "key": "theSslStoreOrderId", "value": "" }
+ ],
+ "item": [
+ {
+ "name": "1. Enroll - New (Email DCV)",
+ "description": "New DV/OV order validated by approver email. Flow: approverlist -> neworder -> (poll) status -> download.",
+ "item": [
+ {
+ "name": "Approver list",
+ "request": {
+ "method": "POST",
+ "header": [{ "key": "Content-Type", "value": "application/json" }],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"AuthRequest\": { \"PartnerCode\": \"{{partnerCode}}\", \"AuthToken\": \"{{authToken}}\" },\n \"ProductCode\": \"{{productCode}}\",\n \"DomainName\": \"{{commonName}}\"\n}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/rest/order/approverlist",
+ "host": ["{{baseUrl}}"],
+ "path": ["rest", "order", "approverlist"]
+ },
+ "description": "Returns the list of approver emails allowed for the domain. The plugin validates the configured Approver Email against this list before ordering."
+ }
+ },
+ {
+ "name": "New order (email DCV)",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "try {",
+ " var j = pm.response.json();",
+ " if (j.TheSSLStoreOrderID) { pm.collectionVariables.set('theSslStoreOrderId', j.TheSSLStoreOrderID); }",
+ "} catch (e) {}"
+ ]
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [{ "key": "Content-Type", "value": "application/json" }],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"AuthRequest\": { \"PartnerCode\": \"{{partnerCode}}\", \"AuthToken\": \"{{authToken}}\" },\n \"CustomOrderID\": \"{{customOrderId}}\",\n \"ProductCode\": \"{{productCode}}\",\n \"ValidityPeriod\": {{validityDays}},\n \"ServerCount\": 1,\n \"CSR\": \"{{csrPem}}\",\n \"DomainName\": \"{{commonName}}\",\n \"WebServerType\": \"{{webServerType}}\",\n \"isCUOrder\": false,\n \"isRenewalOrder\": false,\n \"isTrialOrder\": false,\n \"ApproverEmail\": \"{{approverEmail}}\",\n \"AddInstallationSupport\": false,\n \"SignatureHashAlgorithm\": \"PREFER_SHA2\",\n \"AdminContact\": {\n \"FirstName\": \"Jane\", \"LastName\": \"Doe\", \"Phone\": \"5555555555\", \"Email\": \"{{approverEmail}}\",\n \"OrganizationName\": \"Example Inc\", \"AddressLine1\": \"123 Main St\", \"City\": \"Anytown\",\n \"Region\": \"WA\", \"PostalCode\": \"98000\", \"Country\": \"US\"\n },\n \"TechnicalContact\": {\n \"FirstName\": \"Jane\", \"LastName\": \"Doe\", \"Phone\": \"5555555555\", \"Email\": \"{{approverEmail}}\",\n \"OrganizationName\": \"Example Inc\", \"AddressLine1\": \"123 Main St\", \"City\": \"Anytown\",\n \"Region\": \"WA\", \"PostalCode\": \"98000\", \"Country\": \"US\"\n }\n}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/rest/order/neworder",
+ "host": ["{{baseUrl}}"],
+ "path": ["rest", "order", "neworder"]
+ },
+ "description": "Creates the order. On success the response carries TheSSLStoreOrderID/PartnerOrderID (the test script saves TheSSLStoreOrderID). Contacts are required for OV; DV products (e.g. positivessl) may ignore them."
+ }
+ }
+ ]
+ },
+ {
+ "name": "2. Enroll - New (DNS CNAME DCV)",
+ "description": "New order validated by CNAME. Flow: neworder (CNAMEAuthDVIndicator=true) -> plugin publishes the CNAME the response returns -> (poll) status -> download. Set CNAMEAuthDVIndicator to true.",
+ "item": [
+ {
+ "name": "New order (CNAME DCV)",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "try {",
+ " var j = pm.response.json();",
+ " if (j.TheSSLStoreOrderID) { pm.collectionVariables.set('theSslStoreOrderId', j.TheSSLStoreOrderID); }",
+ "} catch (e) {}"
+ ]
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [{ "key": "Content-Type", "value": "application/json" }],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"AuthRequest\": { \"PartnerCode\": \"{{partnerCode}}\", \"AuthToken\": \"{{authToken}}\" },\n \"CustomOrderID\": \"{{customOrderId}}\",\n \"ProductCode\": \"{{productCode}}\",\n \"ValidityPeriod\": {{validityDays}},\n \"ServerCount\": 1,\n \"CSR\": \"{{csrPem}}\",\n \"DomainName\": \"{{commonName}}\",\n \"WebServerType\": \"{{webServerType}}\",\n \"isCUOrder\": false,\n \"isRenewalOrder\": false,\n \"isTrialOrder\": false,\n \"CNAMEAuthDVIndicator\": true,\n \"ApproverEmail\": \"{{approverEmail}}\",\n \"AddInstallationSupport\": false,\n \"SignatureHashAlgorithm\": \"PREFER_SHA2\",\n \"AdminContact\": {\n \"FirstName\": \"Jane\", \"LastName\": \"Doe\", \"Phone\": \"5555555555\", \"Email\": \"{{approverEmail}}\",\n \"OrganizationName\": \"Example Inc\", \"AddressLine1\": \"123 Main St\", \"City\": \"Anytown\",\n \"Region\": \"WA\", \"PostalCode\": \"98000\", \"Country\": \"US\"\n },\n \"TechnicalContact\": {\n \"FirstName\": \"Jane\", \"LastName\": \"Doe\", \"Phone\": \"5555555555\", \"Email\": \"{{approverEmail}}\",\n \"OrganizationName\": \"Example Inc\", \"AddressLine1\": \"123 Main St\", \"City\": \"Anytown\",\n \"Region\": \"WA\", \"PostalCode\": \"98000\", \"Country\": \"US\"\n }\n}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/rest/order/neworder",
+ "host": ["{{baseUrl}}"],
+ "path": ["rest", "order", "neworder"]
+ },
+ "description": "CNAMEAuthDVIndicator=true requests CNAME DCV. The response contains the CNAME record (CNAMEAuthName -> CNAMEAuthValue, and per-domain entries under OrderStatus.DomainAuthVettingStatus) that the plugin publishes via the mapped CNAME DNS validator."
+ }
+ }
+ ]
+ },
+ {
+ "name": "3. Renew / Reissue",
+ "description": "Renew = new order linked to the prior order (RelatedTheSSLStoreOrderID + isRenewalOrder). Reissue = same order, new CSR.",
+ "item": [
+ {
+ "name": "Order status (prior order)",
+ "request": {
+ "method": "POST",
+ "header": [{ "key": "Content-Type", "value": "application/json" }],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"AuthRequest\": { \"PartnerCode\": \"{{partnerCode}}\", \"AuthToken\": \"{{authToken}}\" },\n \"TheSSLStoreOrderID\": \"{{theSslStoreOrderId}}\"\n}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/rest/order/status",
+ "host": ["{{baseUrl}}"],
+ "path": ["rest", "order", "status"]
+ },
+ "description": "The plugin reads the prior order's expiry here to decide renew vs reissue against RenewalWindow."
+ }
+ },
+ {
+ "name": "Renewal (new order)",
+ "request": {
+ "method": "POST",
+ "header": [{ "key": "Content-Type", "value": "application/json" }],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"AuthRequest\": { \"PartnerCode\": \"{{partnerCode}}\", \"AuthToken\": \"{{authToken}}\" },\n \"CustomOrderID\": \"{{customOrderId}}\",\n \"ProductCode\": \"{{productCode}}\",\n \"RelatedTheSSLStoreOrderID\": \"{{theSslStoreOrderId}}\",\n \"ValidityPeriod\": {{validityDays}},\n \"ServerCount\": 1,\n \"CSR\": \"{{csrPem}}\",\n \"DomainName\": \"{{commonName}}\",\n \"WebServerType\": \"{{webServerType}}\",\n \"isRenewalOrder\": true,\n \"ApproverEmail\": \"{{approverEmail}}\",\n \"SignatureHashAlgorithm\": \"PREFER_SHA2\"\n}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/rest/order/neworder",
+ "host": ["{{baseUrl}}"],
+ "path": ["rest", "order", "neworder"]
+ },
+ "description": "Renewal is a new order with isRenewalOrder=true and RelatedTheSSLStoreOrderID set to the prior order."
+ }
+ },
+ {
+ "name": "Reissue (same order)",
+ "request": {
+ "method": "POST",
+ "header": [{ "key": "Content-Type", "value": "application/json" }],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"AuthRequest\": { \"PartnerCode\": \"{{partnerCode}}\", \"AuthToken\": \"{{authToken}}\" },\n \"TheSSLStoreOrderID\": \"{{theSslStoreOrderId}}\",\n \"CustomOrderID\": \"{{customOrderId}}\",\n \"CSR\": \"{{csrPem}}\",\n \"WebServerType\": \"{{webServerType}}\",\n \"isRenewalOrder\": false,\n \"isWildCard\": false,\n \"CNAMEAuthDVIndicator\": false,\n \"FileAuthDVIndicator\": false,\n \"PreferEnrollmentLink\": false,\n \"ReissueEmail\": \"{{approverEmail}}\",\n \"ApproverEmails\": \"{{approverEmail}}\"\n}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/rest/order/reissue",
+ "host": ["{{baseUrl}}"],
+ "path": ["rest", "order", "reissue"]
+ },
+ "description": "Reissue keeps the same order and submits a new CSR."
+ }
+ }
+ ]
+ },
+ {
+ "name": "4. Status & Download (GetSingleRecord)",
+ "description": "Poll a single order and download the issued certificate. Flow: status -> download when Active.",
+ "item": [
+ {
+ "name": "Order status",
+ "request": {
+ "method": "POST",
+ "header": [{ "key": "Content-Type", "value": "application/json" }],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"AuthRequest\": { \"PartnerCode\": \"{{partnerCode}}\", \"AuthToken\": \"{{authToken}}\" },\n \"TheSSLStoreOrderID\": \"{{theSslStoreOrderId}}\"\n}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/rest/order/status",
+ "host": ["{{baseUrl}}"],
+ "path": ["rest", "order", "status"]
+ },
+ "description": "Returns OrderStatus.MajorStatus (Active/Pending/Cancelled). The plugin maps Active->GENERATED, Pending/Initial->EXTERNALVALIDATION, Cancelled->REVOKED. Can also query by CustomOrderID instead of TheSSLStoreOrderID."
+ }
+ },
+ {
+ "name": "Download certificate",
+ "request": {
+ "method": "POST",
+ "header": [{ "key": "Content-Type", "value": "application/json" }],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"AuthRequest\": { \"PartnerCode\": \"{{partnerCode}}\", \"AuthToken\": \"{{authToken}}\" },\n \"TheSSLStoreOrderID\": \"{{theSslStoreOrderId}}\"\n}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/rest/order/download",
+ "host": ["{{baseUrl}}"],
+ "path": ["rest", "order", "download"]
+ },
+ "description": "Returns the certificate chain (Certificates[].FileContent). The plugin extracts the end-entity certificate. Only meaningful once the order is Active."
+ }
+ }
+ ]
+ },
+ {
+ "name": "5. Synchronize",
+ "description": "Paginated order listing used by CA sync. Flow: query (per page) -> status -> download per order.",
+ "item": [
+ {
+ "name": "Query orders (page)",
+ "request": {
+ "method": "POST",
+ "header": [{ "key": "Content-Type", "value": "application/json" }],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"AuthRequest\": { \"PartnerCode\": \"{{partnerCode}}\", \"AuthToken\": \"{{authToken}}\" },\n \"PageNumber\": 1,\n \"PageSize\": 100\n}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/rest/order/query",
+ "host": ["{{baseUrl}}"],
+ "path": ["rest", "order", "query"]
+ },
+ "description": "Returns a page of orders. The plugin walks pages (PageNumber++) until a short page, then fetches status + download for each. Optional filters: StartDate, EndDate, DomainName, ProductCode."
+ }
+ }
+ ]
+ },
+ {
+ "name": "6. Revoke (Refund request)",
+ "description": "SSL Store models revocation as a refund request.",
+ "item": [
+ {
+ "name": "Refund request (revoke)",
+ "request": {
+ "method": "POST",
+ "header": [{ "key": "Content-Type", "value": "application/json" }],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"AuthRequest\": { \"PartnerCode\": \"{{partnerCode}}\", \"AuthToken\": \"{{authToken}}\" },\n \"TheSSLStoreOrderID\": \"{{theSslStoreOrderId}}\"\n}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/rest/order/refundrequest",
+ "host": ["{{baseUrl}}"],
+ "path": ["rest", "order", "refundrequest"]
+ },
+ "description": "Revokes/refunds the order. Can also target CustomOrderID or SerialNumber. AuthResponse.isError indicates failure."
+ }
+ }
+ ]
+ }
+ ]
+}
diff --git a/postman/SslStoreApi.postman_environment.json b/postman/SslStoreApi.postman_environment.json
new file mode 100644
index 0000000..2edbb5d
--- /dev/null
+++ b/postman/SslStoreApi.postman_environment.json
@@ -0,0 +1,18 @@
+{
+ "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c60",
+ "name": "SSL Store WBAPI",
+ "values": [
+ { "key": "baseUrl", "value": "https://sandbox-wbapi.thesslstore.com", "type": "default", "enabled": true },
+ { "key": "partnerCode", "value": "", "type": "secret", "enabled": true },
+ { "key": "authToken", "value": "", "type": "secret", "enabled": true },
+ { "key": "productCode", "value": "positivessl", "type": "default", "enabled": true },
+ { "key": "commonName", "value": "www.example.com", "type": "default", "enabled": true },
+ { "key": "csrPem", "value": "-----BEGIN CERTIFICATE REQUEST-----\n...replace with your CSR...\n-----END CERTIFICATE REQUEST-----", "type": "default", "enabled": true },
+ { "key": "approverEmail", "value": "admin@example.com", "type": "default", "enabled": true },
+ { "key": "validityDays", "value": "365", "type": "default", "enabled": true },
+ { "key": "webServerType", "value": "Other", "type": "default", "enabled": true },
+ { "key": "customOrderId", "value": "postman-{{$guid}}", "type": "default", "enabled": true },
+ { "key": "theSslStoreOrderId", "value": "", "type": "default", "enabled": true }
+ ],
+ "_postman_variable_scope": "environment"
+}
diff --git a/postman/SslStoreCaGateway.postman_collection.json b/postman/SslStoreCaGateway.postman_collection.json
deleted file mode 100644
index 61d246d..0000000
--- a/postman/SslStoreCaGateway.postman_collection.json
+++ /dev/null
@@ -1,290 +0,0 @@
-{
- "info": {
- "_postman_id": "b3f1a2c4-0000-4a00-9000-sslstorecagw01",
- "name": "SSL Store AnyCA Gateway REST",
- "description": "Exercises the AnyCA Gateway REST (EJBCA-compatible) API that fronts the SSL Store CA plugin, grouped by operation flow: Discovery, Enrollment (email DCV), Enrollment (DNS CNAME DCV), Renew/Reissue, Sync/Search, and Revocation.\n\n## Authentication\nThe gateway REST API uses **mutual TLS (client certificate)** auth, not headers or a bearer token. Configure your client certificate in **Postman → Settings → Certificates**, adding an entry for the gateway host (e.g. `20.7.36.37:8475`) with your PFX/PEM + key. The certificate must be trusted/enrolled by the gateway (the gateway logs the thumbprint via `AuthCertificateValidationCache`). If your gateway uses a self-signed TLS cert, turn **SSL certificate verification** off in Settings.\n\n## Variables\nSet these in the `SSL Store Gateway` environment (see `SslStoreCaGateway.postman_environment.json`): `baseUrl`, `caName`, `endEntityProfile`, `certificateProfile`, `username`, `password`, `commonName`, `csrPem`, `issuerDn`, `serialNumber`, `priorCertSN`, `revocationReason`.\n\n## Notes\n- The AnyCA Gateway REST surface mirrors the EJBCA REST contract; bodies below follow that shape. Product/template parameters (Approver Email, Validity Period, CName Auth Domain Validation, etc.) are normally supplied by Command from the certificate profile's enrollment fields — where a flow needs them they are shown as `extension_data` placeholders and may require tailoring to your profile.\n- `csrPem` must be a single-line PEM with `\\n` escapes, or use Postman's environment editor multi-line value.",
- "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
- },
- "variable": [
- { "key": "baseUrl", "value": "https://20.7.36.37:8475" },
- { "key": "caName", "value": "SslStoreGw" },
- { "key": "endEntityProfile", "value": "AnyCA" },
- { "key": "certificateProfile", "value": "PositiveSsl-RSA" },
- { "key": "username", "value": "Keyfactor_POSTMAN_TEST" },
- { "key": "password", "value": "" },
- { "key": "commonName", "value": "www.example.com" },
- { "key": "csrPem", "value": "-----BEGIN CERTIFICATE REQUEST-----\n...replace with your CSR...\n-----END CERTIFICATE REQUEST-----" },
- { "key": "issuerDn", "value": "CN=SslStoreGw" },
- { "key": "serialNumber", "value": "" },
- { "key": "priorCertSN", "value": "" },
- { "key": "revocationReason", "value": "CESSATION_OF_OPERATION" }
- ],
- "item": [
- {
- "name": "1. Discovery & Health",
- "description": "Read-only calls Command makes when connecting to the CA. Good smoke tests that auth and the plugin are up.",
- "item": [
- {
- "name": "CA management status",
- "request": {
- "method": "GET",
- "header": [],
- "url": {
- "raw": "{{baseUrl}}/ejbca/ejbca-rest-api/v1/ca/status",
- "host": ["{{baseUrl}}"],
- "path": ["ejbca", "ejbca-rest-api", "v1", "ca", "status"]
- },
- "description": "Plugin/CA management status. First call to confirm the gateway and plugin are reachable."
- }
- },
- {
- "name": "List CAs",
- "request": {
- "method": "GET",
- "header": [],
- "url": {
- "raw": "{{baseUrl}}/ejbca/ejbca-rest-api/v1/ca",
- "host": ["{{baseUrl}}"],
- "path": ["ejbca", "ejbca-rest-api", "v1", "ca"]
- },
- "description": "Lists configured CAs (expect your SslStoreGw CA)."
- }
- },
- {
- "name": "Authorized end entity profiles",
- "request": {
- "method": "GET",
- "header": [],
- "url": {
- "raw": "{{baseUrl}}/ejbca/ejbca-rest-api/v2/endentity/profiles/authorized",
- "host": ["{{baseUrl}}"],
- "path": ["ejbca", "ejbca-rest-api", "v2", "endentity", "profiles", "authorized"]
- }
- }
- },
- {
- "name": "Get end entity profile",
- "request": {
- "method": "GET",
- "header": [],
- "url": {
- "raw": "{{baseUrl}}/ejbca/ejbca-rest-api/v2/endentity/profile/{{endEntityProfile}}",
- "host": ["{{baseUrl}}"],
- "path": ["ejbca", "ejbca-rest-api", "v2", "endentity", "profile", "{{endEntityProfile}}"]
- }
- }
- },
- {
- "name": "Get certificate profile",
- "request": {
- "method": "GET",
- "header": [],
- "url": {
- "raw": "{{baseUrl}}/ejbca/ejbca-rest-api/v2/certificate/profile/{{certificateProfile}}",
- "host": ["{{baseUrl}}"],
- "path": ["ejbca", "ejbca-rest-api", "v2", "certificate", "profile", "{{certificateProfile}}"]
- },
- "description": "Certificate profile maps to an SSL Store product (e.g. PositiveSsl-RSA)."
- }
- }
- ]
- },
- {
- "name": "2. Enroll - New (Email DCV)",
- "description": "New enrollment validated by approver email. Order returns pending until the approver clicks the SSL Store email; Command retrieves the cert on the next sync.",
- "item": [
- {
- "name": "Create end entity",
- "request": {
- "method": "POST",
- "header": [{ "key": "Content-Type", "value": "application/json" }],
- "body": {
- "mode": "raw",
- "raw": "{\n \"username\": \"{{username}}\",\n \"password\": \"{{password}}\",\n \"subject_dn\": \"CN={{commonName}}\",\n \"subject_alt_name\": \"dNSName={{commonName}}\",\n \"ca_name\": \"{{caName}}\",\n \"certificate_profile_name\": \"{{certificateProfile}}\",\n \"end_entity_profile_name\": \"{{endEntityProfile}}\",\n \"token\": \"USERGENERATED\",\n \"extension_data\": [\n { \"name\": \"Approver Email\", \"value\": \"admin@example.com\" },\n { \"name\": \"Validity Period (In Days)\", \"value\": \"365\" }\n ]\n}"
- },
- "url": {
- "raw": "{{baseUrl}}/ejbca/ejbca-rest-api/v1/endentity",
- "host": ["{{baseUrl}}"],
- "path": ["ejbca", "ejbca-rest-api", "v1", "endentity"]
- },
- "description": "Registers the end entity + product/template parameters. `extension_data` names must match the certificate profile's enrollment fields."
- }
- },
- {
- "name": "Enroll (certificate request)",
- "request": {
- "method": "POST",
- "header": [{ "key": "Content-Type", "value": "application/json" }],
- "body": {
- "mode": "raw",
- "raw": "{\n \"certificate_request\": \"{{csrPem}}\",\n \"certificate_profile_name\": \"{{certificateProfile}}\",\n \"end_entity_profile_name\": \"{{endEntityProfile}}\",\n \"certificate_authority_name\": \"{{caName}}\",\n \"username\": \"{{username}}\",\n \"password\": \"{{password}}\",\n \"include_chain\": true\n}"
- },
- "url": {
- "raw": "{{baseUrl}}/ejbca/ejbca-rest-api/v1/certificate/certificaterequest",
- "host": ["{{baseUrl}}"],
- "path": ["ejbca", "ejbca-rest-api", "v1", "certificate", "certificaterequest"]
- },
- "description": "Submits the CSR. Expect HTTP 400 'requires external validation' while the order is pending — this is normal; the cert arrives via sync once the approver validates."
- }
- }
- ]
- },
- {
- "name": "3. Enroll - New (DNS CNAME DCV)",
- "description": "New enrollment validated by CNAME. The plugin publishes the CNAME via the mapped CNAME validator, verifies propagation, then polls for issuance up to DcvPollTimeoutSeconds. If issued in time the cert is returned inline; otherwise it returns pending and sync finishes it.",
- "item": [
- {
- "name": "Create end entity (CNAME DCV)",
- "request": {
- "method": "POST",
- "header": [{ "key": "Content-Type", "value": "application/json" }],
- "body": {
- "mode": "raw",
- "raw": "{\n \"username\": \"{{username}}\",\n \"password\": \"{{password}}\",\n \"subject_dn\": \"CN={{commonName}}\",\n \"subject_alt_name\": \"dNSName={{commonName}}\",\n \"ca_name\": \"{{caName}}\",\n \"certificate_profile_name\": \"{{certificateProfile}}\",\n \"end_entity_profile_name\": \"{{endEntityProfile}}\",\n \"token\": \"USERGENERATED\",\n \"extension_data\": [\n { \"name\": \"CName Auth Domain Validation\", \"value\": \"True\" },\n { \"name\": \"Validity Period (In Days)\", \"value\": \"365\" }\n ]\n}"
- },
- "url": {
- "raw": "{{baseUrl}}/ejbca/ejbca-rest-api/v1/endentity",
- "host": ["{{baseUrl}}"],
- "path": ["ejbca", "ejbca-rest-api", "v1", "endentity"]
- },
- "description": "Set `CName Auth Domain Validation=True` (or enable DnsValidationEnabled on the CA connection). A CNAME validator (e.g. Ns1CnameDomainValidator) must be mapped to the domain in the gateway's Domain Validation configuration."
- }
- },
- {
- "name": "Enroll (certificate request)",
- "request": {
- "method": "POST",
- "header": [{ "key": "Content-Type", "value": "application/json" }],
- "body": {
- "mode": "raw",
- "raw": "{\n \"certificate_request\": \"{{csrPem}}\",\n \"certificate_profile_name\": \"{{certificateProfile}}\",\n \"end_entity_profile_name\": \"{{endEntityProfile}}\",\n \"certificate_authority_name\": \"{{caName}}\",\n \"username\": \"{{username}}\",\n \"password\": \"{{password}}\",\n \"include_chain\": true\n}"
- },
- "url": {
- "raw": "{{baseUrl}}/ejbca/ejbca-rest-api/v1/certificate/certificaterequest",
- "host": ["{{baseUrl}}"],
- "path": ["ejbca", "ejbca-rest-api", "v1", "certificate", "certificaterequest"]
- },
- "description": "This call blocks while the plugin publishes the CNAME, verifies propagation, and polls for issuance (up to DcvPollTimeoutSeconds). Returns the cert inline if issued in time, else HTTP 400 'requires external validation' (pending)."
- }
- },
- {
- "name": "Poll issuance (search by CN)",
- "request": {
- "method": "POST",
- "header": [{ "key": "Content-Type", "value": "application/json" }],
- "body": {
- "mode": "raw",
- "raw": "{\n \"max_number_of_results\": 10,\n \"criteria\": [\n { \"property\": \"QUERY\", \"value\": \"{{commonName}}\", \"operation\": \"LIKE\" }\n ]\n}"
- },
- "url": {
- "raw": "{{baseUrl}}/ejbca/ejbca-rest-api/v2/certificate/search",
- "host": ["{{baseUrl}}"],
- "path": ["ejbca", "ejbca-rest-api", "v2", "certificate", "search"]
- },
- "description": "Re-run after enrollment to watch the order flip to issued once Sectigo validates the CNAME on its own schedule."
- }
- }
- ]
- },
- {
- "name": "4. Renew / Reissue",
- "description": "Renew (new order) vs reissue (same order) is chosen by the plugin from the prior order's expiry vs RenewalWindow. Requires the prior certificate serial number.",
- "item": [
- {
- "name": "Renew/Reissue (certificate request)",
- "request": {
- "method": "POST",
- "header": [{ "key": "Content-Type", "value": "application/json" }],
- "body": {
- "mode": "raw",
- "raw": "{\n \"certificate_request\": \"{{csrPem}}\",\n \"certificate_profile_name\": \"{{certificateProfile}}\",\n \"end_entity_profile_name\": \"{{endEntityProfile}}\",\n \"certificate_authority_name\": \"{{caName}}\",\n \"username\": \"{{username}}\",\n \"password\": \"{{password}}\",\n \"include_chain\": true,\n \"extension_data\": [\n { \"name\": \"PriorCertSN\", \"value\": \"{{priorCertSN}}\" }\n ]\n}"
- },
- "url": {
- "raw": "{{baseUrl}}/ejbca/ejbca-rest-api/v1/certificate/certificaterequest",
- "host": ["{{baseUrl}}"],
- "path": ["ejbca", "ejbca-rest-api", "v1", "certificate", "certificaterequest"]
- },
- "description": "In Command this is a renew/reissue enrollment carrying the prior cert serial. `PriorCertSN` must be set to the existing certificate's serial number."
- }
- }
- ]
- },
- {
- "name": "5. Sync / Search",
- "description": "Certificate/end-entity search endpoints Command uses during synchronization and inventory. Useful for verifying stored records and statuses.",
- "item": [
- {
- "name": "Certificate search - all",
- "request": {
- "method": "POST",
- "header": [{ "key": "Content-Type", "value": "application/json" }],
- "body": {
- "mode": "raw",
- "raw": "{\n \"max_number_of_results\": 100,\n \"criteria\": []\n}"
- },
- "url": {
- "raw": "{{baseUrl}}/ejbca/ejbca-rest-api/v2/certificate/search",
- "host": ["{{baseUrl}}"],
- "path": ["ejbca", "ejbca-rest-api", "v2", "certificate", "search"]
- },
- "description": "Returns stored certificates. If this ever 500s with 'm_safeCertContext is an invalid handle', a stored row has empty certificate bytes (fixed for revoked orders in the plugin; run a full sync to repair legacy rows)."
- }
- },
- {
- "name": "Certificate search - by CN",
- "request": {
- "method": "POST",
- "header": [{ "key": "Content-Type", "value": "application/json" }],
- "body": {
- "mode": "raw",
- "raw": "{\n \"max_number_of_results\": 20,\n \"criteria\": [\n { \"property\": \"QUERY\", \"value\": \"{{commonName}}\", \"operation\": \"LIKE\" }\n ]\n}"
- },
- "url": {
- "raw": "{{baseUrl}}/ejbca/ejbca-rest-api/v2/certificate/search",
- "host": ["{{baseUrl}}"],
- "path": ["ejbca", "ejbca-rest-api", "v2", "certificate", "search"]
- }
- }
- },
- {
- "name": "End entity search - by username",
- "request": {
- "method": "POST",
- "header": [{ "key": "Content-Type", "value": "application/json" }],
- "body": {
- "mode": "raw",
- "raw": "{\n \"max_number_of_results\": 10,\n \"criteria\": [\n { \"property\": \"USERNAME\", \"value\": \"{{username}}\", \"operation\": \"EQUAL\" }\n ]\n}"
- },
- "url": {
- "raw": "{{baseUrl}}/ejbca/ejbca-rest-api/v1/endentity/search",
- "host": ["{{baseUrl}}"],
- "path": ["ejbca", "ejbca-rest-api", "v1", "endentity", "search"]
- }
- }
- }
- ]
- },
- {
- "name": "6. Revocation",
- "description": "Revocation maps to SSL Store's refund request. Provide the issuer DN and the certificate serial number.",
- "item": [
- {
- "name": "Revoke certificate",
- "request": {
- "method": "PUT",
- "header": [],
- "url": {
- "raw": "{{baseUrl}}/ejbca/ejbca-rest-api/v1/certificate/{{issuerDn}}/{{serialNumber}}/revoke?reason={{revocationReason}}",
- "host": ["{{baseUrl}}"],
- "path": ["ejbca", "ejbca-rest-api", "v1", "certificate", "{{issuerDn}}", "{{serialNumber}}", "revoke"],
- "query": [
- { "key": "reason", "value": "{{revocationReason}}" }
- ]
- },
- "description": "Revoke by issuer DN + serial. `reason` uses RFC 5280 names (e.g. CESSATION_OF_OPERATION, KEY_COMPROMISE, UNSPECIFIED)."
- }
- }
- ]
- }
- ]
-}
diff --git a/postman/SslStoreCaGateway.postman_environment.json b/postman/SslStoreCaGateway.postman_environment.json
deleted file mode 100644
index abf6850..0000000
--- a/postman/SslStoreCaGateway.postman_environment.json
+++ /dev/null
@@ -1,19 +0,0 @@
-{
- "id": "b3f1a2c4-0000-4e00-9000-sslstorecaenv1",
- "name": "SSL Store Gateway",
- "values": [
- { "key": "baseUrl", "value": "https://20.7.36.37:8475", "type": "default", "enabled": true },
- { "key": "caName", "value": "SslStoreGw", "type": "default", "enabled": true },
- { "key": "endEntityProfile", "value": "AnyCA", "type": "default", "enabled": true },
- { "key": "certificateProfile", "value": "PositiveSsl-RSA", "type": "default", "enabled": true },
- { "key": "username", "value": "Keyfactor_POSTMAN_TEST", "type": "default", "enabled": true },
- { "key": "password", "value": "", "type": "secret", "enabled": true },
- { "key": "commonName", "value": "www.example.com", "type": "default", "enabled": true },
- { "key": "csrPem", "value": "-----BEGIN CERTIFICATE REQUEST-----\n...replace with your CSR...\n-----END CERTIFICATE REQUEST-----", "type": "default", "enabled": true },
- { "key": "issuerDn", "value": "CN=SslStoreGw", "type": "default", "enabled": true },
- { "key": "serialNumber", "value": "", "type": "default", "enabled": true },
- { "key": "priorCertSN", "value": "", "type": "default", "enabled": true },
- { "key": "revocationReason", "value": "CESSATION_OF_OPERATION", "type": "default", "enabled": true }
- ],
- "_postman_variable_scope": "environment"
-}
From cdd7ecb617defa67889c6eb479171d1bea8dd9d3 Mon Sep 17 00:00:00 2001
From: Brian Hill
Date: Wed, 15 Jul 2026 15:21:07 -0400
Subject: [PATCH 25/25] fixed bool values
---
SslStoreCaProxy/SslStoreCAPluginConfig.cs | 34 +++++++++++------------
SslStoreCaProxy/SslStoreCaProxy.cs | 4 ++-
2 files changed, 20 insertions(+), 18 deletions(-)
diff --git a/SslStoreCaProxy/SslStoreCAPluginConfig.cs b/SslStoreCaProxy/SslStoreCAPluginConfig.cs
index 4ab1abe..658ced7 100644
--- a/SslStoreCaProxy/SslStoreCAPluginConfig.cs
+++ b/SslStoreCaProxy/SslStoreCAPluginConfig.cs
@@ -74,7 +74,7 @@ public static Dictionary GetPluginAnnotations()
Comments = "Flag to Enable or Disable the CA connector.",
Hidden = false,
DefaultValue = true,
- Type = "Bool"
+ Type = "Boolean"
},
[ConfigConstants.RenewalWindow] = new PropertyConfigInfo()
{
@@ -91,7 +91,7 @@ public static Dictionary GetPluginAnnotations()
"Route53, Cloudflare) to be deployed and configured on the gateway. When disabled, email approver validation is used.",
Hidden = false,
DefaultValue = false,
- Type = "Bool"
+ Type = "Boolean"
},
[ConfigConstants.DnsVerificationServer] = new PropertyConfigInfo()
{
@@ -391,38 +391,38 @@ public static Dictionary GetTemplateParameterAnnotat
},
["File Auth Domain Validation"] = new PropertyConfigInfo()
{
- Comments = "Use file-based domain validation (True/False).",
+ Comments = "Use file-based domain validation.",
Hidden = false,
- DefaultValue = "False",
- Type = "String"
+ DefaultValue = false,
+ Type = "Boolean"
},
["CName Auth Domain Validation"] = new PropertyConfigInfo()
{
- Comments = "Use CNAME-based domain validation (True/False).",
+ Comments = "Use CNAME-based domain validation.",
Hidden = false,
- DefaultValue = "False",
- Type = "String"
+ DefaultValue = false,
+ Type = "Boolean"
},
["Is CU Order?"] = new PropertyConfigInfo()
{
- Comments = "Is this a CU (Customer) order (True/False).",
+ Comments = "Is this a CU (Customer) order.",
Hidden = false,
- DefaultValue = "False",
- Type = "String"
+ DefaultValue = false,
+ Type = "Boolean"
},
["Is Renewal Order?"] = new PropertyConfigInfo()
{
- Comments = "Is this a renewal order (True/False).",
+ Comments = "Is this a renewal order.",
Hidden = false,
- DefaultValue = "False",
- Type = "String"
+ DefaultValue = false,
+ Type = "Boolean"
},
["Is Trial Order?"] = new PropertyConfigInfo()
{
- Comments = "Is this a trial order (True/False).",
+ Comments = "Is this a trial order.",
Hidden = false,
- DefaultValue = "False",
- Type = "String"
+ DefaultValue = false,
+ Type = "Boolean"
}
};
}
diff --git a/SslStoreCaProxy/SslStoreCaProxy.cs b/SslStoreCaProxy/SslStoreCaProxy.cs
index 184b24d..03c75f6 100644
--- a/SslStoreCaProxy/SslStoreCaProxy.cs
+++ b/SslStoreCaProxy/SslStoreCaProxy.cs
@@ -887,7 +887,9 @@ private bool ResolveUseDnsValidation(EnrollmentProductInfo productInfo)
if (productInfo?.ProductParameters != null &&
productInfo.ProductParameters.TryGetValue("CName Auth Domain Validation", out var flag))
{
- return string.Equals(flag, "True", StringComparison.OrdinalIgnoreCase);
+ // The parameter is a Boolean toggle but arrives as a string ("true"/"false").
+ // bool.TryParse is case-insensitive; also accept the legacy "True"/"False" text.
+ return bool.TryParse(flag?.Trim(), out var parsed) && parsed;
}
return false;