From 5e84e8c57e000481490cd56e689f6c30d64ba29e Mon Sep 17 00:00:00 2001
From: spbsoluble <1661003+spbsoluble@users.noreply.github.com>
Date: Fri, 13 Nov 2026 14:37:00 -0800
Subject: [PATCH 01/11] feat(dcv): V2 DCV for SSL.com-validated domains via the
domain-level endpoints (0094 item 1)
Each pending V2 domain is looked up with GET /domains?search=&exactMatch=true. For
validatedBy=SSLCOM the plugin reads SSL.com's record from GET /domains/{id}/dcv, publishes
its dns-txt recordName/recordValue (not DcvTxtRecordTemplate), verifies with
POST /domains/{id}/dcv/verify {optionId}, and cleans up. dns-cname/http-url can't be staged
through IAnyCAPlugin 3.3's TXT-only IDomainValidator, so those domains stay pending with a
warning. 502s, unknown shapes and 422 PENDING/UNDER_REVIEW leave the order pending for sync;
nothing is cancelled. A failed lookup or emSign row keeps the order-level flow. UCC orders
route each domain by its own row.
Live (sandbox): the /domains row id is domainId; the account has no SSLCOM domains, so the
SSL.com path is unit-tested only.
---
.../V2SpecFollowupsLiveTests.cs | 166 ++++++++
CERTInext.Tests/CERTInext.Tests.csproj | 1 +
.../CERTInextCAPluginV2DcvTests.cs | 28 +-
.../V2SslComDomainDcvClientTests.cs | 201 +++++++++
.../V2SslComDomainDcvPluginTests.cs | 393 ++++++++++++++++++
CERTInext/API/V2/DomainDcvV2.cs | 171 ++++++++
CERTInext/CERTInextCAPlugin.cs | 370 +++++++++++++++--
CERTInext/Client/CERTInextClient.cs | 155 +++++++
CERTInext/Client/ICERTInextClient.cs | 24 ++
CERTInext/Constants.cs | 9 +
CHANGELOG.md | 4 +-
README.md | 16 +-
docsource/architecture.md | 15 +-
docsource/migration-v1-to-v2.md | 1 +
14 files changed, 1512 insertions(+), 42 deletions(-)
create mode 100644 CERTInext.IntegrationTests/V2SpecFollowupsLiveTests.cs
create mode 100644 CERTInext.Tests/V2SslComDomainDcvClientTests.cs
create mode 100644 CERTInext.Tests/V2SslComDomainDcvPluginTests.cs
create mode 100644 CERTInext/API/V2/DomainDcvV2.cs
diff --git a/CERTInext.IntegrationTests/V2SpecFollowupsLiveTests.cs b/CERTInext.IntegrationTests/V2SpecFollowupsLiveTests.cs
new file mode 100644
index 0000000..850271d
--- /dev/null
+++ b/CERTInext.IntegrationTests/V2SpecFollowupsLiveTests.cs
@@ -0,0 +1,166 @@
+// 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,
+// 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.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// Live checks for the V2 spec follow-ups (issues 0094/0095). Each test has its own opt-in flag on
+ /// top of CERTINEXT_USE_V2_API, so one can be run alone. They report what the sandbox does
+ /// in the test output even where the sandbox lacks a feature (for example no SSL.com backend), so
+ /// the result shows what a customer on the same account shape would hit.
+ ///
+ public class V2SpecFollowupsLiveTests : IClassFixture
+ {
+ private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _output;
+ private readonly System.Collections.Generic.Dictionary _env;
+ private readonly string _v2ApiUrl;
+ private readonly string _v2ClientId;
+ private readonly string _v2ClientSecret;
+ private readonly string _v2Domain;
+ private readonly bool _v2Enabled;
+
+ public V2SpecFollowupsLiveTests(IntegrationTestFixture fixture, ITestOutputHelper output)
+ {
+ _fixture = fixture;
+ _output = output;
+ _env = V2EnvHelper.LoadAndPromote();
+ _v2ApiUrl = V2EnvHelper.GetEnv(_env, "CERTINEXT_API_URL");
+ _v2ClientId = V2EnvHelper.GetEnv(_env, "CERTINEXT_CLIENT_ID");
+ _v2ClientSecret = V2EnvHelper.GetEnv(_env, "CERTINEXT_CLIENT_SECRET");
+ _v2Domain = V2EnvHelper.GetEnv(_env, "CERTINEXT_DCV_DOMAIN", "test.example.com");
+ _v2Enabled = !string.IsNullOrWhiteSpace(V2EnvHelper.GetEnv(_env, "CERTINEXT_USE_V2_API"))
+ && !string.IsNullOrWhiteSpace(_v2ApiUrl)
+ && !string.IsNullOrWhiteSpace(_v2ClientId)
+ && !string.IsNullOrWhiteSpace(_v2ClientSecret);
+ }
+
+ private bool Flag(string name) => V2EnvHelper.GetEnv(_env, name) == "1";
+
+ private CERTInextClient BuildV2Client() =>
+ new CERTInextClient(new CERTInextConfig
+ {
+ ApiUrl = _v2ApiUrl,
+ UseV2Api = true,
+ OAuthClientId = _v2ClientId,
+ OAuthClientSecret = _v2ClientSecret,
+ RequestorName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Test",
+ RequestorEmail = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com",
+ SignerIp = "127.0.0.1",
+ SignerPlace = "Gateway Lab",
+ PageSize = 100
+ });
+
+ /// Top-level key names of the first content[] row, for shape discovery (no values).
+ private static string FirstRowKeys(string json)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ if (doc.RootElement.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.Array
+ && c.GetArrayLength() > 0 && c[0].ValueKind == JsonValueKind.Object)
+ return string.Join(", ", c[0].EnumerateObject().Select(p => p.Name));
+ return "(no rows)";
+ }
+ catch (JsonException)
+ {
+ return "(not JSON)";
+ }
+ }
+
+ private static int RowCount(string json)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ return doc.RootElement.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.Array
+ ? c.GetArrayLength() : -1;
+ }
+ catch (JsonException)
+ {
+ return -1;
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // 0094 item 1: SSL.com domain-level DCV (read-only)
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Read-only. Runs the plugin's domain lookup on CERTINEXT_DCV_DOMAIN exactly as the DCV
+ /// flow does, lists any domains the account has with validatedBy=SSLCOM, and, when one
+ /// exists, reads its GET /domains/{id}/dcv to see SSL.com's record shape (method names and
+ /// caStatus only; record values are not printed). Publishes and verifies nothing.
+ /// Opt-in: CERTINEXT_V2_SSLCOM_DCV_PROBE=1.
+ ///
+ [SkippableFact]
+ public async Task SslComDomainDcv_LookupAndShape_Probe()
+ {
+ Skip.If(!_v2Enabled || !Flag("CERTINEXT_V2_SSLCOM_DCV_PROBE"),
+ "Requires CERTINEXT_USE_V2_API + V2 credentials and CERTINEXT_V2_SSLCOM_DCV_PROBE=1.");
+
+ using var client = BuildV2Client();
+
+ // 1. The lookup the DCV flow makes for every pending domain.
+ var raw = await client.ProbeV2GetAsync(
+ $"/api/certinext/v2/domains?search={Uri.EscapeDataString(_v2Domain)}&exactMatch=true");
+ _output.WriteLine($"GET /domains?search={_v2Domain}&exactMatch=true -> HTTP {raw.StatusCode}, " +
+ $"rows={RowCount(raw.Content)}, rowKeys=[{FirstRowKeys(raw.Content)}]");
+ raw.StatusCode.Should().Be(200, "the DCV flow's domain lookup must work for SSL.com detection");
+
+ var row = await client.FindDomainV2Async(_v2Domain);
+ _output.WriteLine(row == null
+ ? "FindDomainV2Async: no row for the test domain (emSign path; nothing to detect)."
+ : $"FindDomainV2Async: id={(string.IsNullOrEmpty(row.DomainId) ? "(MISSING)" : "present")}, " +
+ $"dcvStatus={row.DcvStatus}, validatedBy={row.ValidatedBy ?? "(absent = emSign)"}");
+ if (row != null)
+ row.DomainId.Should().NotBeNullOrEmpty("the SSL.com path needs the row id for /domains/{id}/dcv");
+
+ // 2. Does the account have any SSL.com-validated domains at all?
+ var sslCom = await client.ProbeV2GetAsync("/api/certinext/v2/domains?validatedBy=SSLCOM&limit=5");
+ int sslComRows = RowCount(sslCom.Content);
+ _output.WriteLine($"GET /domains?validatedBy=SSLCOM -> HTTP {sslCom.StatusCode}, rows={sslComRows}, " +
+ $"rowKeys=[{FirstRowKeys(sslCom.Content)}]");
+
+ if (sslCom.StatusCode != 200 || sslComRows <= 0)
+ {
+ _output.WriteLine("RESULT: no SSL.com-validated domains on this account; the SSL.com DCV path " +
+ "is not verifiable live here. Customers on emSign accounts keep the order-level flow.");
+ return;
+ }
+
+ // 3. SSL.com's DCV record shape for the first such domain.
+ using var doc = JsonDocument.Parse(sslCom.Content);
+ var first = doc.RootElement.GetProperty("content")[0];
+ string name = first.TryGetProperty("domainName", out var n) ? n.GetString() : null;
+ var sslRow = await client.FindDomainV2Async(name);
+ _output.WriteLine($"SSL.com domain {name}: validatedBy={sslRow?.ValidatedBy}, dcvStatus={sslRow?.DcvStatus}");
+ try
+ {
+ var details = await client.GetDomainDcvV2Async(sslRow!.DomainId);
+ var option = details.SelectDnsTxtOption();
+ _output.WriteLine($"GET /domains/{{id}}/dcv: method={details.Method}, caStatus={details.CaStatus}, " +
+ $"offered=[{details.DescribeOfferedMethods()}], dnsTxtOptionUsable={option != null}, " +
+ $"recordName={option?.RecordName ?? "(none)"}");
+ }
+ catch (Exception ex)
+ {
+ _output.WriteLine($"GET /domains/{{id}}/dcv failed: {ex.Message}");
+ _output.WriteLine("RESULT: a customer on this account would see DCV left pending with this warning.");
+ }
+ }
+ }
+}
diff --git a/CERTInext.Tests/CERTInext.Tests.csproj b/CERTInext.Tests/CERTInext.Tests.csproj
index 32ac2c9..a07eda6 100644
--- a/CERTInext.Tests/CERTInext.Tests.csproj
+++ b/CERTInext.Tests/CERTInext.Tests.csproj
@@ -22,6 +22,7 @@
+
diff --git a/CERTInext.Tests/CERTInextCAPluginV2DcvTests.cs b/CERTInext.Tests/CERTInextCAPluginV2DcvTests.cs
index f649e94..443c15b 100644
--- a/CERTInext.Tests/CERTInextCAPluginV2DcvTests.cs
+++ b/CERTInext.Tests/CERTInextCAPluginV2DcvTests.cs
@@ -1,16 +1,9 @@
// 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,
-// 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.
+// 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.Concurrent;
@@ -41,8 +34,15 @@ namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests
///
public class CERTInextCAPluginV2DcvTests
{
- private static Mock NewMock() =>
- new Mock(MockBehavior.Strict);
+ private static Mock NewMock()
+ {
+ var mock = new Mock(MockBehavior.Strict);
+ // No account domain row: every domain here takes the emSign DCV path.
+ // V2SslComDomainDcvPluginTests covers SSL.com-validated domains.
+ mock.Setup(c => c.FindDomainV2Async(It.IsAny(), It.IsAny()))
+ .ReturnsAsync((V2DomainRecord)null);
+ return mock;
+ }
private static CERTInextCAPlugin BuildV2DcvPlugin(
ICERTInextClient client, IDomainValidatorFactory factory, string dcvTxtRecordTemplate = null,
diff --git a/CERTInext.Tests/V2SslComDomainDcvClientTests.cs b/CERTInext.Tests/V2SslComDomainDcvClientTests.cs
new file mode 100644
index 0000000..0c005df
--- /dev/null
+++ b/CERTInext.Tests/V2SslComDomainDcvClientTests.cs
@@ -0,0 +1,201 @@
+// 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,
+// 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.Linq;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using WireMock.RequestBuilders;
+using WireMock.ResponseBuilders;
+using WireMock.Server;
+using Xunit;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests
+{
+ ///
+ /// Wire shape of the domain-level DCV calls used for SSL.com-validated domains:
+ /// GET /domains?search=&exactMatch=true, GET /domains/{id}/dcv and
+ /// POST /domains/{id}/dcv/verify.
+ ///
+ public class V2SslComDomainDcvClientTests : IDisposable
+ {
+ private const string DomainsPath = "/api/certinext/v2/domains";
+ private readonly WireMockServer _server;
+
+ public V2SslComDomainDcvClientTests()
+ {
+ _server = WireMockServer.Start();
+ _server
+ .Given(Request.Create().WithPath("/oauth/token").UsingPost())
+ .RespondWith(Response.Create().WithStatusCode(200)
+ .WithHeader("Content-Type", "application/json")
+ .WithBody(MockCertificateData.V2TokenResponseJson()));
+ }
+
+ public void Dispose() => _server.Stop();
+
+ private CERTInextClient BuildClient() =>
+ new CERTInextClient(new CERTInextConfig
+ {
+ ApiUrl = _server.Urls[0],
+ AuthMode = "AccessKey",
+ ApiKey = "test-v1-key",
+ AccountNumber = "12345",
+ UseV2Api = true,
+ OAuthClientId = "my-v2-client",
+ OAuthClientSecret = "my-v2-secret",
+ RequestorName = "Test User",
+ RequestorEmail = "test@example.com"
+ });
+
+ private void Stub(string path, bool post, int status, string body)
+ {
+ var req = Request.Create().WithPath(path);
+ req = post ? req.UsingPost() : req.UsingGet();
+ var resp = Response.Create().WithStatusCode(status);
+ if (body != null)
+ resp = resp.WithHeader("Content-Type", "application/json").WithBody(body);
+ _server.Given(req).RespondWith(resp);
+ }
+
+ [Fact]
+ public async Task FindDomain_SendsExactMatchSearch_ParsesSslComRow()
+ {
+ Stub(DomainsPath, false, 200,
+ "{\"content\":[{\"domainId\":\"dom_1\",\"domainName\":\"example.com\",\"status\":\"ACTIVE\"," +
+ "\"dcvStatus\":\"PENDING\",\"validatedBy\":\"SSLCOM\"}],\"totalElements\":1}");
+
+ using var client = BuildClient();
+ var row = await client.FindDomainV2Async("example.com");
+
+ row.Should().NotBeNull();
+ row!.DomainId.Should().Be("dom_1");
+ row.IsValidatedBySslCom.Should().BeTrue();
+ var query = _server.LogEntries.Single(e => e.RequestMessage.Path == DomainsPath).RequestMessage.Query;
+ query!["search"].Should().Equal("example.com");
+ query["exactMatch"].Should().Equal("true");
+ }
+
+ [Fact]
+ public async Task FindDomain_IgnoresSubstringMatches_ReadsNumericIdAndMissingValidatedBy()
+ {
+ Stub(DomainsPath, false, 200,
+ "{\"content\":[{\"id\":11,\"domainName\":\"sub.example.com\",\"validatedBy\":\"SSLCOM\"}," +
+ "{\"id\":12,\"domainName\":\"Example.com\",\"dcvStatus\":\"VERIFIED\"}]}");
+
+ using var client = BuildClient();
+ var row = await client.FindDomainV2Async("example.com");
+
+ row!.DomainId.Should().Be("12");
+ row.IsValidatedBySslCom.Should().BeFalse("no validatedBy means emSign");
+ }
+
+ [Fact]
+ public async Task FindDomain_NoRows_ReturnsNull()
+ {
+ Stub(DomainsPath, false, 200, "{\"content\":[]}");
+ using var client = BuildClient();
+ (await client.FindDomainV2Async("example.com")).Should().BeNull();
+ }
+
+ [Fact]
+ public async Task GetDomainDcv_ParsesSelectedRecordAndOptions()
+ {
+ Stub(DomainsPath + "/dom_1/dcv", false, 200,
+ "{\"method\":\"dns-txt\",\"recordName\":\"_pki-validation.example.com\",\"recordValue\":\"v1\"," +
+ "\"optionId\":\"opt-1\",\"validatedBy\":\"SSLCOM\",\"caStatus\":\"PENDING\",\"options\":[" +
+ "{\"optionId\":\"opt-1\",\"method\":\"dns-txt\",\"brMethod\":\"3.2.2.4.7\",\"recordName\":\"_pki-validation.example.com\"," +
+ "\"recordValue\":\"v1\",\"coversName\":\"example.com\",\"exactName\":true}," +
+ "{\"optionId\":\"opt-2\",\"method\":\"dns-cname\",\"recordName\":\"_c.example.com\",\"recordValue\":\"t\"}]}");
+
+ using var client = BuildClient();
+ var details = await client.GetDomainDcvV2Async("dom_1");
+
+ details.CaStatus.Should().Be("PENDING");
+ details.Options.Should().HaveCount(2);
+ var picked = details.SelectDnsTxtOption();
+ picked.OptionId.Should().Be("opt-1");
+ picked.RecordName.Should().Be("_pki-validation.example.com");
+ picked.RecordValue.Should().Be("v1");
+ }
+
+ [Fact]
+ public async Task GetDomainDcv_502_Throws()
+ {
+ Stub(DomainsPath + "/dom_1/dcv", false, 502, null);
+ using var client = BuildClient();
+ var act = () => client.GetDomainDcvV2Async("dom_1");
+ (await act.Should().ThrowAsync()).Which.Message.Should().Contain("502");
+ }
+
+ [Fact]
+ public async Task VerifyDomainDcv_200_SendsOptionId_ReturnsVerifiedWithValidTill()
+ {
+ Stub(DomainsPath + "/dom_1/dcv/verify", true, 200, "{\"dcvStatus\":\"VERIFIED\",\"validTill\":\"2027-11-13\"}");
+
+ using var client = BuildClient();
+ var result = await client.VerifyDomainDcvV2Async("dom_1", "opt-1");
+
+ result.Verified.Should().BeTrue();
+ result.ValidTill.Should().Be("2027-11-13");
+ _server.LogEntries.Single(e => e.RequestMessage.Path == DomainsPath + "/dom_1/dcv/verify")
+ .RequestMessage.Body.Should().Be("{\"optionId\":\"opt-1\"}");
+ }
+
+ [Fact]
+ public async Task VerifyDomainDcv_NoOptionId_SendsEmptyObject()
+ {
+ Stub(DomainsPath + "/dom_1/dcv/verify", true, 200, "{}");
+ using var client = BuildClient();
+ await client.VerifyDomainDcvV2Async("dom_1", null);
+ _server.LogEntries.Single(e => e.RequestMessage.Path == DomainsPath + "/dom_1/dcv/verify")
+ .RequestMessage.Body.Should().Be("{}");
+ }
+
+ [Fact]
+ public async Task VerifyDomainDcv_422UnderReview_ReturnsNotVerifiedWithDiagnostics_MasksEmail()
+ {
+ Stub(DomainsPath + "/dom_1/dcv/verify", true, 422,
+ "{\"type\":\"about:blank\",\"title\":\"Unprocessable Entity\",\"status\":422," +
+ "\"diagnostics\":{\"caStatus\":\"UNDER_REVIEW\",\"caMessage\":\"Reviewer admin@example.com is checking\"}}");
+
+ using var client = BuildClient();
+ var result = await client.VerifyDomainDcvV2Async("dom_1", "opt-1");
+
+ result.Verified.Should().BeFalse();
+ result.CaStatus.Should().Be("UNDER_REVIEW");
+ result.CaMessage.Should().NotContain("admin@example.com");
+ }
+
+ [Fact]
+ public async Task VerifyDomainDcv_502_Throws()
+ {
+ Stub(DomainsPath + "/dom_1/dcv/verify", true, 502, null);
+ using var client = BuildClient();
+ var act = () => client.VerifyDomainDcvV2Async("dom_1", "opt-1");
+ (await act.Should().ThrowAsync()).Which.Message.Should().Contain("502");
+ }
+
+ [Fact]
+ public void SelectDnsTxtOption_NoTxt_ReturnsNull_DescribesOffered()
+ {
+ var details = new V2DomainDcvDetails
+ {
+ Method = "http-url",
+ Options = new System.Collections.Generic.List
+ {
+ new V2DcvOption { Method = "dns-cname", RecordName = "a", RecordValue = "b" },
+ new V2DcvOption { Method = "dns-txt", RecordName = "x" } // no value: unusable
+ }
+ };
+ details.SelectDnsTxtOption().Should().BeNull();
+ details.DescribeOfferedMethods().Should().Contain("dns-cname").And.Contain("http-url");
+ }
+ }
+}
diff --git a/CERTInext.Tests/V2SslComDomainDcvPluginTests.cs b/CERTInext.Tests/V2SslComDomainDcvPluginTests.cs
new file mode 100644
index 0000000..33aa236
--- /dev/null
+++ b/CERTInext.Tests/V2SslComDomainDcvPluginTests.cs
@@ -0,0 +1,393 @@
+// 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,
+// 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 FluentAssertions;
+using Keyfactor.AnyGateway.Extensions;
+using Keyfactor.Extensions.CAPlugin.CERTInext.API;
+using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using Keyfactor.PKI.Enums.EJBCA;
+using Moq;
+using Xunit;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests
+{
+ ///
+ /// V2 DCV for domains SSL.com validates (validatedBy: SSLCOM on the account's domain row):
+ /// the plugin must publish SSL.com's own record from GET /domains/{id}/dcv (never
+ /// DcvTxtRecordTemplate), verify through POST /domains/{id}/dcv/verify, leave the order
+ /// pending on any SSL.com-side delay or outage, and keep emSign domains on the order-level flow.
+ /// Driven through like .
+ ///
+ public class V2SslComDomainDcvPluginTests
+ {
+ private const string OrderId = "ord_sslcom_001";
+ private const string Domain = "example.com";
+ private const string DomainId = "dom_8K9mQ2vR";
+ private const string TxtOptionId = "49e7f9e7-c08e-40ba-945d-321a67c0442c";
+ private const string SslComRecordName = "_pki-validation.example.com";
+ private const string SslComRecordValue = "sslcom-dcv-value-1";
+
+ private static CERTInextCAPlugin BuildPlugin(ICERTInextClient client, IDomainValidatorFactory factory) =>
+ new CERTInextCAPlugin(client, factory, new CERTInextConfig
+ {
+ UseV2Api = true,
+ ApiUrl = "https://v2.certinext.io",
+ OAuthClientId = "my-client",
+ OAuthClientSecret = "my-secret",
+ AccountNumber = "12345",
+ AuthMode = "AccessKey",
+ ApiKey = "v1-key",
+ RequestorName = "Test User",
+ RequestorEmail = "test@example.com",
+ SignerIp = "1.2.3.4",
+ SignerPlace = "New York",
+ PickupRetries = 0,
+ PickupDelayInSeconds = 1,
+ DcvEnabled = true,
+ DcvTimeoutMinutes = 1,
+ DcvPropagationDelaySeconds = 1,
+ // Set on purpose: the SSL.com path must ignore it.
+ DcvTxtRecordTemplate = "_emsign-validation.{0}"
+ });
+
+ private static Task Enroll(CERTInextCAPlugin plugin, params string[] sans) =>
+ plugin.Enroll(
+ csr: MockCertificateData.FakeCsrPem,
+ subject: "CN=" + Domain,
+ san: new Dictionary { ["dns"] = sans.Length == 0 ? new[] { Domain } : sans },
+ productInfo: new EnrollmentProductInfo
+ {
+ ProductID = "DV SSL",
+ ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["ProductCode"] = "842",
+ ["ProductFamily"] = "ssl",
+ ["ProductVariant"] = "dv",
+ ["DomainName"] = Domain
+ }
+ },
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+
+ /// Strict mock wired for a V2 DV enrollment that lands in pending-dcv.
+ private static Mock NewEnrollMock(Func trackByCall)
+ {
+ var mock = new Mock(MockBehavior.Strict);
+ mock.Setup(c => c.GetProductDetailsV2Async(It.IsAny()))
+ .ReturnsAsync(new List
+ {
+ new ProductDetail { ProductCode = "842", ProductTypeId = "13", Active = true }
+ });
+ mock.Setup(c => c.PlaceOrderV2Async(
+ It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new V2CreateOrderResponse { OrderId = OrderId, Status = "pending-dcv" });
+ mock.Setup(c => c.SubmitCsrV2Async(
+ It.IsAny(), OrderId, It.IsAny(), It.IsAny()))
+ .Returns(Task.CompletedTask);
+ int calls = 0;
+ mock.Setup(c => c.TrackOrderV2Async(It.IsAny(), OrderId, It.IsAny()))
+ .ReturnsAsync(() => trackByCall(++calls));
+ mock.Setup(c => c.DownloadCertificateV2Async(It.IsAny(), OrderId, It.IsAny()))
+ .ReturnsAsync(new V2CertificateDownloadResponse
+ {
+ OrderId = OrderId, SerialNumber = "AA11BB22", CertificatePem = MockCertificateData.FakePemCertificate
+ });
+ return mock;
+ }
+
+ private static V2OrderStatusResponse Status(string status) =>
+ new V2OrderStatusResponse { OrderId = OrderId, Status = status, Domain = Domain };
+
+ private static V2DomainRecord SslComRow(string name = Domain, string id = DomainId) =>
+ new V2DomainRecord { DomainId = id, DomainName = name, DcvStatus = "PENDING", ValidatedBy = "SSLCOM" };
+
+ private static V2DomainDcvDetails TxtDetails(string recordName = SslComRecordName, string value = SslComRecordValue,
+ string optionId = TxtOptionId) =>
+ new V2DomainDcvDetails
+ {
+ Method = "dns-txt", RecordName = recordName, RecordValue = value, OptionId = optionId,
+ ValidatedBy = "SSLCOM", CaStatus = "PENDING"
+ };
+
+ private static void VerifyNoOrderLevelDcv(Mock mock)
+ {
+ mock.Verify(c => c.GetDcvV2Async(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ mock.Verify(c => c.GetDcvV2Async(It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny()), Times.Never);
+ mock.Verify(c => c.VerifyDcvV2Async(It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task SslComDomain_StagesSslComRecordNotTemplate_VerifiesSelectedOption_Issues()
+ {
+ var mock = NewEnrollMock(n => n == 1 ? Status("pending-dcv") : Status("issued"));
+ mock.Setup(c => c.FindDomainV2Async(Domain, It.IsAny())).ReturnsAsync(SslComRow());
+ mock.Setup(c => c.GetDomainDcvV2Async(DomainId, It.IsAny())).ReturnsAsync(TxtDetails());
+ mock.Setup(c => c.VerifyDomainDcvV2Async(DomainId, TxtOptionId, It.IsAny()))
+ .ReturnsAsync(new V2DomainDcvVerifyResult { Verified = true, ValidTill = "2027-11-13T00:00:00Z" });
+
+ var validator = new FakeDomainValidator();
+ var result = await Enroll(BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)));
+
+ result.Status.Should().Be((int)EndEntityStatus.GENERATED);
+ validator.StagedRecords.Should().ContainSingle()
+ .Which.Should().Be((SslComRecordName, SslComRecordValue),
+ "SSL.com's exact record name and value are published; DcvTxtRecordTemplate is ignored");
+ validator.CleanedUpKeys.Should().Equal(SslComRecordName);
+ mock.Verify(c => c.VerifyDomainDcvV2Async(DomainId, TxtOptionId, It.IsAny()), Times.Once);
+ VerifyNoOrderLevelDcv(mock);
+ }
+
+ [Fact]
+ public async Task SslComDomain_SelectedMethodIsCname_PicksExactNameTxtOption()
+ {
+ var mock = NewEnrollMock(n => n == 1 ? Status("pending-dcv") : Status("issued"));
+ mock.Setup(c => c.FindDomainV2Async(Domain, It.IsAny())).ReturnsAsync(SslComRow());
+ mock.Setup(c => c.GetDomainDcvV2Async(DomainId, It.IsAny()))
+ .ReturnsAsync(new V2DomainDcvDetails
+ {
+ Method = "dns-cname", RecordName = "_cname.example.com", RecordValue = "target.sslcom.example",
+ OptionId = "opt-cname", ValidatedBy = "SSLCOM", CaStatus = "PENDING",
+ Options = new List
+ {
+ new V2DcvOption { OptionId = "opt-cname", Method = "dns-cname", RecordName = "_cname.example.com",
+ RecordValue = "target.sslcom.example", ExactName = true },
+ new V2DcvOption { OptionId = "opt-parent-txt", Method = "dns-txt", RecordName = "_pki-validation.com",
+ RecordValue = "parent-value", ExactName = false },
+ new V2DcvOption { OptionId = "opt-exact-txt", Method = "dns-txt", RecordName = SslComRecordName,
+ RecordValue = SslComRecordValue, ExactName = true }
+ }
+ });
+ mock.Setup(c => c.VerifyDomainDcvV2Async(DomainId, "opt-exact-txt", It.IsAny()))
+ .ReturnsAsync(new V2DomainDcvVerifyResult { Verified = true });
+
+ var validator = new FakeDomainValidator();
+ var result = await Enroll(BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)));
+
+ result.Status.Should().Be((int)EndEntityStatus.GENERATED);
+ validator.StagedRecords.Should().ContainSingle().Which.Should().Be((SslComRecordName, SslComRecordValue),
+ "a CNAME can't be published through IDomainValidator, so the exact-name dns-txt option wins");
+ }
+
+ [Fact]
+ public async Task SslComDomain_OnlyCnameOffered_PublishesNothing_LeavesOrderPending()
+ {
+ var mock = NewEnrollMock(_ => Status("pending-dcv"));
+ mock.Setup(c => c.FindDomainV2Async(Domain, It.IsAny())).ReturnsAsync(SslComRow());
+ mock.Setup(c => c.GetDomainDcvV2Async(DomainId, It.IsAny()))
+ .ReturnsAsync(new V2DomainDcvDetails
+ {
+ Method = "dns-cname", RecordName = "_cname.example.com", RecordValue = "target.sslcom.example",
+ OptionId = "opt-cname", ValidatedBy = "SSLCOM", CaStatus = "PENDING"
+ });
+
+ var validator = new FakeDomainValidator();
+ var result = await Enroll(BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)));
+
+ result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION);
+ validator.StagedRecords.Should().BeEmpty();
+ mock.Verify(c => c.VerifyDomainDcvV2Async(It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never);
+ VerifyNoOrderLevelDcv(mock);
+ }
+
+ [Fact]
+ public async Task SslComDomain_Verify422UnderReview_LeavesOrderPending_CleansUp()
+ {
+ var mock = NewEnrollMock(_ => Status("pending-dcv"));
+ mock.Setup(c => c.FindDomainV2Async(Domain, It.IsAny())).ReturnsAsync(SslComRow());
+ mock.Setup(c => c.GetDomainDcvV2Async(DomainId, It.IsAny())).ReturnsAsync(TxtDetails());
+ mock.Setup(c => c.VerifyDomainDcvV2Async(DomainId, TxtOptionId, It.IsAny()))
+ .ReturnsAsync(new V2DomainDcvVerifyResult
+ {
+ Verified = false, CaStatus = "UNDER_REVIEW", CaMessage = "Manual review in progress"
+ });
+
+ var validator = new FakeDomainValidator();
+ var result = await Enroll(BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)));
+
+ result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION,
+ "UNDER_REVIEW is not a failure: the order stays pending and sync retries");
+ validator.StagedRecords.Should().ContainSingle();
+ validator.CleanedUpKeys.Should().Equal(SslComRecordName);
+ mock.Verify(c => c.CancelOrderV2Async(It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task SslComDomain_Verify502_LeavesOrderPending_CleansUp()
+ {
+ var mock = NewEnrollMock(_ => Status("pending-dcv"));
+ mock.Setup(c => c.FindDomainV2Async(Domain, It.IsAny())).ReturnsAsync(SslComRow());
+ mock.Setup(c => c.GetDomainDcvV2Async(DomainId, It.IsAny())).ReturnsAsync(TxtDetails());
+ mock.Setup(c => c.VerifyDomainDcvV2Async(DomainId, TxtOptionId, It.IsAny()))
+ .ThrowsAsync(new Exception("CERTInext V2 API error during 'V2 verify domain DCV'. HTTP 502."));
+
+ var validator = new FakeDomainValidator();
+ var result = await Enroll(BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)));
+
+ result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION);
+ validator.CleanedUpKeys.Should().Equal(SslComRecordName);
+ }
+
+ [Fact]
+ public async Task SslComDomain_GetDcv502_PublishesNothing_LeavesOrderPending()
+ {
+ var mock = NewEnrollMock(_ => Status("pending-dcv"));
+ mock.Setup(c => c.FindDomainV2Async(Domain, It.IsAny())).ReturnsAsync(SslComRow());
+ mock.Setup(c => c.GetDomainDcvV2Async(DomainId, It.IsAny()))
+ .ThrowsAsync(new Exception("CERTInext V2 API error during 'V2 get domain DCV'. HTTP 502."));
+
+ var validator = new FakeDomainValidator();
+ var result = await Enroll(BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)));
+
+ result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION);
+ validator.StagedRecords.Should().BeEmpty();
+ VerifyNoOrderLevelDcv(mock);
+ }
+
+ [Fact]
+ public async Task SslComDomain_AlreadyValidated_VerifiesWithoutPublishing()
+ {
+ var mock = NewEnrollMock(n => n == 1 ? Status("pending-dcv") : Status("issued"));
+ mock.Setup(c => c.FindDomainV2Async(Domain, It.IsAny())).ReturnsAsync(SslComRow());
+ mock.Setup(c => c.GetDomainDcvV2Async(DomainId, It.IsAny()))
+ .ReturnsAsync(new V2DomainDcvDetails { ValidatedBy = "SSLCOM", CaStatus = "VALIDATED", OptionId = TxtOptionId });
+ mock.Setup(c => c.VerifyDomainDcvV2Async(DomainId, TxtOptionId, It.IsAny()))
+ .ReturnsAsync(new V2DomainDcvVerifyResult { Verified = true });
+
+ var validator = new FakeDomainValidator();
+ var result = await Enroll(BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)));
+
+ result.Status.Should().Be((int)EndEntityStatus.GENERATED);
+ validator.StagedRecords.Should().BeEmpty();
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task EmsignDomainOrFailedLookup_KeepsOrderLevelFlowAndTemplate(bool lookupThrows)
+ {
+ var mock = NewEnrollMock(n => n == 1 ? Status("pending-dcv") : Status("issued"));
+ if (lookupThrows)
+ mock.Setup(c => c.FindDomainV2Async(Domain, It.IsAny()))
+ .ThrowsAsync(new Exception("CERTInext V2 API error during 'V2 find domain'. HTTP 500."));
+ else
+ mock.Setup(c => c.FindDomainV2Async(Domain, It.IsAny()))
+ .ReturnsAsync(new V2DomainRecord { DomainId = DomainId, DomainName = Domain, DcvStatus = "PENDING" });
+ mock.Setup(c => c.GetDcvV2Async(OrderId, It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new V2DcvChallengeResponse { Token = "emsign-token" });
+ mock.Setup(c => c.VerifyDcvV2Async(OrderId, Domain, It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" });
+
+ var validator = new FakeDomainValidator();
+ var result = await Enroll(BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)));
+
+ result.Status.Should().Be((int)EndEntityStatus.GENERATED);
+ validator.StagedRecords.Should().ContainSingle().Which.Should().Be(("_emsign-validation.example.com", "emsign-token"));
+ mock.Verify(c => c.GetDomainDcvV2Async(It.IsAny(), It.IsAny()), Times.Never);
+ mock.Verify(c => c.VerifyDomainDcvV2Async(It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ private const string SanEmsign = "a.example.com";
+ private const string SanSslCom = "b.example.com";
+
+ private static V2OrderStatusResponse UccStatus(string status, string emsignStatus, string sslComStatus) =>
+ new V2OrderStatusResponse
+ {
+ OrderId = OrderId,
+ Status = status,
+ Domain = Domain,
+ Verifications = new V2Verifications
+ {
+ Domain = new V2DomainVerification
+ {
+ Status = "PENDING",
+ Domains = new List
+ {
+ new V2DomainVerificationEntry { Domain = Domain, DcvStatus = "VERIFIED" },
+ new V2DomainVerificationEntry { Domain = SanEmsign, DcvStatus = emsignStatus },
+ new V2DomainVerificationEntry { Domain = SanSslCom, DcvStatus = sslComStatus }
+ }
+ }
+ }
+ };
+
+ [Fact]
+ public async Task Ucc_MixedValidators_EachDomainTakesItsOwnFlow_AllCleanedUp()
+ {
+ var mock = NewEnrollMock(n => n == 1
+ ? UccStatus("pending-dcv", "PENDING", "PENDING")
+ : UccStatus("issued", "VERIFIED", "VERIFIED"));
+ mock.Setup(c => c.FindDomainV2Async(SanEmsign, It.IsAny()))
+ .ReturnsAsync(new V2DomainRecord { DomainId = "dom_a", DomainName = SanEmsign });
+ mock.Setup(c => c.FindDomainV2Async(SanSslCom, It.IsAny()))
+ .ReturnsAsync(SslComRow(SanSslCom, "dom_b"));
+ mock.Setup(c => c.GetDcvV2Async(OrderId, SanEmsign, It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new V2DcvChallengeResponse { Token = "token-a" });
+ mock.Setup(c => c.VerifyDcvV2Async(OrderId, SanEmsign, It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" });
+ mock.Setup(c => c.GetDomainDcvV2Async("dom_b", It.IsAny()))
+ .ReturnsAsync(TxtDetails("_pki-validation.b.example.com", "value-b", "opt-b"));
+ mock.Setup(c => c.VerifyDomainDcvV2Async("dom_b", "opt-b", It.IsAny()))
+ .ReturnsAsync(new V2DomainDcvVerifyResult { Verified = true });
+
+ var validator = new FakeDomainValidator();
+ // One SAN on the CSR, as in CERTInextCAPluginV2DcvTests: the per-domain block comes from Track.
+ var result = await Enroll(BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)));
+
+ result.Status.Should().Be((int)EndEntityStatus.GENERATED);
+ validator.StagedRecords.Should().BeEquivalentTo(new[]
+ {
+ ("_emsign-validation.a.example.com", "token-a"),
+ ("_pki-validation.b.example.com", "value-b")
+ });
+ validator.CleanedUpKeys.Should().BeEquivalentTo(
+ "_emsign-validation.a.example.com", "_pki-validation.b.example.com");
+ mock.Verify(c => c.VerifyDcvV2Async(OrderId, SanSslCom, It.IsAny(), It.IsAny()),
+ Times.Never, "the SSL.com SAN is verified through the domain endpoint only");
+ mock.Verify(c => c.FindDomainV2Async(Domain, It.IsAny()), Times.Never,
+ "the already-VERIFIED primary is not looked up");
+ }
+
+ [Fact]
+ public async Task Ucc_SslComSanUnderReview_EmsignSanStillVerified_OrderStaysPending()
+ {
+ var mock = NewEnrollMock(n => n == 1
+ ? UccStatus("pending-dcv", "PENDING", "PENDING")
+ : UccStatus("pending-dcv", "VERIFIED", "PENDING"));
+ mock.Setup(c => c.FindDomainV2Async(SanEmsign, It.IsAny()))
+ .ReturnsAsync((V2DomainRecord)null);
+ mock.Setup(c => c.FindDomainV2Async(SanSslCom, It.IsAny()))
+ .ReturnsAsync(SslComRow(SanSslCom, "dom_b"));
+ mock.Setup(c => c.GetDcvV2Async(OrderId, SanEmsign, It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new V2DcvChallengeResponse { Token = "token-a" });
+ mock.Setup(c => c.VerifyDcvV2Async(OrderId, SanEmsign, It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new V2DcvVerifyResponse { OverallStatus = "VERIFIED" });
+ mock.Setup(c => c.GetDomainDcvV2Async("dom_b", It.IsAny()))
+ .ReturnsAsync(TxtDetails("_pki-validation.b.example.com", "value-b", "opt-b"));
+ mock.Setup(c => c.VerifyDomainDcvV2Async("dom_b", "opt-b", It.IsAny()))
+ .ReturnsAsync(new V2DomainDcvVerifyResult { Verified = false, CaStatus = "UNDER_REVIEW" });
+
+ var validator = new FakeDomainValidator();
+ // One SAN on the CSR, as in CERTInextCAPluginV2DcvTests: the per-domain block comes from Track.
+ var result = await Enroll(BuildPlugin(mock.Object, new FakeDomainValidatorFactory(validator)));
+
+ result.Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION);
+ validator.CleanedUpKeys.Should().HaveCount(2, "both staged records are cleaned up");
+ }
+ }
+}
diff --git a/CERTInext/API/V2/DomainDcvV2.cs b/CERTInext/API/V2/DomainDcvV2.cs
new file mode 100644
index 0000000..fa6bdb8
--- /dev/null
+++ b/CERTInext/API/V2/DomainDcvV2.cs
@@ -0,0 +1,171 @@
+// 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,
+// 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.Text.Json.Serialization;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.API.V2
+{
+ // ---------------------------------------------------------------------------
+ // V2 domain-level DCV (used for domains validated by SSL.com)
+ //
+ // GET /api/certinext/v2/domains?search={fqdn}&exactMatch=true (find the domain row)
+ // GET /api/certinext/v2/domains/{domainId}/dcv (DcvDetails)
+ // POST /api/certinext/v2/domains/{domainId}/dcv/verify (DcvVerifyRequest)
+ //
+ // The spec types DcvDetails and DcvOption; the list row and the verify bodies are untyped
+ // `object`, so those members are optional and built from the spec prose.
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// One row of GET /domains. The spec leaves the row untyped; the id is read from
+ /// domainId (the name DomainCreateResponse uses) or id.
+ ///
+ public class V2DomainRecord
+ {
+ public string DomainId { get; set; }
+
+ public string DomainName { get; set; }
+
+ /// PENDING, VERIFIED or REJECTED.
+ public string DcvStatus { get; set; }
+
+ /// SSLCOM for SSL.com-validated domains; absent means emSign.
+ public string ValidatedBy { get; set; }
+
+ public bool IsValidatedBySslCom =>
+ string.Equals(ValidatedBy, Constants.ApiV2.ValidatedBySslCom, StringComparison.OrdinalIgnoreCase);
+ }
+
+ /// Spec DcvOption: one DCV method SSL.com offers for a domain.
+ public class V2DcvOption
+ {
+ [JsonPropertyName("optionId")]
+ public string OptionId { get; set; }
+
+ /// dns-txt, http-url, dns-cname or dns-persist.
+ [JsonPropertyName("method")]
+ public string Method { get; set; }
+
+ [JsonPropertyName("brMethod")]
+ public string BrMethod { get; set; }
+
+ /// Where to publish: the DNS record name, or the full URL of the file.
+ [JsonPropertyName("recordName")]
+ public string RecordName { get; set; }
+
+ /// Value to publish. Never logged.
+ [JsonPropertyName("recordValue")]
+ public string RecordValue { get; set; }
+
+ /// Name this option validates; a parent name also covers its subdomains.
+ [JsonPropertyName("coversName")]
+ public string CoversName { get; set; }
+
+ [JsonPropertyName("exactName")]
+ public bool? ExactName { get; set; }
+
+ [JsonPropertyName("expiresAt")]
+ public string ExpiresAt { get; set; }
+ }
+
+ /// Spec DcvDetails, the body of GET /domains/{domainId}/dcv.
+ public class V2DomainDcvDetails
+ {
+ [JsonPropertyName("method")]
+ public string Method { get; set; }
+
+ [JsonPropertyName("txtToken")]
+ public string TxtToken { get; set; }
+
+ [JsonPropertyName("recordName")]
+ public string RecordName { get; set; }
+
+ [JsonPropertyName("recordValue")]
+ public string RecordValue { get; set; }
+
+ [JsonPropertyName("optionId")]
+ public string OptionId { get; set; }
+
+ [JsonPropertyName("options")]
+ public List Options { get; set; }
+
+ [JsonPropertyName("validatedBy")]
+ public string ValidatedBy { get; set; }
+
+ /// PENDING, UNDER_REVIEW, VALIDATED or EXPIRED.
+ [JsonPropertyName("caStatus")]
+ public string CaStatus { get; set; }
+
+ ///
+ /// Picks the option to publish through a DNS provider plugin. Only dns-txt qualifies:
+ /// IAnyCAPlugin 3.3's IDomainValidator.StageValidation(key, value) publishes a TXT record
+ /// and has no record-type argument, so a dns-cname option can't be staged. Prefers the
+ /// selected method when it is dns-txt, then an options[] entry for exactly the
+ /// requested name, then any other dns-txt option. Returns null when none has both a
+ /// record name and a value.
+ ///
+ public V2DcvOption SelectDnsTxtOption()
+ {
+ if (string.Equals(Method, Constants.ApiV2.DcvMethodDnsTxt, StringComparison.OrdinalIgnoreCase)
+ && !string.IsNullOrWhiteSpace(RecordName) && !string.IsNullOrWhiteSpace(RecordValue))
+ {
+ return new V2DcvOption
+ {
+ OptionId = OptionId, Method = Method, RecordName = RecordName, RecordValue = RecordValue
+ };
+ }
+
+ return (Options ?? new List())
+ .Where(o => o != null
+ && string.Equals(o.Method, Constants.ApiV2.DcvMethodDnsTxt, StringComparison.OrdinalIgnoreCase)
+ && !string.IsNullOrWhiteSpace(o.RecordName) && !string.IsNullOrWhiteSpace(o.RecordValue))
+ .OrderByDescending(o => o.ExactName == true)
+ .FirstOrDefault();
+ }
+
+ /// Methods offered, for the "no usable option" warning.
+ public string DescribeOfferedMethods()
+ {
+ var methods = (Options ?? new List())
+ .Select(o => o?.Method)
+ .Append(Method)
+ .Where(m => !string.IsNullOrWhiteSpace(m))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ return methods.Count == 0 ? "(none)" : string.Join(", ", methods);
+ }
+ }
+
+ /// Spec DcvVerifyRequest, the body of POST /domains/{domainId}/dcv/verify.
+ public class V2DomainDcvVerifyRequest
+ {
+ /// SSL.com option to check. Omitted when null, so SSL.com checks the selected option.
+ [JsonPropertyName("optionId")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string OptionId { get; set; }
+ }
+
+ ///
+ /// Outcome of POST /domains/{domainId}/dcv/verify. 200 means validated (with
+ /// validTill); 422 carries diagnostics.caStatus (PENDING, FAILED,
+ /// UNDER_REVIEW, EXPIRED) and diagnostics.caMessage.
+ ///
+ public class V2DomainDcvVerifyResult
+ {
+ public bool Verified { get; set; }
+
+ public string ValidTill { get; set; }
+
+ public string CaStatus { get; set; }
+
+ /// SSL.com's reason, already passed through LogSanitizer.SanitizeCaText.
+ public string CaMessage { get; set; }
+ }
+}
diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs
index 9b5a48c..01719e5 100644
--- a/CERTInext/CERTInextCAPlugin.cs
+++ b/CERTInext/CERTInextCAPlugin.cs
@@ -5290,6 +5290,10 @@ private async Task PerformDcvV2IfNeededAsync(
/// treated as DCV already satisfied: publishing is skipped and
/// the flow proceeds straight to step 4.
///
+ /// A domain whose account row says validatedBy: SSLCOM takes
+ /// instead (SSL.com's own record, domain-level
+ /// endpoints).
+ ///
/// Returns true when DCV steps were executed, false when skipped.
///
private async Task PerformDcvV2SingleDomainAsync(
@@ -5301,6 +5305,11 @@ private async Task PerformDcvV2SingleDomainAsync(
_logger.LogInformation(
"V2 DCV starting for order {OrderId}, domain {Domain}.", orderId, LogSanitizer.Strip(domain));
+ // A domain SSL.com validates takes the domain-level flow with SSL.com's own record.
+ var sslComDomain = await FindSslComDomainAsync(orderId, domain, ct);
+ if (sslComDomain != null)
+ return await PerformDcvV2SslComSingleDomainAsync(orderId, domain, sslComDomain, productFamilySlug, ct);
+
// 1. Fetch challenge
V2DcvChallengeResponse challenge = null;
bool dcvAlreadySatisfied = false;
@@ -5440,31 +5449,7 @@ private async Task PerformDcvV2SingleDomainAsync(
}
// 4. Poll TrackOrderV2 until status leaves pending-dcv
- int timeoutMinutes = _config.GetEffectiveDcvTimeoutMinutes();
- var deadline = DateTime.UtcNow.AddMinutes(timeoutMinutes);
- // Fixed short cadence — decoupled from DcvPropagationDelaySeconds (one-shot
- // DNS wait), not a poll interval. Reusing it here would yield only ~2 polls
- // before the 5-minute timeout.
- int pollSeconds = Constants.Dcv.SyncPropagationDelaySeconds;
-
- while (DateTime.UtcNow < deadline && !ct.IsCancellationRequested)
- {
- await Task.Delay(TimeSpan.FromSeconds(pollSeconds), ct);
- try
- {
- var trackResp = await _client.TrackOrderV2Async(productFamilySlug, orderId, ct);
- _logger.LogDebug(
- "V2 DCV poll. OrderId={OrderId}, Status={Status}", orderId, trackResp.Status);
- if (!string.Equals(trackResp.Status, "pending-dcv", StringComparison.OrdinalIgnoreCase))
- break;
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "V2 DCV: TrackOrderV2 poll failed for order {OrderId}.", orderId);
- break;
- }
- }
-
+ await PollV2UntilLeavesPendingDcvAsync(orderId, productFamilySlug, ct);
return true;
}
finally
@@ -5562,6 +5547,10 @@ private async Task PerformDcvV2MultiDomainAsync(
// already-staged record instead of publishing (and later cleaning up) a duplicate.
var stagedHostnames = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ // Domains SSL.com validates, keyed by domain: verified in Phase 2 through
+ // POST /domains/{id}/dcv/verify instead of the order-level verify.
+ var sslComPlans = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
async Task CleanupStagedAsync()
{
// Concurrent, not sequential — mirrors the V1 multi-SAN cleanup rationale
@@ -5580,6 +5569,48 @@ async Task CleanupStagedAsync()
{
ct.ThrowIfCancellationRequested();
+ var sslComDomain = await FindSslComDomainAsync(orderId, d, ct);
+ if (sslComDomain != null)
+ {
+ var (plan, skipReason) = await PrepareSslComDcvAsync(orderId, d, sslComDomain, ct);
+ if (plan == null)
+ {
+ failedDomains.Add((d, skipReason));
+ continue;
+ }
+ sslComPlans[d] = plan;
+ if (plan.AlreadyValidated)
+ {
+ // Nothing to publish; Phase 2 still asks CERTInext to verify.
+ verifyCandidates.Add(d);
+ continue;
+ }
+
+ string sslComKey = plan.RecordName + "|" + plan.RecordValue;
+ if (stagedHostnames.TryGetValue(sslComKey, out string sslComSharedWith))
+ {
+ _logger.LogInformation(
+ "V2 DCV (SSL.com) record {Hostname} for domain {Domain} on order {OrderId} is " +
+ "already staged (shared with {SharedWith}); reusing it.",
+ LogSanitizer.Strip(plan.RecordName), LogSanitizer.Strip(d), orderId,
+ LogSanitizer.Strip(sslComSharedWith));
+ verifyCandidates.Add(d);
+ continue;
+ }
+
+ string stageError = await StageSslComRecordAsync(orderId, plan, ct);
+ if (stageError != null)
+ {
+ sslComPlans.Remove(d);
+ failedDomains.Add((d, stageError));
+ continue;
+ }
+ stagedHostnames[sslComKey] = d;
+ staged.Add((d, plan.RecordName, plan.Validator));
+ verifyCandidates.Add(d);
+ continue;
+ }
+
V2DcvChallengeResponse challenge = null;
bool alreadySatisfied = false;
try
@@ -5734,6 +5765,16 @@ async Task CleanupStagedAsync()
// share one TXT record.
foreach (var d in verifyCandidates)
{
+ if (sslComPlans.TryGetValue(d, out var sslComPlan))
+ {
+ var (sslComVerified, sslComReason) = await VerifySslComDomainAsync(orderId, sslComPlan, ct);
+ if (sslComVerified)
+ verifiedDomains.Add(d);
+ else
+ failedDomains.Add((d, sslComReason));
+ continue;
+ }
+
try
{
_logger.LogInformation(
@@ -5817,6 +5858,285 @@ async Task CleanupStagedAsync()
return true;
}
+ ///
+ /// Polls until the order leaves
+ /// pending-dcv, a poll fails, or the DCV timeout / ends it.
+ ///
+ private async Task PollV2UntilLeavesPendingDcvAsync(string orderId, string productFamilySlug, CancellationToken ct)
+ {
+ int timeoutMinutes = _config.GetEffectiveDcvTimeoutMinutes();
+ var deadline = DateTime.UtcNow.AddMinutes(timeoutMinutes);
+ // Fixed short cadence — decoupled from DcvPropagationDelaySeconds (one-shot
+ // DNS wait), not a poll interval. Reusing it here would yield only ~2 polls
+ // before the 5-minute timeout.
+ int pollSeconds = Constants.Dcv.SyncPropagationDelaySeconds;
+
+ while (DateTime.UtcNow < deadline && !ct.IsCancellationRequested)
+ {
+ await Task.Delay(TimeSpan.FromSeconds(pollSeconds), ct);
+ try
+ {
+ var trackResp = await _client.TrackOrderV2Async(productFamilySlug, orderId, ct);
+ _logger.LogDebug(
+ "V2 DCV poll. OrderId={OrderId}, Status={Status}", orderId, trackResp.Status);
+ if (!string.Equals(trackResp.Status, "pending-dcv", StringComparison.OrdinalIgnoreCase))
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "V2 DCV: TrackOrderV2 poll failed for order {OrderId}.", orderId);
+ break;
+ }
+ }
+ }
+
+ ///
+ /// What the SSL.com DCV path publishes and verifies for one domain: SSL.com's own record
+ /// from GET /domains/{id}/dcv, never DcvTxtRecordTemplate.
+ ///
+ private sealed class SslComDcvPlan
+ {
+ public string Domain { get; init; }
+ public string DomainId { get; init; }
+ public string OptionId { get; init; }
+ public string RecordName { get; init; }
+ /// Never logged.
+ public string RecordValue { get; init; }
+ /// SSL.com already reports VALIDATED: verify without publishing.
+ public bool AlreadyValidated { get; init; }
+ public Keyfactor.AnyGateway.Extensions.IDomainValidator Validator { get; init; }
+ }
+
+ ///
+ /// Returns the account's domain row when SSL.com validates , else null.
+ /// A failed lookup also returns null, so the order takes the emSign path it took before the
+ /// SSL.com path existed, with a warning; cancellation propagates.
+ ///
+ private async Task FindSslComDomainAsync(string orderId, string domain, CancellationToken ct)
+ {
+ try
+ {
+ var row = await _client.FindDomainV2Async(domain, ct);
+ if (row?.IsValidatedBySslCom != true)
+ return null;
+ _logger.LogInformation(
+ "V2 DCV: domain {Domain} on order {OrderId} is validated by SSL.com; using the domain-level " +
+ "DCV flow with SSL.com's record.", LogSanitizer.Strip(domain), orderId);
+ return row;
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex,
+ "V2 DCV: could not look up domain {Domain} for order {OrderId} (GET /domains); continuing " +
+ "with the emSign DCV flow.", LogSanitizer.Strip(domain), orderId);
+ return null;
+ }
+ }
+
+ ///
+ /// Reads SSL.com's DCV instructions for one domain and picks the dns-txt option to publish.
+ /// Returns a null plan with a reason when DCV can't proceed this pass (SSL.com unreachable,
+ /// unknown shape, no dns-txt option, no DNS provider); the order stays pending and a later
+ /// sync retries. Never cancels the order.
+ ///
+ private async Task<(SslComDcvPlan plan, string skipReason)> PrepareSslComDcvAsync(
+ string orderId, string domain, V2DomainRecord record, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(record.DomainId))
+ {
+ _logger.LogWarning(
+ "V2 DCV (SSL.com): the domain row for {Domain} on order {OrderId} has no id; cannot read " +
+ "SSL.com's DCV record. The order stays pending.", LogSanitizer.Strip(domain), orderId);
+ return (null, "SSL.com domain row has no id");
+ }
+
+ V2DomainDcvDetails details;
+ try
+ {
+ details = await _client.GetDomainDcvV2Async(record.DomainId, ct);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ // Spec: 502 when SSL.com can't be reached.
+ _logger.LogWarning(ex,
+ "V2 DCV (SSL.com): GET /domains/{DomainId}/dcv failed for {Domain} on order {OrderId}; the " +
+ "order stays pending and a later sync retries.",
+ record.DomainId, LogSanitizer.Strip(domain), orderId);
+ return (null, "SSL.com DCV details unavailable");
+ }
+
+ var validator = DomainValidatorFactory.ResolveDomainValidator(StripWildcardPrefix(domain), "dns-01");
+ if (validator == null)
+ {
+ _logger.LogError(
+ "No DNS provider plugin resolved for domain '{Domain}' on V2 order {OrderId}. " +
+ "Ensure the appropriate DNS provider plugin is deployed and configured.",
+ LogSanitizer.Strip(domain), orderId);
+ return (null, "no DNS provider resolved");
+ }
+
+ if (details == null)
+ {
+ _logger.LogWarning(
+ "V2 DCV (SSL.com): GET /domains/{DomainId}/dcv returned no body for {Domain} on order {OrderId}; " +
+ "the order stays pending.", record.DomainId, LogSanitizer.Strip(domain), orderId);
+ return (null, "SSL.com DCV details unavailable");
+ }
+
+ if (string.Equals(details.CaStatus, Constants.ApiV2.SslComCaStatusValidated, StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.LogInformation(
+ "V2 DCV (SSL.com): {Domain} on order {OrderId} is already VALIDATED at SSL.com; verifying " +
+ "without publishing a record.", LogSanitizer.Strip(domain), orderId);
+ return (new SslComDcvPlan
+ {
+ Domain = domain, DomainId = record.DomainId, OptionId = details.OptionId,
+ AlreadyValidated = true, Validator = validator
+ }, null);
+ }
+
+ var option = details.SelectDnsTxtOption();
+ if (option == null)
+ {
+ _logger.LogWarning(
+ "V2 DCV (SSL.com): no dns-txt option for {Domain} on order {OrderId} (offered: {Methods}). " +
+ "The DNS provider interface can only publish TXT records, so dns-cname and http-url can't be " +
+ "used. The order stays pending; validate the domain in the CERTInext portal.",
+ LogSanitizer.Strip(domain), orderId, details.DescribeOfferedMethods());
+ return (null, "no SSL.com dns-txt option");
+ }
+
+ return (new SslComDcvPlan
+ {
+ Domain = domain, DomainId = record.DomainId, OptionId = option.OptionId,
+ RecordName = option.RecordName.Trim().TrimEnd('.'), RecordValue = option.RecordValue,
+ Validator = validator
+ }, null);
+ }
+
+ /// Publishes SSL.com's TXT record. Returns null on success, else the failure reason.
+ private async Task StageSslComRecordAsync(string orderId, SslComDcvPlan plan, CancellationToken ct)
+ {
+ _logger.LogInformation(
+ "Staging V2 DNS TXT record for SSL.com DCV. OrderId={OrderId}, Domain={Domain}, Hostname={Hostname}",
+ orderId, LogSanitizer.Strip(plan.Domain), LogSanitizer.Strip(plan.RecordName));
+ DomainValidationResult stageResult;
+ try
+ {
+ stageResult = await plan.Validator.StageValidation(plan.RecordName, plan.RecordValue, ct);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex,
+ "V2 DCV (SSL.com): DNS provider threw while staging '{Domain}' for order {OrderId}.",
+ LogSanitizer.Strip(plan.Domain), orderId);
+ return "DNS provider plugin threw";
+ }
+ if (!stageResult.Success)
+ {
+ _logger.LogError(
+ "V2 DCV (SSL.com): Failed to stage DNS TXT for '{Domain}' on order {OrderId}: {Error}.",
+ LogSanitizer.Strip(plan.Domain), orderId, LogSanitizer.Strip(stageResult.ErrorMessage));
+ return $"stage failed: {stageResult.ErrorMessage}";
+ }
+ return null;
+ }
+
+ ///
+ /// Calls POST /domains/{id}/dcv/verify for SSL.com's option. Returns false with a reason
+ /// when SSL.com reports PENDING / UNDER_REVIEW / FAILED / EXPIRED (422) or can't be reached
+ /// (502 or any other failure); the order stays pending either way.
+ ///
+ private async Task<(bool verified, string reason)> VerifySslComDomainAsync(
+ string orderId, SslComDcvPlan plan, CancellationToken ct)
+ {
+ _logger.LogInformation(
+ "Triggering V2 domain DCV verification (SSL.com). OrderId={OrderId}, Domain={Domain}, DomainId={DomainId}",
+ orderId, LogSanitizer.Strip(plan.Domain), plan.DomainId);
+ try
+ {
+ var result = await _client.VerifyDomainDcvV2Async(plan.DomainId, plan.OptionId, ct);
+ if (result?.Verified == true)
+ {
+ _logger.LogInformation(
+ "V2 DCV (SSL.com) verified. OrderId={OrderId}, Domain={Domain}, ValidTill={ValidTill}",
+ orderId, LogSanitizer.Strip(plan.Domain), result.ValidTill ?? "(not returned)");
+ return (true, null);
+ }
+ _logger.LogWarning(
+ "V2 DCV (SSL.com) not verified yet for {Domain} on order {OrderId}. CaStatus={CaStatus}, " +
+ "CaMessage={CaMessage}. The order stays pending and a later sync retries.",
+ LogSanitizer.Strip(plan.Domain), orderId, result?.CaStatus ?? "(none)",
+ LogSanitizer.Strip(result?.CaMessage) ?? "(none)");
+ return (false, $"SSL.com caStatus={result?.CaStatus ?? "(none)"}");
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex,
+ "V2 DCV (SSL.com): verify failed for {Domain} on order {OrderId}; the order stays pending and " +
+ "a later sync retries.", LogSanitizer.Strip(plan.Domain), orderId);
+ return (false, "SSL.com verify failed");
+ }
+ }
+
+ ///
+ /// Single-domain DCV for a domain SSL.com validates: read SSL.com's record, publish it, wait for
+ /// propagation, verify through the domain-level endpoint, poll Track Order, clean up.
+ /// DcvTxtRecordTemplate is not used. Returns true when the domain verified.
+ ///
+ private async Task PerformDcvV2SslComSingleDomainAsync(
+ string orderId, string domain, V2DomainRecord record, string productFamilySlug, CancellationToken ct)
+ {
+ var (plan, _) = await PrepareSslComDcvAsync(orderId, domain, record, ct);
+ if (plan == null)
+ return false;
+
+ bool staged = false;
+ try
+ {
+ if (!plan.AlreadyValidated)
+ {
+ if (await StageSslComRecordAsync(orderId, plan, ct) != null)
+ return false;
+ staged = true;
+
+ int delaySeconds = _config.DcvPropagationDelaySeconds > 0 ? _config.DcvPropagationDelaySeconds : 30;
+ _logger.LogInformation(
+ "Waiting {Delay}s for DNS propagation before V2 SSL.com DCV verify. OrderId={OrderId}",
+ delaySeconds, orderId);
+ await Task.Delay(TimeSpan.FromSeconds(delaySeconds), ct);
+ }
+
+ var (verified, _) = await VerifySslComDomainAsync(orderId, plan, ct);
+ if (!verified)
+ return false;
+
+ await PollV2UntilLeavesPendingDcvAsync(orderId, productFamilySlug, ct);
+ return true;
+ }
+ finally
+ {
+ if (staged)
+ await CleanupOneV2StagedRecordAsync(orderId, (domain, plan.RecordName, plan.Validator));
+ }
+ }
+
///
/// Removes one already-published V2 DCV TXT record. Shared cleanup logic for
/// 's staged-domain list — mirrors the
diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs
index 66d5a98..c20c87f 100644
--- a/CERTInext/Client/CERTInextClient.cs
+++ b/CERTInext/Client/CERTInextClient.cs
@@ -1869,6 +1869,161 @@ public async Task VerifyDcvV2Async(string orderId, string d
return result;
}
+ ///
+ public async Task FindDomainV2Async(string domainName, CancellationToken ct = default)
+ {
+ Logger.MethodEntry(LogLevel.Trace);
+ if (string.IsNullOrWhiteSpace(domainName))
+ throw new ArgumentException("A domain name is required.", nameof(domainName));
+ EnsureV2Client();
+ string path = Constants.ApiV2.DomainsPath + "?search=" + Uri.EscapeDataString(domainName) + "&exactMatch=true";
+ var req = await BuildV2RequestAsync(path, Method.Get, ct);
+ var resp = await _httpV2.ExecuteAsync(req, ct);
+ Logger.LogInformation(
+ "CERTInext V2 API call: Method=GET, Path={Path}, HttpStatus={Status}",
+ Constants.ApiV2.DomainsPath, (int)resp.StatusCode);
+ ThrowOnV2Failure(resp, "V2 find domain");
+ var result = ParseDomainListRow(resp.Content, domainName);
+ Logger.MethodExit(LogLevel.Trace);
+ return result;
+ }
+
+ ///
+ public async Task GetDomainDcvV2Async(string domainId, CancellationToken ct = default)
+ {
+ Logger.MethodEntry(LogLevel.Trace);
+ EnsureV2Client();
+ string path = $"{Constants.ApiV2.DomainsPath}/{Uri.EscapeDataString(domainId)}/dcv";
+ var req = await BuildV2RequestAsync(path, Method.Get, ct);
+ var resp = await _httpV2.ExecuteAsync(req, ct);
+ Logger.LogInformation(
+ "CERTInext V2 API call: Method=GET, Path={Path}, HttpStatus={Status}",
+ path, (int)resp.StatusCode);
+ ThrowOnV2Failure(resp, "V2 get domain DCV");
+ var result = DeserializeV2OrThrow(resp, "V2 get domain DCV");
+ Logger.MethodExit(LogLevel.Trace);
+ return result;
+ }
+
+ ///
+ public async Task VerifyDomainDcvV2Async(
+ string domainId, string optionId, CancellationToken ct = default)
+ {
+ Logger.MethodEntry(LogLevel.Trace);
+ EnsureV2Client();
+ string path = $"{Constants.ApiV2.DomainsPath}/{Uri.EscapeDataString(domainId)}/dcv/verify";
+ var req = await BuildV2RequestAsync(path, Method.Post, ct);
+ req.AddJsonBody(JsonSerializer.Serialize(
+ new V2DomainDcvVerifyRequest { OptionId = string.IsNullOrWhiteSpace(optionId) ? null : optionId },
+ GetJsonOptions()));
+ var resp = await _httpV2.ExecuteAsync(req, ct);
+ Logger.LogInformation(
+ "CERTInext V2 API call: Method=POST, Path={Path}, HttpStatus={Status}",
+ path, (int)resp.StatusCode);
+
+ if (resp.StatusCode == (HttpStatusCode)422)
+ {
+ // Spec: "otherwise 422 with diagnostics.caStatus (PENDING, FAILED, UNDER_REVIEW,
+ // EXPIRED) and diagnostics.caMessage (SSL.com's reason)". Not an exception: the caller
+ // leaves the order pending and the next sync retries.
+ LogV2ApiFailure("V2 verify domain DCV", resp, LogLevel.Warning);
+ var (caStatus, caMessage) = ReadDomainVerifyDiagnostics(resp.Content);
+ Logger.MethodExit(LogLevel.Trace);
+ return new V2DomainDcvVerifyResult
+ {
+ Verified = false,
+ CaStatus = caStatus,
+ CaMessage = caMessage == null
+ ? null
+ : LogSanitizer.SanitizeCaText(caMessage, _config.LogSensitiveRequestData)
+ };
+ }
+
+ ThrowOnV2Failure(resp, "V2 verify domain DCV");
+ string validTill = null;
+ if (!string.IsNullOrWhiteSpace(resp.Content))
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(resp.Content);
+ validTill = TryReadString(doc.RootElement, "validTill");
+ }
+ catch (JsonException)
+ {
+ // A 200 is the success signal; validTill is informational only.
+ }
+ }
+ Logger.MethodExit(LogLevel.Trace);
+ return new V2DomainDcvVerifyResult { Verified = true, ValidTill = validTill };
+ }
+
+ ///
+ /// Parses a GET /domains body into the row for , or null.
+ /// Rows come under content (seen live, same envelope as /reports/orders); a bare
+ /// array is accepted too. Rows are matched on domainName case-insensitively, as a guard in
+ /// case exactMatch is ignored and the substring search returns sibling hosts.
+ ///
+ internal static V2DomainRecord ParseDomainListRow(string content, string domainName)
+ {
+ if (string.IsNullOrWhiteSpace(content)) return null;
+ using var doc = JsonDocument.Parse(content);
+ var root = doc.RootElement;
+ JsonElement rows;
+ if (root.ValueKind == JsonValueKind.Array)
+ rows = root;
+ else if (root.ValueKind == JsonValueKind.Object
+ && root.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.Array)
+ rows = c;
+ else
+ throw new InvalidOperationException(
+ "V2 GET /domains returned an unrecognised body (no 'content' array).");
+
+ foreach (var row in rows.EnumerateArray())
+ {
+ if (row.ValueKind != JsonValueKind.Object) continue;
+ string name = TryReadString(row, "domainName") ?? TryReadString(row, "domain");
+ if (!string.Equals(name, domainName, StringComparison.OrdinalIgnoreCase)) continue;
+ return new V2DomainRecord
+ {
+ DomainId = TryReadString(row, "domainId") ?? TryReadString(row, "id"),
+ DomainName = name,
+ DcvStatus = TryReadString(row, "dcvStatus"),
+ ValidatedBy = TryReadString(row, "validatedBy")
+ };
+ }
+ return null;
+ }
+
+ private static (string caStatus, string caMessage) ReadDomainVerifyDiagnostics(string content)
+ {
+ if (string.IsNullOrWhiteSpace(content)) return (null, null);
+ try
+ {
+ using var doc = JsonDocument.Parse(content);
+ if (doc.RootElement.ValueKind == JsonValueKind.Object
+ && doc.RootElement.TryGetProperty("diagnostics", out var diag)
+ && diag.ValueKind == JsonValueKind.Object)
+ return (TryReadString(diag, "caStatus"), TryReadString(diag, "caMessage"));
+ }
+ catch (JsonException)
+ {
+ // Not JSON — no diagnostics to report.
+ }
+ return (null, null);
+ }
+
+ /// Reads a string or number property as a string; null when absent or another kind.
+ private static string TryReadString(JsonElement obj, string name)
+ {
+ if (!obj.TryGetProperty(name, out var v)) return null;
+ return v.ValueKind switch
+ {
+ JsonValueKind.String => v.GetString(),
+ JsonValueKind.Number => v.GetRawText(),
+ _ => null
+ };
+ }
+
///
public async Task> GetProductDetailsV2Async(CancellationToken ct = default)
{
diff --git a/CERTInext/Client/ICERTInextClient.cs b/CERTInext/Client/ICERTInextClient.cs
index b4cd8fe..9736f73 100644
--- a/CERTInext/Client/ICERTInextClient.cs
+++ b/CERTInext/Client/ICERTInextClient.cs
@@ -400,5 +400,29 @@ IAsyncEnumerable ListOrdersV2Async(
///
Task SubmitSslComValidationAsync(
string organizationNumber, V2SslComSubmitRequest request, CancellationToken ct = default);
+
+ ///
+ /// Looks up one account domain by its full name via
+ /// GET /api/certinext/v2/domains?search={fqdn}&exactMatch=true. Returns null when the
+ /// account has no row for it. Used to tell SSL.com-validated domains
+ /// (validatedBy: SSLCOM) from emSign ones before DCV.
+ ///
+ Task FindDomainV2Async(string domainName, CancellationToken ct = default);
+
+ ///
+ /// Reads a domain's DCV instructions via GET /api/certinext/v2/domains/{domainId}/dcv. For a
+ /// domain SSL.com validates, the record name, value and options come live from SSL.com; the
+ /// spec returns 502 when SSL.com can't be reached, which this surfaces as an exception.
+ ///
+ Task GetDomainDcvV2Async(string domainId, CancellationToken ct = default);
+
+ ///
+ /// Triggers DCV for a domain via POST /api/certinext/v2/domains/{domainId}/dcv/verify, optionally
+ /// for one SSL.com . 200 returns Verified=true; a 422 returns
+ /// Verified=false with SSL.com's caStatus/caMessage. Any other failure
+ /// (including the spec's 502 when SSL.com can't be reached) throws.
+ ///
+ Task VerifyDomainDcvV2Async(
+ string domainId, string optionId, CancellationToken ct = default);
}
}
diff --git a/CERTInext/Constants.cs b/CERTInext/Constants.cs
index bd2ec54..3ce5373 100644
--- a/CERTInext/Constants.cs
+++ b/CERTInext/Constants.cs
@@ -492,6 +492,15 @@ public static class ApiV2
public const string OrganizationsPath = "/api/certinext/v2/organizations";
public const string SslComValidationSuffix = "ca-validations/sslcom";
+ // Domain-level DCV, used for domains SSL.com validates: GET {DomainsPath}?search=&exactMatch=true,
+ // GET {DomainsPath}/{domainId}/dcv, POST {DomainsPath}/{domainId}/dcv/verify.
+ public const string DomainsPath = "/api/certinext/v2/domains";
+ // DcvDetails/domain-row validatedBy value for SSL.com; absent means emSign.
+ public const string ValidatedBySslCom = "SSLCOM";
+ public const string DcvMethodDnsTxt = "dns-txt";
+ // SSL.com caStatus on GET .../dcv once the domain is validated.
+ public const string SslComCaStatusValidated = "VALIDATED";
+
// Order status strings (V2 REST — NOT numeric IDs)
public const string StatusPendingDcv = "pending-dcv";
public const string StatusPendingCsr = "pending-csr";
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 78cac3c..60b4c5d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@
- feat(v2): Synchronize reads `/reports/orders` and honors `IgnoreExpired`.
- feat(v2): Incremental syncs look back `V2SyncLookbackHours` (default 72).
- feat(dcv): DNS-01 domain validation for V2 SSL/TLS orders, including every SAN on a UCC order.
+- feat(dcv): V2 DCV for domains SSL.com validates: the plugin looks each pending domain up (`GET /domains`) and, for `validatedBy: SSLCOM`, publishes SSL.com's own `dns-txt` record from `GET /domains/{id}/dcv` (not `DcvTxtRecordTemplate`) and verifies it with `POST /domains/{id}/dcv/verify`. SSL.com outages (502), `UNDER_REVIEW`/`PENDING` results, and domains with no `dns-txt` option leave the order pending for the next sync; emSign domains keep the order-level flow.
- feat(dcv): `DcvEnabled` now defaults to `true`; it needs a DNS provider plugin, or set it to `false`.
- feat(enroll): V2 orders also return a quickly issued certificate in the same `Enroll` call, using the `PickupRetries` and `PickupDelay` settings V1 gained in 1.0.1.
- feat(config): New settings `RequestorDesignation` and `V2SyncLookbackHours`.
@@ -49,6 +50,7 @@
- V2 revoke reasons CERTInext rejects are retried with a substitute (see Revocation Reason Codes in the README).
- V2 `expired` orders are reported as issued, and revoking one is sent to the CA.
- V2 renew and reissue place a new order and don't revoke the original.
+- On V2 accounts whose domains SSL.com validates, DCV now publishes SSL.com's record name and value, not `DcvTxtRecordTemplate`. The DNS provider plugin must be able to write those names (for example `_pki-validation.`).
- Upgrading from 1.0.0 also brings the changes in 1.0.1 and 1.0.2 above. For example, V1 now submits non-DNS SANs; set `SubmitNonDnsSans` to `false` to drop them.
## Known Limitations
@@ -61,7 +63,7 @@
- V1 `AuthMode=OAuth` isn't supported: the live V1 API ignores the bearer token, so V1 needs an access key.
- The V2 incremental sync doesn't yet skip orders the gateway already holds unchanged, as the V1 incremental sync does from 1.0.2, so it re-reads each order listed.
- Without a template `ProductCode`, the product CERTInext resolves depends on the account's catalog. Where several products match a variant, CERTInext may pick one that rejects a client CSR. On the sandbox, plain DV resolves to `917` "SSL DV 1 month" instead of `842`. Set `ProductCode` on the template to choose the product.
-- DCV for domains validated by SSL.com (after the issuing-CA change) isn't supported yet: SSL.com supplies the record name and value through the domain-level DCV endpoints, and the plugin still publishes `DcvTxtRecordTemplate` records through the order-level endpoint.
+- DCV for domains validated by SSL.com is built from the V2 spec and unit-tested but not verified live (the sandbox has no SSL.com-validated domains). Only SSL.com's `dns-txt` option can be published, because the DNS provider interface publishes TXT records only; a domain offered only `dns-cname` or `http-url` stays pending.
- Command's enrollment workflow can give up at 60 s, before the 75 s pickup ceiling. When it does, Command records the enrollment as failed even though the order continues at CERTInext, so the certificate can only arrive through a later sync.
# 1.0.2
diff --git a/README.md b/README.md
index 061f2d3..44f8468 100644
--- a/README.md
+++ b/README.md
@@ -894,6 +894,7 @@ The following V2 limitations are known. None of them is a show-stopper for a sin
deployment, but decide with these in mind rather than discovering them after cutting over:
- **DCV isn't validated end to end** for per-SAN domains on UCC orders, for a domain that has never been validated, or for wildcards. The plugin runs the flow for each of these, but the paths haven't been confirmed against the CA. See [Before You Begin](#before-you-begin-confirm-v2-will-work-for-your-templates).
+- **DCV for SSL.com-validated domains isn't validated live.** The plugin detects `validatedBy: SSLCOM` and publishes SSL.com's own `dns-txt` record, but the sandbox has no such domains. A domain that SSL.com offers only `dns-cname` or `http-url` for stays pending.
- **Document Signer (`ProductFamily=signature`) enrollment isn't supported.** It fails before any
order is placed.
- **Every renewal/reissue places a new order.** See [Renewals and Reissuance](#renewals-and-reissuance)
@@ -1141,7 +1142,7 @@ DNS-01 domain control validation (DCV) proves control of each domain on an order
- **Where DCV runs.** Inline during `Enroll`, during `Synchronize` for pending orders, and during `GetSingleRecord` for a pending order.
- **Where it never runs.** For an order that is already issued, revoked, cancelled, or rejected. V2 also skips Private PKI and Document Signer orders, which have no DCV step.
-- **Record name.** The TXT record is published at `DcvTxtRecordTemplate` (default `_emsign-validation.{0}`), with `{0}` replaced by the domain.
+- **Record name.** The TXT record is published at `DcvTxtRecordTemplate` (default `_emsign-validation.{0}`), with `{0}` replaced by the domain. V2 domains that SSL.com validates are the exception: the plugin publishes SSL.com's own record name and value (see [SSL.com-validated domains](#sslcom-validated-domains-v2)).
- **Wildcards.** The `*.` label is stripped, so the record is published at the base domain, and a wildcard and its apex on the same order share one record.
- **Which domains.** V1 processes only domains CERTInext lists as pending that are unassigned or assigned to the DNS TXT method. V2 processes every domain that is not yet verified.
- **Skip and continue.** A domain that can't be staged (no DNS provider resolves it, the provider fails, or CERTInext returns no token) is logged and skipped so the other domains can still be validated. The order stays pending until the skipped domain is resolved.
@@ -1219,6 +1220,19 @@ sequenceDiagram
For an order that lists per-domain verification state (a multi-domain order, for example), every domain that is not yet `VERIFIED` is staged, with one propagation wait for the whole batch, and the poll waits until each verified domain shows `VERIFIED` (or `REJECTED`). For a single-domain order the poll waits for the status to leave `pending-dcv`, and if `verify` doesn't report `VERIFIED` the plugin removes the record and leaves the order pending. Either way, the next sync or `GetSingleRecord` call retries only the domains that are still unverified.
+##### SSL.com-validated domains (V2)
+
+Before it requests a challenge for a pending domain, the V2 flow looks the domain up (`GET /domains?search={domain}&exactMatch=true`). A domain whose row says `validatedBy: SSLCOM` uses the domain-level DCV endpoints instead of the order-level ones:
+
+1. `GET /domains/{domainId}/dcv` returns SSL.com's record. If SSL.com reports the domain `VALIDATED`, step 2 is skipped.
+2. The plugin picks a `dns-txt` option, preferring the selected one, then one for exactly this name. It publishes that `recordName` and `recordValue` through the DNS provider plugin and ignores `DcvTxtRecordTemplate`. `dns-cname` and `http-url` options are not used, because the DNS provider interface (`IAnyCAPlugin` 3.3) can only publish TXT records.
+3. After `DcvPropagationDelaySeconds`, `POST /domains/{domainId}/dcv/verify` is sent with that option's `optionId`. SSL.com waits about 15 seconds before answering.
+4. The record is removed.
+
+If no `dns-txt` option is offered, the plugin can't publish anything. If SSL.com can't be reached (502 on either call), or verify answers 422 with `caStatus` `PENDING`, `UNDER_REVIEW`, `FAILED` or `EXPIRED`, the plugin logs a warning with SSL.com's status and reason. In every one of these cases the order stays pending, the next sync or `GetSingleRecord` call retries, and the order is never cancelled. Record values are never logged.
+
+A failed lookup, or a domain with no row, takes the order-level flow above, as before. On a multi-domain order, each domain takes the flow its own row selects. **This path is built from the V2 spec and unit-tested, but has not been verified live:** the sandbox account has no SSL.com-validated domains.
+
### Certificate Enrollment
When a requester submits a certificate request through Keyfactor Command, the plugin translates the request into a CERTInext order and returns the result. V1 and V2 follow different flows. V1 handles renewals specially (see [V1 renewal and RenewOrReissue](#v1-renewal-and-reneworreissue)), while V2 places a new order for every enrollment type.
diff --git a/docsource/architecture.md b/docsource/architecture.md
index 77696da..c48e507 100644
--- a/docsource/architecture.md
+++ b/docsource/architecture.md
@@ -218,7 +218,7 @@ DNS-01 domain control validation (DCV) proves control of each domain on an order
- **Where DCV runs.** Inline during `Enroll`, during `Synchronize` for pending orders, and during `GetSingleRecord` for a pending order.
- **Where it never runs.** For an order that is already issued, revoked, cancelled, or rejected. V2 also skips Private PKI and Document Signer orders, which have no DCV step.
-- **Record name.** The TXT record is published at `DcvTxtRecordTemplate` (default `_emsign-validation.{0}`), with `{0}` replaced by the domain.
+- **Record name.** The TXT record is published at `DcvTxtRecordTemplate` (default `_emsign-validation.{0}`), with `{0}` replaced by the domain. V2 domains that SSL.com validates are the exception: the plugin publishes SSL.com's own record name and value (see [SSL.com-validated domains](#sslcom-validated-domains-v2)).
- **Wildcards.** The `*.` label is stripped, so the record is published at the base domain, and a wildcard and its apex on the same order share one record.
- **Which domains.** V1 processes only domains CERTInext lists as pending that are unassigned or assigned to the DNS TXT method. V2 processes every domain that is not yet verified.
- **Skip and continue.** A domain that can't be staged (no DNS provider resolves it, the provider fails, or CERTInext returns no token) is logged and skipped so the other domains can still be validated. The order stays pending until the skipped domain is resolved.
@@ -296,6 +296,19 @@ sequenceDiagram
For an order that lists per-domain verification state (a multi-domain order, for example), every domain that is not yet `VERIFIED` is staged, with one propagation wait for the whole batch, and the poll waits until each verified domain shows `VERIFIED` (or `REJECTED`). For a single-domain order the poll waits for the status to leave `pending-dcv`, and if `verify` doesn't report `VERIFIED` the plugin removes the record and leaves the order pending. Either way, the next sync or `GetSingleRecord` call retries only the domains that are still unverified.
+##### SSL.com-validated domains (V2)
+
+Before it requests a challenge for a pending domain, the V2 flow looks the domain up (`GET /domains?search={domain}&exactMatch=true`). A domain whose row says `validatedBy: SSLCOM` uses the domain-level DCV endpoints instead of the order-level ones:
+
+1. `GET /domains/{domainId}/dcv` returns SSL.com's record. If SSL.com reports the domain `VALIDATED`, step 2 is skipped.
+2. The plugin picks a `dns-txt` option, preferring the selected one, then one for exactly this name. It publishes that `recordName` and `recordValue` through the DNS provider plugin and ignores `DcvTxtRecordTemplate`. `dns-cname` and `http-url` options are not used, because the DNS provider interface (`IAnyCAPlugin` 3.3) can only publish TXT records.
+3. After `DcvPropagationDelaySeconds`, `POST /domains/{domainId}/dcv/verify` is sent with that option's `optionId`. SSL.com waits about 15 seconds before answering.
+4. The record is removed.
+
+If no `dns-txt` option is offered, the plugin can't publish anything. If SSL.com can't be reached (502 on either call), or verify answers 422 with `caStatus` `PENDING`, `UNDER_REVIEW`, `FAILED` or `EXPIRED`, the plugin logs a warning with SSL.com's status and reason. In every one of these cases the order stays pending, the next sync or `GetSingleRecord` call retries, and the order is never cancelled. Record values are never logged.
+
+A failed lookup, or a domain with no row, takes the order-level flow above, as before. On a multi-domain order, each domain takes the flow its own row selects. **This path is built from the V2 spec and unit-tested, but has not been verified live:** the sandbox account has no SSL.com-validated domains.
+
### Certificate Enrollment
When a requester submits a certificate request through Keyfactor Command, the plugin translates the request into a CERTInext order and returns the result. V1 and V2 follow different flows. V1 handles renewals specially (see [V1 renewal and RenewOrReissue](#v1-renewal-and-reneworreissue)), while V2 places a new order for every enrollment type.
diff --git a/docsource/migration-v1-to-v2.md b/docsource/migration-v1-to-v2.md
index 942fdc4..51d4ee6 100644
--- a/docsource/migration-v1-to-v2.md
+++ b/docsource/migration-v1-to-v2.md
@@ -204,6 +204,7 @@ The following V2 limitations are known. None of them is a show-stopper for a sin
deployment, but decide with these in mind rather than discovering them after cutting over:
- **DCV isn't validated end to end** for per-SAN domains on UCC orders, for a domain that has never been validated, or for wildcards. The plugin runs the flow for each of these, but the paths haven't been confirmed against the CA. See [Before You Begin](#before-you-begin-confirm-v2-will-work-for-your-templates).
+- **DCV for SSL.com-validated domains isn't validated live.** The plugin detects `validatedBy: SSLCOM` and publishes SSL.com's own `dns-txt` record, but the sandbox has no such domains. A domain that SSL.com offers only `dns-cname` or `http-url` for stays pending.
- **Document Signer (`ProductFamily=signature`) enrollment isn't supported.** It fails before any
order is placed.
- **Every renewal/reissue places a new order.** See [Renewals and Reissuance](#renewals-and-reissuance)
From 5a308262c996878117facfd879a1aefc67e9a758 Mon Sep 17 00:00:00 2001
From: spbsoluble <1661003+spbsoluble@users.noreply.github.com>
Date: Fri, 13 Nov 2026 14:47:46 -0800
Subject: [PATCH 02/11] fix(revoke): retry rejected privilege-withdrawn as
cessation-of-operation; match CERTInext's newer reason-rejection wording
(0094 item 2)
The 2.0.0 spec dropped privilege-withdrawn from the SSL/TLS revoke enum, and live (2026-11-13)
now rejects it with 422 "Revocation reason privilege-withdrawn is not allowed." - a different
wording from the "Invalid Revoke Reason ID" the fallback matched, so a reason-9 revoke failed
with no retry. The first attempt still sends privilege-withdrawn; a rejection in either wording
now retries once with cessation-of-operation. Opt-in live tests: privilege-withdrawn acceptance
on an own order, and a per-reason acceptance probe.
---
.../V2SpecFollowupsLiveTests.cs | 180 ++++++++++++++++++
CERTInext.Tests/CERTInextCAPluginV2Tests.cs | 44 ++++-
CERTInext/CERTInextCAPlugin.cs | 35 ++--
CHANGELOG.md | 2 +-
README.md | 7 +-
docsource/architecture.md | 7 +-
6 files changed, 255 insertions(+), 20 deletions(-)
diff --git a/CERTInext.IntegrationTests/V2SpecFollowupsLiveTests.cs b/CERTInext.IntegrationTests/V2SpecFollowupsLiveTests.cs
index 850271d..14f4f2e 100644
--- a/CERTInext.IntegrationTests/V2SpecFollowupsLiveTests.cs
+++ b/CERTInext.IntegrationTests/V2SpecFollowupsLiveTests.cs
@@ -6,11 +6,20 @@
// and limitations under the License.
using System;
+using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using FluentAssertions;
+using Keyfactor.AnyGateway.Extensions;
+using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2;
using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using Keyfactor.PKI.Enums.EJBCA;
+using Org.BouncyCastle.Asn1.X509;
+using Org.BouncyCastle.Crypto;
+using Org.BouncyCastle.Crypto.Generators;
+using Org.BouncyCastle.Pkcs;
+using Org.BouncyCastle.Security;
using Xunit;
using Xunit.Abstractions;
@@ -64,6 +73,75 @@ private CERTInextClient BuildV2Client() =>
PageSize = 100
});
+ private CERTInextConfig BuildV2Config() =>
+ new CERTInextConfig
+ {
+ ApiUrl = _v2ApiUrl,
+ UseV2Api = true,
+ V2InlineCsr = V2EnvHelper.InlineCsrFromEnv,
+ OAuthClientId = _v2ClientId,
+ OAuthClientSecret = _v2ClientSecret,
+ RequestorName = _fixture.IsConfigured ? _fixture.Config.RequestorName : "Keyfactor Test",
+ RequestorEmail = _fixture.IsConfigured ? _fixture.Config.RequestorEmail : "test@example.com",
+ RequestorIsdCode = "1",
+ RequestorMobileNumber = "0000000000",
+ SignerPlace = "Gateway Lab",
+ SignerIp = "127.0.0.1",
+ PageSize = 100,
+ DcvEnabled = false,
+ PickupRetries = 0
+ };
+
+ private CERTInextCAPlugin BuildV2Plugin(CERTInextConfig config = null)
+ {
+ config ??= BuildV2Config();
+ return new CERTInextCAPlugin(new CERTInextClient(config), config);
+ }
+
+ /// RSA-2048 PKCS#10 CSR for (BouncyCastle only).
+ private static string GenerateCsrPem(string commonName)
+ {
+ var keyGen = new RsaKeyPairGenerator();
+ keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048));
+ var keyPair = keyGen.GenerateKeyPair();
+ var csr = new Pkcs10CertificationRequest(
+ "SHA256withRSA", new X509Name($"CN={commonName}"), keyPair.Public, null, keyPair.Private);
+ return "-----BEGIN CERTIFICATE REQUEST-----\n"
+ + Convert.ToBase64String(csr.GetEncoded(), Base64FormattingOptions.InsertLineBreaks)
+ + "\n-----END CERTIFICATE REQUEST-----";
+ }
+
+ ///
+ /// Enrolls a pinned DV order for CERTINEXT_DCV_DOMAIN through the plugin and polls Track
+ /// Order (up to ) for issued. Returns the order id and its
+ /// last status; the caller must clean the order up.
+ ///
+ private async Task<(string orderId, string status)> PlaceOwnDvOrderAsync(CERTInextClient client, int waitSeconds = 90)
+ {
+ var result = await BuildV2Plugin().Enroll(
+ csr: GenerateCsrPem(_v2Domain),
+ subject: $"CN={_v2Domain}",
+ san: new Dictionary { ["dns"] = new[] { _v2Domain } },
+ productInfo: V2ProductPinning.Info(Constants.Products.DvSsl, V2PinMode.Pinned),
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.New);
+ _output.WriteLine($"Placed own order: CARequestID={result.CARequestID}, Status={result.Status}, " +
+ $"Message={result.StatusMessage}");
+ result.CARequestID.Should().NotBeNullOrWhiteSpace("the test needs its own order");
+
+ string status = null;
+ var deadline = DateTime.UtcNow.AddSeconds(waitSeconds);
+ while (true)
+ {
+ var tracked = await client.ResolveAndTrackOrderV2Async(result.CARequestID);
+ status = tracked.Status;
+ if (status == Constants.ApiV2.StatusIssued || DateTime.UtcNow >= deadline) break;
+ await Task.Delay(TimeSpan.FromSeconds(10));
+ }
+ _output.WriteLine($"Own order {result.CARequestID}: status={status}");
+ return (result.CARequestID, status);
+ }
+
/// Top-level key names of the first content[] row, for shape discovery (no values).
private static string FirstRowKeys(string json)
{
@@ -162,5 +240,107 @@ public async Task SslComDomainDcv_LookupAndShape_Probe()
_output.WriteLine("RESULT: a customer on this account would see DCV left pending with this warning.");
}
}
+ // ---------------------------------------------------------------------------
+ // 0094 item 2: revoke reason privilege-withdrawn
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Places its own pinned DV order and, once Track Order shows it issued, revokes it
+ /// directly with privilege-withdrawn (dropped from the 2.0.0 spec's SSL/TLS enum) to see
+ /// whether CERTInext still accepts it. If CERTInext rejects it, the order is revoked through the
+ /// plugin with CRL reason 9 instead, which exercises the cessation-of-operation fallback.
+ /// Touches only the order it creates. Opt-in: CERTINEXT_V2_REVOKE_PRIVILEGE_WITHDRAWN=1.
+ ///
+ [SkippableFact]
+ public async Task Revoke_PrivilegeWithdrawn_OwnOrder_ReportsCaAcceptance()
+ {
+ Skip.If(!_v2Enabled || !Flag("CERTINEXT_V2_REVOKE_PRIVILEGE_WITHDRAWN"),
+ "Requires CERTINEXT_USE_V2_API + V2 credentials and CERTINEXT_V2_REVOKE_PRIVILEGE_WITHDRAWN=1.");
+
+ using var client = BuildV2Client();
+ var restoreLogging = TestOutputLogging.RouteTo(_output);
+ string orderId = null;
+ try
+ {
+ string status;
+ (orderId, status) = await PlaceOwnDvOrderAsync(client);
+ Skip.If(status != Constants.ApiV2.StatusIssued,
+ $"Own order {orderId} did not reach issued (status={status}); nothing to revoke. Cleaned up below.");
+
+ try
+ {
+ await client.RevokeOrderV2Async(Constants.ApiV2.FamilySsl, orderId, new V2RevokeRequest
+ {
+ Reason = Constants.RevocationReasonV2.PrivilegeWithdrawn,
+ Note = "V2 integration test, privilege-withdrawn acceptance check"
+ });
+ var after = await client.ResolveAndTrackOrderV2Async(orderId);
+ _output.WriteLine($"RESULT: CERTInext ACCEPTED privilege-withdrawn. Re-tracked status={after.Status}, " +
+ $"revocation reason={after.Revocation?.Reason ?? "(none)"}");
+ }
+ catch (InvalidOperationException ex) when (CERTInextCAPlugin.IsRevokeReasonRejection(ex.Message))
+ {
+ _output.WriteLine($"RESULT: CERTInext REJECTED privilege-withdrawn: {ex.Message}");
+ int viaPlugin = await BuildV2Plugin().Revoke(orderId, string.Empty, 9);
+ _output.WriteLine($"Plugin Revoke(reason 9) with fallback -> {viaPlugin}");
+ viaPlugin.Should().Be((int)EndEntityStatus.REVOKED, "the fallback must revoke the order");
+ }
+ }
+ finally
+ {
+ restoreLogging();
+ await V2OrderCleanup.RevokeOrCancelAsync(client, orderId, "V2 integration test cleanup", _output.WriteLine);
+ }
+ }
+ ///
+ /// Sends each V2 revoke reason to one of the caller's own test orders and reports how CERTInext
+ /// answers. CERTInext checks the reason before the order state, so on an order whose certificate
+ /// isn't generated yet (the 2026-11 sandbox) an accepted reason answers "still being processed"
+ /// and nothing changes, while a rejected one answers with its reason-rejection wording. If the
+ /// certificate has been generated, the first accepted reason revokes the order, so only point
+ /// this at an order the test run created and wants revoked.
+ /// Opt-in: CERTINEXT_V2_REVOKE_REASON_PROBE_ORDER_ID=<own order id>.
+ ///
+ [SkippableFact]
+ public async Task Revoke_ReasonAcceptance_OwnOrder_Probe()
+ {
+ string orderId = V2EnvHelper.GetEnv(_env, "CERTINEXT_V2_REVOKE_REASON_PROBE_ORDER_ID");
+ Skip.If(!_v2Enabled || string.IsNullOrWhiteSpace(orderId),
+ "Requires CERTINEXT_USE_V2_API + V2 credentials and CERTINEXT_V2_REVOKE_REASON_PROBE_ORDER_ID.");
+
+ using var client = BuildV2Client();
+ // Rejected-before-live reasons first, accepted ones last, so the order is revoked (if at all)
+ // only after every rejected reason has been observed.
+ var reasons = new[]
+ {
+ Constants.RevocationReasonV2.Unspecified, Constants.RevocationReasonV2.CACompromise,
+ Constants.RevocationReasonV2.CertificateHold, Constants.RevocationReasonV2.AACompromise,
+ Constants.RevocationReasonV2.PrivilegeWithdrawn, "remove-from-crl",
+ Constants.RevocationReasonV2.AffiliationChanged, Constants.RevocationReasonV2.CessationOfOperation,
+ Constants.RevocationReasonV2.KeyCompromise, Constants.RevocationReasonV2.Superseded
+ };
+ foreach (var reason in reasons)
+ {
+ var status = await client.ResolveAndTrackOrderV2Async(orderId);
+ if (status.Status == Constants.ApiV2.StatusRevoked)
+ {
+ _output.WriteLine($"{reason}: not sent, order is already revoked.");
+ continue;
+ }
+ try
+ {
+ await client.RevokeOrderV2Async(Constants.ApiV2.FamilySsl, orderId,
+ new V2RevokeRequest { Reason = reason, Note = "V2 integration test, reason acceptance probe" });
+ _output.WriteLine($"{reason}: ACCEPTED and revoked.");
+ }
+ catch (Exception ex)
+ {
+ string verdict = CERTInextCAPlugin.IsRevokeReasonRejection(ex.Message) ? "REJECTED (reason)"
+ : ex.Message.Contains("still being processed") ? "accepted (order still being processed)"
+ : "other error";
+ _output.WriteLine($"{reason}: {verdict}: {ex.Message}");
+ }
+ }
+ }
}
}
diff --git a/CERTInext.Tests/CERTInextCAPluginV2Tests.cs b/CERTInext.Tests/CERTInextCAPluginV2Tests.cs
index a6228c2..1240490 100644
--- a/CERTInext.Tests/CERTInextCAPluginV2Tests.cs
+++ b/CERTInext.Tests/CERTInextCAPluginV2Tests.cs
@@ -464,9 +464,13 @@ private static Mock SetupRevokeReasonRejectionMock(List SetupRevokeReasonRejectionMock(List
+ CERTInextCAPlugin.IsRevokeReasonRejection(message).Should().Be(expected);
+
+ [Fact]
+ public async Task Revoke_V2Enabled_PrivilegeWithdrawnAccepted_SentOnceUnchanged()
+ {
+ var seen = new List();
+ var mock = new Mock();
+ mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync(
+ MockCertificateData.V2OrderId1, It.IsAny()))
+ .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse
+ {
+ OrderId = MockCertificateData.V2OrderId1,
+ Status = "issued"
+ }));
+ mock.Setup(c => c.RevokeOrderV2Async(
+ Constants.ApiV2.FamilySsl, MockCertificateData.V2OrderId1,
+ It.IsAny(), It.IsAny()))
+ .Callback((string f, string o, V2RevokeRequest req, CancellationToken ct) => seen.Add(req.Reason))
+ .Returns(Task.CompletedTask);
+
+ var plugin = BuildV2Plugin(mock.Object);
+ var status = await plugin.Revoke(MockCertificateData.V2OrderId1, "AABB", 9u);
+
+ status.Should().Be((int)EndEntityStatus.REVOKED);
+ seen.Should().Equal(Constants.RevocationReasonV2.PrivilegeWithdrawn);
+ }
+
[Fact]
public async Task Revoke_V2Enabled_RejectedReasonNotInFallbackSet_DoesNotRetry()
{
diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs
index 01719e5..17098c7 100644
--- a/CERTInext/CERTInextCAPlugin.cs
+++ b/CERTInext/CERTInextCAPlugin.cs
@@ -3899,35 +3899,48 @@ private static string AssembleV2CertChain(V2CertificateDownloadResponse certResp
}
///
- /// Maps a V2 revoke reason that CERTInext rejects with "Invalid Revoke Reason
- /// ID" to an accepted fallback, or null if
- /// is not one of the known-rejected values. Of the 9 spec-documented reason strings,
- /// only key-compromise, affiliation-changed, superseded,
- /// cessation-of-operation, and privilege-withdrawn are actually accepted
- /// (the same restriction V1's has
- /// always documented) — unspecified, ca-compromise,
+ /// Maps a V2 revoke reason that CERTInext rejects () to an
+ /// accepted fallback, or null if has no fallback.
+ /// Live, only key-compromise, affiliation-changed, superseded, and
+ /// cessation-of-operation are reliably accepted — unspecified, ca-compromise,
/// certificate-hold, and aa-compromise are all rejected.
/// key-compromise is the fallback for the two *-compromise reasons (closest
/// semantic match); cessation-of-operation is the fallback for
/// unspecified and certificate-hold, neither of which implies an actual
/// key compromise — key-compromise there would misrepresent the revoke and
/// trigger the spec's own BR 4.9.1.1 24-hour CRL-turnaround obligation for no reason.
+ /// privilege-withdrawn is no longer in the 2.0.0 spec's SSL/TLS revoke enum and was
+ /// rejected live on 2026-11-13 ("Revocation reason privilege-withdrawn is not allowed."), so it
+ /// also falls back once to cessation-of-operation; the first attempt still sends
+ /// privilege-withdrawn, in case another account or the Private PKI family accepts it.
///
private static string ResolveRejectedRevokeReasonFallback(string rejectedV2Reason) =>
rejectedV2Reason switch
{
Constants.RevocationReasonV2.Unspecified => Constants.RevocationReasonV2.CessationOfOperation,
+ Constants.RevocationReasonV2.PrivilegeWithdrawn => Constants.RevocationReasonV2.CessationOfOperation,
Constants.RevocationReasonV2.CertificateHold => Constants.RevocationReasonV2.CessationOfOperation,
Constants.RevocationReasonV2.CACompromise => Constants.RevocationReasonV2.KeyCompromise,
Constants.RevocationReasonV2.AACompromise => Constants.RevocationReasonV2.KeyCompromise,
_ => null
};
+ ///
+ /// True when a V2 revoke 422 rejected the reason itself. CERTInext has used two wordings:
+ /// "Invalid Revoke Reason ID" and, seen live on 2026-11-13 for privilege-withdrawn,
+ /// "Revocation reason {reason} is not allowed." Neither carries an EMS code.
+ ///
+ internal static bool IsRevokeReasonRejection(string message) =>
+ !string.IsNullOrEmpty(message)
+ && (message.IndexOf("Invalid Revoke Reason ID", StringComparison.OrdinalIgnoreCase) >= 0
+ || System.Text.RegularExpressions.Regex.IsMatch(
+ message, @"Revocation reason \S+ is not allowed", System.Text.RegularExpressions.RegexOptions.IgnoreCase));
+
///
/// Revokes a certificate via the V2 REST API. If CERTInext rejects the resolved
- /// reason with its "Invalid Revoke Reason ID" 422 and the reason is one of the four
- /// known-rejected values (), retries
- /// exactly once with an accepted fallback. Any other revoke failure (including a 422 for a
+ /// reason () and the reason has a fallback
+ /// (the four known-rejected values plus privilege-withdrawn; see
+ /// ), retries exactly once with an accepted fallback. Any other revoke failure (including a 422 for a
/// reason not in that set) is surfaced as-is, with no retry.
///
private async Task RevokeV2Async(string caRequestID, string hexSerialNumber, uint revocationReason)
@@ -4015,7 +4028,7 @@ private async Task RevokeV2Async(string caRequestID, string hexSerialNumber
}
catch (InvalidOperationException ioe) when (
ResolveRejectedRevokeReasonFallback(v2Reason) != null &&
- ioe.Message.IndexOf("Invalid Revoke Reason ID", StringComparison.OrdinalIgnoreCase) >= 0)
+ IsRevokeReasonRejection(ioe.Message))
{
// CERTInext's V2 API rejects several of the spec-documented reason values
// outright (422 "Invalid Revoke Reason ID"), independent of
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 60b4c5d..6823d4d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -47,7 +47,7 @@
- `V2InlineCsr` now defaults to `true`. A connector that already has `V2InlineCsr = false` saved, as connectors created with an earlier 2.0 build do, keeps the two-call flow, which fails for already-validated domains. Set it to `true` on those connectors.
- `SignerIp` (connector and template) is V1 only: V2 no longer sends `agreement.signerIp`, which the V2 spec's agreement schema doesn't define.
- V2 `ProductVariant` is derived from the selected product when unset.
-- V2 revoke reasons CERTInext rejects are retried with a substitute (see Revocation Reason Codes in the README).
+- V2 revoke reasons CERTInext rejects are retried with a substitute (see Revocation Reason Codes in the README). `privilege-withdrawn`, which the 2.0.0 V2 spec dropped from the SSL/TLS enum and CERTInext rejected live on 2026-11-13, is still sent first and now falls back to `cessation-of-operation`. The retry also recognizes CERTInext's newer 422 wording, "Revocation reason {reason} is not allowed."
- V2 `expired` orders are reported as issued, and revoking one is sent to the CA.
- V2 renew and reissue place a new order and don't revoke the original.
- On V2 accounts whose domains SSL.com validates, DCV now publishes SSL.com's record name and value, not `DcvTxtRecordTemplate`. The DNS provider plugin must be able to write those names (for example `_pki-validation.`).
diff --git a/README.md b/README.md
index 44f8468..a628faa 100644
--- a/README.md
+++ b/README.md
@@ -1411,7 +1411,7 @@ sequenceDiagram
Plugin-->>CMD: Error, certificate is not revokable
else Certificate is issued
Plugin->>API: Revoke (order number, reason, remarks)
- opt V2 only, reason in the rejected set and CERTInext replies 422 Invalid Revoke Reason ID
+ opt V2 only, reason has a substitute and CERTInext replies 422 Invalid Revoke Reason ID
Plugin->>API: Revoke again, once, with the substitute reason
end
API-->>Plugin: Revocation confirmed
@@ -1438,11 +1438,12 @@ V1 sends a numeric `revokeReasonId`; V2 sends a kebab-case `reason` string. The
| Superseded (4) | 4 | `superseded` | Accepted |
| Cessation of operation (5) | 5 | `cessation-of-operation` | Accepted |
| Certificate hold (6) | Key compromise (1) | `certificate-hold` | Rejected on V2; retried once as `cessation-of-operation` |
-| Privilege withdrawn (9) | 9 | `privilege-withdrawn` | Accepted |
+| Privilege withdrawn (9) | 9 | `privilege-withdrawn` | Rejected on V2 SSL/TLS since 2026-11-13 (the 2.0.0 spec no longer lists it); retried once as `cessation-of-operation` |
| AA compromise (10) | Key compromise (1) | `aa-compromise` | Rejected on V2; retried once as `key-compromise` |
| Any other code (for example 8, `removeFromCRL`) | Key compromise (1) | `unspecified` | Same as Unspecified |
-- **What CERTInext accepts.** Only key compromise (1), affiliation changed (3), superseded (4), cessation of operation (5), and privilege withdrawn (9), on both API generations. V1 never sends anything else, so it needs no retry.
+- **What CERTInext accepts.** On V2 SSL/TLS: key compromise (1), affiliation changed (3), superseded (4), and cessation of operation (5). Privilege withdrawn (9) was accepted until CERTInext began rejecting it (seen 2026-11-13); V2 still sends it first, and only a rejection triggers the substitute. V1 sends only those five values and has no retry.
+- **Rejection wording.** CERTInext has answered a rejected reason with 422 "Invalid Revoke Reason ID" and, more recently, 422 "Revocation reason {reason} is not allowed." Both trigger the retry.
- **V2 spec versus live behavior.** The V2 spec documents 8 reason values for the SSL/TLS revoke endpoint (`aa-compromise` is listed only for the Private PKI and Document Signer endpoints). Live, SSL/TLS orders reject four of the values, `unspecified`, `ca-compromise`, `certificate-hold`, and `aa-compromise`, with 422 "Invalid Revoke Reason ID".
- **Retry rule.** The plugin retries a rejected reason once with the substitute in the table; any other revoke failure is surfaced as-is.
- **Why `cessation-of-operation`.** It substitutes for `unspecified` and `certificate-hold` because neither implies a key compromise, and `key-compromise` carries the V2 spec's Baseline Requirements §4.9.1.1 24-hour CRL-turnaround obligation, which would misrepresent the revoke. `unspecified` is Command's default reason, so it is the most common revoke case.
diff --git a/docsource/architecture.md b/docsource/architecture.md
index c48e507..c8a0670 100644
--- a/docsource/architecture.md
+++ b/docsource/architecture.md
@@ -487,7 +487,7 @@ sequenceDiagram
Plugin-->>CMD: Error, certificate is not revokable
else Certificate is issued
Plugin->>API: Revoke (order number, reason, remarks)
- opt V2 only, reason in the rejected set and CERTInext replies 422 Invalid Revoke Reason ID
+ opt V2 only, reason has a substitute and CERTInext replies 422 Invalid Revoke Reason ID
Plugin->>API: Revoke again, once, with the substitute reason
end
API-->>Plugin: Revocation confirmed
@@ -514,11 +514,12 @@ V1 sends a numeric `revokeReasonId`; V2 sends a kebab-case `reason` string. The
| Superseded (4) | 4 | `superseded` | Accepted |
| Cessation of operation (5) | 5 | `cessation-of-operation` | Accepted |
| Certificate hold (6) | Key compromise (1) | `certificate-hold` | Rejected on V2; retried once as `cessation-of-operation` |
-| Privilege withdrawn (9) | 9 | `privilege-withdrawn` | Accepted |
+| Privilege withdrawn (9) | 9 | `privilege-withdrawn` | Rejected on V2 SSL/TLS since 2026-11-13 (the 2.0.0 spec no longer lists it); retried once as `cessation-of-operation` |
| AA compromise (10) | Key compromise (1) | `aa-compromise` | Rejected on V2; retried once as `key-compromise` |
| Any other code (for example 8, `removeFromCRL`) | Key compromise (1) | `unspecified` | Same as Unspecified |
-- **What CERTInext accepts.** Only key compromise (1), affiliation changed (3), superseded (4), cessation of operation (5), and privilege withdrawn (9), on both API generations. V1 never sends anything else, so it needs no retry.
+- **What CERTInext accepts.** On V2 SSL/TLS: key compromise (1), affiliation changed (3), superseded (4), and cessation of operation (5). Privilege withdrawn (9) was accepted until CERTInext began rejecting it (seen 2026-11-13); V2 still sends it first, and only a rejection triggers the substitute. V1 sends only those five values and has no retry.
+- **Rejection wording.** CERTInext has answered a rejected reason with 422 "Invalid Revoke Reason ID" and, more recently, 422 "Revocation reason {reason} is not allowed." Both trigger the retry.
- **V2 spec versus live behavior.** The V2 spec documents 8 reason values for the SSL/TLS revoke endpoint (`aa-compromise` is listed only for the Private PKI and Document Signer endpoints). Live, SSL/TLS orders reject four of the values, `unspecified`, `ca-compromise`, `certificate-hold`, and `aa-compromise`, with 422 "Invalid Revoke Reason ID".
- **Retry rule.** The plugin retries a rejected reason once with the substitute in the table; any other revoke failure is surfaced as-is.
- **Why `cessation-of-operation`.** It substitutes for `unspecified` and `certificate-hold` because neither implies a key compromise, and `key-compromise` carries the V2 spec's Baseline Requirements §4.9.1.1 24-hour CRL-turnaround obligation, which would misrepresent the revoke. `unspecified` is Command's default reason, so it is the most common revoke case.
From 56633e2dac8b60b6b8390453a2a0868a2eb1dc75 Mon Sep 17 00:00:00 2001
From: spbsoluble <1661003+spbsoluble@users.noreply.github.com>
Date: Fri, 13 Nov 2026 14:48:04 -0800
Subject: [PATCH 03/11] docs(revoke): describe the 2.0.0 V2 SSL/TLS revoke enum
accurately (0094 item 3)
The spec's SSL/TLS enum now has 9 values including aa-compromise and no privilege-withdrawn;
the bullet said 8 and that aa-compromise was Private PKI/Document Signer only.
---
README.md | 2 +-
docsource/architecture.md | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index a628faa..b338426 100644
--- a/README.md
+++ b/README.md
@@ -1444,7 +1444,7 @@ V1 sends a numeric `revokeReasonId`; V2 sends a kebab-case `reason` string. The
- **What CERTInext accepts.** On V2 SSL/TLS: key compromise (1), affiliation changed (3), superseded (4), and cessation of operation (5). Privilege withdrawn (9) was accepted until CERTInext began rejecting it (seen 2026-11-13); V2 still sends it first, and only a rejection triggers the substitute. V1 sends only those five values and has no retry.
- **Rejection wording.** CERTInext has answered a rejected reason with 422 "Invalid Revoke Reason ID" and, more recently, 422 "Revocation reason {reason} is not allowed." Both trigger the retry.
-- **V2 spec versus live behavior.** The V2 spec documents 8 reason values for the SSL/TLS revoke endpoint (`aa-compromise` is listed only for the Private PKI and Document Signer endpoints). Live, SSL/TLS orders reject four of the values, `unspecified`, `ca-compromise`, `certificate-hold`, and `aa-compromise`, with 422 "Invalid Revoke Reason ID".
+- **V2 spec versus live behavior.** The 2.0.0 spec's SSL/TLS revoke enum has 9 values: `unspecified`, `key-compromise`, `ca-compromise`, `affiliation-changed`, `superseded`, `cessation-of-operation`, `certificate-hold`, `remove-from-crl`, and `aa-compromise`. It doesn't list `privilege-withdrawn`. Live rejects `unspecified`, `ca-compromise`, `certificate-hold`, and `aa-compromise`, and, since 2026-11-13, `privilege-withdrawn`.
- **Retry rule.** The plugin retries a rejected reason once with the substitute in the table; any other revoke failure is surfaced as-is.
- **Why `cessation-of-operation`.** It substitutes for `unspecified` and `certificate-hold` because neither implies a key compromise, and `key-compromise` carries the V2 spec's Baseline Requirements §4.9.1.1 24-hour CRL-turnaround obligation, which would misrepresent the revoke. `unspecified` is Command's default reason, so it is the most common revoke case.
diff --git a/docsource/architecture.md b/docsource/architecture.md
index c8a0670..f6cfc8d 100644
--- a/docsource/architecture.md
+++ b/docsource/architecture.md
@@ -520,7 +520,7 @@ V1 sends a numeric `revokeReasonId`; V2 sends a kebab-case `reason` string. The
- **What CERTInext accepts.** On V2 SSL/TLS: key compromise (1), affiliation changed (3), superseded (4), and cessation of operation (5). Privilege withdrawn (9) was accepted until CERTInext began rejecting it (seen 2026-11-13); V2 still sends it first, and only a rejection triggers the substitute. V1 sends only those five values and has no retry.
- **Rejection wording.** CERTInext has answered a rejected reason with 422 "Invalid Revoke Reason ID" and, more recently, 422 "Revocation reason {reason} is not allowed." Both trigger the retry.
-- **V2 spec versus live behavior.** The V2 spec documents 8 reason values for the SSL/TLS revoke endpoint (`aa-compromise` is listed only for the Private PKI and Document Signer endpoints). Live, SSL/TLS orders reject four of the values, `unspecified`, `ca-compromise`, `certificate-hold`, and `aa-compromise`, with 422 "Invalid Revoke Reason ID".
+- **V2 spec versus live behavior.** The 2.0.0 spec's SSL/TLS revoke enum has 9 values: `unspecified`, `key-compromise`, `ca-compromise`, `affiliation-changed`, `superseded`, `cessation-of-operation`, `certificate-hold`, `remove-from-crl`, and `aa-compromise`. It doesn't list `privilege-withdrawn`. Live rejects `unspecified`, `ca-compromise`, `certificate-hold`, and `aa-compromise`, and, since 2026-11-13, `privilege-withdrawn`.
- **Retry rule.** The plugin retries a rejected reason once with the substitute in the table; any other revoke failure is surfaced as-is.
- **Why `cessation-of-operation`.** It substitutes for `unspecified` and `certificate-hold` because neither implies a key compromise, and `key-compromise` carries the V2 spec's Baseline Requirements §4.9.1.1 24-hour CRL-turnaround obligation, which would misrepresent the revoke. `unspecified` is Command's default reason, so it is the most common revoke case.
From f7766e95adc3d7303cdc831fd962f96d9048eb5b Mon Sep 17 00:00:00 2001
From: spbsoluble <1661003+spbsoluble@users.noreply.github.com>
Date: Fri, 13 Nov 2026 14:51:24 -0800
Subject: [PATCH 04/11] fix(v2): serialize the organization pre-vetting token
as the spec's prevettingToken (0094 item 4)
SslOrganizationRef names it prevettingToken; the DTO sent preVettingToken. Nothing sets the
token today, so this is wire-neutral; a serialization test pins the spelling.
---
.../V2OrganizationEnrollmentTests.cs | 32 +++++++++++--------
CERTInext/API/V2/CertificateRequestV2.cs | 6 ++--
CHANGELOG.md | 1 +
3 files changed, 23 insertions(+), 16 deletions(-)
diff --git a/CERTInext.Tests/V2OrganizationEnrollmentTests.cs b/CERTInext.Tests/V2OrganizationEnrollmentTests.cs
index 8e9a55f..fecd624 100644
--- a/CERTInext.Tests/V2OrganizationEnrollmentTests.cs
+++ b/CERTInext.Tests/V2OrganizationEnrollmentTests.cs
@@ -1,16 +1,9 @@
// 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,
-// 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.
+// 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;
@@ -234,8 +227,19 @@ public void V2CreateSslOrderRequest_Serialization_IncludesOrganizationBlock_ForO
json.Should().Contain("\"organization\"");
json.Should().Contain("\"organizationNumber\":\"ORG-999\"");
json.Should().Contain("\"preVetted\":true");
- // preVettingToken is optional and unset here — must be omitted, not sent as null.
- json.Should().NotContain("preVettingToken");
+ // prevettingToken is optional and unset here — must be omitted, not sent as null.
+ json.Should().NotContainEquivalentOf("prevettingToken");
+ }
+
+ [Fact]
+ public void V2OrganizationParams_Serialization_UsesSpecSpellingPrevettingToken()
+ {
+ var org = new V2OrganizationParams { OrganizationNumber = "ORG-999", PreVettingToken = "tok-123" };
+
+ string json = JsonSerializer.Serialize(org);
+
+ json.Should().Be("{\"organizationNumber\":\"ORG-999\",\"preVetted\":true,\"prevettingToken\":\"tok-123\"}",
+ "the spec's SslOrganizationRef names the field prevettingToken (lower-case v)");
}
[Fact]
diff --git a/CERTInext/API/V2/CertificateRequestV2.cs b/CERTInext/API/V2/CertificateRequestV2.cs
index c29fc5b..e922db9 100644
--- a/CERTInext/API/V2/CertificateRequestV2.cs
+++ b/CERTInext/API/V2/CertificateRequestV2.cs
@@ -90,9 +90,11 @@ public class V2OrganizationParams
///
/// Optional per spec (re-vetting flow token). Not currently surfaced as plugin config;
- /// omitted from the wire body when null/empty.
+ /// omitted from the wire body when null/empty. The wire name is the spec schema's
+ /// SslOrganizationRef.prevettingToken (lower-case "v"); an older spec example spelled
+ /// it preVettingToken.
///
- [JsonPropertyName("preVettingToken")]
+ [JsonPropertyName("prevettingToken")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string PreVettingToken { get; set; }
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6823d4d..c157ca0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -37,6 +37,7 @@
## Chores
- chore(compat): Requires AnyCA Gateway REST 26.2.0 or later; built against `IAnyCAPlugin` 3.3.0 with DNS-01 DCV included.
- chore(compat): The plugin targets both `net8.0` and `net10.0`.
+- chore(v2): The V2 organization block's optional pre-vetting token serializes as the spec's `prevettingToken` (was `preVettingToken`). Nothing sets it yet, so requests are unchanged.
## Upgrade Notes
- AnyCA Gateway REST 25.5.x isn't supported; upgrade the gateway to 26.2.0 or later (Command 25.5 or later works with it).
From 8486405a162dad5fbc8cdc972feddfe59e32f9f5 Mon Sep 17 00:00:00 2001
From: spbsoluble <1661003+spbsoluble@users.noreply.github.com>
Date: Fri, 13 Nov 2026 15:06:50 -0800
Subject: [PATCH 05/11] feat(v2): SSL/TLS reissue via POST
/ssl-certificates/{orderId}/reissue; V2ReissueRevokePrevious (0094 item 5)
A V2 SSL/TLS Reissue, or a RenewOrReissue whose prior certificate is unexpired and outside
RenewalWindowDays (V1's reissue boundary), resolves the prior order from PriorCertSN and calls
/reissue in rekey mode with the new CSR and UCC additionalDomains. The derived order id
(e.g. 1984857369R001) becomes the CARequestID and the normal post-create flow (track,
agreement, DCV, pickup) runs on it. update-sans is never used: Command gives no signal that
only SANs changed.
New connector setting V2ReissueRevokePrevious (Boolean, default false; constants, config,
annotation, ValidateCAConnectionInfo, manifest): sends revokePrevious=true with
revokeReason=superseded, audit-logged before the call.
No prior order, or a 404/422, falls back to a new order and the result message says so. A
5xx, lost response or 2xx without an order id throws with the do-not-resubmit wording and
places nothing. Private PKI/Document Signer keep their behaviour. Order ids were already
handled as opaque strings everywhere (audited); a WireMock test round-trips a derived id
through enroll, GetSingleRecord and Sync. Opt-in live test (not yet run):
CERTINEXT_V2_LIFECYCLE_REISSUE_ENDPOINT=1 + CERTINEXT_V2_REISSUE_SOURCE_ORDER_ID.
---
.../V2SpecFollowupsLiveTests.cs | 133 +++++++
CERTInext.Tests/V2ReissueTests.cs | 325 ++++++++++++++++++
CERTInext/API/V2/CertificateRequestV2.cs | 43 ++-
CERTInext/API/V2/CertificateResponseV2.cs | 20 ++
CERTInext/CERTInextCAPlugin.cs | 181 +++++++++-
CERTInext/CERTInextCAPluginConfig.cs | 20 ++
CERTInext/Client/CERTInextClient.cs | 70 ++++
CERTInext/Client/ICERTInextClient.cs | 10 +
CERTInext/Constants.cs | 7 +
CERTInext/Models/EnrollmentParams.cs | 6 +
CHANGELOG.md | 4 +-
README.md | 34 +-
docsource/architecture.md | 4 +-
docsource/configuration.md | 22 +-
docsource/migration-v1-to-v2.md | 10 +-
integration-manifest.json | 7 +
16 files changed, 869 insertions(+), 27 deletions(-)
create mode 100644 CERTInext.Tests/V2ReissueTests.cs
diff --git a/CERTInext.IntegrationTests/V2SpecFollowupsLiveTests.cs b/CERTInext.IntegrationTests/V2SpecFollowupsLiveTests.cs
index 14f4f2e..4cb8b58 100644
--- a/CERTInext.IntegrationTests/V2SpecFollowupsLiveTests.cs
+++ b/CERTInext.IntegrationTests/V2SpecFollowupsLiveTests.cs
@@ -342,5 +342,138 @@ await client.RevokeOrderV2Async(Constants.ApiV2.FamilySsl, orderId,
}
}
}
+ // ---------------------------------------------------------------------------
+ // 0094 item 5: reissue via POST /ssl-certificates/{orderId}/reissue
+ // ---------------------------------------------------------------------------
+
+ // Orders the user keeps for manual tests (issue 0094 plan) and the plan's example order.
+ private static readonly string[] ProtectedOrders =
+ {
+ "7494553568", "9179852756", "7856523635", "8389474417", "3974245235", "6753965312", "8916985882",
+ "3793212921", "5461336494", "2431278382", "1663545354", "5948124826", "2669121252", "2519635936"
+ };
+
+ ///
+ /// Reissues one sandbox-generated SSL/TLS order through the plugin (EnrollmentType.Reissue,
+ /// V2ReissueRevokePrevious=false, so the source order is left valid) and reports the HTTP
+ /// outcome, the derived order id shape, its status and certificate state, and whether Track Order and
+ /// /reports/orders see it. The derived order is cleaned up (revoked or cancelled) afterwards.
+ /// Run once, only with the maintainer's approval: it creates a derived order.
+ /// Opt-in: CERTINEXT_V2_LIFECYCLE_REISSUE_ENDPOINT=1 and
+ /// CERTINEXT_V2_REISSUE_SOURCE_ORDER_ID=<issued, downloadable order>.
+ ///
+ [SkippableFact]
+ public async Task Reissue_V2_ReissueEndpoint_DerivedOrder_Report()
+ {
+ string sourceId = V2EnvHelper.GetEnv(_env, "CERTINEXT_V2_REISSUE_SOURCE_ORDER_ID");
+ Skip.If(!_v2Enabled || !Flag("CERTINEXT_V2_LIFECYCLE_REISSUE_ENDPOINT") || string.IsNullOrWhiteSpace(sourceId),
+ "Requires CERTINEXT_USE_V2_API + V2 credentials, CERTINEXT_V2_LIFECYCLE_REISSUE_ENDPOINT=1 and " +
+ "CERTINEXT_V2_REISSUE_SOURCE_ORDER_ID.");
+ ProtectedOrders.Should().NotContain(sourceId, "that order is reserved for manual tests");
+
+ using var client = BuildV2Client();
+ var source = await client.TrackOrderV2Async(Constants.ApiV2.FamilySsl, sourceId);
+ _output.WriteLine($"Source {sourceId}: status={source.Status}, certificateState={source.CertificateState}, " +
+ $"domain={source.Domain}, expiresAt={source.ExpiresAt}");
+ Skip.If(source.Status != Constants.ApiV2.StatusIssued, $"Source order is '{source.Status}', not issued.");
+ var cert = await client.DownloadCertificateV2Async(Constants.ApiV2.FamilySsl, sourceId);
+ _output.WriteLine($"Source certificate serial={cert.SerialNumber}");
+
+ var config = BuildV2Config();
+ config.V2ReissueRevokePrevious = false;
+ var reader = new SingleCertificateDataReader(cert.SerialNumber, sourceId,
+ DateTime.TryParse(source.ExpiresAt, out var exp) ? exp.ToUniversalTime() : DateTime.UtcNow.AddDays(60));
+ var plugin = new CERTInextCAPlugin(new CERTInextClient(config), reader, config);
+ var info = V2ProductPinning.Info(Constants.Products.DvSsl, V2PinMode.Pinned,
+ new Dictionary { ["PriorCertSN"] = cert.SerialNumber });
+
+ var restoreLogging = TestOutputLogging.RouteTo(_output);
+ EnrollmentResult result = null;
+ try
+ {
+ result = await plugin.Enroll(
+ csr: GenerateCsrPem(source.Domain),
+ subject: $"CN={source.Domain}",
+ san: new Dictionary { ["dns"] = new[] { source.Domain } },
+ productInfo: info,
+ requestFormat: RequestFormat.PKCS10,
+ enrollmentType: EnrollmentType.Reissue);
+ }
+ catch (Exception ex)
+ {
+ _output.WriteLine($"RESULT: Enroll(Reissue) threw: {ex.GetType().Name}: {ex.Message}");
+ throw;
+ }
+ finally
+ {
+ restoreLogging();
+ }
+
+ _output.WriteLine($"RESULT: CARequestID={result.CARequestID}, Status={result.Status}, Message={result.StatusMessage}");
+ string newId = result.CARequestID ?? string.Empty;
+ bool derived = newId.StartsWith(sourceId, StringComparison.Ordinal) && newId.Length > sourceId.Length;
+ _output.WriteLine(derived
+ ? $"Derived id shape: source id + '{newId.Substring(sourceId.Length)}' suffix"
+ : "Derived id shape: NOT derived from the source (fallback new order?)");
+
+ try
+ {
+ var tracked = await client.ResolveAndTrackOrderV2WithFamilyAsync(result.CARequestID);
+ _output.WriteLine($"Track {result.CARequestID}: family={tracked.family}, status={tracked.status.Status}, " +
+ $"certificateState={tracked.status.CertificateState}");
+
+ string today = DateTime.UtcNow.ToString("yyyy-MM-dd");
+ bool listed = false;
+ await foreach (var row in client.ListOrdersV2Async(today, today))
+ {
+ if (row.OrderNumber == result.CARequestID)
+ {
+ listed = true;
+ _output.WriteLine($"/reports/orders lists it: orderStatus={row.OrderStatus}, " +
+ $"certificateStatus={row.CertificateStatus}, productCode={row.ProductCode}");
+ }
+ }
+ if (!listed) _output.WriteLine("/reports/orders (today) does not list the derived order.");
+
+ var sourceAfter = await client.TrackOrderV2Async(Constants.ApiV2.FamilySsl, sourceId);
+ _output.WriteLine($"Source after reissue: status={sourceAfter.Status} (expected unchanged: issued)");
+ }
+ finally
+ {
+ if (!string.IsNullOrWhiteSpace(result.CARequestID) && result.CARequestID != sourceId)
+ await V2OrderCleanup.RevokeOrCancelAsync(client, result.CARequestID, "V2 integration test cleanup", _output.WriteLine);
+ }
+
+ result.Status.Should().NotBe((int)EndEntityStatus.FAILED, result.StatusMessage);
+ }
+ }
+
+ ///
+ /// Gateway database stand-in for the live reissue test: maps one serial to one order id, as the
+ /// gateway does for PriorCertSN.
+ ///
+ internal sealed class SingleCertificateDataReader : ICertificateDataReader
+ {
+ private readonly string _serial;
+ private readonly string _orderId;
+ private readonly DateTime? _expiry;
+
+ public SingleCertificateDataReader(string serial, string orderId, DateTime? expiry)
+ {
+ _serial = serial;
+ _orderId = orderId;
+ _expiry = expiry;
+ }
+
+ public Task GetRequestIDBySerialNumber(string serialNumber) =>
+ Task.FromResult(string.Equals(serialNumber, _serial, StringComparison.OrdinalIgnoreCase) ? _orderId : null);
+
+ public DateTime? GetExpirationDateByRequestId(string requestId) =>
+ string.Equals(requestId, _orderId, StringComparison.Ordinal) ? _expiry : null;
+
+ public Task GetStatusByRequestID(string requestId) => Task.FromResult(0);
+
+ public Task DoesCertExistForRequestID(string requestId) =>
+ Task.FromResult(string.Equals(requestId, _orderId, StringComparison.Ordinal));
}
}
diff --git a/CERTInext.Tests/V2ReissueTests.cs b/CERTInext.Tests/V2ReissueTests.cs
new file mode 100644
index 0000000..562683d
--- /dev/null
+++ b/CERTInext.Tests/V2ReissueTests.cs
@@ -0,0 +1,325 @@
+// 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,
+// 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.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Keyfactor.AnyGateway.Extensions;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using Keyfactor.PKI.Enums.EJBCA;
+using Moq;
+using WireMock.RequestBuilders;
+using WireMock.ResponseBuilders;
+using WireMock.Server;
+using Xunit;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests
+{
+ ///
+ /// V2 SSL/TLS reissue through POST /ssl-certificates/{orderId}/reissue, end to end with a real
+ /// against WireMock: the rekey body, V2ReissueRevokePrevious,
+ /// the derived (non-numeric) order id through Track, Download, GetSingleRecord and Sync, the 404/422
+ /// fallback to a new order, no fallback on 5xx, and the paths that never reissue.
+ ///
+ public class V2ReissueTests : IDisposable
+ {
+ private const string PriorOrderId = "1984857369";
+ private const string DerivedOrderId = "1984857369R001";
+ private const string NewOrderId = "5550001111";
+ private const string PriorSerial = "0A1B2C3D";
+ private const string SslPath = "/api/certinext/v2/ssl-certificates";
+ private const string ReissuePath = SslPath + "/" + PriorOrderId + "/reissue";
+
+ private readonly WireMockServer _server = WireMockServer.Start();
+
+ public V2ReissueTests()
+ {
+ _server.Given(Request.Create().WithPath("/oauth/token").UsingPost())
+ .RespondWith(Response.Create().WithStatusCode(200)
+ .WithHeader("Content-Type", "application/json")
+ .WithBody(MockCertificateData.V2TokenResponseJson()));
+ _server.Given(Request.Create().WithPath("/api/certinext/v2/catalog/products").UsingGet())
+ .RespondWith(Json(200, "[{\"productCode\":\"842\",\"productTypeID\":\"13\",\"productName\":\"DV SSL\",\"active\":true}]"));
+ foreach (var id in new[] { DerivedOrderId, NewOrderId })
+ {
+ _server.Given(Request.Create().WithPath($"{SslPath}/{id}").UsingGet())
+ .RespondWith(Json(200, MockCertificateData.V2TrackOrderIssuedJson(id)));
+ _server.Given(Request.Create().WithPath($"{SslPath}/{id}/certificate").UsingGet())
+ .RespondWith(Json(200, MockCertificateData.V2CertificateDownloadJson(id)));
+ }
+ _server.Given(Request.Create().WithPath(SslPath).UsingPost())
+ .RespondWith(Json(201, MockCertificateData.V2CreateOrderIssuedJson(NewOrderId)));
+ }
+
+ public void Dispose() => _server.Stop();
+
+ private static IResponseBuilder Json(int status, string body) =>
+ Response.Create().WithStatusCode(status).WithHeader("Content-Type", "application/json").WithBody(body);
+
+ private CERTInextConfig Config(bool revokePrevious = false) => new CERTInextConfig
+ {
+ ApiUrl = _server.Urls[0],
+ UseV2Api = true,
+ OAuthClientId = "my-v2-client",
+ OAuthClientSecret = "my-v2-secret",
+ RequestorName = "Test User",
+ RequestorEmail = "test@example.com",
+ SignerPlace = "New York",
+ PickupRetries = 0,
+ DcvEnabled = false,
+ V2ReissueRevokePrevious = revokePrevious
+ };
+
+ /// Reader that maps to expiring in .
+ private static Mock Reader(int expiresInDays = 200)
+ {
+ var reader = new Mock();
+ reader.Setup(r => r.GetRequestIDBySerialNumber(PriorSerial)).ReturnsAsync(PriorOrderId);
+ reader.Setup(r => r.GetExpirationDateByRequestId(PriorOrderId)).Returns(DateTime.UtcNow.AddDays(expiresInDays));
+ return reader;
+ }
+
+ private CERTInextCAPlugin Plugin(Mock reader, CERTInextConfig config = null)
+ {
+ config ??= Config();
+ return new CERTInextCAPlugin(new CERTInextClient(config), reader.Object, config);
+ }
+
+ private static EnrollmentProductInfo Info(string family = "ssl", string priorSn = PriorSerial, string code = "842")
+ {
+ var p = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["ProductCode"] = code,
+ ["ProductFamily"] = family,
+ ["DomainName"] = family == "private-pki" ? "intranet.example.com" : "example.com",
+ ["RenewalWindowDays"] = "30"
+ };
+ if (family == "private-pki") p["ProductVariant"] = "intranet-ssl";
+ if (priorSn != null) p["PriorCertSN"] = priorSn;
+ return new EnrollmentProductInfo { ProductID = "DV SSL", ProductParameters = p };
+ }
+
+ private static Task Enroll(CERTInextCAPlugin plugin, EnrollmentType type,
+ EnrollmentProductInfo info = null, Dictionary san = null) =>
+ plugin.Enroll(MockCertificateData.FakeCsrPem, "CN=example.com", san ?? new Dictionary(),
+ info ?? Info(), RequestFormat.PKCS10, type);
+
+ private void StubReissue(int status, string body) =>
+ _server.Given(Request.Create().WithPath(ReissuePath).UsingPost()).RespondWith(Json(status, body));
+
+ private List Posts(string path) =>
+ _server.LogEntries.Where(e => e.RequestMessage.Path == path
+ && e.RequestMessage.Method.Equals("POST", StringComparison.OrdinalIgnoreCase)).ToList();
+
+ private JsonElement ReissueBody() =>
+ JsonDocument.Parse(Posts(ReissuePath).Single().RequestMessage.Body!).RootElement;
+
+ [Fact]
+ public async Task Reissue_SendsRekeyGoldenBody_ReturnsDerivedOrderAndCertificate()
+ {
+ StubReissue(201, $"{{\"orderId\":\"{DerivedOrderId}\",\"status\":\"issued\"}}");
+
+ var result = await Enroll(Plugin(Reader()), EnrollmentType.Reissue);
+
+ result.CARequestID.Should().Be(DerivedOrderId);
+ result.Status.Should().Be((int)EndEntityStatus.GENERATED);
+ result.Certificate.Should().StartWith("-----BEGIN CERTIFICATE-----");
+ result.StatusMessage.Should().Contain($"Reissued from CERTInext order {PriorOrderId}");
+ Posts(SslPath).Should().BeEmpty("a successful reissue places no new order");
+
+ var expected = JsonSerializer.Serialize(new Dictionary
+ {
+ ["mode"] = "rekey",
+ ["reason"] = "Reissued via Keyfactor Command AnyCA REST Gateway.",
+ ["csr"] = MockCertificateData.FakeCsrPem,
+ ["revokePrevious"] = false
+ });
+ Posts(ReissuePath).Single().RequestMessage.Body.Should().Be(expected,
+ "rekey with the new CSR, no additionalDomains for a single-domain order, and no revokeReason when not revoking");
+ }
+
+ [Fact]
+ public async Task Reissue_RevokePreviousOn_SendsSuperseded()
+ {
+ StubReissue(201, $"{{\"orderId\":\"{DerivedOrderId}\",\"status\":\"issued\"}}");
+
+ var result = await Enroll(Plugin(Reader(), Config(revokePrevious: true)), EnrollmentType.Reissue);
+
+ result.CARequestID.Should().Be(DerivedOrderId);
+ var body = ReissueBody();
+ body.GetProperty("revokePrevious").GetBoolean().Should().BeTrue();
+ body.GetProperty("revokeReason").GetString().Should().Be("superseded");
+ result.StatusMessage.Should().Contain("revocation of the previous certificate (superseded) was requested");
+ }
+
+ [Fact]
+ public async Task Reissue_UccOrder_SendsAdditionalDomains()
+ {
+ StubReissue(201, $"{{\"orderId\":\"{DerivedOrderId}\",\"status\":\"issued\"}}");
+ // No ProductCode: extra DNS SANs make the order UCC, the same rule as create.
+ var info = Info(code: null);
+ info.ProductParameters.Remove("ProductCode");
+ var san = new Dictionary { ["dns"] = new[] { "example.com", "a.example.com", "b.example.com" } };
+
+ await Enroll(Plugin(Reader()), EnrollmentType.Reissue, info, san);
+
+ ReissueBody().GetProperty("additionalDomains").EnumerateArray().Select(e => e.GetString())
+ .Should().Equal("a.example.com", "b.example.com");
+ }
+
+ [Theory]
+ [InlineData(404, "Order not found under this account.")]
+ [InlineData(422, "Order is not in issued state.")]
+ public async Task Reissue_404Or422_FallsBackToNewOrder_AndSaysSo(int status, string detail)
+ {
+ StubReissue(status, MockCertificateData.V2ProblemDetailsJson(status, "Rejected", detail, "about:blank"));
+
+ var result = await Enroll(Plugin(Reader()), EnrollmentType.Reissue);
+
+ result.CARequestID.Should().Be(NewOrderId);
+ Posts(SslPath).Should().ContainSingle("the fallback places exactly one new order");
+ result.StatusMessage.Should().Contain($"CERTInext rejected the reissue of order {PriorOrderId}")
+ .And.Contain($"HTTP {status}").And.Contain("a new order was placed");
+ }
+
+ [Theory]
+ [InlineData(500)]
+ [InlineData(503)]
+ public async Task Reissue_5xx_DoesNotFallBackOrRetry_SaysDoNotResubmit(int status)
+ {
+ StubReissue(status, "{}");
+
+ Func act = () => Enroll(Plugin(Reader()), EnrollmentType.Reissue);
+
+ (await act.Should().ThrowAsync()).Which.Message.Should().Contain("do not resubmit");
+ Posts(ReissuePath).Should().ContainSingle("a reissue is never retried");
+ Posts(SslPath).Should().BeEmpty("the derived order may exist, so no new order is placed");
+ }
+
+ [Fact]
+ public async Task Reissue_201WithoutOrderId_Throws_DoNotResubmit()
+ {
+ StubReissue(201, "{\"status\":\"issued\"}");
+ Func act = () => Enroll(Plugin(Reader()), EnrollmentType.Reissue);
+ (await act.Should().ThrowAsync()).Which.Message.Should().Contain("do not resubmit");
+ Posts(SslPath).Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task Reissue_NoPriorCertSn_PlacesNewOrder_AndSaysSo()
+ {
+ var result = await Enroll(Plugin(Reader()), EnrollmentType.Reissue, Info(priorSn: null));
+
+ result.CARequestID.Should().Be(NewOrderId);
+ Posts(ReissuePath).Should().BeEmpty();
+ result.StatusMessage.Should().Contain("No prior certificate serial number was supplied");
+ }
+
+ [Fact]
+ public async Task Reissue_PriorOrderNotFoundInGateway_PlacesNewOrder()
+ {
+ var reader = new Mock();
+ reader.Setup(r => r.GetRequestIDBySerialNumber(PriorSerial)).ReturnsAsync((string)null);
+
+ var result = await Enroll(Plugin(reader), EnrollmentType.Reissue);
+
+ result.CARequestID.Should().Be(NewOrderId);
+ Posts(ReissuePath).Should().BeEmpty();
+ result.StatusMessage.Should().Contain("could not be found");
+ }
+
+ [Theory]
+ [InlineData(200, true)] // outside the 30-day window: V1's reissue branch
+ [InlineData(10, false)] // inside the window: a renewal, placed as a new order
+ [InlineData(-5, false)] // expired: a new order
+ public async Task RenewOrReissue_ReissuesOnlyOutsideRenewalWindow(int expiresInDays, bool expectReissue)
+ {
+ StubReissue(201, $"{{\"orderId\":\"{DerivedOrderId}\",\"status\":\"issued\"}}");
+
+ var result = await Enroll(Plugin(Reader(expiresInDays)), EnrollmentType.RenewOrReissue);
+
+ result.CARequestID.Should().Be(expectReissue ? DerivedOrderId : NewOrderId);
+ Posts(ReissuePath).Should().HaveCount(expectReissue ? 1 : 0);
+ }
+
+ [Theory]
+ [InlineData(EnrollmentType.New)]
+ [InlineData(EnrollmentType.Renew)]
+ public async Task NewAndRenew_NeverReissue(EnrollmentType type)
+ {
+ var result = await Enroll(Plugin(Reader()), type);
+ result.CARequestID.Should().Be(NewOrderId);
+ Posts(ReissuePath).Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task PrivatePki_Reissue_KeepsNewOrderBehaviour()
+ {
+ const string pkiPath = "/api/certinext/v2/private-pki-certificates";
+ _server.Given(Request.Create().WithPath(pkiPath).UsingPost())
+ .RespondWith(Json(201, MockCertificateData.V2CreateOrderPendingJson("pki_001")));
+ _server.Given(Request.Create().WithPath(pkiPath + "/pki_001/csr").UsingPut())
+ .RespondWith(Response.Create().WithStatusCode(200));
+ _server.Given(Request.Create().WithPath(pkiPath + "/pki_001").UsingGet())
+ .RespondWith(Json(200, MockCertificateData.V2TrackOrderPendingJson("pki_001")));
+
+ var result = await Enroll(Plugin(Reader()), EnrollmentType.Reissue, Info(family: "private-pki", code: "149"));
+
+ result.CARequestID.Should().Be("pki_001");
+ _server.LogEntries.Should().NotContain(e => e.RequestMessage.Path.EndsWith("/reissue"),
+ "the spec has no Private PKI reissue endpoint");
+ }
+
+ [Fact]
+ public async Task DerivedOrderId_RoundTripsThroughGetSingleRecordAndSync()
+ {
+ _server.Given(Request.Create().WithPath("/api/certinext/v2/reports/orders").UsingGet())
+ .RespondWith(Json(200, MockCertificateData.V2OrdersReportJson(1, 1, new[] { DerivedOrderId },
+ orderStatus: "Order Fulfilled", certificateStatus: "Certificate Downloaded")));
+ var plugin = Plugin(Reader());
+
+ var single = await plugin.GetSingleRecord(DerivedOrderId);
+ single.CARequestID.Should().Be(DerivedOrderId);
+ single.Status.Should().Be((int)EndEntityStatus.GENERATED);
+
+ var buffer = new BlockingCollection(100);
+ await plugin.Synchronize(buffer, null, true, CancellationToken.None);
+ var synced = buffer.ToArray().Should().ContainSingle().Subject;
+ synced.CARequestID.Should().Be(DerivedOrderId);
+ synced.Status.Should().Be((int)EndEntityStatus.GENERATED);
+ synced.Certificate.Should().StartWith("-----BEGIN CERTIFICATE-----");
+ }
+
+ [Fact]
+ public async Task ValidateCAConnectionInfo_RejectsNonBooleanReissueRevokePrevious()
+ {
+ var plugin = new CERTInextCAPlugin();
+ var info = new Dictionary
+ {
+ ["ApiUrl"] = "https://ca.example.com", ["UseV2Api"] = true,
+ ["OAuthClientId"] = "id", ["OAuthClientSecret"] = "secret", ["SignerPlace"] = "NY",
+ ["V2ReissueRevokePrevious"] = "sometimes"
+ };
+ Func act = () => plugin.ValidateCAConnectionInfo(info);
+ await act.Should().ThrowAsync().WithMessage("*V2ReissueRevokePrevious*must be true or false*");
+ }
+
+ [Fact]
+ public void Config_ReissueRevokePrevious_DefaultsFalse_AnnotatedAsBoolean()
+ {
+ new CERTInextConfig().V2ReissueRevokePrevious.Should().BeFalse();
+ var a = new CERTInextCAPlugin().GetCAConnectorAnnotations()[Constants.Config.V2ReissueRevokePrevious];
+ a.Type.Should().Be("Boolean");
+ a.DefaultValue.Should().Be(false);
+ }
+ }
+}
diff --git a/CERTInext/API/V2/CertificateRequestV2.cs b/CERTInext/API/V2/CertificateRequestV2.cs
index e922db9..9637da4 100644
--- a/CERTInext/API/V2/CertificateRequestV2.cs
+++ b/CERTInext/API/V2/CertificateRequestV2.cs
@@ -497,11 +497,11 @@ public class V2SubmitCsrRequest
public class V2RevokeRequest
{
///
- /// RFC 5280 string reason, kebab-case per the V2 spec. Valid values: unspecified,
- /// key-compromise, ca-compromise, affiliation-changed, superseded,
- /// cessation-of-operation, certificate-hold, privilege-withdrawn (plus
- /// aa-compromise on the signature-certificates / private-pki-certificates
- /// endpoints). Sending camelCase gets HTTP 400.
+ /// RFC 5280 string reason, kebab-case per the V2 spec. The 2.0.0 SSL/TLS enum: unspecified,
+ /// key-compromise, ca-compromise, affiliation-changed, superseded, cessation-of-operation,
+ /// certificate-hold, remove-from-crl, aa-compromise (no privilege-withdrawn). Live rejects
+ /// several of these; see CERTInextCAPlugin.ResolveRejectedRevokeReasonFallback.
+ /// Sending camelCase gets HTTP 400.
///
[JsonPropertyName("reason")]
public string Reason { get; set; } = "unspecified";
@@ -510,6 +510,39 @@ public class V2RevokeRequest
public string Note { get; set; }
}
+ ///
+ /// Request body for POST /api/certinext/v2/ssl-certificates/{orderId}/reissue (spec
+ /// ReissueRequest; SSL/TLS only). The response is a new order derived from the original
+ /// (1984857369 → 1984857369R001); the original order is left as it is.
+ ///
+ public class V2ReissueRequest
+ {
+ /// rekey (new CSR replaces the certificate) or update-sans. Required.
+ [JsonPropertyName("mode")]
+ public string Mode { get; set; } = Constants.ApiV2.ReissueModeRekey;
+
+ /// Free text, required, at most 500 characters.
+ [JsonPropertyName("reason")]
+ public string Reason { get; set; }
+
+ [JsonPropertyName("csr")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string Csr { get; set; }
+
+ /// UCC SANs beyond the primary domain, same rule as create's additionalDomains.
+ [JsonPropertyName("additionalDomains")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public System.Collections.Generic.List AdditionalDomains { get; set; }
+
+ [JsonPropertyName("revokePrevious")]
+ public bool RevokePrevious { get; set; }
+
+ /// Sent only with ; the plugin uses superseded.
+ [JsonPropertyName("revokeReason")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string RevokeReason { get; set; }
+ }
+
///
/// Request body for POST /api/certinext/v2/{family}-certificates/{orderId}/cancel
/// ("Cancel Order"). The SSL spec entry marks reason as "required free-text.
diff --git a/CERTInext/API/V2/CertificateResponseV2.cs b/CERTInext/API/V2/CertificateResponseV2.cs
index 4c4b997..d01631a 100644
--- a/CERTInext/API/V2/CertificateResponseV2.cs
+++ b/CERTInext/API/V2/CertificateResponseV2.cs
@@ -370,6 +370,26 @@ public class V2DcvVerifyResponse
public string VerifiedAt { get; set; }
}
+ ///
+ /// Result of POST /ssl-certificates/{orderId}/reissue that the caller can act on. A created
+ /// reissue carries the derived order in ; a 404 (order not found) or 422
+ /// (business rule, e.g. original not issued) is = false with the CA's
+ /// reason, and the caller may place a new order instead. A 5xx or no response is never
+ /// returned here: the client throws, because the derived order may exist.
+ ///
+ public class V2ReissueOutcome
+ {
+ public bool Created { get; set; }
+
+ /// The derived order (orderId, status) when .
+ public V2CreateOrderResponse Order { get; set; }
+
+ public int HttpStatus { get; set; }
+
+ /// CA reason for a rejection, already passed through LogSanitizer.SanitizeCaText.
+ public string Detail { get; set; }
+ }
+
///
/// RFC 7807 Problem Details error response from the V2 API.
/// Content-Type: application/problem+json
diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs
index 17098c7..22533bd 100644
--- a/CERTInext/CERTInextCAPlugin.cs
+++ b/CERTInext/CERTInextCAPlugin.cs
@@ -610,6 +610,14 @@ public async Task ValidateCAConnectionInfo(Dictionary connection
Constants.Config.V2InlineCsr);
}
+ // V2ReissueRevokePrevious is a Boolean (V2 only): a malformed value must not silently decide
+ // whether a reissue revokes the previous certificate.
+ if (connectionInfo.TryGetValue(Constants.Config.V2ReissueRevokePrevious, out object reissueRevokeObj)
+ && reissueRevokeObj != null && !IsBooleanSettingValue(reissueRevokeObj))
+ {
+ errors.Add($"'{Constants.Config.V2ReissueRevokePrevious}' must be true or false. Got: '{reissueRevokeObj}'.");
+ }
+
// SslComSubmitOrganization is a Boolean (V2 only), validated like V2InlineCsr. It accepts a
// third party's subscriber agreement on the organization's behalf, so a malformed value must
// not silently fall back to anything.
@@ -1744,6 +1752,30 @@ private async Task EnrollV2Async(
Dictionary san,
EnrollmentParams ep,
EnrollmentType enrollmentType)
+ {
+ var reissue = new V2ReissueContext();
+ var result = await EnrollV2CoreAsync(csr, subject, san, ep, enrollmentType, reissue);
+ // A reissue (or a reissue that fell back to a new order) says so in Command's result.
+ if (result != null && !string.IsNullOrWhiteSpace(reissue.Note))
+ result.StatusMessage = string.IsNullOrWhiteSpace(result.StatusMessage)
+ ? reissue.Note
+ : result.StatusMessage.TrimEnd() + " " + reissue.Note;
+ return result;
+ }
+
+ /// What the V2 reissue step decided, for the result message.
+ private sealed class V2ReissueContext
+ {
+ public string Note { get; set; }
+ }
+
+ private async Task EnrollV2CoreAsync(
+ string csr,
+ string subject,
+ Dictionary san,
+ EnrollmentParams ep,
+ EnrollmentType enrollmentType,
+ V2ReissueContext reissue)
{
_logger.MethodEntry(LogLevel.Debug);
_logger.LogInformation(
@@ -2302,8 +2334,28 @@ await EnsureSslComOrganizationValidationAsync(
_config.OrganizationNumber, sslProductVariant.ToUpperInvariant());
}
- V2CreateOrderResponse createResp;
- if (isPrivatePki)
+ // V2 SSL/TLS reissue: POST /ssl-certificates/{prior}/reissue creates a derived order that
+ // carries the CSR; the post-create flow below (track, agreement, DCV, pickup) then runs on
+ // that order exactly as on a new one. No prior order, or a 404/422 from CERTInext, places a
+ // new order instead (said so in the result). A 5xx or lost response throws from the client
+ // with a do-not-resubmit message: the derived order may exist, so nothing is placed.
+ V2CreateOrderResponse createResp = null;
+ if (!isPrivatePki)
+ {
+ string priorOrderId = await ResolveV2ReissueSourceOrderAsync(enrollmentType, ep, subject, reissue);
+ if (priorOrderId != null)
+ {
+ createResp = await TryReissueV2Async(priorOrderId, csr, additionalDomains, reissue);
+ if (createResp != null)
+ inlineCsr = true; // the reissue body carried the CSR
+ }
+ }
+
+ if (createResp != null)
+ {
+ // Reissued; nothing to place.
+ }
+ else if (isPrivatePki)
{
// Spec "Private PKI Certificates" body: no productVariant / organization /
// certificate / agreement blocks — "Private PKI has no DCV, no organization
@@ -2646,6 +2698,131 @@ internal static bool IsTransportLevelCsrFailure(Exception ex)
return status < 400 || status >= 500;
}
+ ///
+ /// Decides whether a V2 SSL/TLS enrollment is a reissue and, if so, returns the prior order's
+ /// CARequestID. A reissue is , or
+ /// whose prior certificate is unexpired and expires
+ /// after RenewalWindowDays (V1's reissue branch; inside the window it is a renewal, which
+ /// V2 places as a new order). Returns null when this is not a reissue or the prior order can't
+ /// be resolved from PriorCertSN; the latter records a note for the result message.
+ ///
+ private async Task ResolveV2ReissueSourceOrderAsync(
+ EnrollmentType enrollmentType, EnrollmentParams ep, string subject, V2ReissueContext reissue)
+ {
+ if (enrollmentType != EnrollmentType.Reissue && enrollmentType != EnrollmentType.RenewOrReissue)
+ return null;
+
+ string priorCertSn = ep.PriorCertSn;
+ // SOC2 CC6.1: a read against the gateway's certificate inventory is logged at Information.
+ _logger.LogInformation(
+ "V2 reissue probe — read PriorCertSN from EnrollmentProductInfo. EnrollmentType={EnrollmentType}, " +
+ "Subject={Subject}, PriorCertSN={PriorCertSN}, RenewalWindowDays={WindowDays}",
+ enrollmentType, LogSanitizer.Strip(subject), priorCertSn ?? "(none)", ep.RenewalWindowDays);
+
+ const string fallback = "a new order was placed instead of a reissue";
+ if (string.IsNullOrWhiteSpace(priorCertSn) || _certificateDataReader == null)
+ {
+ if (enrollmentType == EnrollmentType.Reissue)
+ reissue.Note = $"No prior certificate serial number was supplied, so {fallback}.";
+ return null;
+ }
+
+ string priorOrderId;
+ try
+ {
+ priorOrderId = await _certificateDataReader.GetRequestIDBySerialNumber(priorCertSn);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex,
+ "V2 reissue: could not resolve the CARequestID for serial '{SN}'; placing a new order.", priorCertSn);
+ priorOrderId = null;
+ }
+ if (string.IsNullOrWhiteSpace(priorOrderId))
+ {
+ reissue.Note = $"The prior certificate's order could not be found, so {fallback}.";
+ return null;
+ }
+
+ if (enrollmentType == EnrollmentType.RenewOrReissue)
+ {
+ DateTime? expiry = null;
+ try
+ {
+ expiry = _certificateDataReader.GetExpirationDateByRequestId(priorOrderId);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "V2 reissue: could not read the expiry of '{Id}'; placing a new order.", priorOrderId);
+ }
+ DateTime now = DateTime.UtcNow;
+ bool isReissue = expiry.HasValue && expiry.Value > now.AddDays(ep.RenewalWindowDays);
+ // SOX CC6.2 / SOC2 CC7.2: the decision that reuses an existing CA record.
+ _logger.LogInformation(
+ "V2 renew-or-reissue evaluation complete. PriorCARequestID={PriorId}, CertExpiry={Expiry}, " +
+ "RenewalWindowDays={Window}, Reissue={Reissue}",
+ priorOrderId, expiry.HasValue ? expiry.Value.ToString("O") : "(unknown)", ep.RenewalWindowDays, isReissue);
+ if (!isReissue)
+ return null;
+ }
+
+ return priorOrderId;
+ }
+
+ ///
+ /// Calls the SSL/TLS reissue endpoint in rekey mode for .
+ /// Returns the derived order, or null (with a note) when CERTInext answers 404/422 so the caller
+ /// places a new order. With V2ReissueRevokePrevious on, the revoke intent is audit-logged
+ /// before the call. Exceptions (5xx, no response, auth) propagate: the derived order may exist.
+ ///
+ private async Task TryReissueV2Async(
+ string priorOrderId, string csr, List additionalDomains, V2ReissueContext reissue)
+ {
+ bool revokePrevious = _config.V2ReissueRevokePrevious;
+ var request = new V2ReissueRequest
+ {
+ Mode = Constants.ApiV2.ReissueModeRekey,
+ Reason = Constants.ApiV2.ReissueReason,
+ Csr = csr,
+ AdditionalDomains = additionalDomains,
+ RevokePrevious = revokePrevious,
+ RevokeReason = revokePrevious ? Constants.RevocationReasonV2.Superseded : null
+ };
+
+ if (revokePrevious)
+ {
+ // SOX / SOC2 CC7.3: the revoke intent is recorded before the call, as RevokeV2Async does.
+ _logger.LogInformation(
+ "Revocation V2 attempt started (via reissue revokePrevious). CARequestID={Id}, " +
+ "V2Reason={V2Reason}, Setting={Setting}=true",
+ priorOrderId, Constants.RevocationReasonV2.Superseded, Constants.Config.V2ReissueRevokePrevious);
+ }
+ _logger.LogInformation(
+ "V2 reissue started. PriorCARequestID={PriorId}, Mode={Mode}, RevokePrevious={RevokePrevious}, " +
+ "AdditionalDomainCount={Count}",
+ priorOrderId, request.Mode, revokePrevious, additionalDomains?.Count ?? 0);
+
+ var outcome = await _client.ReissueOrderV2Async(priorOrderId, request);
+ if (outcome?.Created == true)
+ {
+ _logger.LogInformation(
+ "V2 reissue created a derived order. PriorCARequestID={PriorId}, NewOrderId={NewId}, " +
+ "Status={Status}, PreviousRevocationRequested={RevokePrevious}",
+ priorOrderId, outcome.Order.OrderId, outcome.Order.Status, revokePrevious);
+ reissue.Note = $"Reissued from CERTInext order {priorOrderId}" +
+ (revokePrevious ? "; revocation of the previous certificate (superseded) was requested." : ".");
+ return outcome.Order;
+ }
+
+ _logger.LogWarning(
+ "V2 reissue of order {PriorId} was rejected (HttpStatus={Status}): {Detail}. Placing a new order instead.",
+ priorOrderId, outcome?.HttpStatus ?? 0, outcome?.Detail ?? "(no detail)");
+ reissue.Note = $"CERTInext rejected the reissue of order {priorOrderId} " +
+ $"(HTTP {outcome?.HttpStatus ?? 0}: {outcome?.Detail ?? "no detail"}), so a new order was placed " +
+ (revokePrevious ? "and the previous certificate was not revoked." : "instead.");
+ return null;
+ }
+
///
/// SSL.com organization-validation pre-check for a V2 OV or EV SSL/TLS order. Advisory only:
/// every failure and every not-validated outcome is logged as a warning and the order is
diff --git a/CERTInext/CERTInextCAPluginConfig.cs b/CERTInext/CERTInextCAPluginConfig.cs
index a7eee92..35cedee 100644
--- a/CERTInext/CERTInextCAPluginConfig.cs
+++ b/CERTInext/CERTInextCAPluginConfig.cs
@@ -461,6 +461,18 @@ public static Dictionary GetCAConnectorAnnotations()
DefaultValue = true,
Type = "Boolean"
},
+ [Constants.Config.V2ReissueRevokePrevious] = new PropertyConfigInfo
+ {
+ Comments = "OPTIONAL (V2 mode only, SSL/TLS reissue): a V2 SSL/TLS reissue goes through CERTInext's " +
+ "reissue endpoint, which creates a new order derived from the original (for example " +
+ "1984857369R001). When true, that call also asks CERTInext to revoke the previous " +
+ "certificate with reason superseded; the intent is audit-logged before the call. When " +
+ "false (the default), the previous certificate stays valid until it expires or is " +
+ "revoked separately. Ignored when UseV2Api is false. Default: false.",
+ Hidden = false,
+ DefaultValue = false,
+ Type = "Boolean"
+ },
[Constants.Config.SslComSubmitOrganization] = new PropertyConfigInfo
{
Comments = "OPTIONAL (V2 mode only): before an OV or EV SSL/TLS order, the plugin checks " +
@@ -1027,6 +1039,14 @@ public class CERTInextConfig
[JsonPropertyName("V2InlineCsr")]
public bool V2InlineCsr { get; set; } = true;
+ ///
+ /// OPTIONAL (V2 mode only), default false. When true, a V2 SSL/TLS reissue
+ /// (POST /ssl-certificates/{orderId}/reissue) sends revokePrevious: true with
+ /// revokeReason: superseded, so CERTInext revokes the certificate being reissued.
+ ///
+ [JsonPropertyName("V2ReissueRevokePrevious")]
+ public bool V2ReissueRevokePrevious { get; set; }
+
///
/// V2 only. When true, an OV/EV SSL/TLS enrollment whose organization SSL.com has not
/// validated (and that is ready to submit) submits it to SSL.com, which accepts SSL.com's
diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs
index c20c87f..83a0418 100644
--- a/CERTInext/Client/CERTInextClient.cs
+++ b/CERTInext/Client/CERTInextClient.cs
@@ -1957,6 +1957,76 @@ public async Task VerifyDomainDcvV2Async(
return new V2DomainDcvVerifyResult { Verified = true, ValidTill = validTill };
}
+ ///
+ public async Task ReissueOrderV2Async(
+ string orderId, V2ReissueRequest request, CancellationToken ct = default)
+ {
+ Logger.MethodEntry(LogLevel.Trace);
+ if (request == null) throw new ArgumentNullException(nameof(request));
+ EnsureV2Client();
+ string path = BuildV2OrderPath(Constants.ApiV2.FamilySsl, orderId) + "/reissue";
+ var req = await BuildV2RequestAsync(path, Method.Post, ct, Guid.NewGuid().ToString());
+ string json = JsonSerializer.Serialize(request, GetJsonOptions());
+ if (Logger.IsEnabled(LogLevel.Trace))
+ Logger.LogTrace("ReissueOrderV2Async request payload: {Payload}",
+ ApplyLoggingRedaction(json, _config.LogSensitiveRequestData));
+ req.AddJsonBody(json);
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+ var resp = await _httpV2.ExecuteAsync(req, ct);
+ sw.Stop();
+ int status = (int)resp.StatusCode;
+ Logger.LogInformation(
+ "CERTInext V2 API call: Method=POST, Path={Path}, HttpStatus={Status}, LatencyMs={Latency}",
+ path, status, sw.ElapsedMilliseconds);
+ if (Logger.IsEnabled(LogLevel.Trace))
+ Logger.LogTrace("ReissueOrderV2Async response: {Body}",
+ ApplyLoggingRedaction(resp.Content, _config.LogSensitiveRequestData));
+
+ // Same rule as the inline-CSR create: the reissue carries the CSR and creates an order, so
+ // a lost response may hide a derived order that issues. Never retried or replaced.
+ if (!resp.IsSuccessful && (status == 0 || status >= 500))
+ {
+ LogV2ApiFailure("V2 reissue order", resp, LogLevel.Warning);
+ throw new Exception(
+ $"CERTInext did not return a usable response to the V2 reissue of order '{orderId}' " +
+ (status == 0 ? "(no HTTP response)" : $"(HTTP {status})") +
+ ". If the reissue was created it will be imported by the next synchronization — do not " +
+ "resubmit immediately. See gateway logs for details.");
+ }
+
+ if (status == 404 || status == 422)
+ {
+ LogV2ApiFailure("V2 reissue order", resp, LogLevel.Warning);
+ Logger.MethodExit(LogLevel.Trace);
+ return new V2ReissueOutcome
+ {
+ Created = false,
+ HttpStatus = status,
+ Detail = ExtractV2ErrorMessage(resp.Content, "V2 reissue order", _config.LogSensitiveRequestData)
+ };
+ }
+
+ ThrowOnV2Failure(resp, "V2 reissue order");
+ V2CreateOrderResponse order = null;
+ try
+ {
+ order = DeserializeV2OrThrow(resp, "V2 reissue order");
+ }
+ catch (Exception deserEx)
+ {
+ Logger.LogWarning(deserEx,
+ "V2 reissue returned HTTP {Status} but the body could not be read; the derived order may exist.",
+ status);
+ }
+ if (string.IsNullOrWhiteSpace(order?.OrderId))
+ throw new Exception(
+ $"CERTInext accepted the V2 reissue of order '{orderId}' but returned no readable order id. " +
+ "The derived order was most likely created and will be imported by the next synchronization — " +
+ "do not resubmit immediately. See gateway logs for details.");
+ Logger.MethodExit(LogLevel.Trace);
+ return new V2ReissueOutcome { Created = true, HttpStatus = status, Order = order };
+ }
+
///
/// Parses a GET /domains body into the row for , or null.
/// Rows come under content (seen live, same envelope as /reports/orders); a bare
diff --git a/CERTInext/Client/ICERTInextClient.cs b/CERTInext/Client/ICERTInextClient.cs
index 9736f73..ef532b9 100644
--- a/CERTInext/Client/ICERTInextClient.cs
+++ b/CERTInext/Client/ICERTInextClient.cs
@@ -424,5 +424,15 @@ Task SubmitSslComValidationAsync(
///
Task VerifyDomainDcvV2Async(
string domainId, string optionId, CancellationToken ct = default);
+
+ ///
+ /// Reissues an SSL/TLS certificate via POST /api/certinext/v2/ssl-certificates/{orderId}/reissue,
+ /// which creates a new order derived from . A 404 or 422 is returned as
+ /// Created=false with the CA's reason. A 5xx, no response, or an unreadable 2xx body throws
+ /// with a do-not-resubmit message (the derived order may exist); 400/401/403 throw as other V2
+ /// calls do. Never retried.
+ ///
+ Task ReissueOrderV2Async(
+ string orderId, V2ReissueRequest request, CancellationToken ct = default);
}
}
diff --git a/CERTInext/Constants.cs b/CERTInext/Constants.cs
index 3ce5373..61a192f 100644
--- a/CERTInext/Constants.cs
+++ b/CERTInext/Constants.cs
@@ -91,6 +91,8 @@ public static class Config
// V1 and V2: sync does not emit pending or issued orders younger than this (issue 0090).
public const string SyncRecentOrderGraceSeconds = "SyncRecentOrderGraceSeconds";
public const string V2InlineCsr = "V2InlineCsr"; // opt-in: SSL/TLS sends the CSR inline on create (V2 only)
+ // V2 SSL/TLS reissue via POST /ssl-certificates/{id}/reissue: also revoke the prior certificate (default off).
+ public const string V2ReissueRevokePrevious = "V2ReissueRevokePrevious";
// V2 OV/EV orders: opt-in submission of the organization to SSL.com for validation, which
// accepts SSL.com's subscriber agreement on the organization's behalf (default off).
public const string SslComSubmitOrganization = "SslComSubmitOrganization";
@@ -498,6 +500,11 @@ public static class ApiV2
// DcvDetails/domain-row validatedBy value for SSL.com; absent means emSign.
public const string ValidatedBySslCom = "SSLCOM";
public const string DcvMethodDnsTxt = "dns-txt";
+
+ // POST /ssl-certificates/{orderId}/reissue (ReissueRequest.mode). The plugin always rekeys:
+ // Command sends a new CSR and gives no signal that only the SANs changed.
+ public const string ReissueModeRekey = "rekey";
+ public const string ReissueReason = "Reissued via Keyfactor Command AnyCA REST Gateway.";
// SSL.com caStatus on GET .../dcv once the domain is validated.
public const string SslComCaStatusValidated = "VALIDATED";
diff --git a/CERTInext/Models/EnrollmentParams.cs b/CERTInext/Models/EnrollmentParams.cs
index 5cc2373..bdca022 100644
--- a/CERTInext/Models/EnrollmentParams.cs
+++ b/CERTInext/Models/EnrollmentParams.cs
@@ -99,6 +99,12 @@ private string GetExplicitProductCode()
///
public string DomainName => GetString(Constants.EnrollmentParam.DomainName, string.Empty);
+ ///
+ /// Serial number of the certificate being renewed or reissued, which Command injects as
+ /// PriorCertSN on renewal flows; null when absent.
+ ///
+ public string PriorCertSn => GetString("PriorCertSN", null);
+
///
/// Per-template subscriber agreement signer name.
/// Falls back to the connector-level RequestorName if empty.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c157ca0..8df42cd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@
- feat(v2): An SSL/TLS order that Track Order shows at `pending-agreement` gets the Subscriber Agreement accepted via `POST /ssl-certificates/{orderId}/agreement`, using the same signer values as create: at most once per order per enroll call or sync pass, audit-logged first, and a failure only warns.
- feat(v2): The create call no longer sends `X-Product-Code` unless the template sets `ProductCode` (SSL/TLS and Private PKI): CERTInext resolves the product from the variant, wildcard shape and `additionalDomains`, and the response's `resolvedProductCode` is logged. An ambiguous catalog match is no longer an error, Private PKI no longer needs `ProductCode`, and without one a wildcard is detected from the `*.` domain and a UCC order from the template's product or extra DNS SANs.
- feat(v2): V2 SSL/TLS enrollment sends the CSR inline in one create call (`V2InlineCsr`, default `true`). Setting it to `false` selects create + Submit CSR, which fails for a domain the account has already validated; Private PKI stays two-call.
+- feat(v2): An SSL/TLS reissue (`Reissue`, or `RenewOrReissue` outside `RenewalWindowDays`) calls `POST /ssl-certificates/{orderId}/reissue` in `rekey` mode with the new CSR, and the derived order (for example `1984857369R001`) becomes the new `CARequestID`. New setting `V2ReissueRevokePrevious` (default `false`) also revokes the previous certificate as `superseded`, audit-logged first. A missing prior order or a 404/422 places a new order instead and says so; a 5xx or lost response fails with "do not resubmit". Not verified live: the sandbox no longer generates certificates.
- feat(v2): Synchronize reads `/reports/orders` and honors `IgnoreExpired`.
- feat(v2): Incremental syncs look back `V2SyncLookbackHours` (default 72).
- feat(dcv): DNS-01 domain validation for V2 SSL/TLS orders, including every SAN on a UCC order.
@@ -50,7 +51,7 @@
- V2 `ProductVariant` is derived from the selected product when unset.
- V2 revoke reasons CERTInext rejects are retried with a substitute (see Revocation Reason Codes in the README). `privilege-withdrawn`, which the 2.0.0 V2 spec dropped from the SSL/TLS enum and CERTInext rejected live on 2026-11-13, is still sent first and now falls back to `cessation-of-operation`. The retry also recognizes CERTInext's newer 422 wording, "Revocation reason {reason} is not allowed."
- V2 `expired` orders are reported as issued, and revoking one is sent to the CA.
-- V2 renew and reissue place a new order and don't revoke the original.
+- V2 renewals place a new order and don't revoke the original. V2 SSL/TLS reissues now go through the reissue endpoint and produce derived order ids such as `1984857369R001`, which aren't numeric. Scripts or reports that assume numeric `CARequestID`s need updating. Set `V2ReissueRevokePrevious` to `true` if a reissue should revoke the previous certificate.
- On V2 accounts whose domains SSL.com validates, DCV now publishes SSL.com's record name and value, not `DcvTxtRecordTemplate`. The DNS provider plugin must be able to write those names (for example `_pki-validation.`).
- Upgrading from 1.0.0 also brings the changes in 1.0.1 and 1.0.2 above. For example, V1 now submits non-DNS SANs; set `SubmitNonDnsSans` to `false` to drop them.
@@ -60,6 +61,7 @@
- Document Signer (`ProductFamily=signature`) enrollment isn't supported under V2.
- Per-SAN DCV on UCC orders, DCV for a never-validated domain, and wildcard DCV aren't validated end to end against the CA.
- Rolling a connector back to V1 after it has issued V2 certificates isn't validated.
+- V2 SSL/TLS reissue through the reissue endpoint isn't verified live (the sandbox stopped generating certificates), and it always uses `rekey`, never `update-sans`.
- V1 `IgnoreExpired` isn't applied yet; it works on V2.
- V1 `AuthMode=OAuth` isn't supported: the live V1 API ignores the bearer token, so V1 needs an access key.
- The V2 incremental sync doesn't yet skip orders the gateway already holds unchanged, as the V1 incremental sync does from 1.0.2, so it re-reads each order listed.
diff --git a/README.md b/README.md
index b338426..c30c04a 100644
--- a/README.md
+++ b/README.md
@@ -223,7 +223,7 @@ In the Keyfactor Command Management Portal, navigate to **Certificate Templates*
| `AutoApprove` | Optional | Boolean | **Currently has no effect** — reserved for future use. The plugin does not call any approval endpoint against CERTInext regardless of this setting. | `false` |
| `RequesterName` | Optional | String | Overrides the connector `RequestorName` for this template when non-blank. Never derived from the certificate subject. | `Keyfactor Automation` |
| `RequesterEmail` | Optional | String | Overrides the connector `RequestorEmail` for this template when non-blank. Never derived from the certificate subject. | `pki-admin@example.com` |
-| `RenewalWindowDays` | Optional | Number | V1 only. Number of days before certificate expiration within which a renewal is attempted instead of a reissue. V2 places a new order for every renewal and reissue. Default: `90`. | `90` |
+| `RenewalWindowDays` | Optional | Number | Number of days before certificate expiration within which a renewal is attempted instead of a reissue. On V2 it decides only whether `RenewOrReissue` uses the SSL/TLS reissue endpoint (outside the window) or places a new order (inside it). Default: `90`. | `90` |
| `KeyType` | Optional | String | Informational. The CSR decides the key algorithm. CERTInext accepts only RSA 2048/3072/4096 and ECC P-256/P-384. | `RSA2048`, `RSA3072`, `RSA4096`, `EC256`, `EC384` |
| `DomainName` | Optional | String | V2 only: primary domain name for SSL/TLS orders (for Private PKI, the primary hostname). If omitted, the plugin uses the CSR subject `CN`. V1 always uses the CSR subject `CN`. | `example.com` |
| `SignerName` | Optional | String | Per-template override for the subscriber agreement signer name. When omitted, defaults to the connector-level `RequestorName` (V2: the requestor name). If no name resolves, V1 sends `Keyfactor Gateway` and logs a warning. | `Jane Smith` |
@@ -561,6 +561,7 @@ V2 mode reuses the connector's `ApiUrl`, `OAuthClientId`, and `OAuthClientSecret
| `SslComSubmitOrganization` | Optional | OV/EV on V2. `true` lets the plugin submit an unvalidated organization to SSL.com, which **accepts SSL.com's subscriber agreement on the organization's behalf**; see [SSL.com organization validation](#sslcom-organization-validation). Default: `false`. |
| `SslComAdminEmail` | Optional | Administrator email sent as `adminEmail` on that submission. Blank omits it. Example: `pki-admin@example.com`. |
| `V2InlineCsr` | Optional | SSL/TLS only. `true` (the default) sends the CSR inline in the create call (a single `POST /ssl-certificates`), as a direct API integration does. `false` creates the order and then submits the CSR; that two-call flow fails for a domain the account has already validated, because CERTInext approves the order at create and then rejects the CSR. A create that times out or returns a 5xx can leave an order that issues and is imported by the next sync rather than cancelled, and the create call can hold about 20 seconds until issuance. Private PKI always uses two calls. Default: `true`. |
+| `V2ReissueRevokePrevious` | Optional | SSL/TLS reissue only. `true` makes the reissue call also revoke the previous certificate with reason `superseded`; the intent is audit-logged first. `false` leaves the previous certificate valid. See [V2 reissue](#v2-reissue). Default: `false`. |
- **Why a lookback:** the API's `from`/`to` filter may bracket either the order-placement date or the issuance date. The window keeps an order created before the last sync but issued afterward (for example a slow DCV order) from being missed.
@@ -682,7 +683,22 @@ CERTInext can report an order as `issued` before the certificate exists: Downloa
- **Log fields:** the lines include CERTInext's `orderState` and `certificateState` text (for example `Approved by System` versus `Certificate Generated`) for diagnosis. Their vocabulary isn't documented, so the plugin never acts on it.
- **Any other download error** is still logged as a warning.
-V2 has no *renew* endpoint. CERTInext does document a `/reissue` endpoint (`mode: rekey|update-sans`, with optional `revokePrevious`/`revokeReason`), but the plugin does not use it — every enrollment type (New, Reissue, Renew, RenewOrReissue) places a fresh V2 order, and the prior order/certificate is left issued rather than auto-revoked.
+#### V2 reissue
+
+V2 has no *renew* endpoint, so `New`, `Renew`, and a `RenewOrReissue` inside the renewal window place a new order. An SSL/TLS reissue uses `POST /ssl-certificates/{orderId}/reissue` instead:
+
+1. **When.** The enrollment type is `Reissue`, or `RenewOrReissue` with a prior certificate that hasn't expired and expires after `RenewalWindowDays` (the same boundary V1 uses).
+2. **Which order.** The plugin finds the prior order from `PriorCertSN` in the gateway database.
+3. **What it sends.** `mode: rekey` with the new CSR, plus `additionalDomains` for a UCC order (the same SAN rules as create), and the reason "Reissued via Keyfactor Command AnyCA REST Gateway." The plugin always rekeys, because Command supplies a new CSR and gives no signal that only the SANs changed. It never uses `update-sans`.
+4. **What comes back.** CERTInext creates a new order derived from the original, for example `1984857369R001`. That id is not numeric. It becomes the new certificate's `CARequestID`, and the usual post-create steps run on it: track, Subscriber Agreement, DCV, and pickup. The original order is left as it is.
+5. **The previous certificate.** It stays valid unless `V2ReissueRevokePrevious` is `true`. Then the reissue also asks CERTInext to revoke it with reason `superseded`, and the intent is audit-logged before the call.
+
+**Fallbacks:**
+- **A new order is placed instead,** and the enrollment result says so, in three cases: no `PriorCertSN`, no matching order in the gateway, or a 404 or 422 from the reissue call (for example, the original is not `issued`).
+- **Nothing is placed on a 5xx or a lost response.** The derived order may already exist. The enrollment fails with a "do not resubmit" message, as an order create does, and the next sync imports the order if it was created.
+- **Private PKI and Document Signer** have no reissue endpoint in the spec, so they keep placing a new order.
+
+**Not verified live:** the 2026-11 sandbox no longer generates certificates. Only its unit and WireMock tests cover this path.
### V2 Revocation Reason Handling
@@ -878,10 +894,11 @@ pointing a production template at the V2 connector. At minimum, confirm:
### Renewals and Reissuance
-CERTInext V2 has no *renew* endpoint. It documents a `/reissue` endpoint (`mode: rekey|update-sans`, with optional `revokePrevious` and `revokeReason`), which the plugin does not use.
+CERTInext V2 has no *renew* endpoint, but SSL/TLS certificates have a `/reissue` endpoint, which the plugin uses for reissues (see [V2 reissue](#v2-reissue)).
-- **Every enrollment type places a new order.** `New`, `Renew`, `Reissue`, and `RenewOrReissue` all take the same path as a new enrollment, without V1's renewal-window logic.
-- **The prior order is untouched.** The old order and certificate stay issued and are not auto-revoked. Command links the old and new certificates through history only.
+- **Reissue uses the reissue endpoint.** An SSL/TLS `Reissue`, or a `RenewOrReissue` outside `RenewalWindowDays`, creates a derived order (`…R001`) from the original. If CERTInext refuses (404 or 422), or the prior order can't be found, a new order is placed instead.
+- **Renewals still place a new order.** This applies to `New`, `Renew`, a `RenewOrReissue` inside the window, and every Private PKI enrollment.
+- **The prior order is untouched by default.** Set `V2ReissueRevokePrevious` to `true` to have a reissue revoke the previous certificate (`superseded`). Renewals never revoke it. Command links the old and new certificates through history only.
- **Each renewal is billed as a new order.** Confirm with eMudhra how your account treats renewals before you rely on free renewals within a subscription term.
### Revocation Reasons After Migrating
@@ -897,8 +914,7 @@ deployment, but decide with these in mind rather than discovering them after cut
- **DCV for SSL.com-validated domains isn't validated live.** The plugin detects `validatedBy: SSLCOM` and publishes SSL.com's own `dns-txt` record, but the sandbox has no such domains. A domain that SSL.com offers only `dns-cname` or `http-url` for stays pending.
- **Document Signer (`ProductFamily=signature`) enrollment isn't supported.** It fails before any
order is placed.
-- **Every renewal/reissue places a new order.** See [Renewals and Reissuance](#renewals-and-reissuance)
- above.
+- **Renewals place a new order; reissue is unverified live.** SSL/TLS reissues use the reissue endpoint, but the sandbox stopped generating certificates before it could be tested end to end. See [Renewals and Reissuance](#renewals-and-reissuance) above.
- **`OrganizationNumber` is required to enroll OV/EV via V2.** V2 OV/EV orders send
`organization.organizationNumber` with `preVetted=true` (mirroring V1's
`organizationDetails.preVetting`); omitting it fails the enrollment. If your account relies on
@@ -1235,7 +1251,7 @@ A failed lookup, or a domain with no row, takes the order-level flow above, as b
### Certificate Enrollment
-When a requester submits a certificate request through Keyfactor Command, the plugin translates the request into a CERTInext order and returns the result. V1 and V2 follow different flows. V1 handles renewals specially (see [V1 renewal and RenewOrReissue](#v1-renewal-and-reneworreissue)), while V2 places a new order for every enrollment type.
+When a requester submits a certificate request through Keyfactor Command, the plugin translates the request into a CERTInext order and returns the result. V1 and V2 follow different flows. V1 handles renewals specially (see [V1 renewal and RenewOrReissue](#v1-renewal-and-reneworreissue)). V2 places a new order for every enrollment type except an SSL/TLS reissue, which uses the reissue endpoint ([V2 reissue](#v2-reissue)).
#### V1 enrollment (New or Reissue)
@@ -1312,7 +1328,7 @@ flowchart TD
#### V2 enrollment flow
-With `UseV2Api = true`, every enrollment type (New, Reissue, Renew, RenewOrReissue) takes the same path and places a new order. The plugin checks the request locally first, so a template or request that can never succeed is rejected before any order is placed.
+With `UseV2Api = true`, every enrollment type takes the same path. The one exception is an SSL/TLS reissue, which calls the reissue endpoint in place of the create call ([V2 reissue](#v2-reissue)); everything else places a new order. The plugin checks the request locally first, so a template or request that can never succeed is rejected before any order is placed.
```mermaid
flowchart TD
diff --git a/docsource/architecture.md b/docsource/architecture.md
index f6cfc8d..4a39580 100644
--- a/docsource/architecture.md
+++ b/docsource/architecture.md
@@ -311,7 +311,7 @@ A failed lookup, or a domain with no row, takes the order-level flow above, as b
### Certificate Enrollment
-When a requester submits a certificate request through Keyfactor Command, the plugin translates the request into a CERTInext order and returns the result. V1 and V2 follow different flows. V1 handles renewals specially (see [V1 renewal and RenewOrReissue](#v1-renewal-and-reneworreissue)), while V2 places a new order for every enrollment type.
+When a requester submits a certificate request through Keyfactor Command, the plugin translates the request into a CERTInext order and returns the result. V1 and V2 follow different flows. V1 handles renewals specially (see [V1 renewal and RenewOrReissue](#v1-renewal-and-reneworreissue)). V2 places a new order for every enrollment type except an SSL/TLS reissue, which uses the reissue endpoint ([V2 reissue](#v2-reissue)).
#### V1 enrollment (New or Reissue)
@@ -388,7 +388,7 @@ flowchart TD
#### V2 enrollment flow
-With `UseV2Api = true`, every enrollment type (New, Reissue, Renew, RenewOrReissue) takes the same path and places a new order. The plugin checks the request locally first, so a template or request that can never succeed is rejected before any order is placed.
+With `UseV2Api = true`, every enrollment type takes the same path. The one exception is an SSL/TLS reissue, which calls the reissue endpoint in place of the create call ([V2 reissue](#v2-reissue)); everything else places a new order. The plugin checks the request locally first, so a template or request that can never succeed is rejected before any order is placed.
```mermaid
flowchart TD
diff --git a/docsource/configuration.md b/docsource/configuration.md
index 02c5b69..2001264 100644
--- a/docsource/configuration.md
+++ b/docsource/configuration.md
@@ -312,7 +312,7 @@ In the Keyfactor Command Management Portal, navigate to **Certificate Templates*
| `AutoApprove` | Optional | Boolean | **Currently has no effect** — reserved for future use. The plugin does not call any approval endpoint against CERTInext regardless of this setting. | `false` |
| `RequesterName` | Optional | String | Overrides the connector `RequestorName` for this template when non-blank. Never derived from the certificate subject. | `Keyfactor Automation` |
| `RequesterEmail` | Optional | String | Overrides the connector `RequestorEmail` for this template when non-blank. Never derived from the certificate subject. | `pki-admin@example.com` |
-| `RenewalWindowDays` | Optional | Number | V1 only. Number of days before certificate expiration within which a renewal is attempted instead of a reissue. V2 places a new order for every renewal and reissue. Default: `90`. | `90` |
+| `RenewalWindowDays` | Optional | Number | Number of days before certificate expiration within which a renewal is attempted instead of a reissue. On V2 it decides only whether `RenewOrReissue` uses the SSL/TLS reissue endpoint (outside the window) or places a new order (inside it). Default: `90`. | `90` |
| `KeyType` | Optional | String | Informational. The CSR decides the key algorithm. CERTInext accepts only RSA 2048/3072/4096 and ECC P-256/P-384. | `RSA2048`, `RSA3072`, `RSA4096`, `EC256`, `EC384` |
| `DomainName` | Optional | String | V2 only: primary domain name for SSL/TLS orders (for Private PKI, the primary hostname). If omitted, the plugin uses the CSR subject `CN`. V1 always uses the CSR subject `CN`. | `example.com` |
| `SignerName` | Optional | String | Per-template override for the subscriber agreement signer name. When omitted, defaults to the connector-level `RequestorName` (V2: the requestor name). If no name resolves, V1 sends `Keyfactor Gateway` and logs a warning. | `Jane Smith` |
@@ -456,7 +456,7 @@ and the operation fails.
The prior order is never revoked automatically. The `RenewalWindowDays` template parameter sets the renewal/reissue boundary per certificate template.
-**V2.** All enrollment types (New, Reissue, Renew, RenewOrReissue) place a new V2 order; there is no renewal-window logic. See [V2 Order Lifecycle](#v2-order-lifecycle).
+**V2.** An SSL/TLS `Reissue`, or a `RenewOrReissue` whose prior certificate is unexpired and expires after `RenewalWindowDays`, uses CERTInext's reissue endpoint (see [V2 reissue](#v2-reissue)). Every other enrollment, and every Private PKI enrollment, places a new V2 order. See [V2 Order Lifecycle](#v2-order-lifecycle).
### Required Order Fields
@@ -513,6 +513,7 @@ V2 mode reuses the connector's `ApiUrl`, `OAuthClientId`, and `OAuthClientSecret
| `SslComSubmitOrganization` | Optional | OV/EV on V2. `true` lets the plugin submit an unvalidated organization to SSL.com, which **accepts SSL.com's subscriber agreement on the organization's behalf**; see [SSL.com organization validation](#sslcom-organization-validation). Default: `false`. |
| `SslComAdminEmail` | Optional | Administrator email sent as `adminEmail` on that submission. Blank omits it. Example: `pki-admin@example.com`. |
| `V2InlineCsr` | Optional | SSL/TLS only. `true` (the default) sends the CSR inline in the create call (a single `POST /ssl-certificates`), as a direct API integration does. `false` creates the order and then submits the CSR; that two-call flow fails for a domain the account has already validated, because CERTInext approves the order at create and then rejects the CSR. A create that times out or returns a 5xx can leave an order that issues and is imported by the next sync rather than cancelled, and the create call can hold about 20 seconds until issuance. Private PKI always uses two calls. Default: `true`. |
+| `V2ReissueRevokePrevious` | Optional | SSL/TLS reissue only. `true` makes the reissue call also revoke the previous certificate with reason `superseded`; the intent is audit-logged first. `false` leaves the previous certificate valid. See [V2 reissue](#v2-reissue). Default: `false`. |
- **Why a lookback:** the API's `from`/`to` filter may bracket either the order-placement date or the issuance date. The window keeps an order created before the last sync but issued afterward (for example a slow DCV order) from being missed.
@@ -634,7 +635,22 @@ CERTInext can report an order as `issued` before the certificate exists: Downloa
- **Log fields:** the lines include CERTInext's `orderState` and `certificateState` text (for example `Approved by System` versus `Certificate Generated`) for diagnosis. Their vocabulary isn't documented, so the plugin never acts on it.
- **Any other download error** is still logged as a warning.
-V2 has no *renew* endpoint. CERTInext does document a `/reissue` endpoint (`mode: rekey|update-sans`, with optional `revokePrevious`/`revokeReason`), but the plugin does not use it — every enrollment type (New, Reissue, Renew, RenewOrReissue) places a fresh V2 order, and the prior order/certificate is left issued rather than auto-revoked.
+#### V2 reissue
+
+V2 has no *renew* endpoint, so `New`, `Renew`, and a `RenewOrReissue` inside the renewal window place a new order. An SSL/TLS reissue uses `POST /ssl-certificates/{orderId}/reissue` instead:
+
+1. **When.** The enrollment type is `Reissue`, or `RenewOrReissue` with a prior certificate that hasn't expired and expires after `RenewalWindowDays` (the same boundary V1 uses).
+2. **Which order.** The plugin finds the prior order from `PriorCertSN` in the gateway database.
+3. **What it sends.** `mode: rekey` with the new CSR, plus `additionalDomains` for a UCC order (the same SAN rules as create), and the reason "Reissued via Keyfactor Command AnyCA REST Gateway." The plugin always rekeys, because Command supplies a new CSR and gives no signal that only the SANs changed. It never uses `update-sans`.
+4. **What comes back.** CERTInext creates a new order derived from the original, for example `1984857369R001`. That id is not numeric. It becomes the new certificate's `CARequestID`, and the usual post-create steps run on it: track, Subscriber Agreement, DCV, and pickup. The original order is left as it is.
+5. **The previous certificate.** It stays valid unless `V2ReissueRevokePrevious` is `true`. Then the reissue also asks CERTInext to revoke it with reason `superseded`, and the intent is audit-logged before the call.
+
+**Fallbacks:**
+- **A new order is placed instead,** and the enrollment result says so, in three cases: no `PriorCertSN`, no matching order in the gateway, or a 404 or 422 from the reissue call (for example, the original is not `issued`).
+- **Nothing is placed on a 5xx or a lost response.** The derived order may already exist. The enrollment fails with a "do not resubmit" message, as an order create does, and the next sync imports the order if it was created.
+- **Private PKI and Document Signer** have no reissue endpoint in the spec, so they keep placing a new order.
+
+**Not verified live:** the 2026-11 sandbox no longer generates certificates. Only its unit and WireMock tests cover this path.
### V2 Revocation Reason Handling
diff --git a/docsource/migration-v1-to-v2.md b/docsource/migration-v1-to-v2.md
index 51d4ee6..5295494 100644
--- a/docsource/migration-v1-to-v2.md
+++ b/docsource/migration-v1-to-v2.md
@@ -188,10 +188,11 @@ pointing a production template at the V2 connector. At minimum, confirm:
### Renewals and Reissuance
-CERTInext V2 has no *renew* endpoint. It documents a `/reissue` endpoint (`mode: rekey|update-sans`, with optional `revokePrevious` and `revokeReason`), which the plugin does not use.
+CERTInext V2 has no *renew* endpoint, but SSL/TLS certificates have a `/reissue` endpoint, which the plugin uses for reissues (see [V2 reissue](#v2-reissue)).
-- **Every enrollment type places a new order.** `New`, `Renew`, `Reissue`, and `RenewOrReissue` all take the same path as a new enrollment, without V1's renewal-window logic.
-- **The prior order is untouched.** The old order and certificate stay issued and are not auto-revoked. Command links the old and new certificates through history only.
+- **Reissue uses the reissue endpoint.** An SSL/TLS `Reissue`, or a `RenewOrReissue` outside `RenewalWindowDays`, creates a derived order (`…R001`) from the original. If CERTInext refuses (404 or 422), or the prior order can't be found, a new order is placed instead.
+- **Renewals still place a new order.** This applies to `New`, `Renew`, a `RenewOrReissue` inside the window, and every Private PKI enrollment.
+- **The prior order is untouched by default.** Set `V2ReissueRevokePrevious` to `true` to have a reissue revoke the previous certificate (`superseded`). Renewals never revoke it. Command links the old and new certificates through history only.
- **Each renewal is billed as a new order.** Confirm with eMudhra how your account treats renewals before you rely on free renewals within a subscription term.
### Revocation Reasons After Migrating
@@ -207,8 +208,7 @@ deployment, but decide with these in mind rather than discovering them after cut
- **DCV for SSL.com-validated domains isn't validated live.** The plugin detects `validatedBy: SSLCOM` and publishes SSL.com's own `dns-txt` record, but the sandbox has no such domains. A domain that SSL.com offers only `dns-cname` or `http-url` for stays pending.
- **Document Signer (`ProductFamily=signature`) enrollment isn't supported.** It fails before any
order is placed.
-- **Every renewal/reissue places a new order.** See [Renewals and Reissuance](#renewals-and-reissuance)
- above.
+- **Renewals place a new order; reissue is unverified live.** SSL/TLS reissues use the reissue endpoint, but the sandbox stopped generating certificates before it could be tested end to end. See [Renewals and Reissuance](#renewals-and-reissuance) above.
- **`OrganizationNumber` is required to enroll OV/EV via V2.** V2 OV/EV orders send
`organization.organizationNumber` with `preVetted=true` (mirroring V1's
`organizationDetails.preVetting`); omitting it fails the enrollment. If your account relies on
diff --git a/integration-manifest.json b/integration-manifest.json
index 3a5e9b8..5aaa0b5 100644
--- a/integration-manifest.json
+++ b/integration-manifest.json
@@ -347,6 +347,13 @@
"example": "true",
"default": "true"
},
+ {
+ "name": "V2ReissueRevokePrevious",
+ "description": "OPTIONAL (V2 mode only, SSL/TLS reissue): a V2 SSL/TLS reissue goes through CERTInext's reissue endpoint, which creates a new order derived from the original (for example 1984857369R001). When true, that call also asks CERTInext to revoke the previous certificate with reason superseded; the intent is audit-logged before the call. When false (the default), the previous certificate stays valid until it expires or is revoked separately. Ignored when UseV2Api is false. Default: false.",
+ "required": "Optional",
+ "example": "false",
+ "default": "false"
+ },
{
"name": "SslComSubmitOrganization",
"description": "OPTIONAL (V2 mode only): before an OV or EV SSL/TLS order, the plugin checks whether SSL.com has validated the organization (OrganizationNumber) at that level, refreshing the status once if not. When this is true and the organization is still unvalidated and ready to submit, the plugin also SUBMITS it to SSL.com for validation. WARNING: enabling this ACCEPTS SSL.COM'S SUBSCRIBER AGREEMENT ON THE ORGANIZATION'S BEHALF; each acceptance is audit-logged. Leave false unless you are authorized to do that. Whatever the outcome, the order is placed anyway and a not-validated organization only logs a warning. Default: false.",
From 07d119a514e71bc52d9d8651c747869bc60ce20d Mon Sep 17 00:00:00 2001
From: spbsoluble <1661003+spbsoluble@users.noreply.github.com>
Date: Fri, 13 Nov 2026 15:13:58 -0800
Subject: [PATCH 06/11] fix(v2): retry the OAuth2 token request on transport
failure; log the transport detail (0095)
A token request that gets no HTTP response is retried twice (1 s, 3 s); it is idempotent and
creates nothing. HTTP answers (400/401/403 included) are final, and order creates are still
never retried. The failure log and exception now carry TransportDetail (RestSharp
ResponseStatus plus the exception chain's types/messages, passed through RedactCredentials
with the configured secret values replaced and capped) instead of only HttpStatus=0.
LogV2ApiFailure/ThrowOnV2Failure, the inline-CSR create and the reissue do the same for any V2
call with no response; ThrowOnV2Failure keeps 'HTTP 0.' first so IsTransportLevelCsrFailure
still reads status 0.
Tests drive a real dropped connection through a loopback TCP proxy (WireMock's faults still
send a 200 status line): enroll, sync and connector validation each succeed with two token
attempts; a permanent drop tries three times with no secret in the message; 400/401/403 are
tried once.
---
.../INTEGRATION_TESTING.md | 2 +-
CERTInext.Tests/FlakyTcpProxy.cs | 91 +++++++
CERTInext.Tests/V2TokenTransportRetryTests.cs | 226 ++++++++++++++++++
CERTInext/Client/CERTInextClient.cs | 103 +++++++-
CHANGELOG.md | 1 +
README.md | 2 +-
docsource/configuration.md | 2 +-
7 files changed, 415 insertions(+), 12 deletions(-)
create mode 100644 CERTInext.Tests/FlakyTcpProxy.cs
create mode 100644 CERTInext.Tests/V2TokenTransportRetryTests.cs
diff --git a/CERTInext.IntegrationTests/INTEGRATION_TESTING.md b/CERTInext.IntegrationTests/INTEGRATION_TESTING.md
index 80e1f1a..d043dc1 100644
--- a/CERTInext.IntegrationTests/INTEGRATION_TESTING.md
+++ b/CERTInext.IntegrationTests/INTEGRATION_TESTING.md
@@ -243,7 +243,7 @@ test that "passes" with the order still pending tells you nothing.
| No `X-Product-Code`: two-call CSR submit returns 422 "PFX based certificate orders are not allowed" | CERTInext resolved the product to one that doesn't take a client CSR (on the sandbox, DV resolves to `917`, not `842`) | The create response's `resolvedProductCode` (logged as `ResolvedProductCode=`). Pin `ProductCode` on the template. |
| No `X-Product-Code`: `GET .../dcv` returns 422 `EMS-1164 Domain Name is not matching`, and `certificateState` is "System Error" | Same product resolution as the row above. It isn't the domain or its letter case. | Pin 842 on the same domain shape; the order issues. |
| Two-call, pinned: `PUT .../csr` returns 422 "Something went Wrong." | The domain is already validated, so the create came back `issued` with `csrSubmitted: false` and the order won't accept a CSR | Trace payloads show the create response. Use `V2InlineCsr=true`, the default since 2.0. |
-| `V2 OAuth2 token acquisition failed ... HttpStatus=0` (enroll, sync, or config save returns 500 "could not connect to CERTInext") | A transient transport failure to the token endpoint. The plugin currently logs no transport detail and doesn't retry the token request; a fix is planned. | Retry once. From a gateway pod, test with `curl --cacert /etc/pki/tls/certs/ca-bundle.crt` (the lab pod's `SSL_CERT_FILE` trusts only the lab CA, so plain curl fails TLS even when .NET works). |
+| `V2 OAuth2 token acquisition failed: no HTTP response after 3 attempt(s)` (enroll, sync, or config save returns 500 "could not connect to CERTInext") | A transport failure to the token endpoint that outlasted the plugin's two retries (1 s, 3 s). The line's `TransportDetail=` names the error, such as connection reset, TLS, or DNS. Builds before issue 0095 logged only `HttpStatus=0` and didn't retry. | Retry once. From a gateway pod, test with `curl --cacert /etc/pki/tls/certs/ca-bundle.crt` (the lab pod's `SSL_CERT_FILE` trusts only the lab CA, so plain curl fails TLS even when .NET works). |
| Unpinned (`V2Template`) rows fail while pinned rows pass | Expected on the sandbox until CERTInext changes how it resolves DV | See the 917 rows above. |
Before blaming the plugin, separate CA-side from plugin-side failures: run the same request **pinned**,
diff --git a/CERTInext.Tests/FlakyTcpProxy.cs b/CERTInext.Tests/FlakyTcpProxy.cs
new file mode 100644
index 0000000..1cbf718
--- /dev/null
+++ b/CERTInext.Tests/FlakyTcpProxy.cs
@@ -0,0 +1,91 @@
+// 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,
+// 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.Net;
+using System.Net.Sockets;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests
+{
+ ///
+ /// Loopback TCP proxy in front of a WireMock server that resets chosen connections before any
+ /// HTTP response, so the client sees a real transport failure (RestSharp status 0). WireMock's own
+ /// faults still send a 200 status line. Connections are numbered from 1 in accept order.
+ ///
+ internal sealed class FlakyTcpProxy : IDisposable
+ {
+ private readonly TcpListener _listener = new TcpListener(IPAddress.Loopback, 0);
+ private readonly int _targetPort;
+ private readonly Func _drop;
+ private readonly CancellationTokenSource _cts = new CancellationTokenSource();
+ private int _connections;
+
+ public FlakyTcpProxy(string targetUrl, Func drop)
+ {
+ _targetPort = new Uri(targetUrl).Port;
+ _drop = drop;
+ _listener.Start();
+ _ = Task.Run(AcceptLoopAsync);
+ }
+
+ public string Url => $"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}";
+
+ public int Connections => Volatile.Read(ref _connections);
+
+ private async Task AcceptLoopAsync()
+ {
+ while (!_cts.IsCancellationRequested)
+ {
+ TcpClient client;
+ try
+ {
+ client = await _listener.AcceptTcpClientAsync(_cts.Token);
+ }
+ catch
+ {
+ return;
+ }
+ int n = Interlocked.Increment(ref _connections);
+ if (_drop(n))
+ {
+ // Linger 0 sends RST: the request fails with no HTTP response at all.
+ client.Client.LingerState = new LingerOption(true, 0);
+ client.Close();
+ continue;
+ }
+ _ = Task.Run(() => PipeAsync(client));
+ }
+ }
+
+ private async Task PipeAsync(TcpClient client)
+ {
+ using (client)
+ using (var upstream = new TcpClient())
+ {
+ try
+ {
+ await upstream.ConnectAsync(IPAddress.Loopback, _targetPort, _cts.Token);
+ var a = client.GetStream();
+ var b = upstream.GetStream();
+ await Task.WhenAny(a.CopyToAsync(b, _cts.Token), b.CopyToAsync(a, _cts.Token));
+ }
+ catch
+ {
+ // A test ended or a side closed: nothing to do.
+ }
+ }
+ }
+
+ public void Dispose()
+ {
+ _cts.Cancel();
+ _listener.Stop();
+ }
+ }
+}
diff --git a/CERTInext.Tests/V2TokenTransportRetryTests.cs b/CERTInext.Tests/V2TokenTransportRetryTests.cs
new file mode 100644
index 0000000..34d7331
--- /dev/null
+++ b/CERTInext.Tests/V2TokenTransportRetryTests.cs
@@ -0,0 +1,226 @@
+// 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,
+// 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.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Keyfactor.AnyGateway.Extensions;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using Keyfactor.PKI.Enums.EJBCA;
+using WireMock.RequestBuilders;
+using WireMock.ResponseBuilders;
+using WireMock.Server;
+using WireMock.Settings;
+using Xunit;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests
+{
+ ///
+ /// Issue 0095: the V2 OAuth2 token request is retried after a transport failure (no HTTP response)
+ /// and the failure's transport detail reaches the log and the exception, without secrets. Any HTTP
+ /// answer, 401/403 included, is never retried.
+ ///
+ public class V2TokenTransportRetryTests : IDisposable
+ {
+ private const string TokenPath = "/oauth/token";
+ private const string Secret = "s3cr3t-client-value";
+ private readonly WireMockServer _server = WireMockServer.Start();
+ private FlakyTcpProxy _proxy;
+
+ public V2TokenTransportRetryTests()
+ {
+ _server.Given(Request.Create().WithPath(TokenPath).UsingPost()).RespondWith(TokenOk());
+ }
+
+ public void Dispose()
+ {
+ _proxy?.Dispose();
+ _server.Stop();
+ }
+
+ /// Routes the client through a proxy that resets the connections selects.
+ private string Via(Func drop)
+ {
+ _proxy = new FlakyTcpProxy(_server.Urls[0], drop);
+ return _proxy.Url;
+ }
+
+ private string _apiUrl;
+
+ private CERTInextConfig Config() => new CERTInextConfig
+ {
+ ApiUrl = _apiUrl ?? _server.Urls[0],
+ UseV2Api = true,
+ OAuthClientId = "my-v2-client",
+ OAuthClientSecret = Secret,
+ RequestorName = "Test User",
+ RequestorEmail = "test@example.com",
+ SignerPlace = "New York",
+ PickupRetries = 0,
+ DcvEnabled = false
+ };
+
+ private CERTInextClient Client(CERTInextConfig config = null) =>
+ new CERTInextClient(config ?? Config()) { V2TokenRetryDelays = new[] { TimeSpan.Zero, TimeSpan.Zero } };
+
+ private int TokenCalls => _server.LogEntries.Count(e => e.RequestMessage.Path == TokenPath);
+
+ private static IResponseBuilder TokenOk() => Response.Create().WithStatusCode(200)
+ .WithHeader("Content-Type", "application/json").WithBody(MockCertificateData.V2TokenResponseJson());
+
+ ///
+ /// The first connection is the first token request (every call needs a token first, and each
+ /// token attempt opens its own connection): reset it, forward the rest.
+ ///
+ private void StubTokenDropsOnceThenSucceeds() => _apiUrl = Via(n => n == 1);
+
+ /// Exactly two token attempts: the reset first one, and the retry that reached the server.
+ private void TokenAttemptsShouldBeTwo() =>
+ TokenCalls.Should().Be(1, "the first attempt's connection was reset before reaching the server, " +
+ "and exactly one retry got through, so two token attempts in total");
+
+ private void StubJson(string path, string body, bool post = false)
+ {
+ var req = Request.Create().WithPath(path);
+ _server.Given(post ? req.UsingPost() : req.UsingGet()).RespondWith(Response.Create().WithStatusCode(post ? 201 : 200)
+ .WithHeader("Content-Type", "application/json").WithBody(body));
+ }
+
+ [Fact]
+ public async Task Enroll_TokenDropsOnce_RetriesAndSucceeds_WithTwoTokenCalls()
+ {
+ StubTokenDropsOnceThenSucceeds();
+ const string orderId = "ord_tok_001";
+ StubJson("/api/certinext/v2/ssl-certificates", MockCertificateData.V2CreateOrderIssuedJson(orderId), post: true);
+ StubJson($"/api/certinext/v2/ssl-certificates/{orderId}", MockCertificateData.V2TrackOrderIssuedJson(orderId));
+ StubJson($"/api/certinext/v2/ssl-certificates/{orderId}/certificate", MockCertificateData.V2CertificateDownloadJson(orderId));
+ var config = Config();
+ var plugin = new CERTInextCAPlugin(Client(config), config);
+
+ var result = await plugin.Enroll(MockCertificateData.FakeCsrPem, "CN=example.com",
+ new Dictionary(),
+ new EnrollmentProductInfo
+ {
+ ProductID = "DV SSL",
+ ProductParameters = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["ProductFamily"] = "ssl", ["ProductVariant"] = "dv", ["DomainName"] = "example.com"
+ }
+ },
+ RequestFormat.PKCS10, EnrollmentType.New);
+
+ result.Status.Should().Be((int)EndEntityStatus.GENERATED);
+ TokenAttemptsShouldBeTwo();
+ _server.LogEntries.Count(e => e.RequestMessage.Path == "/api/certinext/v2/ssl-certificates").Should().Be(1,
+ "only the token request is retried; the order create is sent once");
+ }
+
+ [Fact]
+ public async Task Sync_TokenDropsOnce_RetriesAndSucceeds_WithTwoTokenCalls()
+ {
+ StubTokenDropsOnceThenSucceeds();
+ StubJson("/api/certinext/v2/reports/orders", MockCertificateData.V2OrdersReportJson(1, 1, Array.Empty()));
+ var config = Config();
+ var plugin = new CERTInextCAPlugin(Client(config), config);
+
+ await plugin.Synchronize(new BlockingCollection(10), null, true, CancellationToken.None);
+
+ TokenAttemptsShouldBeTwo();
+ }
+
+ [Fact]
+ public async Task ValidateConnection_TokenDropsOnce_RetriesAndSucceeds_WithTwoTokenCalls()
+ {
+ StubTokenDropsOnceThenSucceeds();
+ StubJson("/api/certinext/v2/auth/me", MockCertificateData.V2AuthMeJson());
+ var plugin = new CERTInextCAPlugin();
+
+ // The config-save check builds its own client, so this uses the real 1 s backoff.
+ await plugin.ValidateCAConnectionInfo(new Dictionary
+ {
+ ["ApiUrl"] = _apiUrl,
+ ["UseV2Api"] = true,
+ ["OAuthClientId"] = "my-v2-client",
+ ["OAuthClientSecret"] = Secret,
+ ["SignerPlace"] = "New York",
+ ["RequestorName"] = "Test User",
+ ["RequestorEmail"] = "test@example.com"
+ });
+
+ TokenAttemptsShouldBeTwo();
+ }
+
+ [Fact]
+ public async Task TokenDropsEveryTime_TriesThreeTimes_MessageHasTransportDetail_NoSecret()
+ {
+ _apiUrl = Via(_ => true);
+ using var client = Client();
+
+ Func act = () => client.GetAuthMeV2Async();
+
+ var ex = (await act.Should().ThrowAsync()).Which;
+ ex.Message.Should().Contain("no HTTP response after 3 attempt(s)")
+ .And.Contain("ResponseStatus=").And.NotContain(Secret);
+ _proxy.Connections.Should().Be(3, "one attempt plus two retries");
+ TokenCalls.Should().Be(0, "no attempt reached the server");
+ }
+
+ [Theory]
+ [InlineData(400)]
+ [InlineData(401)]
+ [InlineData(403)]
+ public async Task TokenHttpError_IsNeverRetried(int status)
+ {
+ _server.Reset();
+ _server.Given(Request.Create().WithPath(TokenPath).UsingPost())
+ .RespondWith(Response.Create().WithStatusCode(status)
+ .WithHeader("Content-Type", "application/json").WithBody("{\"error\":\"invalid_client\"}"));
+ using var client = Client();
+
+ Func act = () => client.GetAuthMeV2Async();
+
+ await act.Should().ThrowAsync();
+ TokenCalls.Should().Be(1);
+ }
+
+ [Fact]
+ public async Task ApiCallWithNoResponse_MessageKeepsHttp0First_AndHasTransportDetail()
+ {
+ // Connection 1 carries the token request; every later connection (the API call) is reset.
+ _apiUrl = Via(n => n > 1);
+ using var client = Client();
+
+ Func act = () => client.TrackOrderV2Async(Constants.ApiV2.FamilySsl, "ord_x");
+
+ var ex = (await act.Should().ThrowAsync()).Which;
+ ex.Message.Should().Contain("HTTP 0. No HTTP response (ResponseStatus=");
+ CERTInextCAPlugin.IsTransportLevelCsrFailure(ex).Should().BeTrue();
+ }
+
+ [Fact]
+ public void DescribeTransportFailure_ScrubsSecretsFromExceptionText()
+ {
+ using var client = Client();
+ var resp = new RestSharp.RestResponse
+ {
+ ResponseStatus = RestSharp.ResponseStatus.Error,
+ ErrorException = new System.Net.Http.HttpRequestException(
+ $"send failed for grant_type=client_credentials&client_secret={Secret}",
+ new System.IO.IOException($"raw {Secret} connection reset by peer"))
+ };
+
+ string detail = client.DescribeTransportFailure(resp);
+
+ detail.Should().Contain("ResponseStatus=Error").And.Contain("HttpRequestException")
+ .And.Contain("IOException").And.Contain("connection reset by peer").And.NotContain(Secret);
+ }
+ }
+}
diff --git a/CERTInext/Client/CERTInextClient.cs b/CERTInext/Client/CERTInextClient.cs
index 83a0418..4d8c5e9 100644
--- a/CERTInext/Client/CERTInextClient.cs
+++ b/CERTInext/Client/CERTInextClient.cs
@@ -1473,7 +1473,7 @@ private async Task PlaceOrderV2CoreAsync(
path, createStatus, sw.ElapsedMilliseconds);
throw new Exception(
"CERTInext did not return a usable response to the V2 order create " +
- (createStatus == 0 ? "(no HTTP response)" : $"(HTTP {createStatus})") +
+ (createStatus == 0 ? $"(no HTTP response: {DescribeTransportFailure(resp)})" : $"(HTTP {createStatus})") +
". If the order was created it will be imported by the next synchronization — do not " +
"resubmit immediately. See gateway logs for details.");
}
@@ -1989,7 +1989,7 @@ public async Task ReissueOrderV2Async(
LogV2ApiFailure("V2 reissue order", resp, LogLevel.Warning);
throw new Exception(
$"CERTInext did not return a usable response to the V2 reissue of order '{orderId}' " +
- (status == 0 ? "(no HTTP response)" : $"(HTTP {status})") +
+ (status == 0 ? $"(no HTTP response: {DescribeTransportFailure(resp)})" : $"(HTTP {status})") +
". If the reissue was created it will be imported by the next synchronization — do not " +
"resubmit immediately. See gateway logs for details.");
}
@@ -2397,14 +2397,39 @@ private async Task GetOrRefreshV2TokenAsync(CancellationToken ct)
_config.ApiUrl, _config.OAuthClientId);
string tokenUrl = _config.ApiUrl.TrimEnd('/') + Constants.ApiV2.TokenPath;
- using var tokenClient = new RestClient(tokenUrl);
- var tokenReq = new RestRequest(string.Empty, Method.Post);
- tokenReq.AddHeader("Content-Type", "application/x-www-form-urlencoded");
- tokenReq.AddParameter("grant_type", "client_credentials");
- tokenReq.AddParameter("client_id", _config.OAuthClientId);
- tokenReq.AddParameter("client_secret", _config.OAuthClientSecret);
- var tokenResp = await tokenClient.ExecuteAsync(tokenReq, ct);
+ // The token request creates nothing and is idempotent, so a transport failure (no HTTP
+ // response at all) is retried with a short backoff; one network blip otherwise fails a
+ // whole enrollment, sync or config save (issue 0095). Any HTTP answer, including
+ // 400/401/403, is final. Order creates are unaffected: they are never retried.
+ RestResponse tokenResp;
+ int maxAttempts = V2TokenRetryDelays.Length + 1;
+ int attempt = 1;
+ while (true)
+ {
+ using var tokenClient = new RestClient(tokenUrl);
+ var tokenReq = new RestRequest(string.Empty, Method.Post);
+ tokenReq.AddHeader("Content-Type", "application/x-www-form-urlencoded");
+ tokenReq.AddParameter("grant_type", "client_credentials");
+ tokenReq.AddParameter("client_id", _config.OAuthClientId);
+ tokenReq.AddParameter("client_secret", _config.OAuthClientSecret);
+
+ tokenResp = await tokenClient.ExecuteAsync(tokenReq, ct);
+ bool transportFailure = (int)tokenResp.StatusCode == 0;
+ if (!transportFailure || attempt >= maxAttempts)
+ break;
+ ct.ThrowIfCancellationRequested();
+
+ TimeSpan delay = V2TokenRetryDelays[attempt - 1];
+ Logger.LogWarning(
+ "V2 OAuth2 token request got no HTTP response (attempt {Attempt}/{Max}). ApiUrl={ApiUrl}, " +
+ "ClientId={ClientId}, TransportDetail={TransportDetail}. Retrying in {DelaySeconds}s.",
+ attempt, maxAttempts, _config.ApiUrl, _config.OAuthClientId,
+ DescribeTransportFailure(tokenResp), delay.TotalSeconds);
+ await Task.Delay(delay, ct);
+ attempt++;
+ }
+
if (!tokenResp.IsSuccessful || string.IsNullOrWhiteSpace(tokenResp.Content))
{
// SOX CC6.1: never log tokenResp.Content — may contain client_secret.
@@ -2438,6 +2463,17 @@ private async Task GetOrRefreshV2TokenAsync(CancellationToken ct)
"portal (Integration → REST APIs → OAuth2) with the OAuth radio button selected. " +
"See gateway logs for details.");
}
+ if ((int)tokenResp.StatusCode == 0)
+ {
+ string transportDetail = DescribeTransportFailure(tokenResp);
+ Logger.LogError(
+ "V2 OAuth2 token acquisition failed: no HTTP response after {Attempts} attempt(s). " +
+ "ApiUrl={ApiUrl}, ClientId={ClientId}, HttpStatus=0, TransportDetail={TransportDetail}",
+ attempt, _config.ApiUrl, _config.OAuthClientId, transportDetail);
+ throw new Exception(
+ $"Failed to obtain V2 OAuth2 token: no HTTP response after {attempt} attempt(s) " +
+ $"({transportDetail}). See gateway logs for details.");
+ }
Logger.LogError(
"V2 OAuth2 token acquisition failed. ApiUrl={ApiUrl}, ClientId={ClientId}, HttpStatus={Status}",
_config.ApiUrl, _config.OAuthClientId, (int)tokenResp.StatusCode);
@@ -2468,6 +2504,37 @@ private async Task GetOrRefreshV2TokenAsync(CancellationToken ct)
}
}
+ ///
+ /// Backoff between V2 token attempts after a transport failure; one more attempt than entries.
+ /// Settable so tests don't wait.
+ ///
+ internal TimeSpan[] V2TokenRetryDelays { get; set; } = { TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(3) };
+
+ ///
+ /// Short, secret-free description of a request that got no HTTP response: RestSharp's
+ /// ResponseStatus and the exception chain's types and messages (for example
+ /// HttpRequestException: Connection reset by peer). RestSharp's error text can echo the
+ /// request, so it is passed through , the configured secret values
+ /// are replaced outright, and the result is capped.
+ ///
+ internal string DescribeTransportFailure(RestResponse resp)
+ {
+ var parts = new List { $"ResponseStatus={resp?.ResponseStatus.ToString() ?? "(none)"}" };
+ Exception ex = resp?.ErrorException;
+ for (int depth = 0; ex != null && depth < 4; depth++, ex = ex.InnerException)
+ parts.Add($"{ex.GetType().Name}: {ex.Message}");
+ if (resp?.ErrorException == null && !string.IsNullOrWhiteSpace(resp?.ErrorMessage))
+ parts.Add(resp.ErrorMessage);
+
+ string text = RedactCredentials(string.Join("; ", parts));
+ foreach (string secret in new[] { _config.OAuthClientSecret, _config.ApiKey })
+ {
+ if (!string.IsNullOrEmpty(secret) && secret.Length >= 4)
+ text = text.Replace(secret, "***REDACTED***");
+ }
+ return Truncate(LogSanitizer.Strip(text), 400);
+ }
+
///
/// Builds a V2 REST request with the Authorization: Bearer header populated from
/// the cached/refreshed V2 token. Optionally adds an Idempotency-Key header.
@@ -2512,6 +2579,11 @@ private void ThrowOnV2Failure(RestResponse resp, string operation)
}
LogV2ApiFailure(operation, resp, LogLevel.Warning);
+ if ((int)resp.StatusCode == 0)
+ // "HTTP 0." stays first: IsTransportLevelCsrFailure reads the first "HTTP n." in the message.
+ throw new Exception(
+ $"CERTInext V2 API error during '{operation}'. HTTP 0. No HTTP response ({DescribeTransportFailure(resp)}). " +
+ "See gateway logs for details.");
string msg = ExtractV2ErrorMessage(resp.Content, operation, _config.LogSensitiveRequestData);
throw new Exception($"CERTInext V2 API error during '{operation}'. HTTP {(int)resp.StatusCode}. {msg}");
}
@@ -2527,6 +2599,19 @@ private void LogV2ApiFailure(string operation, RestResponse resp, LogLevel level
{
// Skip the redaction passes entirely when this level is disabled.
if (!Logger.IsEnabled(level)) return;
+ if (resp != null && (int)resp.StatusCode == 0)
+ {
+ // No HTTP response: there is no body; the transport error is what explains it.
+ Logger.Log(
+ level,
+ "CERTInext V2 API non-success. Operation={Operation}, Method={Method}, Path={Path}, " +
+ "HttpStatus=0, TransportDetail={TransportDetail}",
+ operation,
+ resp.Request?.Method.ToString() ?? "(unknown)",
+ resp.Request?.Resource ?? "(unknown)",
+ DescribeTransportFailure(resp));
+ return;
+ }
string sanitizedBody = Truncate(
ApplyLoggingRedaction(resp?.Content, _config.LogSensitiveRequestData) ?? "(empty)",
LoggedResponseBodyCapBytes);
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8df42cd..ce81c12 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -31,6 +31,7 @@
- fix(logging): Email addresses in CERTInext V2 error text (the problem+json `title`, `detail`, and `errors[]` that end up in exception messages and logs) are masked unless `LogSensitiveRequestData` is on, as V1 error text is.
- fix(logging): V2 payload dumps no longer run redaction when Trace logging is off.
- fix(enroll): V2 order and CSR submissions are never auto-retried after a timeout, which could create duplicate orders (V1 since 1.0.1).
+- fix(v2): A V2 OAuth2 token request that gets no HTTP response (a dropped connection or other transport error) is retried twice, after 1 s and 3 s, so one network blip no longer fails an enrollment, a sync or a connector save. HTTP errors (400/401/403) are not retried, and order creates are still never retried. Token failures and other V2 calls with no response now log the transport detail (`TransportDetail=`: RestSharp's response status and the exception types and messages, with credentials removed) and include a short form in the error, instead of only `HttpStatus=0`.
- fix(sync): The V2 sync also holds back pending and issued orders created in the last 120 s (`SyncRecentOrderGraceSeconds`, `0` disables), so an in-flight enrollment no longer fails with a duplicate key (V1 since 1.0.1).
- fix(enroll): The in-call pickup wait is now capped at 75 s (it was 180 s) for V1 and V2, inside Command's roughly 100 s enrollment-call timeout, so a pickup that used its whole budget is no longer abandoned by Command. The 55 s default (`PickupRetries` 5, `PickupDelay` 10) is unaffected.
- fix(logging): Log redaction no longer leaks the rest of a value after an escaped quote, CERTInext error text has email addresses masked unless `LogSensitiveRequestData` is on, and `mailto:` and userinfo are masked in URI SANs in enrollment logs (V1 and V2).
diff --git a/README.md b/README.md
index c30c04a..e28cdda 100644
--- a/README.md
+++ b/README.md
@@ -576,7 +576,7 @@ V2 mode reuses the connector's `ApiUrl`, `OAuthClientId`, and `OAuthClientSecret
#### V2 Token Caching
-The plugin obtains a V2 bearer token via the standard OAuth2 `client_credentials` grant (`grant_type=client_credentials`, form-encoded) against `{ApiUrl}/oauth/token`. Tokens are cached in memory and reused until 60 seconds before expiry (minimum 30-second cache). Token refresh is thread-safe.
+The plugin obtains a V2 bearer token via the standard OAuth2 `client_credentials` grant (`grant_type=client_credentials`, form-encoded) against `{ApiUrl}/oauth/token`. Tokens are cached in memory and reused until 60 seconds before expiry (minimum 30-second cache). Token refresh is thread-safe. A token request that gets no HTTP response (for example a dropped connection) is retried twice, after 1 and 3 seconds. An HTTP error such as 401 or 403 is not retried. The failure log then carries `TransportDetail=`, which gives the response status and the exception chain, with credentials removed.
### V2 Certificate Template Fields
diff --git a/docsource/configuration.md b/docsource/configuration.md
index 2001264..176a761 100644
--- a/docsource/configuration.md
+++ b/docsource/configuration.md
@@ -528,7 +528,7 @@ V2 mode reuses the connector's `ApiUrl`, `OAuthClientId`, and `OAuthClientSecret
#### V2 Token Caching
-The plugin obtains a V2 bearer token via the standard OAuth2 `client_credentials` grant (`grant_type=client_credentials`, form-encoded) against `{ApiUrl}/oauth/token`. Tokens are cached in memory and reused until 60 seconds before expiry (minimum 30-second cache). Token refresh is thread-safe.
+The plugin obtains a V2 bearer token via the standard OAuth2 `client_credentials` grant (`grant_type=client_credentials`, form-encoded) against `{ApiUrl}/oauth/token`. Tokens are cached in memory and reused until 60 seconds before expiry (minimum 30-second cache). Token refresh is thread-safe. A token request that gets no HTTP response (for example a dropped connection) is retried twice, after 1 and 3 seconds. An HTTP error such as 401 or 403 is not retried. The failure log then carries `TransportDetail=`, which gives the response status and the exception chain, with credentials removed.
### V2 Certificate Template Fields
From 178237553fd6cc71c65d71f3421c23f99ee834e8 Mon Sep 17 00:00:00 2001
From: spbsoluble <1661003+spbsoluble@users.noreply.github.com>
Date: Fri, 13 Nov 2026 16:45:28 -0800
Subject: [PATCH 07/11] test(v2): live probes for report-status mapping and
per-reason revoke acceptance; tidy reissue result message
Sync_UnresolvedReportStatuses_Probe (read-only) lists every /reports/orders status pair, the
rows the mapping can't place (UnresolvedStatusFallbacks) and a Track Order sample of rows whose
two fields disagree. Live: the only unmapped pair is 'On Hold | Setup Pending' (6 orders, Track
404 in every family), so no mapping change; 'Order Accepted | Rejected due to Order
Cancellation' tracks as pending-dcv, so it is not remapped either (Finding 33 update).
The reissue live test now routes logs before building the plugin, and the reissue note is
joined to the result message with a sentence break.
---
.../V2SpecFollowupsLiveTests.cs | 80 ++++++++++++++++++-
CERTInext/CERTInextCAPlugin.cs | 7 +-
2 files changed, 84 insertions(+), 3 deletions(-)
diff --git a/CERTInext.IntegrationTests/V2SpecFollowupsLiveTests.cs b/CERTInext.IntegrationTests/V2SpecFollowupsLiveTests.cs
index 4cb8b58..dcae383 100644
--- a/CERTInext.IntegrationTests/V2SpecFollowupsLiveTests.cs
+++ b/CERTInext.IntegrationTests/V2SpecFollowupsLiveTests.cs
@@ -379,6 +379,8 @@ public async Task Reissue_V2_ReissueEndpoint_DerivedOrder_Report()
var cert = await client.DownloadCertificateV2Async(Constants.ApiV2.FamilySsl, sourceId);
_output.WriteLine($"Source certificate serial={cert.SerialNumber}");
+ // Route logs before the plugin and its client exist: their loggers are created at construction.
+ var restoreLogging = TestOutputLogging.RouteTo(_output);
var config = BuildV2Config();
config.V2ReissueRevokePrevious = false;
var reader = new SingleCertificateDataReader(cert.SerialNumber, sourceId,
@@ -387,7 +389,6 @@ public async Task Reissue_V2_ReissueEndpoint_DerivedOrder_Report()
var info = V2ProductPinning.Info(Constants.Products.DvSsl, V2PinMode.Pinned,
new Dictionary { ["PriorCertSN"] = cert.SerialNumber });
- var restoreLogging = TestOutputLogging.RouteTo(_output);
EnrollmentResult result = null;
try
{
@@ -446,6 +447,83 @@ public async Task Reissue_V2_ReissueEndpoint_DerivedOrder_Report()
result.Status.Should().NotBe((int)EndEntityStatus.FAILED, result.StatusMessage);
}
+ // ---------------------------------------------------------------------------
+ // Sync: which orders make a wide V2 sync report UnresolvedStatusFallbacks (read-only)
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Read-only. Pages the whole /reports/orders listing (no date filter, as a full sync
+ /// does), runs each row through the plugin's display-string mapping, and for every row the
+ /// mapping can't place (the sync's UnresolvedStatusFallbacks) prints the raw
+ /// orderStatus/certificateStatus and what Track Order says. Prints order numbers,
+ /// statuses, product codes and dates only. Opt-in: CERTINEXT_V2_UNRESOLVED_STATUS_PROBE=1.
+ ///
+ [SkippableFact]
+ public async Task Sync_UnresolvedReportStatuses_Probe()
+ {
+ Skip.If(!_v2Enabled || !Flag("CERTINEXT_V2_UNRESOLVED_STATUS_PROBE"),
+ "Requires CERTINEXT_USE_V2_API + V2 credentials and CERTINEXT_V2_UNRESOLVED_STATUS_PROBE=1.");
+
+ using var client = BuildV2Client();
+ int total = 0;
+ var combos = new Dictionary();
+ var unresolved = new List();
+ // Rows the mapping places, but only from one of the two fields, or where the fields disagree.
+ var mismatched = new List();
+ await foreach (var row in client.ListOrdersV2Async())
+ {
+ total++;
+ string key = $"{row.OrderStatus} | {row.CertificateStatus}";
+ combos[key] = combos.TryGetValue(key, out int c) ? c + 1 : 1;
+ if (CERTInextCAPlugin.MapV2ReportStatusToDisposition(row.OrderStatus, row.CertificateStatus) == null)
+ unresolved.Add(row);
+ else if (CERTInextCAPlugin.MapV2ReportStatusToDisposition(null, row.CertificateStatus) == null
+ || CERTInextCAPlugin.MapV2ReportStatusToDisposition(null, row.CertificateStatus)
+ != CERTInextCAPlugin.MapV2ReportStatusToDisposition(row.OrderStatus, null))
+ mismatched.Add(row);
+ }
+ _output.WriteLine($"Report rows: {total}. Status combinations:");
+ foreach (var kv in combos.OrderByDescending(k => k.Value))
+ _output.WriteLine($" {kv.Value,5} {kv.Key}");
+
+ // Track a sample of each mismatched combination (not every row: a combination can have hundreds).
+ var sample = mismatched.GroupBy(r => $"{r.OrderStatus} | {r.CertificateStatus}")
+ .SelectMany(g => g.Take(3)).ToList();
+ _output.WriteLine($"Mapped from one field only, or fields disagree: {mismatched.Count} rows; tracking up to 3 per combination:");
+ foreach (var row in sample)
+ {
+ int? mapped = CERTInextCAPlugin.MapV2ReportStatusToDisposition(row.OrderStatus, row.CertificateStatus);
+ string track;
+ try
+ {
+ var (family, st) = await client.ResolveAndTrackOrderV2WithFamilyAsync(row.OrderNumber);
+ track = $"status={st.Status}, orderState={st.OrderState}, certificateState={st.CertificateState}";
+ }
+ catch (Exception ex)
+ {
+ track = $"{ex.GetType().Name}: {ex.Message}";
+ }
+ _output.WriteLine($" {row.OrderNumber} '{row.OrderStatus} | {row.CertificateStatus}' mapped={mapped} " +
+ $"product={row.ProductCode} -> Track: {track}");
+ }
+
+ _output.WriteLine($"Unresolved (would fall back to Track Order): {unresolved.Count}");
+ foreach (var row in unresolved)
+ {
+ string track;
+ try
+ {
+ var (family, st) = await client.ResolveAndTrackOrderV2WithFamilyAsync(row.OrderNumber);
+ track = $"family={family}, status={st.Status}, orderState={st.OrderState}, certificateState={st.CertificateState}";
+ }
+ catch (Exception ex)
+ {
+ track = $"{ex.GetType().Name}: {ex.Message}";
+ }
+ _output.WriteLine($" {row.OrderNumber} orderStatus='{row.OrderStatus}' certificateStatus='{row.CertificateStatus}' " +
+ $"product={row.ProductCode} orderDate={row.OrderDate} -> Track: {track}");
+ }
+ }
}
///
diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs
index 22533bd..b9027a7 100644
--- a/CERTInext/CERTInextCAPlugin.cs
+++ b/CERTInext/CERTInextCAPlugin.cs
@@ -1757,9 +1757,12 @@ private async Task EnrollV2Async(
var result = await EnrollV2CoreAsync(csr, subject, san, ep, enrollmentType, reissue);
// A reissue (or a reissue that fell back to a new order) says so in Command's result.
if (result != null && !string.IsNullOrWhiteSpace(reissue.Note))
- result.StatusMessage = string.IsNullOrWhiteSpace(result.StatusMessage)
+ {
+ string message = result.StatusMessage?.TrimEnd();
+ result.StatusMessage = string.IsNullOrEmpty(message)
? reissue.Note
- : result.StatusMessage.TrimEnd() + " " + reissue.Note;
+ : (message.EndsWith(".") ? message : message + ".") + " " + reissue.Note;
+ }
return result;
}
From af91cf7d9cd24c2a05da1e5c815c63720013d01f Mon Sep 17 00:00:00 2001
From: spbsoluble <1661003+spbsoluble@users.noreply.github.com>
Date: Fri, 13 Nov 2026 16:53:21 -0800
Subject: [PATCH 08/11] docs(tests): document the V2 spec follow-up opt-in
probes
---
CERTInext.IntegrationTests/INTEGRATION_TESTING.md | 15 +++++++++++++++
docsource/development.md | 3 +++
2 files changed, 18 insertions(+)
diff --git a/CERTInext.IntegrationTests/INTEGRATION_TESTING.md b/CERTInext.IntegrationTests/INTEGRATION_TESTING.md
index d043dc1..8bb01e4 100644
--- a/CERTInext.IntegrationTests/INTEGRATION_TESTING.md
+++ b/CERTInext.IntegrationTests/INTEGRATION_TESTING.md
@@ -250,3 +250,18 @@ Before blaming the plugin, separate CA-side from plugin-side failures: run the s
on a **fresh** name under the validated parent zone, and with the CSR **inline**. Change one variable
at a time. Record confirmed spec-vs-live discrepancies as dated Findings in the CERTInext
support-questions list kept with the maintainers.
+
+## V2 spec follow-up probes (issues 0094/0095)
+
+`V2SpecFollowupsLiveTests` holds one opt-in test per follow-up, each on top of `CERTINEXT_USE_V2_API=1`:
+
+| Flag | Test | Touches |
+|---|---|---|
+| `CERTINEXT_V2_SSLCOM_DCV_PROBE=1` | `SslComDomainDcv_LookupAndShape_Probe` | Read-only |
+| `CERTINEXT_V2_UNRESOLVED_STATUS_PROBE=1` | `Sync_UnresolvedReportStatuses_Probe` | Read-only |
+| `CERTINEXT_V2_REVOKE_PRIVILEGE_WITHDRAWN=1` | `Revoke_PrivilegeWithdrawn_OwnOrder_ReportsCaAcceptance` | Places and revokes its own order |
+| `CERTINEXT_V2_REVOKE_REASON_PROBE_ORDER_ID=` | `Revoke_ReasonAcceptance_OwnOrder_Probe` | Sends each revoke reason to that order |
+| `CERTINEXT_V2_LIFECYCLE_REISSUE_ENDPOINT=1` + `CERTINEXT_V2_REISSUE_SOURCE_ORDER_ID=` | `Reissue_V2_ReissueEndpoint_DerivedOrder_Report` | Creates a derived order, then cleans it up |
+
+On a sandbox that has stopped generating certificates, the cleanup of an order the test created can fail with "Certificate Request still being processed". Clean such orders up later with `CERTINEXT_V2_CLEANUP_ORDERS`.
+
diff --git a/docsource/development.md b/docsource/development.md
index 16de668..43a990a 100644
--- a/docsource/development.md
+++ b/docsource/development.md
@@ -62,6 +62,9 @@ Tests that place real orders, publish DNS records, or cancel orders run only whe
| `CERTINEXT_PRIVATE_PKI_LIVE` | One live Private PKI order and revoke |
| `CERTINEXT_V2_OPS_TESTS` | V2 order-window sweep (maintenance tool) |
| `CERTINEXT_V2_LIFECYCLE_DV_UCC`, `_OV`, `_OV_UCC`, `_EV`, `_WILDCARD_DV`, `_RENEW_REISSUE`, `_FRESH_DCV` | The matching V2 full-lifecycle test |
+| `CERTINEXT_V2_LIFECYCLE_REISSUE_ENDPOINT` (plus `CERTINEXT_V2_REISSUE_SOURCE_ORDER_ID`) | One reissue of an issued order through `/reissue`; creates a derived order and cleans it up |
+| `CERTINEXT_V2_SSLCOM_DCV_PROBE`, `CERTINEXT_V2_UNRESOLVED_STATUS_PROBE` | Read-only probes: SSL.com domain lookup and DCV shape; report status pairs the sync can't map |
+| `CERTINEXT_V2_REVOKE_PRIVILEGE_WITHDRAWN` | Places its own DV order and revokes it with `privilege-withdrawn` |
Set each to `1`. [INTEGRATION_TESTING.md](../CERTInext.IntegrationTests/INTEGRATION_TESTING.md) lists the remaining flags, such as the algorithm matrices, and the extra variables each test needs. To load `~/.env_certinext` into a shell that also exports a flag, use `set -a; . ~/.env_certinext; set +a`.
From d6b54cda13b4d25031c571ad274f709b674170ef Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Sat, 14 Nov 2026 00:57:28 +0000
Subject: [PATCH 09/11] 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 e28cdda..2119b34 100644
--- a/README.md
+++ b/README.md
@@ -205,6 +205,7 @@ CERTInext operates three separate environments. Use the sandbox environment for
* **V2SyncLookbackHours** - OPTIONAL (V2 only): Hours subtracted from the last-sync time on incremental sync, so an order created earlier but issued later is not missed. Default: 72.
* **SyncRecentOrderGraceSeconds** - OPTIONAL: during a Synchronize (full or incremental, in both V1 and V2 modes), a pending or issued order created less than this many seconds ago (plus a small clock margin) is not emitted to the gateway (an issued one is not downloaded); the next incremental pass imports it. This stops the sync from inserting a gateway row for an order whose enrollment is still in flight, which would make the enrollment's own save fail with a duplicate key. Revoked and failed orders are never held back. 0 disables the hold; a negative value uses the default. Default: 120.
* **V2InlineCsr** - OPTIONAL (V2 mode only): when true (the default), SSL/TLS enrollment sends the CSR inline in the order create call (a single POST /ssl-certificates), as a direct API integration does. When false, the plugin creates the order and then submits the CSR (PUT .../csr); that two-call flow fails for a domain the account has already validated, because CERTInext approves the order at create and then rejects the CSR. A create that times out or returns a 5xx can leave an order that still issues; it is not cancelled and the next synchronization imports it, so do not resubmit. Private PKI orders ignore this setting and always use the two-call flow (the CA rejects an inline CSR on Private PKI create). Ignored when UseV2Api is false. Default: true (single call).
+ * **V2ReissueRevokePrevious** - OPTIONAL (V2 mode only, SSL/TLS reissue): a V2 SSL/TLS reissue goes through CERTInext's reissue endpoint, which creates a new order derived from the original (for example 1984857369R001). When true, that call also asks CERTInext to revoke the previous certificate with reason superseded; the intent is audit-logged before the call. When false (the default), the previous certificate stays valid until it expires or is revoked separately. Ignored when UseV2Api is false. Default: false.
* **SslComSubmitOrganization** - OPTIONAL (V2 mode only): before an OV or EV SSL/TLS order, the plugin checks whether SSL.com has validated the organization (OrganizationNumber) at that level, refreshing the status once if not. When this is true and the organization is still unvalidated and ready to submit, the plugin also SUBMITS it to SSL.com for validation. WARNING: enabling this ACCEPTS SSL.COM'S SUBSCRIBER AGREEMENT ON THE ORGANIZATION'S BEHALF; each acceptance is audit-logged. Leave false unless you are authorized to do that. Whatever the outcome, the order is placed anyway and a not-validated organization only logs a warning. Default: false.
* **SslComAdminEmail** - OPTIONAL (V2 mode only): administrator email sent as adminEmail when the plugin submits an organization to SSL.com (only used when SslComSubmitOrganization is true). Blank omits it and SSL.com uses the organization's own contact.
From ae972145632c8ca49ce3475b02b8acffaf7e3ad3 Mon Sep 17 00:00:00 2001
From: spbsoluble <1661003+spbsoluble@users.noreply.github.com>
Date: Fri, 13 Nov 2026 19:19:42 -0800
Subject: [PATCH 10/11] fix(sync): skip report-listed V2 orders Track Order
can't find, instead of counting them as errors
Every 'On Hold | Setup Pending' row on the sandbox falls back to Track Order, which finds the
order in no family. Each one logged an error with a stack trace and counted toward the
25% error-rate abort. Such rows are now skipped for the pass with one Information line and
counted as UntrackableListedOrders in the summary; a later pass tracks them again. Nothing is
emitted for them (no evidence the order failed). The reissue success log now carries the
HTTP status. Adds a read-only live full-sync summary probe.
---
.../INTEGRATION_TESTING.md | 1 +
.../V2SpecFollowupsLiveTests.cs | 34 ++++++++++++++
CERTInext.Tests/CERTInextCAPluginV2Tests.cs | 45 +++++++++++++++++++
CERTInext/CERTInextCAPlugin.cs | 35 ++++++++++++---
CHANGELOG.md | 1 +
README.md | 2 +-
docsource/configuration.md | 2 +-
7 files changed, 112 insertions(+), 8 deletions(-)
diff --git a/CERTInext.IntegrationTests/INTEGRATION_TESTING.md b/CERTInext.IntegrationTests/INTEGRATION_TESTING.md
index 8bb01e4..8bba2db 100644
--- a/CERTInext.IntegrationTests/INTEGRATION_TESTING.md
+++ b/CERTInext.IntegrationTests/INTEGRATION_TESTING.md
@@ -261,6 +261,7 @@ support-questions list kept with the maintainers.
| `CERTINEXT_V2_UNRESOLVED_STATUS_PROBE=1` | `Sync_UnresolvedReportStatuses_Probe` | Read-only |
| `CERTINEXT_V2_REVOKE_PRIVILEGE_WITHDRAWN=1` | `Revoke_PrivilegeWithdrawn_OwnOrder_ReportsCaAcceptance` | Places and revokes its own order |
| `CERTINEXT_V2_REVOKE_REASON_PROBE_ORDER_ID=` | `Revoke_ReasonAcceptance_OwnOrder_Probe` | Sends each revoke reason to that order |
+| `CERTINEXT_V2_FULL_SYNC_PROBE=1` | `Sync_V2_FullSync_Summary_Probe` | Read-only (downloads issued certificates) |
| `CERTINEXT_V2_LIFECYCLE_REISSUE_ENDPOINT=1` + `CERTINEXT_V2_REISSUE_SOURCE_ORDER_ID=` | `Reissue_V2_ReissueEndpoint_DerivedOrder_Report` | Creates a derived order, then cleans it up |
On a sandbox that has stopped generating certificates, the cleanup of an order the test created can fail with "Certificate Request still being processed". Clean such orders up later with `CERTINEXT_V2_CLEANUP_ORDERS`.
diff --git a/CERTInext.IntegrationTests/V2SpecFollowupsLiveTests.cs b/CERTInext.IntegrationTests/V2SpecFollowupsLiveTests.cs
index dcae383..8af23b4 100644
--- a/CERTInext.IntegrationTests/V2SpecFollowupsLiveTests.cs
+++ b/CERTInext.IntegrationTests/V2SpecFollowupsLiveTests.cs
@@ -6,6 +6,7 @@
// and limitations under the License.
using System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
@@ -524,6 +525,39 @@ public async Task Sync_UnresolvedReportStatuses_Probe()
$"product={row.ProductCode} orderDate={row.OrderDate} -> Track: {track}");
}
}
+ ///
+ /// Read-only. Runs the plugin's real full V2 Synchronize (DCV off), scoped to
+ /// CERTINEXT_GROUP_NUMBER like the lab gateway, and reports what it emitted. The plugin's
+ /// own "synchronization complete" line (UnresolvedStatusFallbacks, UntrackableListedOrders,
+ /// Errors) goes to the test output; run with CERTINEXT_TEST_LOG_LEVEL=Information to keep
+ /// the output short. Downloads the certificate of every issued order it lists.
+ /// Opt-in: CERTINEXT_V2_FULL_SYNC_PROBE=1.
+ ///
+ [SkippableFact]
+ public async Task Sync_V2_FullSync_Summary_Probe()
+ {
+ Skip.If(!_v2Enabled || !Flag("CERTINEXT_V2_FULL_SYNC_PROBE"),
+ "Requires CERTINEXT_USE_V2_API + V2 credentials and CERTINEXT_V2_FULL_SYNC_PROBE=1.");
+
+ var restoreLogging = TestOutputLogging.RouteTo(_output);
+ try
+ {
+ var config = BuildV2Config();
+ config.GroupNumber = V2EnvHelper.GetEnv(_env, "CERTINEXT_GROUP_NUMBER", string.Empty);
+ var plugin = BuildV2Plugin(config);
+ var buffer = new BlockingCollection(boundedCapacity: 20_000);
+ await plugin.Synchronize(buffer, lastSync: null, fullSync: true, cancelToken: System.Threading.CancellationToken.None);
+ buffer.CompleteAdding();
+ var byStatus = buffer.GroupBy(r => r.Status).OrderBy(g => g.Key)
+ .Select(g => $"{g.Key}={g.Count()}");
+ _output.WriteLine($"RESULT: emitted {buffer.Count} records by status: {string.Join(", ", byStatus)} " +
+ $"(GroupNumber={(string.IsNullOrEmpty(config.GroupNumber) ? "(none)" : config.GroupNumber)})");
+ }
+ finally
+ {
+ restoreLogging();
+ }
+ }
}
///
diff --git a/CERTInext.Tests/CERTInextCAPluginV2Tests.cs b/CERTInext.Tests/CERTInextCAPluginV2Tests.cs
index 1240490..8c4c49a 100644
--- a/CERTInext.Tests/CERTInextCAPluginV2Tests.cs
+++ b/CERTInext.Tests/CERTInextCAPluginV2Tests.cs
@@ -8,6 +8,7 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
@@ -680,6 +681,50 @@ public async Task Synchronize_V2Enabled_IssuedRow_DownloadsCertificateBody()
It.IsAny(), It.IsAny()), Times.Never);
}
+ [Fact]
+ public async Task Synchronize_V2Enabled_OnHoldSetupPending_TrackNotFound_SkippedNotError_OthersStillSynced()
+ {
+ var mock = new Mock();
+ // Live shape: listed by the report as "On Hold | Setup Pending", 404 in every family.
+ var onHold = ReportRow("3549334354", "On Hold", "Setup Pending");
+ var issued = ReportRow("ord_v2sync_ok", "Order Fulfilled", "Certificate Downloaded");
+ mock.Setup(c => c.ListOrdersV2Async(
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(AsyncEnumerable(onHold, issued));
+ mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync("3549334354", It.IsAny()))
+ .ThrowsAsync(new KeyNotFoundException("V2 order '3549334354' not found in any product family."));
+ mock.Setup(c => c.ResolveAndDownloadCertificateV2Async("ord_v2sync_ok", It.IsAny()))
+ .ReturnsAsync(new V2CertificateDownloadResponse
+ {
+ OrderId = "ord_v2sync_ok", SerialNumber = "AABB", CertificatePem = MockCertificateData.FakePemCertificate
+ });
+
+ var buffer = new BlockingCollection(100);
+ await BuildV2Plugin(mock.Object).Synchronize(buffer, null, true, CancellationToken.None);
+
+ buffer.ToArray().Select(r => r.CARequestID).Should().Equal("ord_v2sync_ok");
+ }
+
+ [Fact]
+ public async Task Synchronize_V2Enabled_ManyUntrackableRows_DoNotTripTheErrorRateAbort()
+ {
+ // Before: each 404 fallback counted as an error, so a pass made mostly of such rows threw
+ // "synchronization aborted ... >25% error rate".
+ var mock = new Mock();
+ var rows = Enumerable.Range(0, 60).Select(i => ReportRow($"onhold_{i}", "On Hold", "Setup Pending")).ToArray();
+ mock.Setup(c => c.ListOrdersV2Async(
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(AsyncEnumerable(rows));
+ mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync(It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new KeyNotFoundException("not found in any product family"));
+
+ var buffer = new BlockingCollection(100);
+ Func act = () => BuildV2Plugin(mock.Object).Synchronize(buffer, null, true, CancellationToken.None);
+
+ await act.Should().NotThrowAsync();
+ buffer.Should().BeEmpty();
+ }
+
[Fact]
public async Task Synchronize_V2Enabled_UnrecognizedStatus_FallsBackToLiveTrack()
{
diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs
index b9027a7..4a82f74 100644
--- a/CERTInext/CERTInextCAPlugin.cs
+++ b/CERTInext/CERTInextCAPlugin.cs
@@ -2810,8 +2810,8 @@ private async Task TryReissueV2Async(
{
_logger.LogInformation(
"V2 reissue created a derived order. PriorCARequestID={PriorId}, NewOrderId={NewId}, " +
- "Status={Status}, PreviousRevocationRequested={RevokePrevious}",
- priorOrderId, outcome.Order.OrderId, outcome.Order.Status, revokePrevious);
+ "HttpStatus={HttpStatus}, Status={Status}, PreviousRevocationRequested={RevokePrevious}",
+ priorOrderId, outcome.Order.OrderId, outcome.HttpStatus, outcome.Order.Status, revokePrevious);
reissue.Note = $"Reissued from CERTInext order {priorOrderId}" +
(revokePrevious ? "; revocation of the previous certificate (superseded) was requested." : ".");
return outcome.Order;
@@ -3508,6 +3508,8 @@ private async Task SynchronizeV2Async(
int errors = 0;
int deferredRecentOrders = 0; // pending/issued orders withheld because they are younger than the grace window
int unresolvedStatusFallbacks = 0; // report rows whose display strings needed a live track call
+ // Report rows Track Order can't find in any family (seen live: "On Hold | Setup Pending").
+ int untrackableListedOrders = 0;
int issuedNotGeneratedYet = 0; // issued orders whose download answered 422 EMS-1165 (certificate not generated yet)
// Sync has no template, so the agreement signer is the connector's: RequestorName (the
// enroll default for SignerName) and SignerPlace. One context per pass: each order is
@@ -3580,8 +3582,28 @@ private async Task SynchronizeV2Async(
"V2 sync: unrecognised report status — falling back to live track. " +
"Id={Id}, OrderStatus={OrderStatus}, CertificateStatus={CertificateStatus}",
row.OrderNumber, row.OrderStatus, row.CertificateStatus);
- (resolvedFamily, trackedStatus) =
- await _client.ResolveAndTrackOrderV2WithFamilyAsync(row.OrderNumber, cancelToken);
+ try
+ {
+ (resolvedFamily, trackedStatus) =
+ await _client.ResolveAndTrackOrderV2WithFamilyAsync(row.OrderNumber, cancelToken);
+ }
+ catch (KeyNotFoundException)
+ {
+ // The report lists the order but Track Order finds it in no family, so
+ // there is nothing to track or download. Seen live for every
+ // "On Hold | Setup Pending" row (meaning undocumented; asked of CERTInext).
+ // Not an error: it must not count toward the error-rate abort. Skipped,
+ // not emitted as failed, because nothing says the order failed; a later
+ // pass tracks it again.
+ _logger.LogInformation(
+ "V2 sync: order listed by /reports/orders but not found by Track Order in any " +
+ "product family; skipping it this pass. Id={Id}, OrderStatus={OrderStatus}, " +
+ "CertificateStatus={CertificateStatus}",
+ row.OrderNumber, row.OrderStatus, row.CertificateStatus);
+ untrackableListedOrders++;
+ skipped++;
+ continue;
+ }
disposition = StatusMapper.V2StatusToRequestDisposition(trackedStatus.Status);
}
@@ -3879,14 +3901,15 @@ await TryAcceptV2AgreementAsync(
#endif
_logger.LogInformation(
"CERTInext V2 synchronization complete. Synced={Synced}, Skipped={Skipped}, Errors={Errors}, " +
- "UnresolvedStatusFallbacks={Fallbacks}. Emitted to gateway buffer: GeneratedWithBody={GenWithBody}, " +
+ "UnresolvedStatusFallbacks={Fallbacks}, UntrackableListedOrders={Untrackable} (listed by the " +
+ "report, not found by Track Order; skipped). Emitted to gateway buffer: GeneratedWithBody={GenWithBody}, " +
"GeneratedNoBody={GenNoBody}, Revoked={Revoked}, Pending={Pending}, " +
"FailedFromBodylessRevoked={FailedFromBodylessRevoked}. " +
"BodylessRevokedSkipped={BodylessRevokedSkipped} (bodyless-revoked guard). " +
"RecentOrdersDeferred={RecentOrdersDeferred} (pending/issued, younger than {Grace}s, imported next pass). " +
"IssuedNotGeneratedYet={IssuedNotGeneratedYet} (issued at the CA, certificate not downloadable yet, " +
"422 EMS-1165; picked up by a later pass). {DcvClause}",
- synced, skipped, errors, unresolvedStatusFallbacks,
+ synced, skipped, errors, unresolvedStatusFallbacks, untrackableListedOrders,
emittedGeneratedWithBody, emittedGeneratedNoBody, emittedRevoked, emittedPending,
emittedFailedFromBodylessRevoked, skippedBodylessRevoked,
deferredRecentOrders, recentOrderGraceSeconds,
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ce81c12..b7f43a5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -31,6 +31,7 @@
- fix(logging): Email addresses in CERTInext V2 error text (the problem+json `title`, `detail`, and `errors[]` that end up in exception messages and logs) are masked unless `LogSensitiveRequestData` is on, as V1 error text is.
- fix(logging): V2 payload dumps no longer run redaction when Trace logging is off.
- fix(enroll): V2 order and CSR submissions are never auto-retried after a timeout, which could create duplicate orders (V1 since 1.0.1).
+- fix(sync): A V2 order that `/reports/orders` lists but Track Order finds in no product family (seen for every `On Hold | Setup Pending` row) is skipped with one Information line and counted as `UntrackableListedOrders` in the sync summary. It used to log an error with a stack trace and count toward the sync's 25% error-rate abort.
- fix(v2): A V2 OAuth2 token request that gets no HTTP response (a dropped connection or other transport error) is retried twice, after 1 s and 3 s, so one network blip no longer fails an enrollment, a sync or a connector save. HTTP errors (400/401/403) are not retried, and order creates are still never retried. Token failures and other V2 calls with no response now log the transport detail (`TransportDetail=`: RestSharp's response status and the exception types and messages, with credentials removed) and include a short form in the error, instead of only `HttpStatus=0`.
- fix(sync): The V2 sync also holds back pending and issued orders created in the last 120 s (`SyncRecentOrderGraceSeconds`, `0` disables), so an in-flight enrollment no longer fails with a duplicate key (V1 since 1.0.1).
- fix(enroll): The in-call pickup wait is now capped at 75 s (it was 180 s) for V1 and V2, inside Command's roughly 100 s enrollment-call timeout, so a pickup that used its whole budget is no longer abandoned by Command. The 55 s default (`PickupRetries` 5, `PickupDelay` 10) is unaffected.
diff --git a/README.md b/README.md
index 2119b34..db1403a 100644
--- a/README.md
+++ b/README.md
@@ -646,7 +646,7 @@ mapped to Failed — see the gateway trace log if certificates unexpectedly show
**V2 synchronization:**
- Synchronize pages through `/reports/orders`. Pages are capped at 100 rows, and `PageSize` is clamped to that.
-- The report carries display strings (for example `Order Fulfilled`, `Certificate Downloaded`) rather than the status enum above. Recognized strings are mapped directly. Any other combination falls back to a live order-status call, so a new display value never silently misclassifies an order.
+- The report carries display strings (for example `Order Fulfilled`, `Certificate Downloaded`) rather than the status enum above. Recognized strings are mapped directly. Any other combination falls back to a live order-status call, so a new display value never silently misclassifies an order. If that call finds the order in no product family, as for every `On Hold | Setup Pending` row on the sandbox, the order is skipped for that pass and counted as `UntrackableListedOrders`; it isn't treated as an error.
- Cancelled and rejected orders are skipped. Issued orders have their certificate chain downloaded.
- CERTInext does not serve the certificate body of a revoked order. A revoked order is propagated as revoked only when the gateway already holds that certificate (the revocation date and reason come from a live status call).
- Otherwise a revoked order is reported as Failed (the gateway has a record without a body) or skipped (no record), so the gateway never stores a revoked record without a certificate.
diff --git a/docsource/configuration.md b/docsource/configuration.md
index 176a761..20a977a 100644
--- a/docsource/configuration.md
+++ b/docsource/configuration.md
@@ -597,7 +597,7 @@ mapped to Failed — see the gateway trace log if certificates unexpectedly show
**V2 synchronization:**
- Synchronize pages through `/reports/orders`. Pages are capped at 100 rows, and `PageSize` is clamped to that.
-- The report carries display strings (for example `Order Fulfilled`, `Certificate Downloaded`) rather than the status enum above. Recognized strings are mapped directly. Any other combination falls back to a live order-status call, so a new display value never silently misclassifies an order.
+- The report carries display strings (for example `Order Fulfilled`, `Certificate Downloaded`) rather than the status enum above. Recognized strings are mapped directly. Any other combination falls back to a live order-status call, so a new display value never silently misclassifies an order. If that call finds the order in no product family, as for every `On Hold | Setup Pending` row on the sandbox, the order is skipped for that pass and counted as `UntrackableListedOrders`; it isn't treated as an error.
- Cancelled and rejected orders are skipped. Issued orders have their certificate chain downloaded.
- CERTInext does not serve the certificate body of a revoked order. A revoked order is propagated as revoked only when the gateway already holds that certificate (the revocation date and reason come from a live status call).
- Otherwise a revoked order is reported as Failed (the gateway has a record without a body) or skipped (no record), so the gateway never stores a revoked record without a certificate.
From a9d4ef6e419878a4eb6778672c3131a2321c7793 Mon Sep 17 00:00:00 2001
From: spbsoluble <1661003+spbsoluble@users.noreply.github.com>
Date: Fri, 13 Nov 2026 19:28:03 -0800
Subject: [PATCH 11/11] fix(sync): name unmapped /reports/orders status pairs
in the V2 sync summary
CERTInext documents none of the report's orderStatus/certificateStatus display strings
(Finding 35), so the only way to learn new ones is to observe them. The summary line now
carries UnmappedReportStatuses=[pair (count); ...] at Information instead of leaving each
to a per-row Debug line.
---
.../V2SyncUnmappedStatusSummaryTests.cs | 73 +++++++++++++++++++
CERTInext/CERTInextCAPlugin.cs | 13 +++-
CHANGELOG.md | 1 +
README.md | 2 +-
docsource/configuration.md | 2 +-
5 files changed, 87 insertions(+), 4 deletions(-)
create mode 100644 CERTInext.Tests/V2SyncUnmappedStatusSummaryTests.cs
diff --git a/CERTInext.Tests/V2SyncUnmappedStatusSummaryTests.cs b/CERTInext.Tests/V2SyncUnmappedStatusSummaryTests.cs
new file mode 100644
index 0000000..d4000e3
--- /dev/null
+++ b/CERTInext.Tests/V2SyncUnmappedStatusSummaryTests.cs
@@ -0,0 +1,73 @@
+// 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,
+// 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.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Keyfactor.AnyGateway.Extensions;
+using Keyfactor.Extensions.CAPlugin.CERTInext.API.V2;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests
+{
+ ///
+ /// The /reports/orders status text is undocumented (Finding 35): the V2 sync summary names every
+ /// orderStatus|certificateStatus pair it couldn't map, with counts, at Information.
+ ///
+ [Collection(LoggingStateCollection.Name)]
+ public class V2SyncUnmappedStatusSummaryTests
+ {
+ private static OrderReportEntryV2 Row(string id, string orderStatus, string certificateStatus) =>
+ new OrderReportEntryV2
+ {
+ OrderNumber = id, OrderStatus = orderStatus, CertificateStatus = certificateStatus,
+ DomainName = "example.com", ProductCode = "842",
+ OrderDate = System.DateTime.UtcNow.AddHours(-1).ToString("o")
+ };
+
+ private static async IAsyncEnumerable Rows(params OrderReportEntryV2[] rows)
+ {
+ foreach (var r in rows) yield return r;
+ await Task.CompletedTask;
+ }
+
+ [Fact]
+ public async Task Summary_ListsEachUnmappedPairWithCount_AndUntrackableCount()
+ {
+ using var capture = new PluginLogCapture();
+ var mock = new Mock();
+ mock.Setup(c => c.ListOrdersV2Async(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(Rows(
+ Row("a1", "On Hold", "Setup Pending"),
+ Row("a2", "On Hold", "Setup Pending"),
+ Row("b1", "Brand New", "Never Seen"),
+ Row("c1", "Order Fulfilled", "Certificate Downloaded")));
+ mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync(It.Is(id => id.StartsWith("a")), It.IsAny()))
+ .ThrowsAsync(new KeyNotFoundException("not found in any product family"));
+ mock.Setup(c => c.ResolveAndTrackOrderV2WithFamilyAsync("b1", It.IsAny()))
+ .ReturnsAsync((Constants.ApiV2.FamilySsl, new V2OrderStatusResponse { OrderId = "b1", Status = "cancelled" }));
+ mock.Setup(c => c.ResolveAndDownloadCertificateV2Async("c1", It.IsAny()))
+ .ReturnsAsync(new V2CertificateDownloadResponse { OrderId = "c1", SerialNumber = "AA", CertificatePem = MockCertificateData.FakePemCertificate });
+ var config = new CERTInextConfig { UseV2Api = true, ApiUrl = "https://v2.certinext.io", OAuthClientId = "id", OAuthClientSecret = "s" };
+ var plugin = new CERTInextCAPlugin(mock.Object, config);
+
+ await plugin.Synchronize(new BlockingCollection(10), null, true, CancellationToken.None);
+
+ var summary = capture.Lines(LogLevel.Information, "synchronization complete").Should().ContainSingle().Subject;
+ summary.Should().Contain("UnresolvedStatusFallbacks=3")
+ .And.Contain("UnmappedReportStatuses=[Brand New | Never Seen (1); On Hold | Setup Pending (2)]")
+ .And.Contain("UntrackableListedOrders=2")
+ .And.Contain("Errors=0");
+ }
+ }
+}
diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs
index 4a82f74..453c44e 100644
--- a/CERTInext/CERTInextCAPlugin.cs
+++ b/CERTInext/CERTInextCAPlugin.cs
@@ -3510,6 +3510,10 @@ private async Task SynchronizeV2Async(
int unresolvedStatusFallbacks = 0; // report rows whose display strings needed a live track call
// Report rows Track Order can't find in any family (seen live: "On Hold | Setup Pending").
int untrackableListedOrders = 0;
+ // Distinct orderStatus|certificateStatus pairs the display-string mapping didn't know, with
+ // counts. The vocabulary is undocumented (Finding 35), so the summary names every new
+ // value at Information instead of leaving it to per-row Debug lines.
+ var unmappedStatusPairs = new SortedDictionary(StringComparer.Ordinal);
int issuedNotGeneratedYet = 0; // issued orders whose download answered 422 EMS-1165 (certificate not generated yet)
// Sync has no template, so the agreement signer is the connector's: RequestorName (the
// enroll default for SignerName) and SignerPlace. One context per pass: each order is
@@ -3578,6 +3582,8 @@ private async Task SynchronizeV2Async(
// exhaustive. Don't guess: fall back to a live TrackOrder call, which
// returns the authoritative V2 `status` enum via StatusMapper.
unresolvedStatusFallbacks++;
+ string pair = $"{row.OrderStatus ?? "(none)"} | {row.CertificateStatus ?? "(none)"}";
+ unmappedStatusPairs[pair] = unmappedStatusPairs.TryGetValue(pair, out int seen) ? seen + 1 : 1;
_logger.LogDebug(
"V2 sync: unrecognised report status — falling back to live track. " +
"Id={Id}, OrderStatus={OrderStatus}, CertificateStatus={CertificateStatus}",
@@ -3901,7 +3907,8 @@ await TryAcceptV2AgreementAsync(
#endif
_logger.LogInformation(
"CERTInext V2 synchronization complete. Synced={Synced}, Skipped={Skipped}, Errors={Errors}, " +
- "UnresolvedStatusFallbacks={Fallbacks}, UntrackableListedOrders={Untrackable} (listed by the " +
+ "UnresolvedStatusFallbacks={Fallbacks}, UnmappedReportStatuses=[{UnmappedPairs}], " +
+ "UntrackableListedOrders={Untrackable} (listed by the " +
"report, not found by Track Order; skipped). Emitted to gateway buffer: GeneratedWithBody={GenWithBody}, " +
"GeneratedNoBody={GenNoBody}, Revoked={Revoked}, Pending={Pending}, " +
"FailedFromBodylessRevoked={FailedFromBodylessRevoked}. " +
@@ -3909,7 +3916,9 @@ await TryAcceptV2AgreementAsync(
"RecentOrdersDeferred={RecentOrdersDeferred} (pending/issued, younger than {Grace}s, imported next pass). " +
"IssuedNotGeneratedYet={IssuedNotGeneratedYet} (issued at the CA, certificate not downloadable yet, " +
"422 EMS-1165; picked up by a later pass). {DcvClause}",
- synced, skipped, errors, unresolvedStatusFallbacks, untrackableListedOrders,
+ synced, skipped, errors, unresolvedStatusFallbacks,
+ LogSanitizer.Strip(string.Join("; ", unmappedStatusPairs.Select(kv => $"{kv.Key} ({kv.Value})"))),
+ untrackableListedOrders,
emittedGeneratedWithBody, emittedGeneratedNoBody, emittedRevoked, emittedPending,
emittedFailedFromBodylessRevoked, skippedBodylessRevoked,
deferredRecentOrders, recentOrderGraceSeconds,
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b7f43a5..a8f3fcd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -32,6 +32,7 @@
- fix(logging): V2 payload dumps no longer run redaction when Trace logging is off.
- fix(enroll): V2 order and CSR submissions are never auto-retried after a timeout, which could create duplicate orders (V1 since 1.0.1).
- fix(sync): A V2 order that `/reports/orders` lists but Track Order finds in no product family (seen for every `On Hold | Setup Pending` row) is skipped with one Information line and counted as `UntrackableListedOrders` in the sync summary. It used to log an error with a stack trace and count toward the sync's 25% error-rate abort.
+- fix(sync): The V2 sync summary lists every `orderStatus | certificateStatus` pair from `/reports/orders` that the plugin didn't recognize, with counts (`UnmappedReportStatuses=[...]`), at Information. CERTInext doesn't document these values, so new ones now show up in ordinary logs.
- fix(v2): A V2 OAuth2 token request that gets no HTTP response (a dropped connection or other transport error) is retried twice, after 1 s and 3 s, so one network blip no longer fails an enrollment, a sync or a connector save. HTTP errors (400/401/403) are not retried, and order creates are still never retried. Token failures and other V2 calls with no response now log the transport detail (`TransportDetail=`: RestSharp's response status and the exception types and messages, with credentials removed) and include a short form in the error, instead of only `HttpStatus=0`.
- fix(sync): The V2 sync also holds back pending and issued orders created in the last 120 s (`SyncRecentOrderGraceSeconds`, `0` disables), so an in-flight enrollment no longer fails with a duplicate key (V1 since 1.0.1).
- fix(enroll): The in-call pickup wait is now capped at 75 s (it was 180 s) for V1 and V2, inside Command's roughly 100 s enrollment-call timeout, so a pickup that used its whole budget is no longer abandoned by Command. The 55 s default (`PickupRetries` 5, `PickupDelay` 10) is unaffected.
diff --git a/README.md b/README.md
index db1403a..1ccdab6 100644
--- a/README.md
+++ b/README.md
@@ -646,7 +646,7 @@ mapped to Failed — see the gateway trace log if certificates unexpectedly show
**V2 synchronization:**
- Synchronize pages through `/reports/orders`. Pages are capped at 100 rows, and `PageSize` is clamped to that.
-- The report carries display strings (for example `Order Fulfilled`, `Certificate Downloaded`) rather than the status enum above. Recognized strings are mapped directly. Any other combination falls back to a live order-status call, so a new display value never silently misclassifies an order. If that call finds the order in no product family, as for every `On Hold | Setup Pending` row on the sandbox, the order is skipped for that pass and counted as `UntrackableListedOrders`; it isn't treated as an error.
+- The report carries display strings (for example `Order Fulfilled`, `Certificate Downloaded`) rather than the status enum above. Recognized strings are mapped directly. Any other combination falls back to a live order-status call, so a new display value never silently misclassifies an order. If that call finds the order in no product family, as for every `On Hold | Setup Pending` row on the sandbox, the order is skipped for that pass and counted as `UntrackableListedOrders`; it isn't treated as an error. CERTInext doesn't document these display strings, so the sync summary names every pair it didn't recognize (`UnmappedReportStatuses=[...]`). Report a new pair so it can be mapped.
- Cancelled and rejected orders are skipped. Issued orders have their certificate chain downloaded.
- CERTInext does not serve the certificate body of a revoked order. A revoked order is propagated as revoked only when the gateway already holds that certificate (the revocation date and reason come from a live status call).
- Otherwise a revoked order is reported as Failed (the gateway has a record without a body) or skipped (no record), so the gateway never stores a revoked record without a certificate.
diff --git a/docsource/configuration.md b/docsource/configuration.md
index 20a977a..f8eae46 100644
--- a/docsource/configuration.md
+++ b/docsource/configuration.md
@@ -597,7 +597,7 @@ mapped to Failed — see the gateway trace log if certificates unexpectedly show
**V2 synchronization:**
- Synchronize pages through `/reports/orders`. Pages are capped at 100 rows, and `PageSize` is clamped to that.
-- The report carries display strings (for example `Order Fulfilled`, `Certificate Downloaded`) rather than the status enum above. Recognized strings are mapped directly. Any other combination falls back to a live order-status call, so a new display value never silently misclassifies an order. If that call finds the order in no product family, as for every `On Hold | Setup Pending` row on the sandbox, the order is skipped for that pass and counted as `UntrackableListedOrders`; it isn't treated as an error.
+- The report carries display strings (for example `Order Fulfilled`, `Certificate Downloaded`) rather than the status enum above. Recognized strings are mapped directly. Any other combination falls back to a live order-status call, so a new display value never silently misclassifies an order. If that call finds the order in no product family, as for every `On Hold | Setup Pending` row on the sandbox, the order is skipped for that pass and counted as `UntrackableListedOrders`; it isn't treated as an error. CERTInext doesn't document these display strings, so the sync summary names every pair it didn't recognize (`UnmappedReportStatuses=[...]`). Report a new pair so it can be mapped.
- Cancelled and rejected orders are skipped. Issued orders have their certificate chain downloaded.
- CERTInext does not serve the certificate body of a revoked order. A revoked order is propagated as revoked only when the gateway already holds that certificate (the revocation date and reason come from a live status call).
- Otherwise a revoked order is reported as Failed (the gateway has a record without a body) or skipped (no record), so the gateway never stores a revoked record without a certificate.