diff --git a/.github/workflows/keyfactor-starter-workflow.yml b/.github/workflows/keyfactor-starter-workflow.yml index 1cf6642..0f3d3ae 100644 --- a/.github/workflows/keyfactor-starter-workflow.yml +++ b/.github/workflows/keyfactor-starter-workflow.yml @@ -11,8 +11,7 @@ on: jobs: call-starter-workflow: - uses: keyfactor/actions/.github/workflows/starter.yml@port-update-catalog-to-v5 - + uses: keyfactor/actions/.github/workflows/starter.yml@v5 with: command_token_url: ${{ vars.COMMAND_TOKEN_URL }} command_hostname: ${{ vars.COMMAND_HOSTNAME }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 1470e43..897676a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +# 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 (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 + 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 * Migrated from CAProxy.AnyGateway (BaseCAConnector) to IAnyCAPlugin interface diff --git a/README.md b/README.md index ffc5047..39fdfc1 100644 --- a/README.md +++ b/README.md @@ -46,13 +46,14 @@ 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 ## 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. @@ -273,6 +274,11 @@ 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` | + | **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 @@ -305,6 +311,45 @@ 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. + * **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 + + 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. + + 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 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. 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 + plugin resolved" error. * **CA Connection** @@ -316,6 +361,11 @@ 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. + * **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 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 @@ -406,6 +456,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 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 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 index ba17d13..3888fcd 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 new file mode 100644 index 0000000..332a332 --- /dev/null +++ b/SslStoreCaProxy/Clients/DNS/DnsVerificationHelper.cs @@ -0,0 +1,208 @@ +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 DefaultMaxVerificationAttempts = 3; + private const int DefaultVerificationDelaySeconds = 10; + private readonly int _maxVerificationAttempts; + private readonly int _verificationDelaySeconds; + + /// + /// 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. + /// 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)) + { + _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) + { + 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); + + // 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++) + { + 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.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) + { + _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.LogInformation("Waiting {Delay} seconds before next DNS verification attempt...", _verificationDelaySeconds); + await Task.Delay(TimeSpan.FromSeconds(_verificationDelaySeconds)); + } + } + + var totalWaitSeconds = (_maxVerificationAttempts - 1) * _verificationDelaySeconds; + _logger.LogWarning("DNS record did not propagate within {MaxAttempts} attempts (~{TotalSeconds} second(s))", + _maxVerificationAttempts, totalWaitSeconds); + 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/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/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..658ced7 100644 --- a/SslStoreCaProxy/SslStoreCAPluginConfig.cs +++ b/SslStoreCaProxy/SslStoreCAPluginConfig.cs @@ -15,6 +15,11 @@ 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 DnsVerificationServer = "DnsVerificationServer"; + public static string DnsPropagationMaxAttempts = "DnsPropagationMaxAttempts"; + public static string DnsPropagationDelaySeconds = "DnsPropagationDelaySeconds"; + public static string DcvPollTimeoutSeconds = "DcvPollTimeoutSeconds"; } public class Config @@ -25,6 +30,11 @@ 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 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() @@ -64,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() { @@ -72,6 +82,53 @@ 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 = "Boolean" + }, + [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" + }, + [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" + }, + [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" } }; } @@ -334,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 04bd232..03c75f6 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 @@ -22,40 +24,100 @@ 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"; + + /// 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; 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(); + 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", () => + { + 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 = 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)); + + _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}, 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, _config.DcvPollTimeoutSeconds, _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() @@ -129,45 +191,75 @@ 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}"); + + 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 @@ -176,9 +268,10 @@ 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")) + if (!productParameters.ContainsKey("PriorCertSN")) { // Extract domain name from CSR subject and SANs from the Keyfactor san parameter var domainName = subject?.Split(',') @@ -191,49 +284,110 @@ 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); + 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) { - _logger.LogTrace($"Approver Email {productInfo.ProductParameters["Approver Email"]}"); - arrayApproverEmails = productInfo.ProductParameters["Approver Email"].Split(new char[] { ',' }); - } + flow.Branch("EmailApproverValidation"); + string[] arrayApproverEmails = Array.Empty(); + if (productParameters.ContainsKey("Approver Email")) + { + _logger.LogTrace($"Approver Email {productParameters["Approver Email"]}"); + arrayApproverEmails = 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))); + _logger.LogTrace($"Validating approver emails against {allDomains.Count} domain(s): {string.Join(", ", allDomains)}"); - 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)}"); + 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); - _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 - }; + 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++; } - count++; + flow.EndBranch(); } - 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); + 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)) + { + 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, + 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. + if (!string.IsNullOrEmpty(enrollmentResponse.TheSslStoreOrderId) && !string.IsNullOrEmpty(enrollmentResponse.PartnerOrderId)) + { + 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.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, @@ -243,12 +397,19 @@ 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"]; + 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); + flow.Step("LookupPriorRequestId", $"SN={sn} -> {caRequestId}"); _logger.LogTrace($"Prior CA Request ID: {caRequestId}"); var priorSslStoreOrderId = ParseSslStoreOrderId(caRequestId); @@ -265,7 +426,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 @@ -275,34 +438,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 { @@ -319,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 @@ -370,6 +565,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); @@ -377,63 +574,106 @@ 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) + var isIssued = certStatus == (int)EndEntityStatus.GENERATED; + var isRevoked = certStatus == (int)EndEntityStatus.REVOKED; + + if (isIssued || isRevoked) { - var fullChain = string.Join("\n", certResponse.Certificates.Select(c => c.FileContent)); - var endEntityCert = X509Utilities.ExtractEndEntityCertificateContents(fullChain, null); - certificate = Convert.ToBase64String(endEntityCert.RawData); + // 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 () => 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) + { + // Order is issued - best-effort cleanup of any DNS validation records we published. + await flow.StepAsync("CleanupDnsValidation", + async () => await CleanupDnsValidationAsync(orderStatusResponse)); + } + flow.EndBranch(); } - } - _logger.MethodExit(); - return new AnyCAPluginCertificate + _logger.LogInformation("GetSingleRecord result for '{CaRequestId}': Status={Status}, certReturned={HasCert}", + caRequestId, certStatus, !string.IsNullOrEmpty(certificate)); + + 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 @@ -444,6 +684,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; } @@ -451,22 +692,30 @@ 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) + 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) + // 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) { - var fullChain = string.Join("\n", certResponse.Certificates.Select(c => c.FileContent)); - 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) || - certStatus == (int)EndEntityStatus.REVOKED) + if ((isIssued || isRevoked) && fileContent.Length > 0) { + added++; blockingBuffer.Add(new AnyCAPluginCertificate { CARequestID = compositeId, @@ -475,21 +724,129 @@ public async Task Synchronize(BlockingCollection blockin ProductID = $"{orderStatusResponse.ProductCode}" }, cancelToken); } + else + { + if (isRevoked) + _logger.LogWarning("Skipping revoked order {CompositeId} with no downloadable certificate (nothing to store).", compositeId); + 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; } + finally + { + _logger.MethodExit(); + } + } - _logger.MethodExit(); + /// + /// 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); + } + + /// + /// 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) @@ -517,5 +874,206 @@ 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)) + { + // 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; + } + + /// + /// 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) + { + 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, 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) + { + _logger.LogInformation($"Staging CNAME validation record for {domain}: {recordName} -> {recordValue}"); + + IDomainValidator validator; + try + { + 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, 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, 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; + 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"; + 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); + + // 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; + 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)"); + _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 ""; + } + + /// + /// 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) + { + _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, DnsValidationType); + 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.LogInformation("Cleaned up DNS validation record {RecordName} for '{Domain}'.", recordName, domain); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Best-effort cleanup of DNS validation record {RecordName} for '{Domain}' failed.", recordName, domain); + } + } + } + + /// + /// 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/docs/enrollment-flows.md b/docs/enrollment-flows.md new file mode 100644 index 0000000..8d743dc --- /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. **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")` +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/docsource/configuration.md b/docsource/configuration.md index d3592a4..b56cd0c 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,11 @@ 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` | +| **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 @@ -237,6 +243,45 @@ 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. +* **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 + +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. + +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 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. 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 +plugin resolved" error. ## Certificate Template Creation Step @@ -329,6 +374,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 diff --git a/integration-manifest.json b/integration-manifest.json index cae479c..a343037 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -7,9 +7,9 @@ "status": "production", "integration_type": "anyca-plugin", "support_level": "kf-supported", - "link_github": true, - "update_catalog": true, - "gateway_framework": "25.5", + "link_github": false, + "update_catalog": false, + "gateway_framework": "26.2", "about": { "carest": { "ca_plugin_config": [ @@ -36,6 +36,26 @@ { "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": "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." + }, + { + "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": [ diff --git a/postman/README.md b/postman/README.md new file mode 100644 index 0000000..bd07815 --- /dev/null +++ b/postman/README.md @@ -0,0 +1,75 @@ +# Postman collection — SSL Store WBAPI + +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 | +|------|---------| +| `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 WBAPI** environment (top-right). +3. Fill in `partnerCode`, `authToken`, and `csrPem` (at minimum). + +## Base URL + +- Sandbox: `https://sandbox-wbapi.thesslstore.com` (default) +- Production: `https://wbapi.thesslstore.com` + +## 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://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" +}