From 39e1312f248dc3e7a577e9654c198c5539ebed5e Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:10:15 -0700 Subject: [PATCH 01/13] build!: target net10.0 only and default DcvSupport to true MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin now builds against net10.0 exclusively (dropped net8.0 multi-targeting across all four projects), and DcvSupport now defaults to true — the default/CI-shipped build targets DCV-capable AnyCA Gateway 26.x hosts (Keyfactor.AnyGateway.IAnyCAPlugin 3.3.0, now a stable release) instead of GA 25.5.x. The GA/no-DCV build (IAnyCAPlugin 3.2.0) is now the opt-in path via -p:DcvSupport=false. integration-manifest.json's gateway_framework moves 25.5.0 -> 26.0.0 to match. README regenerated via doctool to reflect the net10.0-only install path and updated compatibility line. Removed the Microsoft.Bcl.AsyncInterfaces/System.IO.Pipelines/System.Text.Encodings.Web TFM-support overrides that are no longer needed on net10.0, and pinned WireMock.Net's transitive Scriban.Signed/System.Linq.Dynamic.Core/ OpenTelemetry.* dependencies past several CVEs surfaced once net10.0 became the only restore target. --- .../CERTInext.IntegrationRunner.csproj | 2 +- .../CERTInext.IntegrationTests.csproj | 12 ++-- CERTInext.Tests/CERTInext.Tests.csproj | 22 ++++--- CERTInext/CERTInext.csproj | 29 ++++----- DCV_BUILD_SUPPORT.md | 41 +++++++++++++ README.md | 59 +++++++++++++++++-- integration-manifest.json | 2 +- 7 files changed, 128 insertions(+), 39 deletions(-) create mode 100644 DCV_BUILD_SUPPORT.md diff --git a/CERTInext.IntegrationRunner/CERTInext.IntegrationRunner.csproj b/CERTInext.IntegrationRunner/CERTInext.IntegrationRunner.csproj index 19d52f0..6d90ca0 100644 --- a/CERTInext.IntegrationRunner/CERTInext.IntegrationRunner.csproj +++ b/CERTInext.IntegrationRunner/CERTInext.IntegrationRunner.csproj @@ -1,7 +1,7 @@ Exe - net8.0 + net10.0 disable warnings 12.0 diff --git a/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj b/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj index bd3ec73..6cfb4a5 100644 --- a/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj +++ b/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj @@ -1,14 +1,14 @@ - net8.0 + net10.0 disable warnings 12.0 false true - - false + + true $(DefineConstants);SUPPORTS_DCV @@ -36,9 +36,5 @@ - - - - diff --git a/CERTInext.Tests/CERTInext.Tests.csproj b/CERTInext.Tests/CERTInext.Tests.csproj index 84ce7a6..f53c709 100644 --- a/CERTInext.Tests/CERTInext.Tests.csproj +++ b/CERTInext.Tests/CERTInext.Tests.csproj @@ -1,14 +1,14 @@ - net8.0 + net10.0 disable warnings 12.0 false true - - false + + true $(DefineConstants);SUPPORTS_DCV @@ -33,11 +33,15 @@ all - + - - - - + + + + + diff --git a/CERTInext/CERTInext.csproj b/CERTInext/CERTInext.csproj index f683ea8..225d961 100644 --- a/CERTInext/CERTInext.csproj +++ b/CERTInext/CERTInext.csproj @@ -1,32 +1,32 @@ - net8.0;net10.0 + net10.0 Keyfactor.Extensions.CAPlugin.CERTInext CERTInextCAPlugin disable warnings 12.0 - false + true $(DefineConstants);SUPPORTS_DCV true - + matches the gateway host: 3.3.0 (DCV / 26.x hosts) by default, or 3.2.0 + (no-DCV / 25.5.x hosts, e.g. anygateway-rest 25.5.0) with -p:DcvSupport=false. The 3.3-only + IDomainValidatorFactory is only referenced from #if SUPPORTS_DCV code, so the no-DCV + 3.2.0 build still compiles cleanly. 3.3.0 is now a stable release (no longer prerelease + -78770-...) — see issue 0003. --> + @@ -36,9 +36,6 @@ Keyfactor.PKI 5.5.0. 2.0.0 has three known-moderate CVEs (GHSA-8xfc-gm6g-vgpv, GHSA-m44j-cfrm-g8qc, GHSA-v435-xc8x-wvr9), all fixed in 2.4.0+. --> - - - diff --git a/DCV_BUILD_SUPPORT.md b/DCV_BUILD_SUPPORT.md new file mode 100644 index 0000000..b78a3d8 --- /dev/null +++ b/DCV_BUILD_SUPPORT.md @@ -0,0 +1,41 @@ +# DNS-01 DCV: build flag and code fencing + +## Build flag + +DNS-01 DCV support is gated behind a single MSBuild property, **`DcvSupport`**, default `true`. It's defined in `CERTInext/CERTInext.csproj`: + +``` +dotnet build -p:DcvSupport=false +``` + +- `DcvSupport=true` (default): compiles against `Keyfactor.AnyGateway.IAnyCAPlugin` **3.3.0** (stable release), defines `SUPPORTS_DCV` — this is what CI ships, targeting 26.x/DCV-capable gateway hosts. +- `DcvSupport=false`: compiles against **3.2.0**, no `SUPPORTS_DCV` constant, targeting GA gateway hosts (AnyCA Gateway 25.5.x, see issue 0003). + +The project also only targets **`net10.0`** (single TFM, no more `net8.0`/`net10.0` multi-targeting). + +Relevant lines: [`CERTInext.csproj#L18-L19`](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInext.csproj#L18-L19) (the flag → `SUPPORTS_DCV` define) and [`#L29-L30`](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInext.csproj#L29-L30) (the package-version swap). + +The two test projects mirror this flag so DCV test files only compile in when asked: +- [`CERTInext.Tests.csproj#L11-L12`](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext.Tests/CERTInext.Tests.csproj#L11-L12) +- [`CERTInext.IntegrationTests.csproj#L11-L12`](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj#L11-L12) + +## Where `#if SUPPORTS_DCV` fences the feature + +All in `CERTInext/CERTInextCAPlugin.cs`: + +| Lines | What's fenced | +|---|---| +| [22–24](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L22-L24) | `using` alias for `IDomainValidatorFactory` | +| [72–75](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L72-L75) | typed `DomainValidatorFactory` property (casts the untyped `_domainValidatorFactory` field) | +| [155–163](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L155-L163) | internal test constructor that injects an `IDomainValidatorFactory` | +| [178–197](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L178-L197) | `SetDomainValidatorFactory` — real assignment vs. `#else` no-op log | +| [789–798](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L789-L798) | `Synchronize`: DCV-during-sync bookkeeping vars (age window, per-pass cap, counters) | +| [849–897](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L849-L897) | `Synchronize`: the actual per-order DCV-during-sync gate/attempt/refetch logic | +| [1014–1021](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L1014-L1021) | `Synchronize`: builds the DCV summary log clause — real stats vs. `#else` "not supported on this build" | +| [1108–1171](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L1108-L1171) | `EnrollNewAsync`: runs DCV right after a fresh order is placed, then polls for issuance | +| [1373–1424](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L1373-L1424) | `TryRunDcvDuringSyncAsync` — full retry-path body vs. `#else` `return false` no-op | +| [1442–1704](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L1442-L1704) | `PerformDcvIfNeededAsync` itself — the whole method only exists in the DCV build | + +Design note baked into the comments: `_domainValidatorFactory` is deliberately typed as `object` (not `IDomainValidatorFactory`) so the JIT never needs to resolve the 3.3-only type when `SUPPORTS_DCV` is off — casts only happen inside method bodies fenced by `#if`, which is what lets the no-DCV build load cleanly on a 3.2.0 gateway host (see the field comment at [L40-L60](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L40-L60), issue #7). + +Reminder: DCV is now the default build — `-p:DcvSupport=false` is the opt-out for GA/no-DCV hosts. Without it, the build targets 26.x hosts and depends on the stable `3.3.0` package. diff --git a/README.md b/README.md index d15656d..624eada 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ The CERTInext AnyCA Gateway REST plugin extends the certificate lifecycle capabi ## Compatibility -The CERTInext AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 25.5.0 and later. +The CERTInext AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 26.0.0 and later. ## Support The CERTInext 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. @@ -83,16 +83,15 @@ CERTInext operates three separate environments. Use the sandbox environment for 2. On the server hosting the AnyCA Gateway REST, download and unzip the latest [CERTInext AnyCA Gateway REST plugin](https://github.com/Keyfactor/certinext-caplugin/releases/latest) from GitHub. -3. Copy the unzipped directory (usually called `net8.0` or `net10.0`) to the Extensions directory: +3. Copy the unzipped directory (usually called `net10.0`) to the Extensions directory: ```shell Depending on your AnyCA Gateway REST version, copy the unzipped directory to one of the following locations: - Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net8.0\Extensions Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net10.0\Extensions ``` - > The directory containing the CERTInext AnyCA Gateway REST plugin DLLs (`net8.0` or `net10.0`) can be named anything, as long as it is unique within the `Extensions` directory. + > The directory containing the CERTInext AnyCA Gateway REST plugin DLLs (`net10.0`) can be named anything, as long as it is unique within the `Extensions` directory. 4. Restart the AnyCA Gateway REST service. @@ -338,6 +337,58 @@ To retrieve the full list of product codes available to your account, call the ` > Note: SSL/TLS products are supported on standard accounts — see the SSL/TLS table above for the exact sandbox/production code pair for each product. Private PKI (Production `100`, `104` / Sandbox `149`), S/MIME (`894`), and document-signing products (`819`–`827`) require special provisioning by eMudhra and are not available on standard SSL/TLS accounts — ordering them returns EMS-1162. +## Mechanics + +### Authentication + +Every CERTInext API call is an HTTP POST with a JSON body. There is no Authorization header. Instead, the body carries a `meta` block with an `authKey` field computed as: + +``` +authKey = SHA256(accessKey + requestTs + requestTxnId) +``` + +Where `requestTs` is the ISO 8601 timestamp and `requestTxnId` is a unique transaction UUID generated per request. The raw access key is never transmitted — only the derived hash is sent. This computation happens automatically on every outbound call. When `AuthMode` is `OAuth`, the gateway obtains a bearer token via the configured client credentials flow and injects it into the `meta` block instead. + +### Enrollment Decision Logic + +When the gateway calls `Enroll`, the plugin selects between three paths based on the enrollment type and the age of the prior certificate: + +1. **New enrollment** — no prior certificate exists. A new `GenerateOrderSSL` request is submitted. +2. **Renewal** — a prior certificate exists and its expiry is within the `RenewalWindowDays` threshold (default: 90 days). A new `GenerateOrderSSL` order is submitted within the configured renewal window (CERTInext has no dedicated renewal endpoint; the renewal-window check governs how Command tracks old→new, not which API is called). +3. **Reissue** — a prior certificate exists but is outside the renewal window. A new `GenerateOrderSSL` order is placed with the updated CSR/subject, replacing the prior certificate under a new subscription. + +The `RenewalWindowDays` template parameter controls the renewal/reissue boundary per certificate template. + +### Required Order Fields + +The `GenerateOrderSSL` API requires an `additionalInformation.remarks` field in every order request body. The gateway populates this field automatically with the text `"Issued via Keyfactor Command AnyCA REST Gateway."`. If you encounter error `EMS-918: Additional Information cannot be empty`, verify that the gateway version is current and that the field is being sent. + +### Order Lifecycle and Pending Approval + +CERTInext orders pass through several internal status stages before a certificate is issued. The plugin maps these to Keyfactor enrollment statuses as follows: + +- **Issued** (status 9, 20) → certificate returned immediately. +- **Pending approval** (status 2, 8, 15, 24) → enrollment returns a pending status to Command. If `AutoApprove` is enabled on the template, the plugin attempts automatic approval before returning. +- **Rejected / cancelled** (status 4, 5, 13, 14) → enrollment fails with an error. + +The gateway polls the `TrackOrder` endpoint during sync to pick up certificates that were approved after the initial enrollment call. + +### Synchronization + +Synchronization uses the `GetOrderReport` endpoint with paginated results (controlled by `PageSize`, default 100, max 500). Each page is fetched sequentially until all orders are retrieved. The plugin maps each order's status to a Keyfactor certificate status and returns the result set to the gateway framework, which reconciles it against the Command inventory. + +Expired certificates are included by default. Set `IgnoreExpired: true` on the connector to skip them during sync. + +### Product Code Resolution + +When an enrollment request arrives, the numeric CERTInext product code is resolved in this order: + +1. `ProductCode` template parameter (explicit override — use for sandbox or non-standard codes). +2. `ProfileId` template parameter (deprecated alias, accepted for backward compatibility). +3. Default production code looked up from the selected product name (e.g. **DV SSL** → `838`). + +If none of these yield a code, enrollment fails with a validation error. + ## Architecture This document describes how the CERTInext AnyCA Gateway REST plugin integrates with Keyfactor Command and the CERTInext certificate authority. It covers the three primary certificate lifecycle operations — synchronization, enrollment, and revocation — and how the plugin routes each through the CERTInext API. diff --git a/integration-manifest.json b/integration-manifest.json index 8a6d4ee..ff51a74 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -7,7 +7,7 @@ "link_github": true, "update_catalog": true, "description": "AnyCA REST Gateway plugin for CERTInext (eMudhra) certificate lifecycle management platform", - "gateway_framework": "25.5.0", + "gateway_framework": "26.0.0", "release_dir": "CERTInext/bin/Release", "release_project": "CERTInext/CERTInext.csproj", "about": { From bdc4d30bf8d5a125dcdc3ffa0420730bc497eba1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 22 Jul 2026 17:11:08 +0000 Subject: [PATCH 02/13] docs: auto-generate README and documentation [skip ci] --- README.md | 52 ---------------------------------------------------- 1 file changed, 52 deletions(-) diff --git a/README.md b/README.md index 624eada..aa1d062 100644 --- a/README.md +++ b/README.md @@ -337,58 +337,6 @@ To retrieve the full list of product codes available to your account, call the ` > Note: SSL/TLS products are supported on standard accounts — see the SSL/TLS table above for the exact sandbox/production code pair for each product. Private PKI (Production `100`, `104` / Sandbox `149`), S/MIME (`894`), and document-signing products (`819`–`827`) require special provisioning by eMudhra and are not available on standard SSL/TLS accounts — ordering them returns EMS-1162. -## Mechanics - -### Authentication - -Every CERTInext API call is an HTTP POST with a JSON body. There is no Authorization header. Instead, the body carries a `meta` block with an `authKey` field computed as: - -``` -authKey = SHA256(accessKey + requestTs + requestTxnId) -``` - -Where `requestTs` is the ISO 8601 timestamp and `requestTxnId` is a unique transaction UUID generated per request. The raw access key is never transmitted — only the derived hash is sent. This computation happens automatically on every outbound call. When `AuthMode` is `OAuth`, the gateway obtains a bearer token via the configured client credentials flow and injects it into the `meta` block instead. - -### Enrollment Decision Logic - -When the gateway calls `Enroll`, the plugin selects between three paths based on the enrollment type and the age of the prior certificate: - -1. **New enrollment** — no prior certificate exists. A new `GenerateOrderSSL` request is submitted. -2. **Renewal** — a prior certificate exists and its expiry is within the `RenewalWindowDays` threshold (default: 90 days). A new `GenerateOrderSSL` order is submitted within the configured renewal window (CERTInext has no dedicated renewal endpoint; the renewal-window check governs how Command tracks old→new, not which API is called). -3. **Reissue** — a prior certificate exists but is outside the renewal window. A new `GenerateOrderSSL` order is placed with the updated CSR/subject, replacing the prior certificate under a new subscription. - -The `RenewalWindowDays` template parameter controls the renewal/reissue boundary per certificate template. - -### Required Order Fields - -The `GenerateOrderSSL` API requires an `additionalInformation.remarks` field in every order request body. The gateway populates this field automatically with the text `"Issued via Keyfactor Command AnyCA REST Gateway."`. If you encounter error `EMS-918: Additional Information cannot be empty`, verify that the gateway version is current and that the field is being sent. - -### Order Lifecycle and Pending Approval - -CERTInext orders pass through several internal status stages before a certificate is issued. The plugin maps these to Keyfactor enrollment statuses as follows: - -- **Issued** (status 9, 20) → certificate returned immediately. -- **Pending approval** (status 2, 8, 15, 24) → enrollment returns a pending status to Command. If `AutoApprove` is enabled on the template, the plugin attempts automatic approval before returning. -- **Rejected / cancelled** (status 4, 5, 13, 14) → enrollment fails with an error. - -The gateway polls the `TrackOrder` endpoint during sync to pick up certificates that were approved after the initial enrollment call. - -### Synchronization - -Synchronization uses the `GetOrderReport` endpoint with paginated results (controlled by `PageSize`, default 100, max 500). Each page is fetched sequentially until all orders are retrieved. The plugin maps each order's status to a Keyfactor certificate status and returns the result set to the gateway framework, which reconciles it against the Command inventory. - -Expired certificates are included by default. Set `IgnoreExpired: true` on the connector to skip them during sync. - -### Product Code Resolution - -When an enrollment request arrives, the numeric CERTInext product code is resolved in this order: - -1. `ProductCode` template parameter (explicit override — use for sandbox or non-standard codes). -2. `ProfileId` template parameter (deprecated alias, accepted for backward compatibility). -3. Default production code looked up from the selected product name (e.g. **DV SSL** → `838`). - -If none of these yield a code, enrollment fails with a validation error. - ## Architecture This document describes how the CERTInext AnyCA Gateway REST plugin integrates with Keyfactor Command and the CERTInext certificate authority. It covers the three primary certificate lifecycle operations — synchronization, enrollment, and revocation — and how the plugin routes each through the CERTInext API. From 117ba562cf63fb01f7361ae86e7d466c692260b3 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:58:26 -0700 Subject: [PATCH 03/13] fix: wire up ValidityYears, surface manual DCV guidance, support CNAME-delegated DCV - Issue 0005: EnrollmentParams.ValidityYears is now read and takes precedence over ValidityDays/SubscriptionValidityYears when building the order request. Previously declared as an enrollment param but never consumed. - Issue 0007: when DCV is enabled but no IDomainValidatorFactory is wired, the enrollment result now surfaces the exact TXT record name/value the operator needs to publish manually, instead of leaving the order pending with only a log line. - Issue 0006: PerformDcvIfNeededAsync can now follow CNAME delegation for the DCV challenge hostname (bounded depth, loop-detected via DnsClient.NET, resolving against OS-configured resolvers) and route both the TXT record target and the DNS-provider lookup to the terminal name. Gated behind new DcvFollowCnameDelegation config flag, default off/unchanged behavior. --- .../CERTInextCAPluginCoverageTests.cs | 67 ++++++ CERTInext.Tests/CERTInextCAPluginDcvTests.cs | 214 +++++++++++++++++- .../CERTInextClientRequestShapeTests.cs | 36 +++ CERTInext.Tests/CnameResolverTests.cs | 160 +++++++++++++ CERTInext.Tests/FakeDomainValidator.cs | 26 +++ CERTInext/API/CertificateRequest.cs | 9 + CERTInext/CERTInext.csproj | 6 + CERTInext/CERTInextCAPlugin.cs | 162 ++++++++++++- CERTInext/CERTInextCAPluginConfig.cs | 23 ++ CERTInext/Client/CERTInextClient.cs | 23 +- CERTInext/Constants.cs | 9 + CERTInext/Dcv/CnameResolver.cs | 146 ++++++++++++ CERTInext/Models/EnrollmentParams.cs | 7 + integration-manifest.json | 4 + 14 files changed, 878 insertions(+), 14 deletions(-) create mode 100644 CERTInext.Tests/CnameResolverTests.cs create mode 100644 CERTInext/Dcv/CnameResolver.cs diff --git a/CERTInext.Tests/CERTInextCAPluginCoverageTests.cs b/CERTInext.Tests/CERTInextCAPluginCoverageTests.cs index f684f7d..6c7301e 100644 --- a/CERTInext.Tests/CERTInextCAPluginCoverageTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginCoverageTests.cs @@ -815,6 +815,73 @@ await plugin.Enroll( "invalid ValidityDays should fall back to null (use profile default)"); } + // --------------------------------------------------------------------------- + // Issue 0005: ValidityYears template parameter must reach EnrollCertificateRequest + // --------------------------------------------------------------------------- + + [Fact] + public async Task Enroll_PassesValidityYearsToRequest() + { + EnrollCertificateRequest capturedRequest = null; + + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), + It.IsAny())) + .Callback((req, _) => capturedRequest = req) + .ReturnsAsync(MockCertificateData.IssuedEnrollResponse()); + + var plugin = new CERTInextCAPlugin(mock.Object); + + var productInfo = MakeProductInfo(extras: new Dictionary + { + ["ValidityYears"] = "3" + }); + + await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=test.example.com", + san: null, + productInfo: productInfo, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + capturedRequest.Should().NotBeNull(); + capturedRequest!.ValidityYears.Should().Be(3); + } + + [Fact] + public async Task Enroll_WithInvalidValidityYears_FallsBackToNull() + { + EnrollCertificateRequest capturedRequest = null; + + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync( + It.IsAny(), + It.IsAny())) + .Callback((req, _) => capturedRequest = req) + .ReturnsAsync(MockCertificateData.IssuedEnrollResponse()); + + var plugin = new CERTInextCAPlugin(mock.Object); + + var productInfo = MakeProductInfo(extras: new Dictionary + { + ["ValidityYears"] = "not-a-number" + }); + + await plugin.Enroll( + csr: MockCertificateData.FakeCsrPem, + subject: "CN=test.example.com", + san: null, + productInfo: productInfo, + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + capturedRequest.Should().NotBeNull(); + capturedRequest!.ValidityYears.Should().BeNull( + "invalid ValidityYears should fall back to null (use ValidityDays/connector default)"); + } + // --------------------------------------------------------------------------- // C4a: Enroll with SAN dict containing null value array → EnrollCertificateAsync still called // --------------------------------------------------------------------------- diff --git a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs index 837ae8d..262e6fe 100644 --- a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs @@ -10,6 +10,7 @@ using Keyfactor.AnyGateway.Extensions; using Keyfactor.Extensions.CAPlugin.CERTInext.API; using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.Extensions.CAPlugin.CERTInext.Dcv; using Keyfactor.PKI.Enums.EJBCA; using Moq; using Xunit; @@ -318,7 +319,7 @@ public async Task Dcv_Skipped_WhenDcvEnabledFalse() // --------------------------------------------------------------------------- [Fact] - public async Task Dcv_SilentlyNoOps_WhenNoFactoryInjected_AndDcvEnabledTrue() + public async Task Dcv_NoFactoryInjected_StillReturnsCAsPendingResult_WhenNoGuidanceAvailable() { // Simulates a v3.2 gateway host: plugin instantiated via the parameterless // public production constructor, DcvEnabled=true in the connector config, @@ -326,12 +327,28 @@ public async Task Dcv_SilentlyNoOps_WhenNoFactoryInjected_AndDcvEnabledTrue() // (because the host's IAnyCAPlugin assembly doesn't even have that interface). // Enroll must: // * NOT throw (no missing-type / null-factory exception), - // * NOT touch the CA's TrackOrder for DCV purposes, - // * return the enrollment result the CA gave us (here: pending). + // * best-effort probe TrackOrder for manual DCV guidance (issue 0007) — + // this is the one behavior change from the prior "hard no-op": the plugin + // no longer refuses to touch TrackOrder at all, it just can't stage/verify + // anything without a validator, + // * still return the CA's pending status/certificate unchanged when that probe + // turns up no usable domainVerification data (as here). var mock = NewMock(); mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(MockCertificateData.PendingEnrollResponse()); + mock.Setup(c => c.TrackOrderAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new TrackOrderResponse + { + OrderDetails = new TrackOrderResponseDetails + { + OrderStatusId = "1", + CertificateStatusId = "1" + // DomainVerification intentionally null/unpopulated — nothing for the + // guidance probe to report on yet. + } + }); + // Internal test ctor with factory = null AND DcvEnabled = true. var plugin = new CERTInextCAPlugin(mock.Object, domainValidatorFactory: null, DcvConfig(enabled: true)); @@ -339,9 +356,9 @@ public async Task Dcv_SilentlyNoOps_WhenNoFactoryInjected_AndDcvEnabledTrue() result.Should().NotBeNull(); result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION, - "with no factory the CA's pending response must be passed through unchanged"); - mock.Verify(c => c.TrackOrderAsync(It.IsAny(), It.IsAny()), Times.Never, - "EnrollNewAsync must short-circuit the DCV block when _domainValidatorFactory is null"); + "with no factory and no guidance available, the CA's pending response must be passed through unchanged"); + result.EnrollmentContext.Should().BeEmpty( + "no domainVerification data was available to build manual DCV guidance from"); } [Fact] @@ -816,5 +833,190 @@ public async Task Dcv_WaitsForIssuance_AfterDcvVerifies() mock.Verify(c => c.GetCertificateAsync(MockCertificateData.DcvOrderId, It.IsAny()), Times.AtLeast(2), "plugin should have polled at least twice for issuance"); } + + // --------------------------------------------------------------------------- + // Issue 0007 — no IDomainValidatorFactory wired at all: surface manual DCV + // guidance (TXT record name + expected value) via EnrollmentContext/StatusMessage + // instead of silently leaving the order pending with only a log line. + // + // Distinct from Dcv_Throws_WhenNoProviderForDomain above, which covers a factory + // that *was* injected but returns null for the specific domain — that case still + // throws unchanged; this case is "no factory object at all". + // --------------------------------------------------------------------------- + + [Fact] + public async Task Dcv_NoFactoryWired_SurfacesManualTxtGuidanceInEnrollmentResult() + { + var mock = NewMock(); + string orderNumber = MockCertificateData.DcvOrderId; + string domain = MockCertificateData.DcvDomain; + string token = MockCertificateData.DcvToken; + + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = orderNumber, Status = "pending" }); + + mock.Setup(c => c.TrackOrderAsync(orderNumber, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvPendingTrackResponse(orderNumber, domain)); + + mock.Setup(c => c.GetDcvAsync(orderNumber, domain, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse(token)); + + // No factory injected at all — the "unwired" case from issue #7. + var plugin = new CERTInextCAPlugin(mock.Object, (IDomainValidatorFactory)null, DcvConfig()); + + var result = await Enroll(plugin); + + string expectedHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, domain); + result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION, + "the order is still genuinely pending — only the message/context changes"); + result.EnrollmentContext.Should().ContainKey(expectedHostname); + result.EnrollmentContext[expectedHostname].Should().Be(token); + result.StatusMessage.Should().Contain(expectedHostname).And.Contain(token); + + // No staging/verification attempted — there's no validator to do it with. + mock.Verify(c => c.VerifyDcvAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Dcv_NoFactoryWired_WhenGuidanceLookupFails_FallsBackToPlainPendingMessage() + { + var mock = NewMock(); + string orderNumber = MockCertificateData.DcvOrderId; + + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = orderNumber, Status = "pending" }); + + // TrackOrder fails outright — guidance lookup must fail closed, not throw out of Enroll. + mock.Setup(c => c.TrackOrderAsync(orderNumber, It.IsAny())) + .ThrowsAsync(new Exception("simulated TrackOrder outage")); + + var plugin = new CERTInextCAPlugin(mock.Object, (IDomainValidatorFactory)null, DcvConfig()); + + var result = await Enroll(plugin); + + result.EnrollmentContext.Should().BeEmpty(); + result.StatusMessage.Should().Contain("pending approval", + "behavior must be unchanged from before the fix when guidance can't be built"); + } + + [Fact] + public async Task Dcv_NoFactoryWired_ButDcvDisabled_DoesNotAttemptGuidanceLookup() + { + var mock = NewMock(); + string orderNumber = MockCertificateData.DcvOrderId; + + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = orderNumber, Status = "pending" }); + + var config = DcvConfig(enabled: false); + var plugin = new CERTInextCAPlugin(mock.Object, (IDomainValidatorFactory)null, config); + + var result = await Enroll(plugin); + + result.EnrollmentContext.Should().BeEmpty(); + mock.Verify(c => c.TrackOrderAsync(It.IsAny(), It.IsAny()), Times.Never, + "no DCV work of any kind should be attempted when DcvEnabled=false"); + } + + // --------------------------------------------------------------------------- + // Issue 0006 — CNAME delegation for the DCV challenge hostname. + // --------------------------------------------------------------------------- + + [Fact] + public async Task Dcv_CnameDelegationEnabled_RoutesToTerminalNameValidator() + { + var (mock, _) = HappyPathMocks(); + + const string terminalName = "validate.dns-provider.net"; + string challengeHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, MockCertificateData.DcvDomain); + + var validator = new FakeDomainValidator(); + // Keyed ONLY on the terminal name — if the plugin still queried the raw domain + // (or the raw challenge hostname) this would return null and the enrollment + // would throw "No DNS provider plugin is configured". + var factory = new KeyedDomainValidatorFactory(new Dictionary + { + [terminalName] = validator + }); + + var cnameChain = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [challengeHostname] = terminalName + }; + var resolver = new CnameResolver((name, ct) => + { + cnameChain.TryGetValue(name, out var target); + return Task.FromResult(target); + }); + + var config = DcvConfig(dcvWaitForIssuanceSeconds: 10); + config.DcvFollowCnameDelegation = true; + + var plugin = new CERTInextCAPlugin(mock.Object, factory, config, resolver); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + factory.RequestedKeys.Should().ContainSingle().Which.Should().Be(terminalName, + "the DNS provider factory must be queried with the resolved terminal name, not the raw domain"); + validator.StagedRecords.Should().ContainSingle().Which.key.Should().Be(terminalName, + "the TXT record must be published at the CNAME's terminal name"); + validator.CleanedUpKeys.Should().ContainSingle().Which.Should().Be(terminalName); + } + + [Fact] + public async Task Dcv_CnameDelegationDisabled_UsesRawDomainUnchanged() + { + // Flag left at its default (false): behavior must be byte-for-byte identical to the + // pre-issue-0006 happy path — the terminal-name resolver is never even consulted. + var (mock, validator) = HappyPathMocks(); + var plugin = BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator), + DcvConfig(dcvWaitForIssuanceSeconds: 10)); + + var result = await Enroll(plugin); + + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + string expectedHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, MockCertificateData.DcvDomain); + validator.StagedRecords.Should().ContainSingle().Which.Should().Be((expectedHostname, MockCertificateData.DcvToken)); + } + + [Fact] + public async Task Dcv_CnameDelegationEnabled_LoopDetected_ThrowsCleanly() + { + var mock = NewMock(); + mock.Setup(c => c.EnrollCertificateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EnrollCertificateResponse { Id = MockCertificateData.DcvOrderId, Status = "pending" }); + mock.Setup(c => c.TrackOrderAsync(MockCertificateData.DcvOrderId, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvPendingTrackResponse()); + mock.Setup(c => c.GetDcvAsync(MockCertificateData.DcvOrderId, MockCertificateData.DcvDomain, Constants.Dcv.MethodDnsTxt, It.IsAny())) + .ReturnsAsync(MockCertificateData.DcvTokenResponse()); + + string challengeHostname = string.Format(Constants.Dcv.DefaultTxtRecordTemplate, MockCertificateData.DcvDomain); + var loopChain = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [challengeHostname] = "loop-a.example.com", + ["loop-a.example.com"] = challengeHostname // cycles back to the start + }; + var resolver = new CnameResolver((name, ct) => + { + loopChain.TryGetValue(name, out var target); + return Task.FromResult(target); + }); + + var config = DcvConfig(); + config.DcvFollowCnameDelegation = true; + + var plugin = new CERTInextCAPlugin( + mock.Object, new FakeDomainValidatorFactory(new FakeDomainValidator()), config, resolver); + + Func act = () => Enroll(plugin); + + await act.Should().ThrowAsync().WithMessage("*loop*"); + + // Nothing should have been staged before the loop was detected. + mock.Verify(c => c.VerifyDcvAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } } } diff --git a/CERTInext.Tests/CERTInextClientRequestShapeTests.cs b/CERTInext.Tests/CERTInextClientRequestShapeTests.cs index 4e59495..9b958a8 100644 --- a/CERTInext.Tests/CERTInextClientRequestShapeTests.cs +++ b/CERTInext.Tests/CERTInextClientRequestShapeTests.cs @@ -288,5 +288,41 @@ public async Task ValidityDays_OnRequest_OverridesConnectorDefault() CapturedOrderBody().GetProperty("subscriptionDetails") .GetProperty("validity").GetString().Should().Be("2"); } + + // ----------------------------------------------------------------------- + // Issue 0005 — ValidityYears request-parameter reaches the order body and + // takes precedence over both ValidityDays and the connector default. + // ----------------------------------------------------------------------- + + [Fact] + public async Task ValidityYears_OnRequest_OverridesConnectorDefaultAndValidityDays() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.SubscriptionValidityYears = "1"; // connector default = 1 year + + var req = BasicEnrollRequest(); + req.ValidityDays = 730; // would compute to 2 years if ValidityYears weren't set + req.ValidityYears = 3; // explicit override — must win + + await BuildClient(cfg).EnrollCertificateAsync(req); + + CapturedOrderBody().GetProperty("subscriptionDetails") + .GetProperty("validity").GetString().Should().Be("3"); + } + + [Fact] + public async Task ValidityYears_Unset_FallsBackToValidityDaysThenConnectorDefault() + { + StubHappyEnroll(); + var cfg = MinimalConfig(); + cfg.SubscriptionValidityYears = "2"; + + // ValidityYears not set on the request — connector default must be used. + await BuildClient(cfg).EnrollCertificateAsync(BasicEnrollRequest()); + + CapturedOrderBody().GetProperty("subscriptionDetails") + .GetProperty("validity").GetString().Should().Be("2"); + } } } diff --git a/CERTInext.Tests/CnameResolverTests.cs b/CERTInext.Tests/CnameResolverTests.cs new file mode 100644 index 0000000..1e1d179 --- /dev/null +++ b/CERTInext.Tests/CnameResolverTests.cs @@ -0,0 +1,160 @@ +// Copyright 2024 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// At http://www.apache.org/licenses/LICENSE-2.0 + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.Extensions.CAPlugin.CERTInext.Dcv; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests +{ + /// + /// Unit tests for 's hop-walking algorithm (depth cap + loop + /// detection). Exercised via the internal delegate-injection constructor against a fake + /// in-memory CNAME chain map, so no real DNS queries are made. + /// + public class CnameResolverTests + { + /// + /// Builds a resolver whose single-hop lookup is backed by + /// (source name → CNAME target). Names absent from the map are terminal (no CNAME). + /// + private static CnameResolver ResolverFor(Dictionary chain) => + new CnameResolver((name, ct) => + { + chain.TryGetValue(name, out var target); + return Task.FromResult(target); + }); + + [Fact] + public async Task ResolveTerminalNameAsync_NoCname_ReturnsSameName() + { + var resolver = ResolverFor(new Dictionary()); + + string result = await resolver.ResolveTerminalNameAsync("_dcv-challenge.example.com"); + + result.Should().Be("_dcv-challenge.example.com"); + } + + [Fact] + public async Task ResolveTerminalNameAsync_SingleHop_ReturnsTarget() + { + var chain = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["_dcv-challenge.example.com"] = "validate.dns-provider.net" + }; + var resolver = ResolverFor(chain); + + string result = await resolver.ResolveTerminalNameAsync("_dcv-challenge.example.com"); + + result.Should().Be("validate.dns-provider.net"); + } + + [Fact] + public async Task ResolveTerminalNameAsync_MultiHopChain_FollowsToTerminalName() + { + var chain = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["_dcv-challenge.example.com"] = "hop1.delegated.net", + ["hop1.delegated.net"] = "hop2.delegated.net", + ["hop2.delegated.net"] = "terminal.dns-provider.net" + // terminal.dns-provider.net absent → terminal + }; + var resolver = ResolverFor(chain); + + string result = await resolver.ResolveTerminalNameAsync("_dcv-challenge.example.com"); + + result.Should().Be("terminal.dns-provider.net"); + } + + [Fact] + public async Task ResolveTerminalNameAsync_TrailingDotAndCase_AreNormalized() + { + var chain = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["_dcv-challenge.example.com"] = "Target.Delegated.NET." + }; + var resolver = ResolverFor(chain); + + string result = await resolver.ResolveTerminalNameAsync("_dcv-challenge.example.com"); + + result.Should().Be("Target.Delegated.NET", + "trailing root dot should be stripped for use as a hostname/lookup key"); + } + + // --------------------------------------------------------------------------- + // Loop detection + // --------------------------------------------------------------------------- + + [Fact] + public async Task ResolveTerminalNameAsync_DirectLoop_ThrowsCleanly() + { + var chain = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["a.example.com"] = "b.example.com", + ["b.example.com"] = "a.example.com" // cycles back + }; + var resolver = ResolverFor(chain); + + Func act = () => resolver.ResolveTerminalNameAsync("a.example.com"); + + await act.Should().ThrowAsync() + .WithMessage("*loop detected*"); + } + + [Fact] + public async Task ResolveTerminalNameAsync_SelfLoop_ThrowsCleanly() + { + var chain = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["a.example.com"] = "a.example.com" + }; + var resolver = ResolverFor(chain); + + Func act = () => resolver.ResolveTerminalNameAsync("a.example.com"); + + await act.Should().ThrowAsync() + .WithMessage("*loop detected*"); + } + + // --------------------------------------------------------------------------- + // Depth cap + // --------------------------------------------------------------------------- + + [Fact] + public async Task ResolveTerminalNameAsync_ChainWithinDepthCap_Succeeds() + { + // 9 hops (< MaxCnameDepth=10) then terminal — must succeed. + var chain = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < 9; i++) + chain[$"hop{i}.example.com"] = $"hop{i + 1}.example.com"; + // hop9.example.com has no further entry → terminal + + var resolver = ResolverFor(chain); + + string result = await resolver.ResolveTerminalNameAsync("hop0.example.com"); + + result.Should().Be("hop9.example.com"); + } + + [Fact] + public async Task ResolveTerminalNameAsync_ChainExceedingDepthCap_ThrowsCleanly() + { + // 11 distinct hops (> MaxCnameDepth=10), no loop — must fail on depth, not hang. + var chain = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < 11; i++) + chain[$"hop{i}.example.com"] = $"hop{i + 1}.example.com"; + + var resolver = ResolverFor(chain); + + Func act = () => resolver.ResolveTerminalNameAsync("hop0.example.com"); + + await act.Should().ThrowAsync() + .WithMessage("*maximum depth*"); + } + } +} diff --git a/CERTInext.Tests/FakeDomainValidator.cs b/CERTInext.Tests/FakeDomainValidator.cs index 6b42475..277cbfb 100644 --- a/CERTInext.Tests/FakeDomainValidator.cs +++ b/CERTInext.Tests/FakeDomainValidator.cs @@ -66,4 +66,30 @@ internal sealed class FakeDomainValidatorFactory : IDomainValidatorFactory /// The validator this factory returns; exposed for assertions in tests. public IDomainValidator PrimaryValidator => _validator; } + + /// + /// Factory that resolves a different validator per exact domain lookup key, keyed + /// case-insensitively. Used by CNAME-delegation tests (issue 0006) to prove that the + /// terminal (resolved) name — not the raw enrollment domain — is what gets used to look up + /// the DNS provider plugin. + /// + internal sealed class KeyedDomainValidatorFactory : IDomainValidatorFactory + { + private readonly Dictionary _validatorsByDomain; + + public KeyedDomainValidatorFactory(Dictionary validatorsByDomain) + { + _validatorsByDomain = new Dictionary( + validatorsByDomain, System.StringComparer.OrdinalIgnoreCase); + } + + /// Every lookup key this factory was asked to resolve, in call order. + public List RequestedKeys { get; } = new(); + + public IDomainValidator ResolveDomainValidator(string domain, string validationType) + { + RequestedKeys.Add(domain); + return _validatorsByDomain.TryGetValue(domain, out var validator) ? validator : null; + } + } } diff --git a/CERTInext/API/CertificateRequest.cs b/CERTInext/API/CertificateRequest.cs index 7f02df0..e7ddeed 100644 --- a/CERTInext/API/CertificateRequest.cs +++ b/CERTInext/API/CertificateRequest.cs @@ -570,6 +570,15 @@ public class EnrollCertificateRequest [JsonPropertyName("csr")] public string Csr { get; set; } + /// + /// Explicit subscription validity in years (1, 2, or 3), sourced from the + /// ValidityYears template enrollment parameter. Takes precedence over + /// when set. See issue 0005. + /// + [JsonPropertyName("validityYears")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? ValidityYears { get; set; } + [JsonPropertyName("validityDays")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? ValidityDays { get; set; } diff --git a/CERTInext/CERTInext.csproj b/CERTInext/CERTInext.csproj index 225d961..021b4b5 100644 --- a/CERTInext/CERTInext.csproj +++ b/CERTInext/CERTInext.csproj @@ -28,6 +28,12 @@ -78770-...) — see issue 0003. --> + + diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs index 231f611..b73dd06 100644 --- a/CERTInext/CERTInextCAPlugin.cs +++ b/CERTInext/CERTInextCAPlugin.cs @@ -15,6 +15,7 @@ using Keyfactor.AnyGateway.Extensions; using Keyfactor.Extensions.CAPlugin.CERTInext.API; using Keyfactor.Extensions.CAPlugin.CERTInext.Client; +using Keyfactor.Extensions.CAPlugin.CERTInext.Dcv; using Keyfactor.Extensions.CAPlugin.CERTInext.Models; using Keyfactor.Logging; using Keyfactor.PKI.Enums.EJBCA; @@ -83,6 +84,14 @@ public class CERTInextCAPlugin : IAnyCAPlugin, IDisposable // to stage TXT records for the same order. The value byte is unused; this is a set. private readonly ConcurrentDictionary _dcvInFlight = new(); +#if SUPPORTS_DCV + // Issue 0006: resolves CNAME delegation for the DCV challenge hostname when + // DcvFollowCnameDelegation is enabled. Only ever referenced from PerformDcvIfNeededAsync, + // which is itself SUPPORTS_DCV-only, so this stays fenced alongside it. Defaults to the + // real DNS-backed resolver; test constructors may override it with a fake. + private ICnameResolver _cnameResolver = new CnameResolver(); +#endif + // --------------------------------------------------------------------------- // Constructors // --------------------------------------------------------------------------- @@ -153,12 +162,18 @@ internal CERTInextCAPlugin(ICERTInextClient client, CERTInextConfig config) /// [InternalsVisibleTo]. See issue #7. /// #if SUPPORTS_DCV - internal CERTInextCAPlugin(ICERTInextClient client, IDomainValidatorFactory domainValidatorFactory, CERTInextConfig config = null) + internal CERTInextCAPlugin( + ICERTInextClient client, + IDomainValidatorFactory domainValidatorFactory, + CERTInextConfig config = null, + ICnameResolver cnameResolver = null) { _client = client; _clientWasInjected = true; _domainValidatorFactory = domainValidatorFactory; _config = config ?? new CERTInextConfig(); + if (cnameResolver != null) + _cnameResolver = cnameResolver; } #endif @@ -1094,6 +1109,7 @@ private async Task EnrollNewAsync( { ProfileId = ep.ProfileId, Csr = csr, + ValidityYears = ep.ValidityYears > 0 ? ep.ValidityYears : (int?)null, ValidityDays = ep.ValidityDays > 0 ? ep.ValidityDays : (int?)null, Subject = subject, Sans = BuildSanList(san), @@ -1168,12 +1184,116 @@ private async Task EnrollNewAsync( } } } + else if (_domainValidatorFactory == null && _config.DcvEnabled && !string.IsNullOrEmpty(orderNumber)) + { + // Issue 0007: DCV is enabled but no IDomainValidatorFactory was ever injected + // (see the startup warning in Initialize), so the plugin cannot stage the DNS + // TXT record automatically — the order will simply sit at EXTERNALVALIDATION + // until an operator notices. Best-effort surface the exact record they need to + // publish manually via EnrollmentContext/StatusMessage so Command's UI shows it, + // instead of leaving them with only a log line to go on. + var guidance = await TryBuildManualDcvGuidanceAsync(orderNumber, CancellationToken.None); + if (guidance.HasValue) + { + var pendingResult = BuildEnrollmentResult(enrollResp, ep.AutoApprove); + pendingResult.StatusMessage = $"{pendingResult.StatusMessage} {guidance.Value.message}"; + foreach (var kvp in guidance.Value.context) + pendingResult.EnrollmentContext[kvp.Key] = kvp.Value; + + _logger.MethodExit(LogLevel.Debug); + return pendingResult; + } + } #endif _logger.MethodExit(LogLevel.Debug); return BuildEnrollmentResult(enrollResp, ep.AutoApprove); } +#if SUPPORTS_DCV + /// + /// Best-effort helper for issue 0007: when DCV is enabled but no + /// has been injected, the plugin cannot stage the + /// DNS TXT record automatically. Rather than leave the operator with only a log line, + /// this builds the exact TXT record name(s) and expected value(s) they need to publish + /// by hand, reusing the same TrackOrder/GetDcv calls + /// would have used — without attempting to stage/verify anything itself (there's no + /// validator to do that with). + /// + /// Returns null when no pending DNS-TXT domains could be identified (e.g. the + /// order isn't yet DCV-eligible) or the lookup itself failed — in that case behavior is + /// unchanged from before this fix: the caller falls back to the original pending message + /// and the operator has to consult logs, same as before. + /// + private async Task<(string message, Dictionary context)?> TryBuildManualDcvGuidanceAsync( + string orderNumber, CancellationToken ct) + { + try + { + var track = await _client.TrackOrderAsync(orderNumber, ct); + var domainVerification = track.OrderDetails?.DomainVerification; + if (domainVerification == null) + return null; + + var pendingDomains = domainVerification.GetDomainEntries() + .Where(kvp => + { + if (!string.Equals(kvp.Value?.DcvStatus, Constants.Dcv.StatusPending, StringComparison.Ordinal)) + return false; + string method = kvp.Value?.DcvMethod ?? string.Empty; + return string.IsNullOrEmpty(method) + || string.Equals(method, Constants.Dcv.MethodDnsTxt, StringComparison.Ordinal) + || string.Equals(method, Constants.Dcv.MethodDnsTxtLabel, StringComparison.OrdinalIgnoreCase); + }) + .ToList(); + + if (pendingDomains.Count == 0) + return null; + + string template = string.IsNullOrWhiteSpace(_config.DcvTxtRecordTemplate) + ? Constants.Dcv.DefaultTxtRecordTemplate + : _config.DcvTxtRecordTemplate; + + var context = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (domain, _) in pendingDomains) + { + try + { + var dcvResp = await _client.GetDcvAsync(orderNumber, domain, Constants.Dcv.MethodDnsTxt, ct); + string token = dcvResp?.DcvDetails?.Token; + if (!string.IsNullOrWhiteSpace(token)) + context[string.Format(template, domain)] = token; + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Could not retrieve DCV token while building manual-publish guidance. " + + "OrderNumber={OrderNumber}, Domain={Domain}", orderNumber, domain); + } + } + + if (context.Count == 0) + return null; + + string message = + "DCV is enabled but no DNS provider plugin is configured on this gateway, so the " + + "required DNS TXT record(s) could not be published automatically. Publish the " + + "following record(s) manually to complete validation: " + + string.Join("; ", context.Select(kvp => $"{kvp.Key} = {kvp.Value}")) + + ". The certificate will be issued once the record(s) resolve and the order is " + + "picked up by a later synchronization."; + + return (message, context); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Failed to build manual DCV guidance for order {OrderNumber}.", orderNumber); + return null; + } + } +#endif + /// /// Handles Renew and RenewOrReissue enrollment flows. /// Determines whether to renew (API call on existing ID) or fall back to new @@ -1635,10 +1755,46 @@ private async Task PerformDcvIfNeededAsync( : _config.DcvTxtRecordTemplate; string hostname = string.Format(template, domain); - var validator = DomainValidatorFactory.ResolveDomainValidator(domain, "dns-01"); + // Issue 0006: when the operator has delegated the challenge subdomain to a + // separate validation zone via CNAME (a common pattern to keep automation + // credentials out of the production zone), follow that delegation to the + // terminal name so both the TXT record target and the DNS-provider lookup key + // route to the zone that actually owns the record. Off by default — when + // DcvFollowCnameDelegation is false, hostname/lookup key are unchanged from + // today (byte-for-byte identical behavior for existing, non-delegated deployments). + string validatorLookupKey = domain; + if (_config.DcvFollowCnameDelegation) + { + string terminalName; + try + { + terminalName = await _cnameResolver.ResolveTerminalNameAsync(hostname, ct); + } + catch (Exception ex) + { + _logger.LogError(ex, + "CNAME delegation resolution failed for DCV challenge hostname '{Hostname}' " + + "(order {OrderNumber}, domain {Domain}).", + hostname, orderNumber, domain); + throw; + } + + if (!string.Equals(terminalName, hostname, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation( + "DCV CNAME delegation followed. OrderNumber={OrderNumber}, Domain={Domain}, " + + "ChallengeHostname={Hostname}, ResolvedTerminal={Terminal}", + orderNumber, domain, hostname, terminalName); + } + + hostname = terminalName; + validatorLookupKey = terminalName; + } + + var validator = DomainValidatorFactory.ResolveDomainValidator(validatorLookupKey, "dns-01"); if (validator == null) throw new InvalidOperationException( - $"No DNS provider plugin is configured for domain '{domain}'. " + + $"No DNS provider plugin is configured for domain '{validatorLookupKey}'. " + "Ensure the appropriate DNS provider plugin is deployed and configured on the gateway."); _logger.LogInformation( diff --git a/CERTInext/CERTInextCAPluginConfig.cs b/CERTInext/CERTInextCAPluginConfig.cs index 43d0537..1fc067f 100644 --- a/CERTInext/CERTInextCAPluginConfig.cs +++ b/CERTInext/CERTInextCAPluginConfig.cs @@ -358,6 +358,19 @@ public static Dictionary GetCAConnectorAnnotations() Hidden = false, DefaultValue = Constants.Dcv.DefaultSyncMaxPerPass, Type = "Number" + }, + [Constants.Config.DcvFollowCnameDelegation] = new PropertyConfigInfo + { + Comments = "OPTIONAL: When true, and the DNS TXT challenge hostname for a domain is delegated via " + + "CNAME to a different DNS zone (e.g. a validation subdomain CNAMEd to a dedicated zone, " + + "a common pattern for keeping automation credentials out of the production zone), the " + + $"plugin follows the CNAME chain (bounded to {Constants.Dcv.MaxCnameDepth} hops, with loop " + + "detection) and both publishes the TXT record and resolves the DNS provider plugin against " + + "the terminal (resolved) name instead of the raw challenge hostname. Off by default so " + + "existing non-delegated deployments are unaffected. Default: false.", + Hidden = false, + DefaultValue = false, + Type = "Boolean" } }; } @@ -746,6 +759,16 @@ public class CERTInextConfig [JsonPropertyName("DcvSyncMaxPerPass")] public int DcvSyncMaxPerPass { get; set; } = Constants.Dcv.DefaultSyncMaxPerPass; + /// + /// When true, follows CNAME delegation for the DCV challenge hostname (bounded to + /// hops, with loop detection), using the + /// resolved terminal name for both the TXT record target and the DNS-provider-plugin + /// lookup key (issue 0006). Off by default — existing non-delegated deployments are + /// unaffected. + /// + [JsonPropertyName("DcvFollowCnameDelegation")] + public bool DcvFollowCnameDelegation { get; set; } = false; + /// /// Returns the effective DCV timeout, preferring the environment variable over the /// config field so operators can adjust the ceiling without a connector reconfiguration. diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs index 255b65a..bcb48d4 100644 --- a/CERTInext/Client/CERTInextClient.cs +++ b/CERTInext/Client/CERTInextClient.cs @@ -1312,12 +1312,25 @@ private static LegacyGetCertificateResponse MapOrderReportEntryToLegacy(OrderRep private GenerateOrderSslRequest BuildOrderRequestFromLegacyEnrollRequest(EnrollCertificateRequest request) { - // Map ValidityDays → CERTInext's year-based validity. Default 1. - string validityYears = request.ValidityDays.HasValue - ? Math.Ceiling(request.ValidityDays.Value / 365.0).ToString("0") - : (string.IsNullOrWhiteSpace(_config.SubscriptionValidityYears) + // Resolve subscription validity years. Precedence (issue 0005): + // 1. Explicit ValidityYears template parameter (request.ValidityYears) — wins outright. + // 2. Legacy ValidityDays template parameter, divided by 365 and rounded up. + // 3. Connector-level SubscriptionValidityYears config default. + string validityYears; + if (request.ValidityYears.HasValue && request.ValidityYears.Value > 0) + { + validityYears = request.ValidityYears.Value.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + else if (request.ValidityDays.HasValue) + { + validityYears = Math.Ceiling(request.ValidityDays.Value / 365.0).ToString("0"); + } + else + { + validityYears = string.IsNullOrWhiteSpace(_config.SubscriptionValidityYears) ? "1" - : _config.SubscriptionValidityYears); + : _config.SubscriptionValidityYears; + } string requestorName = request.RequesterName ?? _config.RequestorName ?? "Keyfactor Gateway"; string requestorEmail = request.RequesterEmail ?? _config.RequestorEmail ?? string.Empty; diff --git a/CERTInext/Constants.cs b/CERTInext/Constants.cs index 83e6929..689acd9 100644 --- a/CERTInext/Constants.cs +++ b/CERTInext/Constants.cs @@ -70,6 +70,10 @@ public static class Config public const string DcvSyncMaxOrderAgeHours = "DcvSyncMaxOrderAgeHours"; public const string DcvSyncMaxPerPass = "DcvSyncMaxPerPass"; + // When true, follows CNAME delegation for the DCV challenge hostname before staging + // the TXT record / resolving the DNS provider plugin (issue 0006). Off by default. + public const string DcvFollowCnameDelegation = "DcvFollowCnameDelegation"; + // Environment variable that overrides DcvTimeoutMinutes when set. public const string DcvTimeoutMinutesEnvVar = "CERTINEXT_DCV_TIMEOUT_MINUTES"; public const string DcvWaitForChallengeSecondsEnvVar = "CERTINEXT_DCV_WAIT_FOR_CHALLENGE_SECONDS"; @@ -295,6 +299,11 @@ public static class Dcv // A few seconds is enough for the staged TXT to be visible to CERTInext's resolver; // if a verify lands too early, the order simply stays pending and is retried next pass. public const int SyncPropagationDelaySeconds = 3; + + // Maximum number of CNAME hops followed when DcvFollowCnameDelegation is enabled + // (issue 0006). Bounds worst-case resolution time and backstops the loop-detection + // check against pathological/misconfigured zones. + public const int MaxCnameDepth = 10; } // Legacy string revocation reasons — retained so StatusMapper still compiles. diff --git a/CERTInext/Dcv/CnameResolver.cs b/CERTInext/Dcv/CnameResolver.cs new file mode 100644 index 0000000..dfe3d3a --- /dev/null +++ b/CERTInext/Dcv/CnameResolver.cs @@ -0,0 +1,146 @@ +// Copyright 2024 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.Linq; +using System.Threading; +using System.Threading.Tasks; +using DnsClient; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.Dcv +{ + /// + /// Resolves the terminal (final) name of a possible CNAME delegation chain. + /// + /// Issue 0006: customers commonly delegate a DCV challenge subdomain (e.g. + /// _emsign-validation.example.com) via CNAME to a dedicated validation zone, to keep + /// automation credentials out of the production DNS zone. Following that delegation lets the + /// plugin publish the TXT record — and resolve the DNS provider plugin — against the zone + /// that actually owns the record, instead of the raw challenge hostname. + /// + internal interface ICnameResolver + { + /// + /// Follows the CNAME chain starting at and returns the terminal + /// name (the last name in the chain that has no further CNAME record). Returns + /// unchanged when it has no CNAME record at all. + /// + /// + /// Thrown when a loop is detected (a name is revisited) or the chain exceeds + /// hops. + /// + Task ResolveTerminalNameAsync(string name, CancellationToken ct = default); + } + + /// + /// Default implementation. Performs bounded, loop-detected + /// CNAME-chasing by issuing one CNAME-type DNS query per hop via + /// DnsClient.NET, which resolves + /// against the OS-configured resolver(s) correctly cross-platform — including reading + /// /etc/resolv.conf on Linux, which is how the gateway's Kubernetes pods are + /// configured — and natively handles TCP fallback for truncated responses and DNS name + /// compression. A hand-rolled raw-socket DNS client was considered and rejected: it is more + /// protocol-parsing risk than this feature needs, and .NET's own + /// NetworkInterface-based DNS-server discovery is unreliable on Linux containers. + /// + /// The hop-walking algorithm (depth cap + loop detection) is factored out from the actual + /// DNS query via the internal delegate constructor so it can be unit-tested deterministically + /// against a fake single-hop lookup function, without making real DNS queries. + /// + internal sealed class CnameResolver : ICnameResolver + { + private readonly Func> _lookupOneHop; + + /// Production constructor — resolves hops via real DNS CNAME queries. + public CnameResolver() : this(QueryCnameOneHopAsync) + { + } + + /// + /// Test-injection constructor: supply a fake single-hop lookup function (e.g. backed by + /// an in-memory CNAME chain map) so the depth-cap/loop-detection logic below can be + /// exercised without any real network I/O. + /// + internal CnameResolver(Func> lookupOneHop) + { + _lookupOneHop = lookupOneHop ?? throw new ArgumentNullException(nameof(lookupOneHop)); + } + + /// + public async Task ResolveTerminalNameAsync(string name, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Name must not be null or empty.", nameof(name)); + + string current = NormalizeName(name); + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + + for (int hop = 0; hop < Constants.Dcv.MaxCnameDepth; hop++) + { + if (!visited.Add(current)) + throw new InvalidOperationException( + $"CNAME loop detected while resolving '{name}' — '{current}' was already visited " + + $"after {hop} hop(s). Check the delegated zone for a circular CNAME chain."); + + ct.ThrowIfCancellationRequested(); + string target = await _lookupOneHop(current, ct).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(target)) + return current; // no further CNAME — this name is terminal + + current = NormalizeName(target); + } + + throw new InvalidOperationException( + $"CNAME chain for '{name}' exceeded the maximum depth of {Constants.Dcv.MaxCnameDepth} hop(s)."); + } + + private static string NormalizeName(string name) => name.Trim().TrimEnd('.'); + + // ----------------------------------------------------------------------- + // Real DNS CNAME lookup (single hop) — via DnsClient.NET. + // ----------------------------------------------------------------------- + + // A single shared LookupClient is safe for concurrent use. Its default constructor + // discovers the OS-configured resolver(s) the same way `dig`/`nslookup` would (reading + // /etc/resolv.conf on Linux, the Windows resolver configuration on Windows, etc.), + // which is what the gateway's Kubernetes pods rely on — unlike + // NetworkInterface.GetAllNetworkInterfaces()...DnsAddresses, which is unreliable/empty + // on Linux containers. + private static readonly LookupClient SharedLookupClient = new LookupClient(); + + /// + /// Queries the CNAME record for against the OS-configured DNS + /// resolver(s). Returns the CNAME target, or null when the name has no CNAME + /// record (i.e. it is terminal). + /// + private static async Task QueryCnameOneHopAsync(string name, CancellationToken ct) + { + IDnsQueryResponse response; + try + { + response = await SharedLookupClient.QueryAsync(name, QueryType.CNAME, cancellationToken: ct) + .ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + throw new InvalidOperationException($"Unable to resolve DNS records for '{name}'.", ex); + } + + if (response.HasError) + { + // NXDOMAIN / "no records" are not failures here — they just mean this name has + // no CNAME record, so it's terminal. + return null; + } + + var cnameRecord = response.Answers.CnameRecords().FirstOrDefault(); + return cnameRecord?.CanonicalName?.Value; + } + } +} diff --git a/CERTInext/Models/EnrollmentParams.cs b/CERTInext/Models/EnrollmentParams.cs index 69b662f..7933e81 100644 --- a/CERTInext/Models/EnrollmentParams.cs +++ b/CERTInext/Models/EnrollmentParams.cs @@ -52,6 +52,13 @@ public string ProductCode /// Alias for ProductCode — kept for backward compat. public string ProfileId => ProductCode; + /// + /// Requested subscription validity in years (1, 2, or 3); 0 means "not set" — + /// falls back to (if set) and then to the connector-level + /// SubscriptionValidityYears config default. See issue 0005. + /// + public int ValidityYears => GetInt(Constants.EnrollmentParam.ValidityYears, 0); + /// Requested validity in days; 0 means "use profile default". public int ValidityDays => GetInt(Constants.EnrollmentParam.ValidityDays, 0); diff --git a/integration-manifest.json b/integration-manifest.json index ff51a74..6399f10 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -172,6 +172,10 @@ { "name": "DcvSyncMaxPerPass", "description": "OPTIONAL: Maximum number of pending DV orders the plugin will attempt to drive through DCV in a single synchronization pass. Bounds the per-pass cost regardless of backlog size; remaining pending orders are reported as-is and picked up on a later pass (the per-minute incremental scan keeps recent orders moving). Set to 0 to disable the cap. Default: 50." + }, + { + "name": "DcvFollowCnameDelegation", + "description": "OPTIONAL: When true, and the DNS TXT challenge hostname for a domain is delegated via CNAME to a different DNS zone (e.g. a validation subdomain CNAMEd to a dedicated zone, a common pattern for keeping automation credentials out of the production zone), the plugin follows the CNAME chain (bounded to 10 hops, with loop detection) and both publishes the TXT record and resolves the DNS provider plugin against the terminal (resolved) name instead of the raw challenge hostname. Off by default so existing non-delegated deployments are unaffected. Default: false." } ], "enrollment_config": [ From 7e8174beafd8b9f1052a113edf21bdf4aa5e72d4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 22 Jul 2026 21:00:05 +0000 Subject: [PATCH 04/13] docs: auto-generate README and documentation [skip ci] --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index aa1d062..9b995a2 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,7 @@ CERTInext operates three separate environments. Use the sandbox environment for * **DcvWaitForIssuanceSeconds** - OPTIONAL: How long (seconds) the plugin will wait inside Enroll() after DCV verifies for CERTInext to finish generating the certificate. CERTInext issuance is async — DCV may be verified but the cert PEM isn't yet available for download. Without this wait, Enroll() returns a pending result and the issued cert is picked up by the next sync cycle. Setting to 0 disables the wait (single-fetch behaviour). Can also be set via the CERTINEXT_DCV_WAIT_FOR_ISSUANCE_SECONDS environment variable; the env var takes precedence when both are set. Default: 60. * **DcvSyncMaxOrderAgeHours** - OPTIONAL: During synchronization, only pending DV orders younger than this many hours are eligible to be driven through DCV. This keeps a sync pass fast when there is a large backlog of old, never-completing pending orders (e.g. abandoned orders or domains outside the configured DNS provider's zone): they age out and are simply reported as pending rather than retried every pass. Recently-placed orders (the ones that legitimately deferred DCV) are always within the window and complete via the normal scan cadence. Set to 0 to disable the age filter (attempt DCV for all pending). Default: 24. * **DcvSyncMaxPerPass** - OPTIONAL: Maximum number of pending DV orders the plugin will attempt to drive through DCV in a single synchronization pass. Bounds the per-pass cost regardless of backlog size; remaining pending orders are reported as-is and picked up on a later pass (the per-minute incremental scan keeps recent orders moving). Set to 0 to disable the cap. Default: 50. + * **DcvFollowCnameDelegation** - OPTIONAL: When true, and the DNS TXT challenge hostname for a domain is delegated via CNAME to a different DNS zone (e.g. a validation subdomain CNAMEd to a dedicated zone, a common pattern for keeping automation credentials out of the production zone), the plugin follows the CNAME chain (bounded to 10 hops, with loop detection) and both publishes the TXT record and resolves the DNS provider plugin against the terminal (resolved) name instead of the raw challenge hostname. Off by default so existing non-delegated deployments are unaffected. Default: false. 2. A Keyfactor Command certificate template maps an enrollment request to a specific CERTInext product. Create one template per CERTInext product that you want to make available to requesters. From 59778ab4f04bafa5b71e83b62089d1f82a7848ff Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:08:50 -0700 Subject: [PATCH 05/13] test: add live-DNS integration coverage for issue 0006's CnameResolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exercises the production DnsClient.NET-backed CnameResolver directly against real Cloudflare-hosted DNS records (creating/cleaning up temporary CNAME records via the same API pattern as CloudflareDomainValidator), independent of CERTInext order placement — confirms real two-hop CNAME chasing and the no-CNAME/terminal-on-first-hop case actually work over the network, not just the fake-delegate unit tests. --- .../CnameResolverLiveDnsTests.cs | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 CERTInext.IntegrationTests/CnameResolverLiveDnsTests.cs diff --git a/CERTInext.IntegrationTests/CnameResolverLiveDnsTests.cs b/CERTInext.IntegrationTests/CnameResolverLiveDnsTests.cs new file mode 100644 index 0000000..5f0b810 --- /dev/null +++ b/CERTInext.IntegrationTests/CnameResolverLiveDnsTests.cs @@ -0,0 +1,148 @@ +// Copyright 2026 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// At http://www.apache.org/licenses/LICENSE-2.0 +// +// Live-DNS validation for issue 0006's CnameResolver. This deliberately does NOT go through +// CERTInext order placement (which is currently blocked by sandbox credit exhaustion, issue +// 0004) — it stages real CNAME records in the same Cloudflare zone used for DCV tests and +// exercises the production Dcv.CnameResolver directly against public DNS, to confirm the +// DnsClient.NET-backed resolution actually works end-to-end (as opposed to only the +// hop-walking algorithm, which the unit tests already cover via a fake single-hop delegate). + +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Keyfactor.Extensions.CAPlugin.CERTInext.Dcv; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests +{ + public class CnameResolverLiveDnsTests : IClassFixture, IAsyncLifetime + { + private const string CfApiBase = "https://api.cloudflare.com/client/v4"; + + private readonly IntegrationTestFixture _fixture; + private HttpClient _http; + private string _hopAName; + private string _hopBName; + private string _hopCName; + private string _hopARecordId; + private string _hopBRecordId; + + public CnameResolverLiveDnsTests(IntegrationTestFixture fixture) + { + _fixture = fixture; + } + + public Task InitializeAsync() => Task.CompletedTask; + + public async Task DisposeAsync() + { + if (_http == null) + return; + + foreach (var recordId in new[] { _hopARecordId, _hopBRecordId }) + { + if (string.IsNullOrEmpty(recordId)) + continue; + try + { + await _http.DeleteAsync($"{CfApiBase}/zones/{_fixture.CloudflareZoneId}/dns_records/{recordId}"); + } + catch + { + // best-effort cleanup + } + } + + _http.Dispose(); + } + + private async Task CreateCnameRecordAsync(string name, string target) + { + var payload = new { type = "CNAME", name, content = target, ttl = 60, proxied = false }; + var response = await _http.PostAsJsonAsync($"{CfApiBase}/zones/{_fixture.CloudflareZoneId}/dns_records", payload); + string body = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, $"Cloudflare CNAME creation failed for '{name}': {body}"); + + using var doc = JsonDocument.Parse(body); + Assert.True(doc.RootElement.GetProperty("success").GetBoolean(), $"Cloudflare reported failure: {body}"); + return doc.RootElement.GetProperty("result").GetProperty("id").GetString(); + } + + /// + /// Two-hop live chain: hopA → hopB → hopC (hopC is never created, so it is terminal — + /// exactly like a delegated validation zone whose target has no further CNAME). Confirms + /// the production (real DnsClient.NET queries against the + /// OS-configured resolver) walks a real, publicly-resolvable CNAME chain correctly. + /// + [SkippableFact] + public async Task ResolveTerminalNameAsync_FollowsRealTwoHopCnameChain() + { + Skip.If(!_fixture.IsCloudflareConfigured, + "CERTINEXT_CF_API_TOKEN / CERTINEXT_CF_ZONE_ID / CERTINEXT_DCV_DOMAIN are required."); + + string baseDomain = IntegrationTestData.DcvTestDomain; + string suffix = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); + _hopAName = $"_cname-resolver-test-a-{suffix}.{baseDomain}"; + _hopBName = $"_cname-resolver-test-b-{suffix}.{baseDomain}"; + _hopCName = $"_cname-resolver-test-c-{suffix}.{baseDomain}"; + + _http = new HttpClient(); + _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _fixture.CloudflareApiToken); + + _hopARecordId = await CreateCnameRecordAsync(_hopAName, _hopBName); + _hopBRecordId = await CreateCnameRecordAsync(_hopBName, _hopCName); + + var resolver = new CnameResolver(); + + // Freshly-created records can take a few seconds to propagate across Cloudflare's + // edge / the resolver's negative-cache TTL, and a not-yet-propagated record reads as + // "no CNAME" (a clean terminal result, not an exception) rather than an error — so + // retry until the expected terminal name is reached, not just on thrown exceptions. + string terminal = null; + Exception lastError = null; + for (int attempt = 0; attempt < 8; attempt++) + { + await Task.Delay(TimeSpan.FromSeconds(3)); + try + { + terminal = await resolver.ResolveTerminalNameAsync(_hopAName, CancellationToken.None); + if (string.Equals(terminal.TrimEnd('.'), _hopCName.TrimEnd('.'), StringComparison.OrdinalIgnoreCase)) + break; + } + catch (Exception ex) + { + lastError = ex; + } + } + + Assert.True(terminal != null, $"Failed to resolve after retries: {lastError}"); + Assert.Equal(_hopCName.TrimEnd('.'), terminal.TrimEnd('.'), ignoreCase: true); + } + + /// + /// A name with no CNAME record at all (the DCV domain apex, which is expected to carry + /// ordinary A/AAAA/TXT records for the existing DCV integration tests, not a CNAME) must + /// resolve to itself unchanged — confirms the "terminal on first hop" path against real + /// DNS, not just the fake-delegate unit tests. + /// + [SkippableFact] + public async Task ResolveTerminalNameAsync_NoCname_ReturnsInputUnchanged() + { + Skip.If(!_fixture.IsCloudflareConfigured, + "CERTINEXT_CF_API_TOKEN / CERTINEXT_CF_ZONE_ID / CERTINEXT_DCV_DOMAIN are required."); + + string baseDomain = IntegrationTestData.DcvTestDomain; + var resolver = new CnameResolver(); + + string terminal = await resolver.ResolveTerminalNameAsync(baseDomain, CancellationToken.None); + + Assert.Equal(baseDomain.TrimEnd('.'), terminal.TrimEnd('.'), ignoreCase: true); + } + } +} From 8ccbf2fdecbc3de906d4436d08bbe3c01c8f6c9f Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:17:39 -0700 Subject: [PATCH 06/13] docs: add 1.1.0 changelog entry Covers the net10.0-only/DcvSupport-default-true build change plus the ValidityYears fix, manual DCV guidance, and CNAME-delegation support added on top of it in this PR. --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6065cb2..6aad409 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +# 1.1.0 + +## Features +- feat(dcv): Support DNS CNAME-delegated DCV challenges — when a validation hostname is CNAMEd to a separate zone (e.g. to keep automation credentials out of the production zone), the plugin now follows the chain and routes both the TXT record and the DNS provider lookup to the terminal name. Off by default; opt in with the new `DcvFollowCnameDelegation` setting. +- feat(dcv): When DCV is enabled but no DNS provider plugin is configured, the enrollment result now surfaces the exact TXT record name and value to publish manually, instead of leaving the order pending with no guidance beyond the plugin log. + +## Bug Fixes +- fix(enroll): The per-template `ValidityYears` parameter now actually controls subscription validity. Previously it was exposed in Command's UI but silently ignored, with the connector-level default always winning. + +## Build +- build!: Target `net10.0` only — dropped `net8.0` multi-targeting across the plugin, tests, and integration runner. +- build!: `DcvSupport` now defaults to `true`. The default/CI-shipped build targets DCV-capable AnyCA Gateway 26.x (`IAnyCAPlugin 3.3.0`, now stable); the previous default — `IAnyCAPlugin 3.2.0`, AnyCA Gateway 25.5.x, no DCV — is available via `-p:DcvSupport=false`. +- chore(deps): Added `DnsClient.NET` for CNAME resolution. Pinned `WireMock.Net` and its transitive `Scriban.Signed` / `System.Linq.Dynamic.Core` / `OpenTelemetry.*` dependencies past several CVEs that only surfaced once `net10.0` became the sole restore target. + # 1.0.0 Initial release of the CERTInext (emSign Hub) AnyCA REST Gateway plugin. From 12638885d5c107ad92d96dd0e5e77f28eccfdf6f Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:21:13 -0700 Subject: [PATCH 07/13] chore: stop tracking DCV_BUILD_SUPPORT.md Local working notes, not meant for the repo. --- .gitignore | 3 +++ DCV_BUILD_SUPPORT.md | 41 ----------------------------------------- 2 files changed, 3 insertions(+), 41 deletions(-) delete mode 100644 DCV_BUILD_SUPPORT.md diff --git a/.gitignore b/.gitignore index 609bcd8..9fda16c 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ terraform/terraform.tfvars # Analysis / scratch — never commit analysis/ +# Local-only working notes — never commit +DCV_BUILD_SUPPORT.md + diff --git a/DCV_BUILD_SUPPORT.md b/DCV_BUILD_SUPPORT.md deleted file mode 100644 index b78a3d8..0000000 --- a/DCV_BUILD_SUPPORT.md +++ /dev/null @@ -1,41 +0,0 @@ -# DNS-01 DCV: build flag and code fencing - -## Build flag - -DNS-01 DCV support is gated behind a single MSBuild property, **`DcvSupport`**, default `true`. It's defined in `CERTInext/CERTInext.csproj`: - -``` -dotnet build -p:DcvSupport=false -``` - -- `DcvSupport=true` (default): compiles against `Keyfactor.AnyGateway.IAnyCAPlugin` **3.3.0** (stable release), defines `SUPPORTS_DCV` — this is what CI ships, targeting 26.x/DCV-capable gateway hosts. -- `DcvSupport=false`: compiles against **3.2.0**, no `SUPPORTS_DCV` constant, targeting GA gateway hosts (AnyCA Gateway 25.5.x, see issue 0003). - -The project also only targets **`net10.0`** (single TFM, no more `net8.0`/`net10.0` multi-targeting). - -Relevant lines: [`CERTInext.csproj#L18-L19`](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInext.csproj#L18-L19) (the flag → `SUPPORTS_DCV` define) and [`#L29-L30`](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInext.csproj#L29-L30) (the package-version swap). - -The two test projects mirror this flag so DCV test files only compile in when asked: -- [`CERTInext.Tests.csproj#L11-L12`](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext.Tests/CERTInext.Tests.csproj#L11-L12) -- [`CERTInext.IntegrationTests.csproj#L11-L12`](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext.IntegrationTests/CERTInext.IntegrationTests.csproj#L11-L12) - -## Where `#if SUPPORTS_DCV` fences the feature - -All in `CERTInext/CERTInextCAPlugin.cs`: - -| Lines | What's fenced | -|---|---| -| [22–24](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L22-L24) | `using` alias for `IDomainValidatorFactory` | -| [72–75](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L72-L75) | typed `DomainValidatorFactory` property (casts the untyped `_domainValidatorFactory` field) | -| [155–163](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L155-L163) | internal test constructor that injects an `IDomainValidatorFactory` | -| [178–197](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L178-L197) | `SetDomainValidatorFactory` — real assignment vs. `#else` no-op log | -| [789–798](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L789-L798) | `Synchronize`: DCV-during-sync bookkeeping vars (age window, per-pass cap, counters) | -| [849–897](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L849-L897) | `Synchronize`: the actual per-order DCV-during-sync gate/attempt/refetch logic | -| [1014–1021](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L1014-L1021) | `Synchronize`: builds the DCV summary log clause — real stats vs. `#else` "not supported on this build" | -| [1108–1171](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L1108-L1171) | `EnrollNewAsync`: runs DCV right after a fresh order is placed, then polls for issuance | -| [1373–1424](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L1373-L1424) | `TryRunDcvDuringSyncAsync` — full retry-path body vs. `#else` `return false` no-op | -| [1442–1704](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L1442-L1704) | `PerformDcvIfNeededAsync` itself — the whole method only exists in the DCV build | - -Design note baked into the comments: `_domainValidatorFactory` is deliberately typed as `object` (not `IDomainValidatorFactory`) so the JIT never needs to resolve the 3.3-only type when `SUPPORTS_DCV` is off — casts only happen inside method bodies fenced by `#if`, which is what lets the no-DCV build load cleanly on a 3.2.0 gateway host (see the field comment at [L40-L60](https://github.com/Keyfactor/certinext-caplugin/blob/fb6e414956f708f0bef417bd8a8ddf52854ab11e/CERTInext/CERTInextCAPlugin.cs#L40-L60), issue #7). - -Reminder: DCV is now the default build — `-p:DcvSupport=false` is the opt-out for GA/no-DCV hosts. Without it, the build targets 26.x hosts and depends on the stable `3.3.0` package. From df9b6dd020a00f57eb24beb512090b095ea36734 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:56:12 -0700 Subject: [PATCH 08/13] docs: simplify 1.1.0 changelog entry for end users Drop the Build section's internal package/version detail and fold the .NET 8 removal into Chores, framed around the actual customer-facing reason: DCV requires Command 26.2+, which requires .NET 10+. --- CHANGELOG.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aad409..bce090e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,15 @@ # 1.1.0 ## Features -- feat(dcv): Support DNS CNAME-delegated DCV challenges — when a validation hostname is CNAMEd to a separate zone (e.g. to keep automation credentials out of the production zone), the plugin now follows the chain and routes both the TXT record and the DNS provider lookup to the terminal name. Off by default; opt in with the new `DcvFollowCnameDelegation` setting. -- feat(dcv): When DCV is enabled but no DNS provider plugin is configured, the enrollment result now surfaces the exact TXT record name and value to publish manually, instead of leaving the order pending with no guidance beyond the plugin log. +- feat(dcv): DCV can now follow CNAME-delegated validation zones — useful if your DNS automation credentials live in a separate zone from production. Off by default; opt in with the new `DcvFollowCnameDelegation` setting. +- feat(dcv): If DCV is enabled but no DNS provider is configured, enrollment now shows the DNS TXT record you need to publish manually, instead of leaving the order stuck with no explanation. ## Bug Fixes -- fix(enroll): The per-template `ValidityYears` parameter now actually controls subscription validity. Previously it was exposed in Command's UI but silently ignored, with the connector-level default always winning. +- fix(enroll): The per-template `ValidityYears` setting is now honored — previously it had no effect, and subscription validity always came from the connector-level default instead. -## Build -- build!: Target `net10.0` only — dropped `net8.0` multi-targeting across the plugin, tests, and integration runner. -- build!: `DcvSupport` now defaults to `true`. The default/CI-shipped build targets DCV-capable AnyCA Gateway 26.x (`IAnyCAPlugin 3.3.0`, now stable); the previous default — `IAnyCAPlugin 3.2.0`, AnyCA Gateway 25.5.x, no DCV — is available via `-p:DcvSupport=false`. -- chore(deps): Added `DnsClient.NET` for CNAME resolution. Pinned `WireMock.Net` and its transitive `Scriban.Signed` / `System.Linq.Dynamic.Core` / `OpenTelemetry.*` dependencies past several CVEs that only surfaced once `net10.0` became the sole restore target. +## Chores +- chore(compat): Dropped .NET 8 support. DCV requires Keyfactor Command 26.2+, which requires .NET 10+, so the `DcvSupport` build flag now defaults to `true` — pass `-p:DcvSupport=false` if you're not yet on Command 26.2+. +- chore(deps): Added `DnsClient.NET` for DNS resolution; bumped `WireMock.Net` and its related test dependencies to pick up CVE fixes. # 1.0.0 From 75acb75d857c0136367bab9d70354a4e834c104d Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:11:53 -0700 Subject: [PATCH 09/13] chore: convert Makefile to justfile Replaces make with just as the command runner for build/test/API smoke-test targets, and updates every doc/script reference from `make` to `just` accordingly. README.md regenerated from docsource to match. --- .github/PULL_REQUEST_TEMPLATE.md | 6 +- .../INTEGRATION_TESTING.md | 6 +- CERTInext.IntegrationTests/TESTING.md | 14 +- README.md | 54 +- docsource/configuration.md | 2 +- docsource/development.md | 53 +- Makefile => justfile | 536 +++++++++--------- scripts/generate-order.sh | 2 +- scripts/register/00-register-all.sh | 6 +- scripts/register/README.md | 10 +- scripts/revoke-order.sh | 2 +- scripts/v2/orders-report.sh | 4 +- scripts/v2/revoke-private-pki.sh | 2 +- scripts/v2/revoke-ssl.sh | 2 +- 14 files changed, 371 insertions(+), 328 deletions(-) rename Makefile => justfile (62%) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 86ef9e7..a927312 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -10,9 +10,9 @@ -- [ ] `make test` passes (unit tests) -- [ ] `make integration-test` passes (requires `~/.env_certinext`) -- [ ] `make coverage` shows no coverage regression +- [ ] `just test` passes (unit tests) +- [ ] `just integration-test` passes (requires `~/.env_certinext`) +- [ ] `just coverage` shows no coverage regression - [ ] Terraform changes validated with `terraform plan` - [ ] Tested only docs/config — no runtime changes diff --git a/CERTInext.IntegrationTests/INTEGRATION_TESTING.md b/CERTInext.IntegrationTests/INTEGRATION_TESTING.md index 441f573..3850303 100644 --- a/CERTInext.IntegrationTests/INTEGRATION_TESTING.md +++ b/CERTInext.IntegrationTests/INTEGRATION_TESTING.md @@ -70,10 +70,10 @@ The file is parsed line by line: dotnet test CERTInext.IntegrationTests/ --verbosity normal ``` -### Using the Makefile +### Using the justfile ```sh -make integration-test +just integration-test ``` ### From the solution root (all tests including unit tests) @@ -160,4 +160,4 @@ never transmitted over the wire — only the derived `authKey` hash is sent. | All tests skipped | Missing or empty `~/.env_certinext` | Create the file with required variables | | `Ping` fails with 401 | Wrong `CERTINEXT_ACCESS_KEY` | Regenerate the key in the CERTInext portal | | `Ping` fails with timeout | Wrong `CERTINEXT_API_URL` | Verify the URL matches your account region | -| `GetOrderReport` returns 0 orders | Account has no orders | Place a test order first (see `make generate-order` in the project Makefile) | +| `GetOrderReport` returns 0 orders | Account has no orders | Place a test order first (see `just generate-order` in the project justfile) | diff --git a/CERTInext.IntegrationTests/TESTING.md b/CERTInext.IntegrationTests/TESTING.md index b961130..1453658 100644 --- a/CERTInext.IntegrationTests/TESTING.md +++ b/CERTInext.IntegrationTests/TESTING.md @@ -31,7 +31,7 @@ Key findings verified against sandbox account `9374221333` in April 2026: To discover the valid product codes for a new account, use: ```sh -make probe-products +just probe-products ``` This places `saveAndHold=1` draft orders for all known SSL/TLS product codes and reports @@ -73,7 +73,7 @@ CERTINEXT_REQUESTOR_MOBILE=0000000000 | `CERTINEXT_ACCOUNT_NUMBER` | Yes | Your CERTInext account number (numeric string) | | `CERTINEXT_GROUP_NUMBER` | No | Group number for order placement, filtering, and `GetProductDetails`. Required on some sandbox accounts for `GetProductDetails` to return a non-empty list. | | `CERTINEXT_ORG_NUMBER` | No | Organization number for OV/EV order placement | -| `CERTINEXT_PRODUCT_CODE` | Yes | Numeric product code for the target account. **This is per-account** — obtain the correct code for your account by calling `GetProductDetails` (or `make probe-products`). Default shown is for sandbox account `9374221333`. | +| `CERTINEXT_PRODUCT_CODE` | Yes | Numeric product code for the target account. **This is per-account** — obtain the correct code for your account by calling `GetProductDetails` (or `just probe-products`). Default shown is for sandbox account `9374221333`. | | `CERTINEXT_REQUESTOR_EMAIL` | Yes | Email submitted with test orders — must be registered in the account | | `CERTINEXT_REQUESTOR_NAME` | Yes | Name submitted with test orders | | `CERTINEXT_REQUESTOR_MOBILE` | No | Mobile number submitted with test orders | @@ -258,16 +258,16 @@ never transmitted over the wire — only the derived `authKey` hash is sent. When setting up a brand-new CERTInext sandbox account to run integration tests: -1. **Discover valid product codes** — run `make probe-products` from the repo root. This places +1. **Discover valid product codes** — run `just probe-products` from the repo root. This places `saveAndHold=1` draft orders for all known SSL/TLS product codes and reports which ones your account accepts. Use the first DV SSL code that returns a `requestNumber` as your `CERTINEXT_PRODUCT_CODE`. -2. **Set `CERTINEXT_GROUP_NUMBER`** — if `make probe-products` or `GetProductDetails` returns no +2. **Set `CERTINEXT_GROUP_NUMBER`** — if `just probe-products` or `GetProductDetails` returns no products, find your group number in the CERTInext portal under **Delegation → Groups** and add it to `~/.env_certinext`. The `GetProductDetails` API requires it on some accounts. -3. **Run connectivity tests first** — `make integration-test` or +3. **Run connectivity tests first** — `just integration-test` or `dotnet test CERTInext.IntegrationTests/ -v normal`. The `ConnectivityTests` class verifies credentials. The `LifecycleTests` class places real orders — it can be run even before any orders exist. @@ -293,10 +293,10 @@ When setting up a brand-new CERTInext sandbox account to run integration tests: | All tests skipped | Missing or empty `~/.env_certinext` | Create the file with `CERTINEXT_API_URL` and `CERTINEXT_ACCESS_KEY` | | `Ping` fails with 401/403 | Wrong `CERTINEXT_ACCESS_KEY` | Regenerate the key in the CERTInext portal under Integrations → APIs | | `Ping` fails with timeout or 404 | Wrong `CERTINEXT_API_URL` | Verify the URL matches your account region (see API URL table above) | -| `Enroll` fails with "Invalid Product Code" (EMS-1162) | Wrong `CERTINEXT_PRODUCT_CODE` | Run `make probe-products` to discover the codes provisioned for your account | +| `Enroll` fails with "Invalid Product Code" (EMS-1162) | Wrong `CERTINEXT_PRODUCT_CODE` | Run `just probe-products` to discover the codes provisioned for your account | | `GetProductDetails` returns empty list | `CERTINEXT_GROUP_NUMBER` not set | Add your group number to `~/.env_certinext`; some accounts require it for `GetProductDetails` to return results | | `Enroll` fails with "Additional Information cannot be empty" (EMS-918) | Old plugin version missing `additionalInformation.remarks` | Rebuild and redeploy the plugin — the `remarks` field is now populated automatically | | `Enroll` fails with "Invalid Organization Number" (EMS-1073) | OV/EV product code selected with an unregistered org | Use a DV SSL product code for automated tests, or register and approve your org in CERTInext first | | Revoke step skips with "not GENERATED" | Sandbox DV SSL order requires domain validation and RA approval | Expected behavior for public DV SSL in sandbox — log in to the CERTInext portal and approve the pending order, then re-run; or use a private PKI product that auto-approves | | `OrderReportTests` all skip | Fresh account with no orders | Run `LifecycleTests` first to place at least one order | -| `ProductTests` asserts configured product code is not found | `CERTINEXT_PRODUCT_CODE` set to a code not provisioned for the account | Run `make probe-products` and update `CERTINEXT_PRODUCT_CODE` with a valid code | +| `ProductTests` asserts configured product code is not found | `CERTINEXT_PRODUCT_CODE` set to a code not provisioned for the account | Run `just probe-products` and update `CERTINEXT_PRODUCT_CODE` with a valid code | diff --git a/README.md b/README.md index 9b995a2..d9e13b5 100644 --- a/README.md +++ b/README.md @@ -275,7 +275,7 @@ CERTInext uses numeric product codes to identify certificate types. **Product co To retrieve the exact codes available to your account, call the `GetProductDetails` endpoint: - If you have a `GroupNumber` configured, include it in the request `productDetails` block — some accounts require this to return a non-empty list. -- Use the `make get-product-details-group` Makefile target to retrieve products from the sandbox with `groupNumber` included. +- Use the `just get-product-details-group` justfile target to retrieve products from the sandbox with `groupNumber` included. > Note: Product codes differ between the sandbox and production environments. Always verify the correct code before switching environments. @@ -338,6 +338,58 @@ To retrieve the full list of product codes available to your account, call the ` > Note: SSL/TLS products are supported on standard accounts — see the SSL/TLS table above for the exact sandbox/production code pair for each product. Private PKI (Production `100`, `104` / Sandbox `149`), S/MIME (`894`), and document-signing products (`819`–`827`) require special provisioning by eMudhra and are not available on standard SSL/TLS accounts — ordering them returns EMS-1162. +## Mechanics + +### Authentication + +Every CERTInext API call is an HTTP POST with a JSON body. There is no Authorization header. Instead, the body carries a `meta` block with an `authKey` field computed as: + +``` +authKey = SHA256(accessKey + requestTs + requestTxnId) +``` + +Where `requestTs` is the ISO 8601 timestamp and `requestTxnId` is a unique transaction UUID generated per request. The raw access key is never transmitted — only the derived hash is sent. This computation happens automatically on every outbound call. When `AuthMode` is `OAuth`, the gateway obtains a bearer token via the configured client credentials flow and injects it into the `meta` block instead. + +### Enrollment Decision Logic + +When the gateway calls `Enroll`, the plugin selects between three paths based on the enrollment type and the age of the prior certificate: + +1. **New enrollment** — no prior certificate exists. A new `GenerateOrderSSL` request is submitted. +2. **Renewal** — a prior certificate exists and its expiry is within the `RenewalWindowDays` threshold (default: 90 days). A new `GenerateOrderSSL` order is submitted within the configured renewal window (CERTInext has no dedicated renewal endpoint; the renewal-window check governs how Command tracks old→new, not which API is called). +3. **Reissue** — a prior certificate exists but is outside the renewal window. A new `GenerateOrderSSL` order is placed with the updated CSR/subject, replacing the prior certificate under a new subscription. + +The `RenewalWindowDays` template parameter controls the renewal/reissue boundary per certificate template. + +### Required Order Fields + +The `GenerateOrderSSL` API requires an `additionalInformation.remarks` field in every order request body. The gateway populates this field automatically with the text `"Issued via Keyfactor Command AnyCA REST Gateway."`. If you encounter error `EMS-918: Additional Information cannot be empty`, verify that the gateway version is current and that the field is being sent. + +### Order Lifecycle and Pending Approval + +CERTInext orders pass through several internal status stages before a certificate is issued. The plugin maps these to Keyfactor enrollment statuses as follows: + +- **Issued** (status 9, 20) → certificate returned immediately. +- **Pending approval** (status 2, 8, 15, 24) → enrollment returns a pending status to Command. If `AutoApprove` is enabled on the template, the plugin attempts automatic approval before returning. +- **Rejected / cancelled** (status 4, 5, 13, 14) → enrollment fails with an error. + +The gateway polls the `TrackOrder` endpoint during sync to pick up certificates that were approved after the initial enrollment call. + +### Synchronization + +Synchronization uses the `GetOrderReport` endpoint with paginated results (controlled by `PageSize`, default 100, max 500). Each page is fetched sequentially until all orders are retrieved. The plugin maps each order's status to a Keyfactor certificate status and returns the result set to the gateway framework, which reconciles it against the Command inventory. + +Expired certificates are included by default. Set `IgnoreExpired: true` on the connector to skip them during sync. + +### Product Code Resolution + +When an enrollment request arrives, the numeric CERTInext product code is resolved in this order: + +1. `ProductCode` template parameter (explicit override — use for sandbox or non-standard codes). +2. `ProfileId` template parameter (deprecated alias, accepted for backward compatibility). +3. Default production code looked up from the selected product name (e.g. **DV SSL** → `838`). + +If none of these yield a code, enrollment fails with a validation error. + ## Architecture This document describes how the CERTInext AnyCA Gateway REST plugin integrates with Keyfactor Command and the CERTInext certificate authority. It covers the three primary certificate lifecycle operations — synchronization, enrollment, and revocation — and how the plugin routes each through the CERTInext API. diff --git a/docsource/configuration.md b/docsource/configuration.md index 41c872e..0f90459 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -150,7 +150,7 @@ CERTInext uses numeric product codes to identify certificate types. **Product co To retrieve the exact codes available to your account, call the `GetProductDetails` endpoint: - If you have a `GroupNumber` configured, include it in the request `productDetails` block — some accounts require this to return a non-empty list. -- Use the `make get-product-details-group` Makefile target to retrieve products from the sandbox with `groupNumber` included. +- Use the `just get-product-details-group` justfile target to retrieve products from the sandbox with `groupNumber` included. > Note: Product codes differ between the sandbox and production environments. Always verify the correct code before switching environments. diff --git a/docsource/development.md b/docsource/development.md index 14c6cff..56768bf 100644 --- a/docsource/development.md +++ b/docsource/development.md @@ -5,8 +5,9 @@ This document covers local development, testing, and live API smoke-testing for ## Prerequisites - .NET SDK 8.0 or later -- `python3` (used for HMAC computation in Makefile API targets) -- `jq` (used for JSON pretty-printing in Makefile API targets) +- [`just`](https://github.com/casey/just) (command runner used for build/test/API targets) +- `python3` (used for HMAC computation in justfile API targets) +- `jq` (used for JSON pretty-printing in justfile API targets) - `~/.env_certinext` populated with credentials (see below) ## Credentials File @@ -33,12 +34,12 @@ CERTINEXT_SIGNER_IP= | Target | Command | Description | |---|---|---| -| Build | `make build` | `dotnet build` the solution | -| Unit tests | `make test` | Run all mock/unit tests | -| Integration tests | `make integration-test` | Run live API tests (requires `~/.env_certinext`; tests skip automatically if credentials are absent) | -| Coverage report (terminal) | `make coverage` | Run tests with XPlat coverage and print summary | -| Coverage report (browser) | `make coverage-report` | Same as `coverage`, then opens HTML report in the default browser | -| Clean | `make clean` | `dotnet clean` and wipe coverage output directories | +| Build | `just build` | `dotnet build` the solution | +| Unit tests | `just test` | Run all mock/unit tests | +| Integration tests | `just integration-test` | Run live API tests (requires `~/.env_certinext`; tests skip automatically if credentials are absent) | +| Coverage report (terminal) | `just coverage` | Run tests with XPlat coverage and print summary | +| Coverage report (browser) | `just coverage-report` | Same as `coverage`, then opens HTML report in the default browser | +| Clean | `just clean` | `dotnet clean` and wipe coverage output directories | ### Build variants — `DcvSupport` (DCV vs no-DCV) @@ -49,7 +50,7 @@ the host (see issue 0003). | Build | Command | IAnyCAPlugin | DCV | Target gateway host | |---|---|---|---|---| -| **No-DCV (default)** | `make build` / `dotnet build` | `3.2.0` (stable) | fenced out (`#if SUPPORTS_DCV`) | AnyCA Gateway **25.5.x** (IAnyCAPlugin 3.2.0) | +| **No-DCV (default)** | `just build` / `dotnet build` | `3.2.0` (stable) | fenced out (`#if SUPPORTS_DCV`) | AnyCA Gateway **25.5.x** (IAnyCAPlugin 3.2.0) | | **DCV** | `dotnet build -p:DcvSupport=true` | `3.3.0-PRERELEASE` | enabled | AnyCA Gateway **26.x** (IAnyCAPlugin ≥ 3.3) | The **default is the no-DCV / 3.2.0 build** — it is the GA artifact that loads and persists on the @@ -65,31 +66,31 @@ All API targets source `~/.env_certinext`, compute the HMAC `authKey` (`SHA256(a **Start here when setting up a new environment:** ```bash -make ping # should return {"meta": {"status": "1", ...}} -make products # lists product codes for your account -make orders # lists recent orders — useful to find an ORDER_NUMBER to test with +just ping # should return {"meta": {"status": "1", ...}} +just products # lists product codes for your account +just orders # lists recent orders — useful to find an ORDER_NUMBER to test with ``` | Target | Command | Description | |---|---|---| -| Verify credentials | `make ping` | `ValidateCredentials` — confirms the access key and account number are accepted | -| List products | `make products` | `GetProductDetails` — shows all certificate product codes available to your group | -| List orders | `make orders [PAGE=1] [PAGE_SIZE=10]` | `GetOrderReport` — paginated order listing | -| Track an order | `make get-order ORDER_NUMBER=NNNNN` | `TrackOrder` — returns current status for a specific order | -| Download a certificate | `make get-cert ORDER_NUMBER=NNNNN` | `GetCertificate` — returns the PEM chain for a specific order | -| Place a draft order | `make generate-order DOMAIN=example.com [CSR_FILE=req.pem] [VALIDITY=1] [SAVE_AND_HOLD=1]` | `GenerateOrderSSL` — places a new order; `SAVE_AND_HOLD=1` (default) creates a draft | -| Revoke an order | `make revoke-order ORDER_NUMBER=NNNNN [REASON_ID=1]` | `RevokeOrder` — revokes an issued certificate | -| Attach a CSR to a draft | `make submit-csr ORDER_NUMBER=NNNNN CSR_FILE=req.pem` | `SubmitCSR` — attaches a CSR to a saveAndHold draft order | -| Discover product codes | `make probe-products` | Places `saveAndHold=1` draft orders for all known SSL/TLS product codes and reports which ones the account accepts | -| Cancel one pending order | `scripts/reject-order.sh ORDER_NUMBER=NNNNN` | Shell script — cancels a single pending order (not a `make` target) | -| Cancel all pending orders | `scripts/reject-all-pending.sh` | Shell script — dry-run by default; set `REJECT_ALL_PENDING=1` to fire (not a `make` target) | -| Show API target help | `make api-help` | Prints usage for all API targets | +| Verify credentials | `just ping` | `ValidateCredentials` — confirms the access key and account number are accepted | +| List products | `just products` | `GetProductDetails` — shows all certificate product codes available to your group | +| List orders | `just PAGE=1 PAGE_SIZE=10 orders` | `GetOrderReport` — paginated order listing | +| Track an order | `just ORDER_NUMBER=NNNNN get-order` | `TrackOrder` — returns current status for a specific order | +| Download a certificate | `just ORDER_NUMBER=NNNNN get-cert` | `GetCertificate` — returns the PEM chain for a specific order | +| Place a draft order | `just DOMAIN=example.com [CSR_FILE=req.pem] [VALIDITY=1] [SAVE_AND_HOLD=1] generate-order` | `GenerateOrderSSL` — places a new order; `SAVE_AND_HOLD=1` (default) creates a draft | +| Revoke an order | `just ORDER_NUMBER=NNNNN [REASON_ID=1] revoke-order` | `RevokeOrder` — revokes an issued certificate | +| Attach a CSR to a draft | `just ORDER_NUMBER=NNNNN CSR_FILE=req.pem submit-csr` | `SubmitCSR` — attaches a CSR to a saveAndHold draft order | +| Discover product codes | `just probe-products` | Places `saveAndHold=1` draft orders for all known SSL/TLS product codes and reports which ones the account accepts | +| Cancel one pending order | `scripts/reject-order.sh ORDER_NUMBER=NNNNN` | Shell script — cancels a single pending order (not a `just` target) | +| Cancel all pending orders | `scripts/reject-all-pending.sh` | Shell script — dry-run by default; set `REJECT_ALL_PENDING=1` to fire (not a `just` target) | +| Show API target help | `just api-help` | Prints usage for all API targets | > Note: `TrackOrder` and `GetCertificate` require a formal `orderNumber`, which is only assigned after a draft order is submitted and approved. Draft orders (created with `saveAndHold:"1"`) have a `requestNumber` but no `orderNumber` until that point. ## Draft Orders (saveAndHold) -Setting `SAVE_AND_HOLD=1` (the default) on `make generate-order` places an order in "On Hold" state without triggering billing, DCV, or CA issuance. This is useful for validating that an order payload is accepted by the API. +Setting `SAVE_AND_HOLD=1` (the default) on `just generate-order` places an order in "On Hold" state without triggering billing, DCV, or CA issuance. This is useful for validating that an order payload is accepted by the API. Draft orders behave as follows: @@ -107,7 +108,7 @@ The `CERTInext.IntegrationTests/` project contains live API tests that run again Run them with: ```bash -make integration-test +just integration-test ``` See `CERTInext.IntegrationTests/INTEGRATION_TESTING.md` for a full description of each test, what it validates, and the expected API state. diff --git a/Makefile b/justfile similarity index 62% rename from Makefile rename to justfile index c2a726f..3d36967 100644 --- a/Makefile +++ b/justfile @@ -1,6 +1,8 @@ -SLN := certinext-caplugin.sln -COVERAGE_DIR := /tmp/certinext-coverage -REPORT_DIR := /tmp/certinext-coverage-report +SLN := "certinext-caplugin.sln" +COVERAGE_DIR := "/tmp/certinext-coverage" +REPORT_DIR := "/tmp/certinext-coverage-report" + +repo := "Keyfactor/certinext-caplugin" # --------------------------------------------------------------------------- # V2 API credentials — set CERTINEXT_V2_API_URL in ~/.env_certinext. @@ -9,82 +11,33 @@ REPORT_DIR := /tmp/certinext-coverage-report # CERTINEXT_V2_API_URL=https://sandbox-us.certinext.io # --------------------------------------------------------------------------- -.PHONY: build test integration-test coverage coverage-report open-coverage clean \ - ping \ - get-product-details products \ - get-product-details-group \ - probe-products \ - generate-test-csr \ - get-order-report orders \ - track-order get-order \ - get-certificate get-cert \ - get-dcv \ - verify-dcv \ - generate-order \ - revoke-order \ - submit-csr \ - list-cas \ - register register-profiles register-ca-config register-claims \ - register-command-ca register-import register-enrollment \ - create-product \ - generate-order-igtf \ - generate-order-149-fresh \ - generate-order-private-pki \ - probe-endpoints \ - get-field-details \ - show-postman-bodies \ - show-postman-variables \ - probe-private-pki-payloads \ - api-help \ - v2-ping \ - v2-list-products \ - v2-get-custom-fields \ - v2-list-groups \ - v2-list-organizations \ - v2-list-domains \ - v2-create-ssl-order \ - v2-track-order \ - v2-get-dcv \ - v2-verify-dcv \ - v2-submit-csr \ - v2-accept-agreement \ - v2-download-certificate \ - v2-revoke-ssl \ - v2-cancel-ssl-order \ - v2-create-private-pki-order \ - v2-track-private-pki \ - v2-submit-csr-private-pki \ - v2-download-certificate-private-pki \ - v2-revoke-private-pki \ - v2-orders-report - build: - dotnet build $(SLN) + dotnet build {{SLN}} test: - dotnet test $(SLN) --verbosity normal + dotnet test {{SLN}} --verbosity normal integration-test: - dotnet test CERTInext.IntegrationTests/ --verbosity normal + dotnet test CERTInext.IntegrationTests/ --verbosity normal coverage: - rm -rf $(COVERAGE_DIR) - dotnet test $(SLN) \ - --collect:"XPlat Code Coverage" \ - --results-directory $(COVERAGE_DIR) - dotnet tool install -g dotnet-reportgenerator-globaltool 2>/dev/null || true - reportgenerator \ - -reports:"$(COVERAGE_DIR)/**/coverage.cobertura.xml" \ - -targetdir:$(REPORT_DIR) \ - -reporttypes:"TextSummary;Html" - @cat $(REPORT_DIR)/Summary.txt + rm -rf {{COVERAGE_DIR}} + dotnet test {{SLN}} \ + --collect:"XPlat Code Coverage" \ + --results-directory {{COVERAGE_DIR}} + dotnet tool install -g dotnet-reportgenerator-globaltool 2>/dev/null || true + reportgenerator \ + -reports:"{{COVERAGE_DIR}}/**/coverage.cobertura.xml" \ + -targetdir:{{REPORT_DIR}} \ + -reporttypes:"TextSummary;Html" + @cat {{REPORT_DIR}}/Summary.txt coverage-report: coverage - open $(REPORT_DIR)/index.html + open {{REPORT_DIR}}/index.html clean: - dotnet clean $(SLN) - rm -rf $(COVERAGE_DIR) $(REPORT_DIR) + dotnet clean {{SLN}} + rm -rf {{COVERAGE_DIR}} {{REPORT_DIR}} # --------------------------------------------------------------------------- # API smoke tests (credentials from ~/.env_certinext) @@ -100,7 +53,7 @@ clean: # --------------------------------------------------------------------------- ping: - @scripts/ping.sh + @scripts/ping.sh # --------------------------------------------------------------------------- # GetProductDetails — POST {baseURL}GetProductDetails @@ -108,8 +61,10 @@ ping: # Aliases: products # --------------------------------------------------------------------------- -get-product-details products: - @scripts/get-product-details.sh +get-product-details: + @scripts/get-product-details.sh + +alias products := get-product-details # --------------------------------------------------------------------------- # GetProductDetails with groupNumber — POST {baseURL}GetProductDetails @@ -120,7 +75,7 @@ get-product-details products: # --------------------------------------------------------------------------- get-product-details-group: - @scripts/get-product-details-group.sh + @scripts/get-product-details-group.sh # --------------------------------------------------------------------------- # generate-test-csr — generates a fresh RSA-2048 PKCS#10 CSR for @@ -129,12 +84,12 @@ get-product-details-group: # --------------------------------------------------------------------------- generate-test-csr: - @openssl req -new -newkey rsa:2048 -nodes \ - -subj "/CN=test-integration.example.com" \ - -addext "subjectAltName=DNS:test-integration.example.com" \ - -out /tmp/certinext-test.csr \ - -keyout /tmp/certinext-test.key 2>/dev/null; \ - echo "CSR written to /tmp/certinext-test.csr" + @openssl req -new -newkey rsa:2048 -nodes \ + -subj "/CN=test-integration.example.com" \ + -addext "subjectAltName=DNS:test-integration.example.com" \ + -out /tmp/certinext-test.csr \ + -keyout /tmp/certinext-test.key 2>/dev/null; \ + echo "CSR written to /tmp/certinext-test.csr" # --------------------------------------------------------------------------- # probe-products — places saveAndHold=1 draft orders for every SSL/TLS @@ -156,10 +111,10 @@ generate-test-csr: # 149 Sandbox emSign Intranet SSL 1 Year (Private PKI) # --------------------------------------------------------------------------- -PROBE_DOMAIN ?= test-integration.example.com +PROBE_DOMAIN := "test-integration.example.com" probe-products: generate-test-csr - @PROBE_DOMAIN=$(PROBE_DOMAIN) scripts/probe-products.sh + @PROBE_DOMAIN={{PROBE_DOMAIN}} scripts/probe-products.sh # --------------------------------------------------------------------------- # GetOrderReport — POST {baseURL}GetOrderReport @@ -177,11 +132,13 @@ probe-products: generate-test-csr # Use orderNumber for all post-issuance operations (TrackOrder, GetCertificate, RevokeOrder). # --------------------------------------------------------------------------- -PAGE ?= 1 -PAGE_SIZE ?= 10 +PAGE := "1" +PAGE_SIZE := "10" + +get-order-report: + @PAGE={{PAGE}} PAGE_SIZE={{PAGE_SIZE}} scripts/get-order-report.sh -get-order-report orders: - @PAGE=$(PAGE) PAGE_SIZE=$(PAGE_SIZE) scripts/get-order-report.sh +alias orders := get-order-report # --------------------------------------------------------------------------- # TrackOrder — POST {baseURL}TrackOrder @@ -194,8 +151,12 @@ get-order-report orders: # Using a requestNumber will return EMS-943 (order not found). # --------------------------------------------------------------------------- -track-order get-order: - @ORDER_NUMBER=$(ORDER_NUMBER) scripts/track-order.sh +ORDER_NUMBER := "" + +track-order: + @ORDER_NUMBER={{ORDER_NUMBER}} scripts/track-order.sh + +alias get-order := track-order # --------------------------------------------------------------------------- # GetCertificate — POST {baseURL}GetCertificate @@ -204,9 +165,10 @@ track-order get-order: # Required: ORDER_NUMBER= # --------------------------------------------------------------------------- -get-certificate get-cert: - @ORDER_NUMBER=$(ORDER_NUMBER) scripts/get-certificate.sh +get-certificate: + @ORDER_NUMBER={{ORDER_NUMBER}} scripts/get-certificate.sh +alias get-cert := get-certificate # --------------------------------------------------------------------------- # GetDcv — POST {baseURL}GetDcv @@ -216,10 +178,11 @@ get-certificate get-cert: # Optional: DCV_METHOD=1 (1=DNS TXT, 2=HTTP file, 3=Email; default 1) # --------------------------------------------------------------------------- -DCV_METHOD ?= 1 +DOMAIN_NAME := "" +DCV_METHOD := "1" get-dcv: - @ORDER_NUMBER=$(ORDER_NUMBER) DOMAIN_NAME=$(DOMAIN_NAME) DCV_METHOD=$(DCV_METHOD) scripts/get-dcv.sh + @ORDER_NUMBER={{ORDER_NUMBER}} DOMAIN_NAME={{DOMAIN_NAME}} DCV_METHOD={{DCV_METHOD}} scripts/get-dcv.sh # --------------------------------------------------------------------------- # VerifyDcv — POST {baseURL}VerifyDcv @@ -231,7 +194,7 @@ get-dcv: # --------------------------------------------------------------------------- verify-dcv: - @ORDER_NUMBER=$(ORDER_NUMBER) DOMAIN_NAME=$(DOMAIN_NAME) DCV_METHOD=$(DCV_METHOD) scripts/verify-dcv.sh + @ORDER_NUMBER={{ORDER_NUMBER}} DOMAIN_NAME={{DOMAIN_NAME}} DCV_METHOD={{DCV_METHOD}} scripts/verify-dcv.sh # --------------------------------------------------------------------------- # GenerateOrderSSL — POST {baseURL}GenerateOrderSSL @@ -241,7 +204,7 @@ verify-dcv: # SAVE_AND_HOLD=1 (default) — "1"=save draft, "0"=submit immediately # # On success, prints the requestNumber (draft ID) prominently. -# Use requestNumber to attach a CSR later (make submit-csr). +# Use requestNumber to attach a CSR later (just submit-csr). # Once the order is formally submitted, an orderNumber is assigned — use that # for TrackOrder, GetCertificate, and RevokeOrder. # @@ -249,13 +212,14 @@ verify-dcv: # Reads CERTINEXT_REQUESTOR_MOBILE from ~/.env_certinext (digits only, no country code). # --------------------------------------------------------------------------- -DOMAIN ?= -CSR_FILE ?= -VALIDITY ?= 1 -SAVE_AND_HOLD ?= 1 +DOMAIN := "" +CSR_FILE := "" +VALIDITY := "1" +SAVE_AND_HOLD := "1" +CODE := "" generate-order: - @DOMAIN=$(DOMAIN) CSR_FILE=$(CSR_FILE) VALIDITY=$(VALIDITY) SAVE_AND_HOLD=$(SAVE_AND_HOLD) CODE=$(CODE) scripts/generate-order.sh + @DOMAIN={{DOMAIN}} CSR_FILE={{CSR_FILE}} VALIDITY={{VALIDITY}} SAVE_AND_HOLD={{SAVE_AND_HOLD}} CODE={{CODE}} scripts/generate-order.sh # --------------------------------------------------------------------------- # RevokeOrder — POST {baseURL}RevokeOrder @@ -268,10 +232,10 @@ generate-order: # 5 = CessationOfOperation 9 = PrivilegeWithdrawn # --------------------------------------------------------------------------- -REASON_ID ?= 1 +REASON_ID := "1" revoke-order: - @ORDER_NUMBER=$(ORDER_NUMBER) REASON_ID=$(REASON_ID) scripts/revoke-order.sh + @ORDER_NUMBER={{ORDER_NUMBER}} REASON_ID={{REASON_ID}} scripts/revoke-order.sh # --------------------------------------------------------------------------- # SubmitCSR — POST {baseURL}SubmitCSR @@ -280,7 +244,7 @@ revoke-order: # --------------------------------------------------------------------------- submit-csr: - @ORDER_NUMBER=$(ORDER_NUMBER) CSR_FILE=$(CSR_FILE) scripts/submit-csr.sh + @ORDER_NUMBER={{ORDER_NUMBER}} CSR_FILE={{CSR_FILE}} scripts/submit-csr.sh # --------------------------------------------------------------------------- # list-cas — Sub-CA listing via API @@ -299,7 +263,7 @@ submit-csr: # --------------------------------------------------------------------------- list-cas: - @scripts/list-cas.sh + @scripts/list-cas.sh # --------------------------------------------------------------------------- # register-* — provision profiles/templates into the AnyCA REST Gateway and @@ -307,17 +271,17 @@ list-cas: # NOT the CERTInext API — see scripts/lib/command-auth.sh for the env contract # (TOKEN_URL, OIDC_CLIENT_ID/SECRET, GATEWAY_HOST, COMMAND_HOST, ...). # -# make register # full provisioning (stages 01..06) -# make register DRY_RUN=1 # DRY_RUN forwards to every stage -# make register SKIP_03=1 # skip a stage by number +# just register # full provisioning (stages 01..06) +# DRY_RUN=1 just register # DRY_RUN forwards to every stage +# SKIP_03=1 just register # skip a stage by number # # Per-stage (each idempotent; add DRY_RUN=1 for an offline preview): -# make register-profiles # 01 gateway certificate profiles [CHECK=1] -# make register-ca-config # 02 gateway CAConnection + Templates -# make register-claims # 03 gateway access claims (IAM) -# make register-command-ca # 04 register CA in Command -# make register-import # 05 import templates into Command [CHECK=1] -# make register-enrollment # 06 enrollment patterns + template KeyRetention +# just register-profiles # 01 gateway certificate profiles [CHECK=1] +# just register-ca-config # 02 gateway CAConnection + Templates +# just register-claims # 03 gateway access claims (IAM) +# just register-command-ca # 04 register CA in Command +# just register-import # 05 import templates into Command [CHECK=1] +# just register-enrollment # 06 enrollment patterns + template KeyRetention # # Stages 01 and 06 are VERIFIED live; 02-05 are built from docs/reference # captures — validate against a live gateway/Command before relying on them. @@ -325,26 +289,27 @@ list-cas: # NOTE: stage 04 (and stage 02's CA-connection PUT) touch the CA config, which # is fragile — leave it alone unless explicitly required. # --------------------------------------------------------------------------- + register: - @scripts/register/00-register-all.sh + @scripts/register/00-register-all.sh register-profiles: - @scripts/register/01-gateway-profiles.sh + @scripts/register/01-gateway-profiles.sh register-ca-config: - @scripts/register/02-gateway-ca-config.sh + @scripts/register/02-gateway-ca-config.sh register-claims: - @scripts/register/03-gateway-claims.sh + @scripts/register/03-gateway-claims.sh register-command-ca: - @scripts/register/04-command-register-ca.sh + @scripts/register/04-command-register-ca.sh register-import: - @scripts/register/05-command-import-templates.sh + @scripts/register/05-command-import-templates.sh register-enrollment: - @scripts/register/06-command-enrollment-patterns.sh + @scripts/register/06-command-enrollment-patterns.sh # --------------------------------------------------------------------------- # create-product — Create a custom product via API @@ -360,7 +325,7 @@ register-enrollment: # --------------------------------------------------------------------------- create-product: - @scripts/create-product.sh + @scripts/create-product.sh # --------------------------------------------------------------------------- # generate-order-igtf — Place a Private PKI order using product 149 @@ -374,11 +339,11 @@ create-product: # Optional: IGTF_CSR_FILE= IGTF_DOMAIN=test-igtf.example.com SAVE_AND_HOLD=1 # --------------------------------------------------------------------------- -IGTF_DOMAIN ?= test-igtf.example.com -IGTF_CSR_FILE ?= /tmp/certinext-igtf-test.csr +IGTF_DOMAIN := "test-igtf.example.com" +IGTF_CSR_FILE := "/tmp/certinext-igtf-test.csr" generate-order-igtf: generate-test-csr - @IGTF_CSR_FILE=$(IGTF_CSR_FILE) IGTF_DOMAIN=$(IGTF_DOMAIN) SAVE_AND_HOLD=$(SAVE_AND_HOLD) scripts/generate-order-igtf.sh + @IGTF_CSR_FILE={{IGTF_CSR_FILE}} IGTF_DOMAIN={{IGTF_DOMAIN}} SAVE_AND_HOLD={{SAVE_AND_HOLD}} scripts/generate-order-igtf.sh # --------------------------------------------------------------------------- # generate-order-149-fresh — Place product-149 Private PKI order with a @@ -388,7 +353,7 @@ generate-order-igtf: generate-test-csr # --------------------------------------------------------------------------- generate-order-149-fresh: - @SAVE_AND_HOLD=$(SAVE_AND_HOLD) scripts/generate-order-149-fresh.sh + @SAVE_AND_HOLD={{SAVE_AND_HOLD}} scripts/generate-order-149-fresh.sh # --------------------------------------------------------------------------- # generate-order-private-pki — Place a Private PKI order for any product code @@ -399,12 +364,12 @@ generate-order-149-fresh: # Optional: PRIVATE_PKI_CODE=149 PRIVATE_PKI_DOMAIN=... PRIVATE_PKI_CSR=... SAVE_AND_HOLD=1 # --------------------------------------------------------------------------- -PRIVATE_PKI_CODE ?= 149 -PRIVATE_PKI_DOMAIN ?= test-private-pki.example.com -PRIVATE_PKI_CSR ?= /tmp/certinext-igtf-test.csr +PRIVATE_PKI_CODE := "149" +PRIVATE_PKI_DOMAIN := "test-private-pki.example.com" +PRIVATE_PKI_CSR := "/tmp/certinext-igtf-test.csr" generate-order-private-pki: generate-test-csr - @PRIVATE_PKI_CODE=$(PRIVATE_PKI_CODE) PRIVATE_PKI_DOMAIN=$(PRIVATE_PKI_DOMAIN) PRIVATE_PKI_CSR=$(PRIVATE_PKI_CSR) SAVE_AND_HOLD=$(SAVE_AND_HOLD) scripts/generate-order-private-pki.sh + @PRIVATE_PKI_CODE={{PRIVATE_PKI_CODE}} PRIVATE_PKI_DOMAIN={{PRIVATE_PKI_DOMAIN}} PRIVATE_PKI_CSR={{PRIVATE_PKI_CSR}} SAVE_AND_HOLD={{SAVE_AND_HOLD}} scripts/generate-order-private-pki.sh # --------------------------------------------------------------------------- # probe-endpoints — Probe candidate product-management and CA-listing endpoints @@ -417,7 +382,7 @@ generate-order-private-pki: generate-test-csr # --------------------------------------------------------------------------- probe-endpoints: - @scripts/probe-endpoints.sh + @scripts/probe-endpoints.sh # --------------------------------------------------------------------------- # get-field-details — GetFieldDetails for a specific product code @@ -428,11 +393,11 @@ probe-endpoints: # Optional: PRODUCT_CODE=149 CATEGORY_ID=8 # --------------------------------------------------------------------------- -PRODUCT_CODE ?= 149 -CATEGORY_ID ?= 8 +PRODUCT_CODE := "149" +CATEGORY_ID := "8" get-field-details: - @PRODUCT_CODE=$(PRODUCT_CODE) CATEGORY_ID=$(CATEGORY_ID) scripts/get-field-details.sh + @PRODUCT_CODE={{PRODUCT_CODE}} CATEGORY_ID={{CATEGORY_ID}} scripts/get-field-details.sh # --------------------------------------------------------------------------- # show-postman-bodies — Extract request bodies from the Postman collection @@ -441,20 +406,20 @@ get-field-details: # collection. Use FILTER= to narrow output (case-insensitive substring). # # Examples: -# make show-postman-bodies # print all -# make show-postman-bodies FILTER="private pki" # Private PKI only -# make show-postman-bodies FILTER=igtf # IGTF only -# make show-postman-bodies FILTER=intranet # Intranet SSL only +# just show-postman-bodies # print all +# FILTER="private pki" just show-postman-bodies # Private PKI only +# FILTER=igtf just show-postman-bodies # IGTF only +# FILTER=intranet just show-postman-bodies # Intranet SSL only # # Wraps scripts/extract_postman_bodies.py — run that script directly for # additional options (--collection, etc.). # --------------------------------------------------------------------------- -FILTER ?= +FILTER := "" show-postman-bodies: - @python3 /Users/sbailey/RiderProjects/certinext-caplugin/scripts/extract_postman_bodies.py \ - --filter "$(FILTER)" + @python3 /Users/sbailey/RiderProjects/certinext-caplugin/scripts/extract_postman_bodies.py \ + --filter "{{FILTER}}" # --------------------------------------------------------------------------- # show-postman-variables — Extract collection-level variable values @@ -465,7 +430,7 @@ show-postman-bodies: # --------------------------------------------------------------------------- show-postman-variables: - @python3 /Users/sbailey/RiderProjects/certinext-caplugin/scripts/extract_postman_variables.py + @python3 /Users/sbailey/RiderProjects/certinext-caplugin/scripts/extract_postman_variables.py # --------------------------------------------------------------------------- # probe-private-pki-payloads — Try three payload variants for @@ -479,11 +444,11 @@ show-postman-variables: # --------------------------------------------------------------------------- probe-private-pki-payloads: generate-test-csr - @python3 /Users/sbailey/RiderProjects/certinext-caplugin/scripts/order_private_pki_minimal.py \ - --csr /tmp/certinext-test.csr \ - --domain "$(IGTF_DOMAIN)" \ - --product "$(PRIVATE_PKI_CODE)" \ - --save-and-hold "$(SAVE_AND_HOLD)" + @python3 /Users/sbailey/RiderProjects/certinext-caplugin/scripts/order_private_pki_minimal.py \ + --csr /tmp/certinext-test.csr \ + --domain "{{IGTF_DOMAIN}}" \ + --product "{{PRIVATE_PKI_CODE}}" \ + --save-and-hold "{{SAVE_AND_HOLD}}" # --------------------------------------------------------------------------- # V2 API targets (credentials + CERTINEXT_V2_API_URL from ~/.env_certinext) @@ -504,8 +469,8 @@ probe-private-pki-payloads: generate-test-csr # --------------------------------------------------------------------------- v2-ping: - @echo "V2 ping — GET /api/certinext/v2/auth/me" - @scripts/v2/ping.sh + @echo "V2 ping — GET /api/certinext/v2/auth/me" + @scripts/v2/ping.sh # --------------------------------------------------------------------------- # v2-list-products — GET /api/certinext/v2/catalog/products @@ -514,8 +479,8 @@ v2-ping: # --------------------------------------------------------------------------- v2-list-products: - @echo "V2 list products — GET /api/certinext/v2/catalog/products" - @scripts/v2/list-products.sh + @echo "V2 list products — GET /api/certinext/v2/catalog/products" + @scripts/v2/list-products.sh # --------------------------------------------------------------------------- # v2-get-custom-fields — GET /api/certinext/v2/catalog/products/{code}/custom-fields @@ -523,11 +488,11 @@ v2-list-products: # Required: PRODUCT_CODE= # --------------------------------------------------------------------------- -V2_PRODUCT_CODE ?= 842 +V2_PRODUCT_CODE := "842" v2-get-custom-fields: - @echo "V2 get custom fields — GET /api/certinext/v2/catalog/products/$(V2_PRODUCT_CODE)/custom-fields" - @PRODUCT_CODE=$(V2_PRODUCT_CODE) scripts/v2/get-custom-fields.sh + @echo "V2 get custom fields — GET /api/certinext/v2/catalog/products/{{V2_PRODUCT_CODE}}/custom-fields" + @PRODUCT_CODE={{V2_PRODUCT_CODE}} scripts/v2/get-custom-fields.sh # --------------------------------------------------------------------------- # v2-list-groups — GET /api/certinext/v2/groups @@ -536,8 +501,8 @@ v2-get-custom-fields: # --------------------------------------------------------------------------- v2-list-groups: - @echo "V2 list groups — GET /api/certinext/v2/groups" - @scripts/v2/list-groups.sh + @echo "V2 list groups — GET /api/certinext/v2/groups" + @scripts/v2/list-groups.sh # --------------------------------------------------------------------------- # v2-list-organizations — GET /api/certinext/v2/organizations @@ -546,8 +511,8 @@ v2-list-groups: # --------------------------------------------------------------------------- v2-list-organizations: - @echo "V2 list organizations — GET /api/certinext/v2/organizations" - @scripts/v2/list-organizations.sh + @echo "V2 list organizations — GET /api/certinext/v2/organizations" + @scripts/v2/list-organizations.sh # --------------------------------------------------------------------------- # v2-list-domains — GET /api/certinext/v2/domains @@ -556,8 +521,8 @@ v2-list-organizations: # --------------------------------------------------------------------------- v2-list-domains: - @echo "V2 list domains — GET /api/certinext/v2/domains" - @scripts/v2/list-domains.sh + @echo "V2 list domains — GET /api/certinext/v2/domains" + @scripts/v2/list-domains.sh # --------------------------------------------------------------------------- # v2-create-ssl-order — POST /api/certinext/v2/ssl-certificates @@ -570,12 +535,12 @@ v2-list-domains: # v2-revoke-ssl, and v2-cancel-ssl-order. # --------------------------------------------------------------------------- -V2_DOMAIN ?= -V2_VARIANT ?= dv +V2_DOMAIN := "" +V2_VARIANT := "dv" v2-create-ssl-order: - @echo "V2 create SSL order — POST /api/certinext/v2/ssl-certificates" - @PRODUCT_CODE=$(V2_PRODUCT_CODE) DOMAIN=$(V2_DOMAIN) VARIANT=$(V2_VARIANT) scripts/v2/create-ssl-order.sh + @echo "V2 create SSL order — POST /api/certinext/v2/ssl-certificates" + @PRODUCT_CODE={{V2_PRODUCT_CODE}} DOMAIN={{V2_DOMAIN}} VARIANT={{V2_VARIANT}} scripts/v2/create-ssl-order.sh # --------------------------------------------------------------------------- # v2-track-order — GET /api/certinext/v2/ssl-certificates/{orderId} @@ -586,11 +551,11 @@ v2-create-ssl-order: # (or cancelled / revoked) # --------------------------------------------------------------------------- -ORDER_ID ?= +ORDER_ID := "" v2-track-order: - @echo "V2 track SSL order — GET /api/certinext/v2/ssl-certificates/$(ORDER_ID)" - @ORDER_ID=$(ORDER_ID) scripts/v2/track-order.sh + @echo "V2 track SSL order — GET /api/certinext/v2/ssl-certificates/{{ORDER_ID}}" + @ORDER_ID={{ORDER_ID}} scripts/v2/track-order.sh # --------------------------------------------------------------------------- # v2-get-dcv — GET /api/certinext/v2/ssl-certificates/{orderId}/dcv?domain={domain} @@ -599,8 +564,8 @@ v2-track-order: # --------------------------------------------------------------------------- v2-get-dcv: - @echo "V2 get DCV challenges — GET /api/certinext/v2/ssl-certificates/$(ORDER_ID)/dcv" - @ORDER_ID=$(ORDER_ID) DOMAIN=$(V2_DOMAIN) scripts/v2/get-dcv.sh + @echo "V2 get DCV challenges — GET /api/certinext/v2/ssl-certificates/{{ORDER_ID}}/dcv" + @ORDER_ID={{ORDER_ID}} DOMAIN={{V2_DOMAIN}} scripts/v2/get-dcv.sh # --------------------------------------------------------------------------- # v2-verify-dcv — POST /api/certinext/v2/ssl-certificates/{orderId}/dcv/verify @@ -609,11 +574,11 @@ v2-get-dcv: # Optional: METHOD=http-url (also: dns-txt, email) # --------------------------------------------------------------------------- -V2_DCV_METHOD ?= http-url +V2_DCV_METHOD := "http-url" v2-verify-dcv: - @echo "V2 verify DCV — POST /api/certinext/v2/ssl-certificates/$(ORDER_ID)/dcv/verify" - @ORDER_ID=$(ORDER_ID) DOMAIN=$(V2_DOMAIN) METHOD=$(V2_DCV_METHOD) scripts/v2/verify-dcv.sh + @echo "V2 verify DCV — POST /api/certinext/v2/ssl-certificates/{{ORDER_ID}}/dcv/verify" + @ORDER_ID={{ORDER_ID}} DOMAIN={{V2_DOMAIN}} METHOD={{V2_DCV_METHOD}} scripts/v2/verify-dcv.sh # --------------------------------------------------------------------------- # v2-submit-csr — PUT /api/certinext/v2/ssl-certificates/{orderId}/csr @@ -621,11 +586,11 @@ v2-verify-dcv: # Required: ORDER_ID= CSR_FILE= # --------------------------------------------------------------------------- -V2_CSR_FILE ?= +V2_CSR_FILE := "" v2-submit-csr: - @echo "V2 submit CSR (SSL) — PUT /api/certinext/v2/ssl-certificates/$(ORDER_ID)/csr" - @ORDER_ID=$(ORDER_ID) CSR_FILE=$(V2_CSR_FILE) scripts/v2/submit-csr.sh + @echo "V2 submit CSR (SSL) — PUT /api/certinext/v2/ssl-certificates/{{ORDER_ID}}/csr" + @ORDER_ID={{ORDER_ID}} CSR_FILE={{V2_CSR_FILE}} scripts/v2/submit-csr.sh # --------------------------------------------------------------------------- # v2-accept-agreement — POST /api/certinext/v2/ssl-certificates/{orderId}/agreement @@ -634,8 +599,8 @@ v2-submit-csr: # --------------------------------------------------------------------------- v2-accept-agreement: - @echo "V2 accept agreement — POST /api/certinext/v2/ssl-certificates/$(ORDER_ID)/agreement" - @ORDER_ID=$(ORDER_ID) scripts/v2/accept-agreement.sh + @echo "V2 accept agreement — POST /api/certinext/v2/ssl-certificates/{{ORDER_ID}}/agreement" + @ORDER_ID={{ORDER_ID}} scripts/v2/accept-agreement.sh # --------------------------------------------------------------------------- # v2-download-certificate — GET /api/certinext/v2/ssl-certificates/{orderId}/certificate @@ -644,8 +609,8 @@ v2-accept-agreement: # --------------------------------------------------------------------------- v2-download-certificate: - @echo "V2 download certificate (SSL) — GET /api/certinext/v2/ssl-certificates/$(ORDER_ID)/certificate" - @ORDER_ID=$(ORDER_ID) scripts/v2/download-certificate.sh + @echo "V2 download certificate (SSL) — GET /api/certinext/v2/ssl-certificates/{{ORDER_ID}}/certificate" + @ORDER_ID={{ORDER_ID}} scripts/v2/download-certificate.sh # --------------------------------------------------------------------------- # v2-revoke-ssl — POST /api/certinext/v2/ssl-certificates/{orderId}/revoke @@ -657,11 +622,11 @@ v2-download-certificate: # affiliationChanged, superseded, cessationOfOperation, privilegeWithdrawn # --------------------------------------------------------------------------- -V2_REASON ?= superseded +V2_REASON := "superseded" v2-revoke-ssl: - @echo "V2 revoke SSL — POST /api/certinext/v2/ssl-certificates/$(ORDER_ID)/revoke" - @ORDER_ID=$(ORDER_ID) REASON=$(V2_REASON) scripts/v2/revoke-ssl.sh + @echo "V2 revoke SSL — POST /api/certinext/v2/ssl-certificates/{{ORDER_ID}}/revoke" + @ORDER_ID={{ORDER_ID}} REASON={{V2_REASON}} scripts/v2/revoke-ssl.sh # --------------------------------------------------------------------------- # v2-cancel-ssl-order — POST /api/certinext/v2/ssl-certificates/{orderId}/cancel @@ -670,8 +635,8 @@ v2-revoke-ssl: # --------------------------------------------------------------------------- v2-cancel-ssl-order: - @echo "V2 cancel SSL order — POST /api/certinext/v2/ssl-certificates/$(ORDER_ID)/cancel" - @ORDER_ID=$(ORDER_ID) scripts/v2/cancel-ssl-order.sh + @echo "V2 cancel SSL order — POST /api/certinext/v2/ssl-certificates/{{ORDER_ID}}/cancel" + @ORDER_ID={{ORDER_ID}} scripts/v2/cancel-ssl-order.sh # --------------------------------------------------------------------------- # v2-create-private-pki-order — POST /api/certinext/v2/private-pki-certificates @@ -683,13 +648,13 @@ v2-cancel-ssl-order: # v2-revoke-private-pki. # --------------------------------------------------------------------------- -V2_HOSTNAME ?= -V2_CA_PROFILE_ID ?= -V2_MASTER_PRODUCT_ID ?= +V2_HOSTNAME := "" +V2_CA_PROFILE_ID := "" +V2_MASTER_PRODUCT_ID := "" v2-create-private-pki-order: - @echo "V2 create Private PKI order — POST /api/certinext/v2/private-pki-certificates" - @PRODUCT_CODE=$(V2_PRODUCT_CODE) HOSTNAME=$(V2_HOSTNAME) CA_PROFILE_ID=$(V2_CA_PROFILE_ID) MASTER_PRODUCT_ID=$(V2_MASTER_PRODUCT_ID) scripts/v2/create-private-pki-order.sh + @echo "V2 create Private PKI order — POST /api/certinext/v2/private-pki-certificates" + @PRODUCT_CODE={{V2_PRODUCT_CODE}} HOSTNAME={{V2_HOSTNAME}} CA_PROFILE_ID={{V2_CA_PROFILE_ID}} MASTER_PRODUCT_ID={{V2_MASTER_PRODUCT_ID}} scripts/v2/create-private-pki-order.sh # --------------------------------------------------------------------------- # v2-track-private-pki — GET /api/certinext/v2/private-pki-certificates/{orderId} @@ -700,8 +665,8 @@ v2-create-private-pki-order: # --------------------------------------------------------------------------- v2-track-private-pki: - @echo "V2 track Private PKI order — GET /api/certinext/v2/private-pki-certificates/$(ORDER_ID)" - @ORDER_ID=$(ORDER_ID) scripts/v2/track-private-pki.sh + @echo "V2 track Private PKI order — GET /api/certinext/v2/private-pki-certificates/{{ORDER_ID}}" + @ORDER_ID={{ORDER_ID}} scripts/v2/track-private-pki.sh # --------------------------------------------------------------------------- # v2-submit-csr-private-pki — PUT /api/certinext/v2/private-pki-certificates/{orderId}/csr @@ -710,8 +675,8 @@ v2-track-private-pki: # --------------------------------------------------------------------------- v2-submit-csr-private-pki: - @echo "V2 submit CSR (Private PKI) — PUT /api/certinext/v2/private-pki-certificates/$(ORDER_ID)/csr" - @ORDER_ID=$(ORDER_ID) CSR_FILE=$(V2_CSR_FILE) scripts/v2/submit-csr-private-pki.sh + @echo "V2 submit CSR (Private PKI) — PUT /api/certinext/v2/private-pki-certificates/{{ORDER_ID}}/csr" + @ORDER_ID={{ORDER_ID}} CSR_FILE={{V2_CSR_FILE}} scripts/v2/submit-csr-private-pki.sh # --------------------------------------------------------------------------- # v2-download-certificate-private-pki — GET /api/certinext/v2/private-pki-certificates/{orderId}/certificate @@ -720,8 +685,8 @@ v2-submit-csr-private-pki: # --------------------------------------------------------------------------- v2-download-certificate-private-pki: - @echo "V2 download certificate (Private PKI) — GET /api/certinext/v2/private-pki-certificates/$(ORDER_ID)/certificate" - @ORDER_ID=$(ORDER_ID) scripts/v2/download-certificate-private-pki.sh + @echo "V2 download certificate (Private PKI) — GET /api/certinext/v2/private-pki-certificates/{{ORDER_ID}}/certificate" + @ORDER_ID={{ORDER_ID}} scripts/v2/download-certificate-private-pki.sh # --------------------------------------------------------------------------- # v2-revoke-private-pki — POST /api/certinext/v2/private-pki-certificates/{orderId}/revoke @@ -734,99 +699,124 @@ v2-download-certificate-private-pki: # --------------------------------------------------------------------------- v2-revoke-private-pki: - @echo "V2 revoke Private PKI — POST /api/certinext/v2/private-pki-certificates/$(ORDER_ID)/revoke" - @ORDER_ID=$(ORDER_ID) REASON=$(V2_REASON) scripts/v2/revoke-private-pki.sh + @echo "V2 revoke Private PKI — POST /api/certinext/v2/private-pki-certificates/{{ORDER_ID}}/revoke" + @ORDER_ID={{ORDER_ID}} REASON={{V2_REASON}} scripts/v2/revoke-private-pki.sh # --------------------------------------------------------------------------- # v2-orders-report — GET /api/certinext/v2/reports/orders?page=0&size=50 # Paginated order history across all product types. # NOTE: currently returns 501 Not Implemented. -# Use v1 make get-order-report (POST /emSignHub-API/GetOrderReport) meanwhile. +# Use v1 just get-order-report (POST /emSignHub-API/GetOrderReport) meanwhile. # --------------------------------------------------------------------------- v2-orders-report: - @echo "V2 orders report — GET /api/certinext/v2/reports/orders (NOTE: currently 501)" - @scripts/v2/orders-report.sh + @echo "V2 orders report — GET /api/certinext/v2/reports/orders (NOTE: currently 501)" + @scripts/v2/orders-report.sh # --------------------------------------------------------------------------- # Help # --------------------------------------------------------------------------- api-help: - @echo "" - @echo "CERTInext API smoke-test targets (credentials from ~/.env_certinext):" - @echo "" - @echo " make ping" - @echo " ValidateCredentials — verify credentials are accepted" - @echo "" - @echo " make get-product-details (alias: products)" - @echo " GetProductDetails — list available certificate products" - @echo " Note: some sandbox accounts require groupNumber to return results." - @echo " Use get-product-details-group if this target returns an empty list." - @echo "" - @echo " make get-product-details-group" - @echo " GetProductDetails — same as get-product-details but explicitly passes" - @echo " groupNumber from CERTINEXT_GROUP_NUMBER. Use this when the plain" - @echo " get-product-details target returns an empty list." - @echo "" - @echo " make generate-test-csr" - @echo " Generate a fresh RSA-2048 CSR for CN=test-integration.example.com" - @echo " and write it to /tmp/certinext-test.csr. Required by probe-products." - @echo "" - @echo " make probe-products [PROBE_DOMAIN=test-integration.example.com]" - @echo " Place saveAndHold=1 draft orders for all SSL/TLS product codes" - @echo " provisioned on the sandbox account (842–851, 149) and report which" - @echo " codes are accepted. A code returning a requestNumber is valid." - @echo " Depends on generate-test-csr (called automatically)." - @echo "" - @echo " make get-order-report (alias: orders) [PAGE=1] [PAGE_SIZE=10]" - @echo " GetOrderReport — paginated order listing" - @echo "" - @echo " make track-order (alias: get-order) ORDER_NUMBER=NNNNN" - @echo " TrackOrder — fetch current status for a specific order" - @echo "" - @echo " make get-certificate (alias: get-cert) ORDER_NUMBER=NNNNN" - @echo " GetCertificate — download issued certificate PEM for an order" - @echo "" - @echo " make generate-order DOMAIN=example.com [CSR_FILE=req.pem] [VALIDITY=1] [SAVE_AND_HOLD=1]" - @echo " GenerateOrderSSL — place a new SSL/TLS certificate order" - @echo " VALIDITY is subscription years: 1, 2, or 3 (default 1)" - @echo " SAVE_AND_HOLD=1 (default) saves as draft — returns requestNumber" - @echo " SAVE_AND_HOLD=0 submits immediately — returns orderNumber" - @echo " Prints requestNumber prominently on success (use with submit-csr)" - @echo "" - @echo " make revoke-order ORDER_NUMBER=NNNNN [REASON_ID=1]" - @echo " RevokeOrder — revoke an issued certificate" - @echo " REASON_ID: 1=KeyCompromise 3=AffiliationChanged 4=Superseded" - @echo " 5=CessationOfOperation 9=PrivilegeWithdrawn" - @echo "" - @echo " make submit-csr ORDER_NUMBER=NNNNN CSR_FILE=req.pem" - @echo " SubmitCSR — attach a CSR to a saveAndHold (draft) order" - @echo "" - @echo " make list-cas" - @echo " Document that no Sub-CA listing endpoint exists in the CERTInext API." - @echo " CA information must be obtained via the sandbox portal UI." - @echo "" - @echo " make create-product" - @echo " Document that no product management (create/configure) endpoint exists" - @echo " in the CERTInext REST API. Products must be created via the portal UI." - @echo "" - @echo " make generate-order-igtf [IGTF_CSR_FILE=/tmp/certinext-igtf-test.csr]" - @echo " GenerateOrderPrivatePKI — place a Private PKI order using product 149" - @echo " (Sandbox emSign Intranet SSL, the only active Private PKI product on this" - @echo " sandbox account). Uses saveAndHold=1 by default." - @echo " NOTE: product 108 (IGTF Host) is not provisioned on this account." - @echo "" - @echo " make generate-order-private-pki [PRIVATE_PKI_CSR=...] [PRIVATE_PKI_DOMAIN=...] [PRIVATE_PKI_CODE=149]" - @echo " GenerateOrderPrivatePKI — place a Private PKI order for any product code." - @echo " Defaults to product 149. Use PRIVATE_PKI_CODE= to override." - @echo "" - @echo " make probe-endpoints" - @echo " POST a minimal meta block to every candidate product-management and" - @echo " CA-listing endpoint name. 404 = does not exist. Any other response" - @echo " (including an application errorCode) = endpoint exists." - @echo "" - @echo " make get-field-details [PRODUCT_CODE=149] [CATEGORY_ID=8]" - @echo " GetFieldDetails — return the field definition for a product code." - @echo " Shows which certificateInformation fields are mandatory vs optional." - @echo "" + @echo "" + @echo "CERTInext API smoke-test targets (credentials from ~/.env_certinext):" + @echo "" + @echo " just ping" + @echo " ValidateCredentials — verify credentials are accepted" + @echo "" + @echo " just get-product-details (alias: products)" + @echo " GetProductDetails — list available certificate products" + @echo " Note: some sandbox accounts require groupNumber to return results." + @echo " Use get-product-details-group if this target returns an empty list." + @echo "" + @echo " just get-product-details-group" + @echo " GetProductDetails — same as get-product-details but explicitly passes" + @echo " groupNumber from CERTINEXT_GROUP_NUMBER. Use this when the plain" + @echo " get-product-details target returns an empty list." + @echo "" + @echo " just generate-test-csr" + @echo " Generate a fresh RSA-2048 CSR for CN=test-integration.example.com" + @echo " and write it to /tmp/certinext-test.csr. Required by probe-products." + @echo "" + @echo " just probe-products [PROBE_DOMAIN=test-integration.example.com]" + @echo " Place saveAndHold=1 draft orders for all SSL/TLS product codes" + @echo " provisioned on the sandbox account (842–851, 149) and report which" + @echo " codes are accepted. A code returning a requestNumber is valid." + @echo " Depends on generate-test-csr (called automatically)." + @echo "" + @echo " just get-order-report (alias: orders) [PAGE=1] [PAGE_SIZE=10]" + @echo " GetOrderReport — paginated order listing" + @echo "" + @echo " just track-order (alias: get-order) ORDER_NUMBER=NNNNN" + @echo " TrackOrder — fetch current status for a specific order" + @echo "" + @echo " just get-certificate (alias: get-cert) ORDER_NUMBER=NNNNN" + @echo " GetCertificate — download issued certificate PEM for an order" + @echo "" + @echo " just generate-order DOMAIN=example.com [CSR_FILE=req.pem] [VALIDITY=1] [SAVE_AND_HOLD=1]" + @echo " GenerateOrderSSL — place a new SSL/TLS certificate order" + @echo " VALIDITY is subscription years: 1, 2, or 3 (default 1)" + @echo " SAVE_AND_HOLD=1 (default) saves as draft — returns requestNumber" + @echo " SAVE_AND_HOLD=0 submits immediately — returns orderNumber" + @echo " Prints requestNumber prominently on success (use with submit-csr)" + @echo "" + @echo " just revoke-order ORDER_NUMBER=NNNNN [REASON_ID=1]" + @echo " RevokeOrder — revoke an issued certificate" + @echo " REASON_ID: 1=KeyCompromise 3=AffiliationChanged 4=Superseded" + @echo " 5=CessationOfOperation 9=PrivilegeWithdrawn" + @echo "" + @echo " just submit-csr ORDER_NUMBER=NNNNN CSR_FILE=req.pem" + @echo " SubmitCSR — attach a CSR to a saveAndHold (draft) order" + @echo "" + @echo " just list-cas" + @echo " Document that no Sub-CA listing endpoint exists in the CERTInext API." + @echo " CA information must be obtained via the sandbox portal UI." + @echo "" + @echo " just create-product" + @echo " Document that no product management (create/configure) endpoint exists" + @echo " in the CERTInext REST API. Products must be created via the portal UI." + @echo "" + @echo " just generate-order-igtf [IGTF_CSR_FILE=/tmp/certinext-igtf-test.csr]" + @echo " GenerateOrderPrivatePKI — place a Private PKI order using product 149" + @echo " (Sandbox emSign Intranet SSL, the only active Private PKI product on this" + @echo " sandbox account). Uses saveAndHold=1 by default." + @echo " NOTE: product 108 (IGTF Host) is not provisioned on this account." + @echo "" + @echo " just generate-order-private-pki [PRIVATE_PKI_CSR=...] [PRIVATE_PKI_DOMAIN=...] [PRIVATE_PKI_CODE=149]" + @echo " GenerateOrderPrivatePKI — place a Private PKI order for any product code." + @echo " Defaults to product 149. Use PRIVATE_PKI_CODE= to override." + @echo "" + @echo " just probe-endpoints" + @echo " POST a minimal meta block to every candidate product-management and" + @echo " CA-listing endpoint name. 404 = does not exist. Any other response" + @echo " (including an application errorCode) = endpoint exists." + @echo "" + @echo " just get-field-details [PRODUCT_CODE=149] [CATEGORY_ID=8]" + @echo " GetFieldDetails — return the field definition for a product code." + @echo " Shows which certificateInformation fields are mandatory vs optional." + @echo "" + +# --------------------------------------------------------------------------- +# Release management +# --------------------------------------------------------------------------- + +# Delete all GitHub releases (and their tags) matching a glob pattern, e.g.: +# just clean-releases '1.0.0-rc*' +clean-releases pattern: + #!/usr/bin/env bash + set -euo pipefail + tags=$(gh release list --repo {{repo}} --limit 200 --json tagName -q '.[].tagName' | grep -E "^$(echo '{{pattern}}' | sed 's/\*/.*/g')$" || true) + if [ -z "$tags" ]; then + echo "No releases matching '{{pattern}}' found." + exit 0 + fi + echo "$tags" + read -p "Delete the above releases + tags from {{repo}}? [y/N] " confirm + if [ "$confirm" != "y" ]; then + echo "Aborted." + exit 1 + fi + for tag in $tags; do + echo "Deleting $tag..." + gh release delete "$tag" --repo {{repo}} --cleanup-tag --yes + done diff --git a/scripts/generate-order.sh b/scripts/generate-order.sh index 680b8a6..0844102 100755 --- a/scripts/generate-order.sh +++ b/scripts/generate-order.sh @@ -107,5 +107,5 @@ echo "" echo "==> Full response:" echo "$result" | jq . echo "" -echo "==> requestNumber (draft ID — use with make submit-csr):" +echo "==> requestNumber (draft ID — use with just submit-csr):" echo "$result" | jq -r '.orderDetails.requestNumber // .meta.errorMessage // "none"' diff --git a/scripts/register/00-register-all.sh b/scripts/register/00-register-all.sh index 1ae3de4..2edd72d 100755 --- a/scripts/register/00-register-all.sh +++ b/scripts/register/00-register-all.sh @@ -5,9 +5,9 @@ # runs them in sequence, skipping any stage whose script does not yet exist # (stages 02-06 are added incrementally) or whose SKIP_ flag is set to 1. # -# make register -# SKIP_03=1 make register # skip claims -# DRY_RUN=1 make register # forwarded to every stage +# just register +# SKIP_03=1 just register # skip claims +# DRY_RUN=1 just register # forwarded to every stage set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" diff --git a/scripts/register/README.md b/scripts/register/README.md index a2d31dd..3f8ca88 100644 --- a/scripts/register/README.md +++ b/scripts/register/README.md @@ -11,7 +11,7 @@ vendor API. Shared auth/host logic lives in [`../lib/command-auth.sh`](../lib/co ## Stages -| Stage | Script | `make` target | Side | Notes | +| Stage | Script | `just` target | Side | Notes | |------:|--------|---------------|------|-------| | 01 | `01-gateway-profiles.sh` | `register-profiles` | Gateway | one cert profile per product. **Verified.** | | 02 | `02-gateway-ca-config.sh` | `register-ca-config` | Gateway | CAConnection + Templates[]. ⚠️ touches CA config — opt-in. | @@ -77,7 +77,7 @@ The **typical** path is OAuth2 client_credentials against `/KeyfactorAPI`: export GATEWAY_HOST= COMMAND_HOST= export TOKEN_URL=https:///application/o/token/ export OIDC_CLIENT_ID=... OIDC_CLIENT_SECRET=... -make register-profiles # client_creds used automatically (no cookie/token set) +just register-profiles # client_creds used automatically (no cookie/token set) ``` > **Cookie auth (e.g. the "HV3" lab, intdev01.lab.kfpki.com)** — used when ops @@ -90,12 +90,12 @@ make register-profiles # client_creds used automatically (no cookie/t > # gateway side > export GATEWAY_HOST=intdev01.lab.kfpki.com GATEWAY_BASE_PATH=/certinext-0 > export GATEWAY_COOKIE="$(tr -d '\r\n' < ~/.certinext_gw_cookie)" -> make register-profiles # CHECK=1 to verify, DRY_RUN=1 to preview +> just register-profiles # CHECK=1 to verify, DRY_RUN=1 to preview > > # command side (after templates imported) > export COMMAND_HOST=intdev01.lab.kfpki.com CONFIGURATION_TENANT=certinext-0 > export COMMAND_COOKIE="$(tr -d '\r\n' < ~/.certinext_kfcportal_cookie)" -> make register-enrollment # stage 06: patterns + KeyRetention=Indefinite +> just register-enrollment # stage 06: patterns + KeyRetention=Indefinite > ``` Per-stage env knobs are documented in each script's header comment. @@ -126,7 +126,7 @@ product to a certificate profile so enrollment can resolve a CA. Two things bite export GATEWAY_LOGICAL_NAME=CertiNext # the live CA's LogicalName export CERTINEXT_SIGNER_PLACE=Gateway export PRODUCT_CODE_MAP_JSON='{"DV SSL":"842","OV SSL":"846", ...}' -make register-ca-config +just register-ca-config ``` ## Stage 06 — Command EnrollmentPatterns schema (verified 2026-06-09) diff --git a/scripts/revoke-order.sh b/scripts/revoke-order.sh index 20c0d29..f4b723c 100755 --- a/scripts/revoke-order.sh +++ b/scripts/revoke-order.sh @@ -16,5 +16,5 @@ read -r ts txn authKey <<< "$(certinext_meta)" echo "RevokeOrder orderNumber=$ORDER_NUMBER revokeReasonId=$REASON_ID ts=$ts txn=$txn" curl -s -X POST "$CERTINEXT_API_URL/RevokeOrder" \ -H "Content-Type: application/json" \ - -d "{\"meta\":{\"ver\":\"1.0\",\"ts\":\"$ts\",\"txn\":\"$txn\",\"accountNumber\":\"$CERTINEXT_ACCOUNT_NUMBER\",\"authKey\":\"$authKey\"},\"revocationDetails\":{\"orderNumber\":\"$ORDER_NUMBER\",\"requestorEmail\":\"$CERTINEXT_REQUESTOR_EMAIL\",\"revokeReasonId\":\"$REASON_ID\",\"revokeRemarks\":\"Revoked via Makefile smoke test.\"}}" \ + -d "{\"meta\":{\"ver\":\"1.0\",\"ts\":\"$ts\",\"txn\":\"$txn\",\"accountNumber\":\"$CERTINEXT_ACCOUNT_NUMBER\",\"authKey\":\"$authKey\"},\"revocationDetails\":{\"orderNumber\":\"$ORDER_NUMBER\",\"requestorEmail\":\"$CERTINEXT_REQUESTOR_EMAIL\",\"revokeReasonId\":\"$REASON_ID\",\"revokeRemarks\":\"Revoked via justfile smoke test.\"}}" \ | jq . diff --git a/scripts/v2/orders-report.sh b/scripts/v2/orders-report.sh index 178b263..f73891f 100755 --- a/scripts/v2/orders-report.sh +++ b/scripts/v2/orders-report.sh @@ -1,13 +1,13 @@ #!/usr/bin/env bash # V2 reports/orders — paginated order history. # NOTE: currently returns 501 Not Implemented. -# Use v1 make get-order-report (POST /emSignHub-API/GetOrderReport) meanwhile. +# Use v1 just get-order-report (POST /emSignHub-API/GetOrderReport) meanwhile. set -euo pipefail . ~/.env_certinext . "$(dirname "$0")/../lib/certinext-v2-auth.sh" echo "V2 GET /api/certinext/v2/reports/orders?page=0&size=50" -echo "NOTE: this endpoint currently returns 501 Not Implemented — use v1 make get-order-report as a fallback." +echo "NOTE: this endpoint currently returns 501 Not Implemented — use v1 just get-order-report as a fallback." curl -s -X GET "$CERTINEXT_V2_API_URL/api/certinext/v2/reports/orders?page=0&size=50" \ -H "Authorization: Bearer $CERTINEXT_V2_TOKEN" \ -H "Accept: application/json" \ diff --git a/scripts/v2/revoke-private-pki.sh b/scripts/v2/revoke-private-pki.sh index c3e15f9..20ef7da 100755 --- a/scripts/v2/revoke-private-pki.sh +++ b/scripts/v2/revoke-private-pki.sh @@ -27,5 +27,5 @@ curl -s -X POST "$CERTINEXT_V2_API_URL/api/certinext/v2/private-pki-certificates -H "Authorization: Bearer $CERTINEXT_V2_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $idempotency_key" \ - -d "$(jq -n --arg reason "$REASON" '{reason:$reason,note:"Revoked via Makefile smoke test."}')" \ + -d "$(jq -n --arg reason "$REASON" '{reason:$reason,note:"Revoked via justfile smoke test."}')" \ | jq . diff --git a/scripts/v2/revoke-ssl.sh b/scripts/v2/revoke-ssl.sh index 0cc6d28..2dd782c 100755 --- a/scripts/v2/revoke-ssl.sh +++ b/scripts/v2/revoke-ssl.sh @@ -27,5 +27,5 @@ curl -s -X POST "$CERTINEXT_V2_API_URL/api/certinext/v2/ssl-certificates/$ORDER_ -H "Authorization: Bearer $CERTINEXT_V2_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $idempotency_key" \ - -d "$(jq -n --arg reason "$REASON" '{reason:$reason,note:"Revoked via Makefile smoke test."}')" \ + -d "$(jq -n --arg reason "$REASON" '{reason:$reason,note:"Revoked via justfile smoke test."}')" \ | jq . From 5bcde333a522f2f3e9c90e32d53e1bfa5f762af6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 22 Jul 2026 22:12:39 +0000 Subject: [PATCH 10/13] docs: auto-generate README and documentation [skip ci] --- README.md | 52 ---------------------------------------------------- 1 file changed, 52 deletions(-) diff --git a/README.md b/README.md index d9e13b5..5b33b3a 100644 --- a/README.md +++ b/README.md @@ -338,58 +338,6 @@ To retrieve the full list of product codes available to your account, call the ` > Note: SSL/TLS products are supported on standard accounts — see the SSL/TLS table above for the exact sandbox/production code pair for each product. Private PKI (Production `100`, `104` / Sandbox `149`), S/MIME (`894`), and document-signing products (`819`–`827`) require special provisioning by eMudhra and are not available on standard SSL/TLS accounts — ordering them returns EMS-1162. -## Mechanics - -### Authentication - -Every CERTInext API call is an HTTP POST with a JSON body. There is no Authorization header. Instead, the body carries a `meta` block with an `authKey` field computed as: - -``` -authKey = SHA256(accessKey + requestTs + requestTxnId) -``` - -Where `requestTs` is the ISO 8601 timestamp and `requestTxnId` is a unique transaction UUID generated per request. The raw access key is never transmitted — only the derived hash is sent. This computation happens automatically on every outbound call. When `AuthMode` is `OAuth`, the gateway obtains a bearer token via the configured client credentials flow and injects it into the `meta` block instead. - -### Enrollment Decision Logic - -When the gateway calls `Enroll`, the plugin selects between three paths based on the enrollment type and the age of the prior certificate: - -1. **New enrollment** — no prior certificate exists. A new `GenerateOrderSSL` request is submitted. -2. **Renewal** — a prior certificate exists and its expiry is within the `RenewalWindowDays` threshold (default: 90 days). A new `GenerateOrderSSL` order is submitted within the configured renewal window (CERTInext has no dedicated renewal endpoint; the renewal-window check governs how Command tracks old→new, not which API is called). -3. **Reissue** — a prior certificate exists but is outside the renewal window. A new `GenerateOrderSSL` order is placed with the updated CSR/subject, replacing the prior certificate under a new subscription. - -The `RenewalWindowDays` template parameter controls the renewal/reissue boundary per certificate template. - -### Required Order Fields - -The `GenerateOrderSSL` API requires an `additionalInformation.remarks` field in every order request body. The gateway populates this field automatically with the text `"Issued via Keyfactor Command AnyCA REST Gateway."`. If you encounter error `EMS-918: Additional Information cannot be empty`, verify that the gateway version is current and that the field is being sent. - -### Order Lifecycle and Pending Approval - -CERTInext orders pass through several internal status stages before a certificate is issued. The plugin maps these to Keyfactor enrollment statuses as follows: - -- **Issued** (status 9, 20) → certificate returned immediately. -- **Pending approval** (status 2, 8, 15, 24) → enrollment returns a pending status to Command. If `AutoApprove` is enabled on the template, the plugin attempts automatic approval before returning. -- **Rejected / cancelled** (status 4, 5, 13, 14) → enrollment fails with an error. - -The gateway polls the `TrackOrder` endpoint during sync to pick up certificates that were approved after the initial enrollment call. - -### Synchronization - -Synchronization uses the `GetOrderReport` endpoint with paginated results (controlled by `PageSize`, default 100, max 500). Each page is fetched sequentially until all orders are retrieved. The plugin maps each order's status to a Keyfactor certificate status and returns the result set to the gateway framework, which reconciles it against the Command inventory. - -Expired certificates are included by default. Set `IgnoreExpired: true` on the connector to skip them during sync. - -### Product Code Resolution - -When an enrollment request arrives, the numeric CERTInext product code is resolved in this order: - -1. `ProductCode` template parameter (explicit override — use for sandbox or non-standard codes). -2. `ProfileId` template parameter (deprecated alias, accepted for backward compatibility). -3. Default production code looked up from the selected product name (e.g. **DV SSL** → `838`). - -If none of these yield a code, enrollment fails with a validation error. - ## Architecture This document describes how the CERTInext AnyCA Gateway REST plugin integrates with Keyfactor Command and the CERTInext certificate authority. It covers the three primary certificate lifecycle operations — synchronization, enrollment, and revocation — and how the plugin routes each through the CERTInext API. From d810510d3af1c737e960428c71923aa525ce8c32 Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Thu, 23 Jul 2026 07:54:53 -0700 Subject: [PATCH 11/13] chore(docs): Update copyright year header --- CERTInext.IntegrationRunner/Program.cs | 2 +- CERTInext.IntegrationTests/AlgorithmMatrixTests.cs | 2 +- CERTInext.IntegrationTests/CloudflareDomainValidator.cs | 2 +- CERTInext.IntegrationTests/ConnectivityTests.cs | 2 +- CERTInext.IntegrationTests/DcvLifecycleTests.cs | 2 +- CERTInext.IntegrationTests/IntegrationSkip.cs | 2 +- CERTInext.IntegrationTests/IntegrationTestFixture.cs | 2 +- CERTInext.IntegrationTests/IntegrationTestFixtureTests.cs | 2 +- CERTInext.IntegrationTests/KeyAlgorithms.cs | 2 +- CERTInext.IntegrationTests/LifecycleTests.cs | 2 +- CERTInext.IntegrationTests/OrderReportTests.cs | 2 +- CERTInext.IntegrationTests/PluginSmokeTests.cs | 2 +- CERTInext.IntegrationTests/ProductTests.cs | 2 +- CERTInext.IntegrationTests/SmokeTests.cs | 2 +- CERTInext.IntegrationTests/StubDomainValidator.cs | 2 +- CERTInext.Tests/CERTInextCAPluginCoverageTests.cs | 2 +- CERTInext.Tests/CERTInextCAPluginDcvTests.cs | 2 +- CERTInext.Tests/CERTInextCAPluginPublicSurfaceTests.cs | 2 +- CERTInext.Tests/CERTInextCAPluginTests.cs | 2 +- CERTInext.Tests/CERTInextClientCoverageTests.cs | 2 +- CERTInext.Tests/CERTInextClientRequestShapeTests.cs | 2 +- CERTInext.Tests/CERTInextClientTests.cs | 2 +- CERTInext.Tests/CnameResolverTests.cs | 2 +- CERTInext.Tests/ExtractSerialFromPemTests.cs | 2 +- CERTInext.Tests/FakeDomainValidator.cs | 2 +- CERTInext.Tests/MockCertificateData.cs | 2 +- CERTInext.Tests/RateLimitRetryTests.cs | 2 +- CERTInext.Tests/RedactCredentialsTests.cs | 2 +- CERTInext/API/CertificateRequest.cs | 2 +- CERTInext/API/CertificateResponse.cs | 2 +- CERTInext/CERTInextCAPlugin.cs | 2 +- CERTInext/CERTInextCAPluginConfig.cs | 2 +- CERTInext/Client/CERTInextClient.cs | 2 +- CERTInext/Client/ICERTInextClient.cs | 2 +- CERTInext/Constants.cs | 2 +- CERTInext/Dcv/CnameResolver.cs | 2 +- CERTInext/Models/EnrollmentParams.cs | 2 +- CERTInext/Models/StatusMapper.cs | 2 +- 38 files changed, 38 insertions(+), 38 deletions(-) diff --git a/CERTInext.IntegrationRunner/Program.cs b/CERTInext.IntegrationRunner/Program.cs index 0364a2b..159cd2c 100644 --- a/CERTInext.IntegrationRunner/Program.cs +++ b/CERTInext.IntegrationRunner/Program.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 Keyfactor // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. // At http://www.apache.org/licenses/LICENSE-2.0 diff --git a/CERTInext.IntegrationTests/AlgorithmMatrixTests.cs b/CERTInext.IntegrationTests/AlgorithmMatrixTests.cs index 2a8cb2b..a941b35 100644 --- a/CERTInext.IntegrationTests/AlgorithmMatrixTests.cs +++ b/CERTInext.IntegrationTests/AlgorithmMatrixTests.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 Keyfactor // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. // At http://www.apache.org/licenses/LICENSE-2.0 diff --git a/CERTInext.IntegrationTests/CloudflareDomainValidator.cs b/CERTInext.IntegrationTests/CloudflareDomainValidator.cs index 89c01eb..dca615c 100644 --- a/CERTInext.IntegrationTests/CloudflareDomainValidator.cs +++ b/CERTInext.IntegrationTests/CloudflareDomainValidator.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 Keyfactor // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. // At http://www.apache.org/licenses/LICENSE-2.0 diff --git a/CERTInext.IntegrationTests/ConnectivityTests.cs b/CERTInext.IntegrationTests/ConnectivityTests.cs index 1d9ddf5..ece7808 100644 --- a/CERTInext.IntegrationTests/ConnectivityTests.cs +++ b/CERTInext.IntegrationTests/ConnectivityTests.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 Keyfactor // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. // At http://www.apache.org/licenses/LICENSE-2.0 diff --git a/CERTInext.IntegrationTests/DcvLifecycleTests.cs b/CERTInext.IntegrationTests/DcvLifecycleTests.cs index 24ba0f1..bf89812 100644 --- a/CERTInext.IntegrationTests/DcvLifecycleTests.cs +++ b/CERTInext.IntegrationTests/DcvLifecycleTests.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 Keyfactor // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. // At http://www.apache.org/licenses/LICENSE-2.0 diff --git a/CERTInext.IntegrationTests/IntegrationSkip.cs b/CERTInext.IntegrationTests/IntegrationSkip.cs index 409f76a..f0c3d1e 100644 --- a/CERTInext.IntegrationTests/IntegrationSkip.cs +++ b/CERTInext.IntegrationTests/IntegrationSkip.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 Keyfactor // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. // At http://www.apache.org/licenses/LICENSE-2.0 diff --git a/CERTInext.IntegrationTests/IntegrationTestFixture.cs b/CERTInext.IntegrationTests/IntegrationTestFixture.cs index 8e4f637..e96df3a 100644 --- a/CERTInext.IntegrationTests/IntegrationTestFixture.cs +++ b/CERTInext.IntegrationTests/IntegrationTestFixture.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 Keyfactor // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. // At http://www.apache.org/licenses/LICENSE-2.0 diff --git a/CERTInext.IntegrationTests/IntegrationTestFixtureTests.cs b/CERTInext.IntegrationTests/IntegrationTestFixtureTests.cs index 1db8470..b9743bb 100644 --- a/CERTInext.IntegrationTests/IntegrationTestFixtureTests.cs +++ b/CERTInext.IntegrationTests/IntegrationTestFixtureTests.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 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, diff --git a/CERTInext.IntegrationTests/KeyAlgorithms.cs b/CERTInext.IntegrationTests/KeyAlgorithms.cs index 6f2489b..9e3fdf6 100644 --- a/CERTInext.IntegrationTests/KeyAlgorithms.cs +++ b/CERTInext.IntegrationTests/KeyAlgorithms.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 Keyfactor // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. // At http://www.apache.org/licenses/LICENSE-2.0 diff --git a/CERTInext.IntegrationTests/LifecycleTests.cs b/CERTInext.IntegrationTests/LifecycleTests.cs index 185ff64..0de08f9 100644 --- a/CERTInext.IntegrationTests/LifecycleTests.cs +++ b/CERTInext.IntegrationTests/LifecycleTests.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 Keyfactor // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. // At http://www.apache.org/licenses/LICENSE-2.0 diff --git a/CERTInext.IntegrationTests/OrderReportTests.cs b/CERTInext.IntegrationTests/OrderReportTests.cs index b4a0f28..256a27d 100644 --- a/CERTInext.IntegrationTests/OrderReportTests.cs +++ b/CERTInext.IntegrationTests/OrderReportTests.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 Keyfactor // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. // At http://www.apache.org/licenses/LICENSE-2.0 diff --git a/CERTInext.IntegrationTests/PluginSmokeTests.cs b/CERTInext.IntegrationTests/PluginSmokeTests.cs index 9d3b74d..6bf15e5 100644 --- a/CERTInext.IntegrationTests/PluginSmokeTests.cs +++ b/CERTInext.IntegrationTests/PluginSmokeTests.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 Keyfactor // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. // At http://www.apache.org/licenses/LICENSE-2.0 diff --git a/CERTInext.IntegrationTests/ProductTests.cs b/CERTInext.IntegrationTests/ProductTests.cs index 99f45f3..ca61f84 100644 --- a/CERTInext.IntegrationTests/ProductTests.cs +++ b/CERTInext.IntegrationTests/ProductTests.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 Keyfactor // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. // At http://www.apache.org/licenses/LICENSE-2.0 diff --git a/CERTInext.IntegrationTests/SmokeTests.cs b/CERTInext.IntegrationTests/SmokeTests.cs index 8817413..890f466 100644 --- a/CERTInext.IntegrationTests/SmokeTests.cs +++ b/CERTInext.IntegrationTests/SmokeTests.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 Keyfactor // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. // At http://www.apache.org/licenses/LICENSE-2.0 diff --git a/CERTInext.IntegrationTests/StubDomainValidator.cs b/CERTInext.IntegrationTests/StubDomainValidator.cs index 2493021..c2888e5 100644 --- a/CERTInext.IntegrationTests/StubDomainValidator.cs +++ b/CERTInext.IntegrationTests/StubDomainValidator.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 Keyfactor // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. // At http://www.apache.org/licenses/LICENSE-2.0 diff --git a/CERTInext.Tests/CERTInextCAPluginCoverageTests.cs b/CERTInext.Tests/CERTInextCAPluginCoverageTests.cs index 6c7301e..18cdcf3 100644 --- a/CERTInext.Tests/CERTInextCAPluginCoverageTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginCoverageTests.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 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, diff --git a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs index 262e6fe..f1de8f8 100644 --- a/CERTInext.Tests/CERTInextCAPluginDcvTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginDcvTests.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 Keyfactor // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. // At http://www.apache.org/licenses/LICENSE-2.0 diff --git a/CERTInext.Tests/CERTInextCAPluginPublicSurfaceTests.cs b/CERTInext.Tests/CERTInextCAPluginPublicSurfaceTests.cs index 2fd1ad1..62766c7 100644 --- a/CERTInext.Tests/CERTInextCAPluginPublicSurfaceTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginPublicSurfaceTests.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 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, diff --git a/CERTInext.Tests/CERTInextCAPluginTests.cs b/CERTInext.Tests/CERTInextCAPluginTests.cs index 3ec5df1..7524624 100644 --- a/CERTInext.Tests/CERTInextCAPluginTests.cs +++ b/CERTInext.Tests/CERTInextCAPluginTests.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 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, diff --git a/CERTInext.Tests/CERTInextClientCoverageTests.cs b/CERTInext.Tests/CERTInextClientCoverageTests.cs index 000fbab..e2de7f7 100644 --- a/CERTInext.Tests/CERTInextClientCoverageTests.cs +++ b/CERTInext.Tests/CERTInextClientCoverageTests.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 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, diff --git a/CERTInext.Tests/CERTInextClientRequestShapeTests.cs b/CERTInext.Tests/CERTInextClientRequestShapeTests.cs index 9b958a8..f1d38df 100644 --- a/CERTInext.Tests/CERTInextClientRequestShapeTests.cs +++ b/CERTInext.Tests/CERTInextClientRequestShapeTests.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 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, diff --git a/CERTInext.Tests/CERTInextClientTests.cs b/CERTInext.Tests/CERTInextClientTests.cs index e473e89..bdade13 100644 --- a/CERTInext.Tests/CERTInextClientTests.cs +++ b/CERTInext.Tests/CERTInextClientTests.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 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, diff --git a/CERTInext.Tests/CnameResolverTests.cs b/CERTInext.Tests/CnameResolverTests.cs index 1e1d179..b83aa64 100644 --- a/CERTInext.Tests/CnameResolverTests.cs +++ b/CERTInext.Tests/CnameResolverTests.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 Keyfactor // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. // At http://www.apache.org/licenses/LICENSE-2.0 diff --git a/CERTInext.Tests/ExtractSerialFromPemTests.cs b/CERTInext.Tests/ExtractSerialFromPemTests.cs index f8064dd..5f708fe 100644 --- a/CERTInext.Tests/ExtractSerialFromPemTests.cs +++ b/CERTInext.Tests/ExtractSerialFromPemTests.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 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, diff --git a/CERTInext.Tests/FakeDomainValidator.cs b/CERTInext.Tests/FakeDomainValidator.cs index 277cbfb..6dc1fb8 100644 --- a/CERTInext.Tests/FakeDomainValidator.cs +++ b/CERTInext.Tests/FakeDomainValidator.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 Keyfactor // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. // At http://www.apache.org/licenses/LICENSE-2.0 diff --git a/CERTInext.Tests/MockCertificateData.cs b/CERTInext.Tests/MockCertificateData.cs index ee6644b..7b7615a 100644 --- a/CERTInext.Tests/MockCertificateData.cs +++ b/CERTInext.Tests/MockCertificateData.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 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, diff --git a/CERTInext.Tests/RateLimitRetryTests.cs b/CERTInext.Tests/RateLimitRetryTests.cs index 7750073..fdfb66b 100644 --- a/CERTInext.Tests/RateLimitRetryTests.cs +++ b/CERTInext.Tests/RateLimitRetryTests.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 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, diff --git a/CERTInext.Tests/RedactCredentialsTests.cs b/CERTInext.Tests/RedactCredentialsTests.cs index fad3e46..bec595b 100644 --- a/CERTInext.Tests/RedactCredentialsTests.cs +++ b/CERTInext.Tests/RedactCredentialsTests.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 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, diff --git a/CERTInext/API/CertificateRequest.cs b/CERTInext/API/CertificateRequest.cs index e7ddeed..336c8a1 100644 --- a/CERTInext/API/CertificateRequest.cs +++ b/CERTInext/API/CertificateRequest.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 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, diff --git a/CERTInext/API/CertificateResponse.cs b/CERTInext/API/CertificateResponse.cs index 3b3103f..dbaea80 100644 --- a/CERTInext/API/CertificateResponse.cs +++ b/CERTInext/API/CertificateResponse.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 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, diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs index b73dd06..d6b384c 100644 --- a/CERTInext/CERTInextCAPlugin.cs +++ b/CERTInext/CERTInextCAPlugin.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 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, diff --git a/CERTInext/CERTInextCAPluginConfig.cs b/CERTInext/CERTInextCAPluginConfig.cs index 1fc067f..33fa362 100644 --- a/CERTInext/CERTInextCAPluginConfig.cs +++ b/CERTInext/CERTInextCAPluginConfig.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 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, diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs index bcb48d4..2c604aa 100644 --- a/CERTInext/Client/CERTInextClient.cs +++ b/CERTInext/Client/CERTInextClient.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 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, diff --git a/CERTInext/Client/ICERTInextClient.cs b/CERTInext/Client/ICERTInextClient.cs index cbba099..008e39d 100644 --- a/CERTInext/Client/ICERTInextClient.cs +++ b/CERTInext/Client/ICERTInextClient.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 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, diff --git a/CERTInext/Constants.cs b/CERTInext/Constants.cs index 689acd9..061aff8 100644 --- a/CERTInext/Constants.cs +++ b/CERTInext/Constants.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 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, diff --git a/CERTInext/Dcv/CnameResolver.cs b/CERTInext/Dcv/CnameResolver.cs index dfe3d3a..0fc5dfe 100644 --- a/CERTInext/Dcv/CnameResolver.cs +++ b/CERTInext/Dcv/CnameResolver.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 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, diff --git a/CERTInext/Models/EnrollmentParams.cs b/CERTInext/Models/EnrollmentParams.cs index 7933e81..9932ed8 100644 --- a/CERTInext/Models/EnrollmentParams.cs +++ b/CERTInext/Models/EnrollmentParams.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 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, diff --git a/CERTInext/Models/StatusMapper.cs b/CERTInext/Models/StatusMapper.cs index 59795f8..e596717 100644 --- a/CERTInext/Models/StatusMapper.cs +++ b/CERTInext/Models/StatusMapper.cs @@ -1,4 +1,4 @@ -// Copyright 2024 Keyfactor +// Copyright 2026 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, From c4d26565ddec665b38b2345d64b7d121c0de895f Mon Sep 17 00:00:00 2001 From: spbsoluble <1661003+spbsoluble@users.noreply.github.com> Date: Thu, 23 Jul 2026 07:59:16 -0700 Subject: [PATCH 12/13] chore(docs): Update framework min version to `26.2.0` --- integration-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration-manifest.json b/integration-manifest.json index 6399f10..c9deccf 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -7,7 +7,7 @@ "link_github": true, "update_catalog": true, "description": "AnyCA REST Gateway plugin for CERTInext (eMudhra) certificate lifecycle management platform", - "gateway_framework": "26.0.0", + "gateway_framework": "26.2.0", "release_dir": "CERTInext/bin/Release", "release_project": "CERTInext/CERTInext.csproj", "about": { From 09ab00f97113e0957d658985b4c3ec97c7cf4f6f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Jul 2026 14:59:55 +0000 Subject: [PATCH 13/13] docs: auto-generate README and documentation [skip ci] --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5b33b3a..4732d47 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ The CERTInext AnyCA Gateway REST plugin extends the certificate lifecycle capabi ## Compatibility -The CERTInext AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 26.0.0 and later. +The CERTInext AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 26.2.0 and later. ## Support The CERTInext 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.