From 2f222c637992eeb69c5e71daa822833fadfa1ed0 Mon Sep 17 00:00:00 2001
From: spbsoluble <1661003+spbsoluble@users.noreply.github.com>
Date: Thu, 5 Nov 2026 13:10:36 -0800
Subject: [PATCH 1/5] fix(sync): never emit a body-less REVOKED record the
gateway doesn't hold (0049, V1)
The V1 client never downloads a revoked certificate's body (CERTInext refuses it,
and IsCertificateDownloadable excludes status 22), so Synchronize and
GetSingleRecord emitted every revoked order with Certificate = null. For an order
the gateway never stored with a body (revoked at CERTInext before the gateway saw
it issued), the gateway persists a row with RevocationDate set and no certificate,
never clears that date, and its revoked-certificate search then fails with
"m_safeCertContext is an invalid handle" on every Command scan of the CA.
Port the V2 0049 guard to V1: before emitting a body-less REVOKED record, ask
ICertificateDataReader.GetExpirationDateByRequestId whether the gateway holds a body.
- date returned (gateway holds a body) -> emit REVOKED unchanged, so out-of-band
CA revokes still propagate.
- null (row exists, no body) -> emit FAILED with no RevocationDate/Reason.
- ArgumentException (no row), any other error, or no reader -> Sync skips the
record; GetSingleRecord returns FAILED (it can't skip).
The sync summary now reports FailedFromBodylessRevoked and BodylessRevokedSkipped.
Rows already written this way are not repaired by the plugin (the gateway never
nulls RevocationDate); they need the documented DB repair. Gateway 26.2.1 has the
same revoked-search query, so the guard is still needed after a gateway upgrade.
---
CERTInext.Tests/BodylessRevokedGuardTests.cs | 244 +++++++++++++++++++
CERTInext/CERTInextCAPlugin.cs | 134 +++++++++-
CHANGELOG.md | 5 +
3 files changed, 380 insertions(+), 3 deletions(-)
create mode 100644 CERTInext.Tests/BodylessRevokedGuardTests.cs
diff --git a/CERTInext.Tests/BodylessRevokedGuardTests.cs b/CERTInext.Tests/BodylessRevokedGuardTests.cs
new file mode 100644
index 0000000..17d4dbd
--- /dev/null
+++ b/CERTInext.Tests/BodylessRevokedGuardTests.cs
@@ -0,0 +1,244 @@
+// 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.API;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using Keyfactor.PKI.Enums.EJBCA;
+using Moq;
+using Xunit;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests
+{
+ ///
+ /// Issue 0049 (V1 port): the V1 client never downloads a revoked certificate's body, so a
+ /// revoked order always reaches Synchronize/GetSingleRecord with Certificate = null.
+ /// Emitted as-is for an order the gateway never stored with a body, that record poisons the
+ /// gateway's revoked-certificate search (FromDER on an empty body → 500 on every Command scan).
+ /// The guard consults :
+ /// a date → REVOKED unchanged; null → FAILED without revocation data; no row / error /
+ /// no reader → Sync skips, GetSingleRecord returns FAILED.
+ ///
+ public class BodylessRevokedGuardTests
+ {
+ private const string Id = MockCertificateData.CertId3;
+
+ /// A revoked record as the V1 client returns it after the sync refetch: no body.
+ private static LegacyGetCertificateResponse BodylessRevoked() =>
+ new LegacyGetCertificateResponse
+ {
+ Id = Id,
+ Status = "revoked",
+ Certificate = null,
+ ProfileId = MockCertificateData.ProfileIdTls,
+ RevokedAt = new DateTime(2026, 10, 20, 0, 0, 0, DateTimeKind.Utc),
+ RevocationReason = "keyCompromise"
+ };
+
+ private static async IAsyncEnumerable AsyncEnum(
+ IEnumerable items)
+ {
+ foreach (var item in items)
+ {
+ yield return item;
+ await Task.Yield();
+ }
+ }
+
+ private static Mock ClientReturning(LegacyGetCertificateResponse refetched)
+ {
+ var mock = new Mock(MockBehavior.Loose);
+ mock.Setup(c => c.ListCertificatesAsync(
+ It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(AsyncEnum(new[] { BodylessRevoked() }));
+ mock.Setup(c => c.GetCertificateAsync(Id, It.IsAny()))
+ .ReturnsAsync(refetched);
+ return mock;
+ }
+
+ private static async Task> RunSync(
+ ICERTInextClient client, ICertificateDataReader reader)
+ {
+ var plugin = new CERTInextCAPlugin(client, reader, new CERTInextConfig());
+ var buffer = new BlockingCollection(10);
+ await plugin.Synchronize(buffer, lastSync: null, fullSync: true, cancelToken: CancellationToken.None);
+ return buffer.ToList();
+ }
+
+ // -------------------------------------------------------------------------------------
+ // Synchronize
+ // -------------------------------------------------------------------------------------
+
+ [Fact]
+ public async Task Sync_GatewayHoldsBody_EmitsBodylessRevokedUnchanged()
+ {
+ var reader = new Mock(MockBehavior.Strict);
+ reader.Setup(r => r.GetExpirationDateByRequestId(Id)).Returns(new DateTime(2027, 1, 1));
+
+ var results = await RunSync(ClientReturning(BodylessRevoked()).Object, reader.Object);
+
+ results.Should().ContainSingle();
+ results[0].Status.Should().Be((int)EndEntityStatus.REVOKED,
+ "an out-of-band CA revoke must still propagate when the gateway already holds the body");
+ results[0].Certificate.Should().BeNull();
+ results[0].RevocationDate.Should().NotBeNull();
+ results[0].RevocationReason.Should().Be(1);
+ }
+
+ [Fact]
+ public async Task Sync_GatewayRowWithoutBody_EmitsFailedWithoutRevocationData()
+ {
+ var reader = new Mock(MockBehavior.Strict);
+ reader.Setup(r => r.GetExpirationDateByRequestId(Id)).Returns((DateTime?)null);
+
+ var results = await RunSync(ClientReturning(BodylessRevoked()).Object, reader.Object);
+
+ results.Should().ContainSingle();
+ results[0].Status.Should().Be((int)EndEntityStatus.FAILED);
+ results[0].Certificate.Should().BeNull();
+ results[0].RevocationDate.Should().BeNull(
+ "a RevocationDate on a body-less row is what the gateway's revoked search crashes on");
+ results[0].RevocationReason.Should().Be(0);
+ }
+
+ [Fact]
+ public async Task Sync_NoGatewayRow_SkipsRecord()
+ {
+ var reader = new Mock(MockBehavior.Strict);
+ reader.Setup(r => r.GetExpirationDateByRequestId(Id)).Throws(new ArgumentException("no row"));
+
+ var results = await RunSync(ClientReturning(BodylessRevoked()).Object, reader.Object);
+
+ results.Should().BeEmpty("a brand-new body-less REVOKED row could never be un-poisoned");
+ }
+
+ [Fact]
+ public async Task Sync_ReaderThrowsUnexpected_SkipsRecord()
+ {
+ var reader = new Mock(MockBehavior.Strict);
+ reader.Setup(r => r.GetExpirationDateByRequestId(Id)).Throws(new InvalidOperationException("db down"));
+
+ var results = await RunSync(ClientReturning(BodylessRevoked()).Object, reader.Object);
+
+ results.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task Sync_NoReaderInjected_SkipsRecord()
+ {
+ var results = await RunSync(ClientReturning(BodylessRevoked()).Object, reader: null);
+
+ results.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task Sync_RefetchFails_MetadataOnlyRevokedIsStillGuarded()
+ {
+ var mock = new Mock(MockBehavior.Loose);
+ mock.Setup(c => c.ListCertificatesAsync(
+ It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(AsyncEnum(new[] { BodylessRevoked() }));
+ mock.Setup(c => c.GetCertificateAsync(Id, It.IsAny()))
+ .ThrowsAsync(new Exception("TrackOrder 500"));
+ var reader = new Mock(MockBehavior.Strict);
+ reader.Setup(r => r.GetExpirationDateByRequestId(Id)).Returns((DateTime?)null);
+
+ var results = await RunSync(mock.Object, reader.Object);
+
+ results.Should().ContainSingle();
+ results[0].Status.Should().Be((int)EndEntityStatus.FAILED);
+ results[0].RevocationDate.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task Sync_RevokedWithBody_DoesNotConsultReader()
+ {
+ // Strict mock with no setups: any reader call fails the test.
+ var reader = new Mock(MockBehavior.Strict);
+
+ var results = await RunSync(
+ ClientReturning(MockCertificateData.RevokedCertRecord(Id)).Object, reader.Object);
+
+ results.Should().ContainSingle();
+ results[0].Status.Should().Be((int)EndEntityStatus.REVOKED);
+ results[0].Certificate.Should().NotBeNullOrWhiteSpace();
+ }
+
+ // -------------------------------------------------------------------------------------
+ // GetSingleRecord
+ // -------------------------------------------------------------------------------------
+
+ private static Task RunGetSingle(ICertificateDataReader reader)
+ {
+ var mock = new Mock(MockBehavior.Loose);
+ mock.Setup(c => c.GetCertificateAsync(Id, It.IsAny()))
+ .ReturnsAsync(BodylessRevoked());
+ var plugin = new CERTInextCAPlugin(mock.Object, reader, new CERTInextConfig());
+ return plugin.GetSingleRecord(Id);
+ }
+
+ [Fact]
+ public async Task GetSingleRecord_GatewayHoldsBody_ReturnsRevoked()
+ {
+ var reader = new Mock(MockBehavior.Strict);
+ reader.Setup(r => r.GetExpirationDateByRequestId(Id)).Returns(new DateTime(2027, 1, 1));
+
+ var record = await RunGetSingle(reader.Object);
+
+ record.Status.Should().Be((int)EndEntityStatus.REVOKED);
+ record.RevocationDate.Should().NotBeNull();
+ }
+
+ [Fact]
+ public async Task GetSingleRecord_GatewayRowWithoutBody_ReturnsFailed()
+ {
+ var reader = new Mock(MockBehavior.Strict);
+ reader.Setup(r => r.GetExpirationDateByRequestId(Id)).Returns((DateTime?)null);
+
+ var record = await RunGetSingle(reader.Object);
+
+ record.Status.Should().Be((int)EndEntityStatus.FAILED);
+ record.RevocationDate.Should().BeNull();
+ record.RevocationReason.Should().Be(0);
+ }
+
+ [Fact]
+ public async Task GetSingleRecord_NoGatewayRow_ReturnsFailed()
+ {
+ var reader = new Mock(MockBehavior.Strict);
+ reader.Setup(r => r.GetExpirationDateByRequestId(Id)).Throws(new ArgumentException("no row"));
+
+ var record = await RunGetSingle(reader.Object);
+
+ record.Status.Should().Be((int)EndEntityStatus.FAILED, "GetSingleRecord can't skip");
+ record.RevocationDate.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task GetSingleRecord_NoReaderInjected_ReturnsFailed()
+ {
+ var record = await RunGetSingle(reader: null);
+
+ record.Status.Should().Be((int)EndEntityStatus.FAILED);
+ record.RevocationDate.Should().BeNull();
+ }
+ }
+}
diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs
index 73e5c0a..c72a690 100644
--- a/CERTInext/CERTInextCAPlugin.cs
+++ b/CERTInext/CERTInextCAPlugin.cs
@@ -716,11 +716,26 @@ public async Task GetSingleRecord(string caRequestID)
var record = MapToAnyCAPluginCertificate(cert);
+ // Issue 0049: a REVOKED record with no certificate body must never be returned
+ // as-is unless the gateway already holds a body for this order — see
+ // DecideBodylessRevokedRecord. GetSingleRecord can't skip (it must return
+ // something), so both the no-row and can't-tell cases fall through to FAILED.
+ if (record.Status == (int)EndEntityStatus.REVOKED && string.IsNullOrWhiteSpace(record.Certificate))
+ {
+ var decision = DecideBodylessRevokedRecord(caRequestID);
+ if (decision != BodylessRevokedDecision.EmitRevoked)
+ {
+ record.Status = (int)EndEntityStatus.FAILED;
+ record.RevocationDate = null;
+ record.RevocationReason = 0;
+ }
+ }
+
// SOC2 CC7.3: certificate retrieval is a security-relevant read operation;
// must be logged at Information so it is captured in production.
_logger.LogInformation(
- "GetSingleRecord complete. CARequestID={Id}, Status={Status}, SerialNumber={Serial}",
- caRequestID, cert.Status, cert.SerialNumber ?? "(none)");
+ "GetSingleRecord complete. CARequestID={Id}, Status={Status}, Disposition={Disposition}, SerialNumber={Serial}",
+ caRequestID, cert.Status, record.Status, cert.SerialNumber ?? "(none)");
_logger.MethodExit(LogLevel.Debug);
return record;
}
@@ -860,6 +875,8 @@ public async Task Synchronize(
// Emit-side accounting (issue 0003): what the plugin hands to the gateway buffer.
int emittedGeneratedWithBody = 0, emittedGeneratedNoBody = 0, emittedRevoked = 0, emittedPending = 0;
+ // Issue 0049: bodyless-REVOKED guard decisions — see DecideBodylessRevokedRecord.
+ int emittedFailedFromBodylessRevoked = 0, skippedBodylessRevoked = 0;
try
{
@@ -1036,6 +1053,32 @@ public async Task Synchronize(
// persistence, not the plugin. Per-record detail is at Debug; the aggregate is
// logged at Information in the completion summary below.
bool recordHasBody = !string.IsNullOrWhiteSpace(record.Certificate);
+
+ // Issue 0049: never hand the gateway buffer a REVOKED record with no
+ // certificate body unless the gateway already holds one for this order —
+ // see DecideBodylessRevokedRecord's doc comment. "Skip" means this row is
+ // dropped entirely (not added to blockingBuffer) rather than risk creating
+ // a row the gateway can never un-poison.
+ if (record.Status == (int)EndEntityStatus.REVOKED && !recordHasBody)
+ {
+ var decision = DecideBodylessRevokedRecord(record.CARequestID);
+ if (decision == BodylessRevokedDecision.Skip)
+ {
+ skipped++;
+ skippedBodylessRevoked++;
+ continue;
+ }
+ if (decision == BodylessRevokedDecision.EmitFailed)
+ {
+ record.Status = (int)EndEntityStatus.FAILED;
+ record.RevocationDate = null;
+ record.RevocationReason = 0;
+ emittedFailedFromBodylessRevoked++;
+ }
+ // EmitRevoked falls through — record stays REVOKED with no body,
+ // exactly as before (propagates an out-of-band CA revoke).
+ }
+
if (record.Status == (int)EndEntityStatus.GENERATED)
{
if (recordHasBody) emittedGeneratedWithBody++; else emittedGeneratedNoBody++;
@@ -1103,10 +1146,13 @@ public async Task Synchronize(
_logger.LogInformation(
"CERTInext synchronization complete. Synced={Synced}, Skipped={Skipped} (withBody={SkippedWithBody}), " +
"Errors={Errors}. Emitted to gateway buffer: GeneratedWithBody={GenWithBody}, " +
- "GeneratedNoBody={GenNoBody}, Revoked={Revoked}, Pending={Pending}. " +
+ "GeneratedNoBody={GenNoBody}, Revoked={Revoked}, Pending={Pending}, " +
+ "FailedFromBodylessRevoked={FailedFromBodylessRevoked}. " +
+ "BodylessRevokedSkipped={BodylessRevokedSkipped} (issue 0049 guard). " +
"RecentOrderDeferred={RecentOrderDeferred} (younger than {Grace}s, imported next pass). {DcvClause}",
synced, skipped, skippedWithBody, errors,
emittedGeneratedWithBody, emittedGeneratedNoBody, emittedRevoked, emittedPending,
+ emittedFailedFromBodylessRevoked, skippedBodylessRevoked,
deferredRecentOrders, recentOrderGraceSeconds,
dcvClause);
}
@@ -2654,6 +2700,88 @@ private EnrollmentResult BuildEnrollmentResult(EnrollCertificateResponse resp, b
/// Maps a to the gateway's
/// type.
///
+ ///
+ /// Outcome of — what a caller should do with a
+ /// REVOKED disposition that has no certificate body (issue 0049).
+ ///
+ private enum BodylessRevokedDecision
+ {
+ /// Gateway already holds a body for this order — emit REVOKED with no
+ /// body, exactly as before (this is how out-of-band CA revokes propagate).
+ EmitRevoked,
+ /// Gateway has a row but no body — downgrade to FAILED so the gateway
+ /// never persists a REVOKED row with Certificate = null.
+ EmitFailed,
+ /// Gateway has no row at all (or the reader can't be consulted) —
+ /// the caller should not create a brand-new poisoned row.
+ Skip
+ }
+
+ ///
+ /// Issue 0049: CERTInext does not serve a revoked certificate's body (the client only
+ /// calls DownloadCertificate for issued statuses, see
+ /// CERTInextClient.IsCertificateDownloadable), so a revoked order always arrives
+ /// here with no body. Emitting that as a body-less REVOKED record is what poisons the
+ /// gateway when it has never stored the certificate: it persists the row with
+ /// RevocationDate set and Certificate = null, never clears the date
+ /// afterward, and its own revoked-certificate search then calls FromDER on the
+ /// empty body on every future scan of this CA — a 500 that blocks *all* Command sync for
+ /// the CA, not just this record.
+ ///
+ /// A body-less REVOKED record is safe — and required, to propagate out-of-band CA
+ /// revokes — only when the gateway already holds a certificate body for this
+ /// CARequestID (the gateway keeps the stored body and applies status/date/reason on top
+ /// of it). is the
+ /// per-order probe for that: it returns the stored cert's NotAfter when a body is held,
+ /// null when the row exists but has no body, and throws
+ /// when there is no row at all.
+ ///
+ private BodylessRevokedDecision DecideBodylessRevokedRecord(string caRequestId)
+ {
+ if (_certificateDataReader == null)
+ {
+ _logger.LogWarning(
+ "No ICertificateDataReader available to check whether the gateway holds a " +
+ "certificate body for revoked order '{Id}' — skipping this cycle rather than risk " +
+ "poisoning a fresh gateway row with a body-less REVOKED record.", caRequestId);
+ return BodylessRevokedDecision.Skip;
+ }
+
+ try
+ {
+ DateTime? expiry = _certificateDataReader.GetExpirationDateByRequestId(caRequestId);
+ if (expiry.HasValue)
+ {
+ _logger.LogDebug(
+ "Gateway already holds a certificate body for revoked order '{Id}' " +
+ "(expires {Expiry:O}) — emitting body-less REVOKED to propagate the revocation.",
+ caRequestId, expiry.Value);
+ return BodylessRevokedDecision.EmitRevoked;
+ }
+
+ _logger.LogInformation(
+ "Gateway has a row for revoked order '{Id}' with no certificate body — " +
+ "emitting FAILED instead of a body-less REVOKED record to avoid poisoning the " +
+ "gateway's revoked-certificate search.", caRequestId);
+ return BodylessRevokedDecision.EmitFailed;
+ }
+ catch (ArgumentException)
+ {
+ _logger.LogInformation(
+ "Gateway has no row at all for revoked order '{Id}' — skipping rather than " +
+ "create a body-less REVOKED row the gateway can never un-poison.", caRequestId);
+ return BodylessRevokedDecision.Skip;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex,
+ "Failed to determine whether the gateway holds a certificate body for revoked " +
+ "order '{Id}' — skipping this cycle rather than risk emitting a body-less REVOKED " +
+ "record. The revocation will be retried on a later sync/single-record call.", caRequestId);
+ return BodylessRevokedDecision.Skip;
+ }
+ }
+
private static AnyCAPluginCertificate MapToAnyCAPluginCertificate(LegacyGetCertificateResponse cert)
{
int status = StatusMapper.ToRequestDisposition(cert.Status);
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f2d68f7..e92be7c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,8 @@
+# 1.0.2
+
+## Bug Fixes
+- fix(sync): `Synchronize` and `GetSingleRecord` no longer send the gateway a revoked record without a certificate unless the gateway already stores that certificate. Such a record made the gateway's revoked-certificate search fail, which stopped every Command scan of the CA. A revoked order the gateway has without a certificate is now reported as failed; one the gateway has never seen is skipped. Rows already stored this way must be repaired in the gateway database (0049).
+
# 1.0.1
## Bug Fixes
From 2e7f69acf7cd9e73205355eac670407dad0affc9 Mon Sep 17 00:00:00 2001
From: spbsoluble <1661003+spbsoluble@users.noreply.github.com>
Date: Thu, 5 Nov 2026 13:10:36 -0800
Subject: [PATCH 2/5] test(integration): opt-in probe for V1 download of a
revoked order's body (0049)
Read-only. CERTINEXT_PROBE_REVOKED_DOWNLOAD=1 calls DownloadCertificate directly on
a revoked order (CERTINEXT_REVOKED_ORDER_NUMBER, or the first revoked order in the
report). Sandbox 2026-11-05: refused with "Invalid request status.", confirming
there is no body to backfill.
---
.../RevokedDownloadProbeTests.cs | 97 +++++++++++++++++++
CHANGELOG.md | 2 +-
2 files changed, 98 insertions(+), 1 deletion(-)
create mode 100644 CERTInext.IntegrationTests/RevokedDownloadProbeTests.cs
diff --git a/CERTInext.IntegrationTests/RevokedDownloadProbeTests.cs b/CERTInext.IntegrationTests/RevokedDownloadProbeTests.cs
new file mode 100644
index 0000000..6c31542
--- /dev/null
+++ b/CERTInext.IntegrationTests/RevokedDownloadProbeTests.cs
@@ -0,0 +1,97 @@
+// 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.Threading;
+using System.Threading.Tasks;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.IntegrationTests
+{
+ ///
+ /// Read-only probe (issue 0049, V1 port): does the V1 DownloadCertificate endpoint serve the body
+ /// of an already-revoked order? The client never asks today (IsCertificateDownloadable
+ /// excludes status 22), so a revoked order first seen by sync is always body-less and the plugin
+ /// must downgrade it to FAILED. If CERTInext does serve it, sync could backfill the body instead.
+ /// This test calls DownloadCertificate directly, bypassing that gate, and reports what came back.
+ /// It does not assert either outcome. No orders are created or changed.
+ ///
+ /// Opt-in: CERTINEXT_PROBE_REVOKED_DOWNLOAD=1. Target: CERTINEXT_REVOKED_ORDER_NUMBER,
+ /// or the first revoked order in the account's order report when unset.
+ ///
+ public class RevokedDownloadProbeTests : IClassFixture
+ {
+ private const string OptInFlag = "CERTINEXT_PROBE_REVOKED_DOWNLOAD";
+ private const string OrderNumberVar = "CERTINEXT_REVOKED_ORDER_NUMBER";
+ private const int MaxScanRows = 2000;
+
+ private readonly IntegrationTestFixture _fixture;
+ private readonly ITestOutputHelper _output;
+
+ public RevokedDownloadProbeTests(IntegrationTestFixture fixture, ITestOutputHelper output)
+ {
+ _fixture = fixture;
+ _output = output;
+ }
+
+ [SkippableFact]
+ public async Task DownloadCertificate_ForRevokedOrder_ReportsWhetherBodyIsServed()
+ {
+ IntegrationSkip.IfNotConfigured(_fixture);
+ Skip.If(Environment.GetEnvironmentVariable(OptInFlag) != "1",
+ $"Opt-in read-only probe; set {OptInFlag}=1 to run.");
+
+ string orderNumber = Environment.GetEnvironmentVariable(OrderNumberVar);
+ if (string.IsNullOrWhiteSpace(orderNumber))
+ {
+ int scanned = 0;
+ await foreach (var entry in _fixture.Client.ListCertificatesAsync(
+ issuedAfter: null, pageSize: 100, ct: CancellationToken.None))
+ {
+ if (++scanned > MaxScanRows) break;
+ if (string.Equals(entry.Status, "revoked", StringComparison.OrdinalIgnoreCase))
+ {
+ orderNumber = entry.Id;
+ break;
+ }
+ }
+ _output.WriteLine($"scanned={scanned} rows for a revoked order");
+ }
+ Skip.If(string.IsNullOrWhiteSpace(orderNumber),
+ $"No revoked order found; set {OrderNumberVar} to one.");
+
+ var track = await _fixture.Client.TrackOrderAsync(orderNumber, CancellationToken.None);
+ string statusId = track.OrderDetails?.CertificateStatusId;
+ _output.WriteLine($"order={orderNumber} certificateStatusId={statusId ?? "(null)"} " +
+ $"revokeProcessedDate={(string.IsNullOrWhiteSpace(track.OrderDetails?.RevocationDetails?.RevokeProcessedDate) ? "(none)" : "present")}");
+ Skip.If(statusId != Constants.CertificateStatusId.CertificateRevoked.ToString(),
+ $"Order {orderNumber} is not revoked (certificateStatusId={statusId}).");
+
+ try
+ {
+ var resp = await _fixture.Client.DownloadCertificateAsync(orderNumber, CancellationToken.None);
+ string pem = resp.CertificateDetails?.EndEntityCertificate;
+ _output.WriteLine(string.IsNullOrWhiteSpace(pem)
+ ? "RESULT: DownloadCertificate succeeded but returned NO end-entity body."
+ : $"RESULT: DownloadCertificate SERVED the revoked body ({pem.Length} chars, " +
+ $"startsWithPemHeader={pem.TrimStart().StartsWith("-----BEGIN", StringComparison.Ordinal)}).");
+ }
+ catch (Exception ex)
+ {
+ _output.WriteLine($"RESULT: DownloadCertificate REFUSED the revoked order: {TestOutputScrub.Describe(ex)}");
+ }
+ }
+ }
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e92be7c..4c50015 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,7 @@
# 1.0.2
## Bug Fixes
-- fix(sync): `Synchronize` and `GetSingleRecord` no longer send the gateway a revoked record without a certificate unless the gateway already stores that certificate. Such a record made the gateway's revoked-certificate search fail, which stopped every Command scan of the CA. A revoked order the gateway has without a certificate is now reported as failed; one the gateway has never seen is skipped. Rows already stored this way must be repaired in the gateway database (0049).
+- fix(sync): `Synchronize` and `GetSingleRecord` no longer send the gateway a revoked record without a certificate unless the gateway already stores that certificate. Such a record made the gateway's revoked-certificate search fail, which stopped every Command scan of the CA. A revoked order the gateway has without a certificate is now reported as failed; one the gateway has never seen is skipped. Rows already stored this way must be repaired in the gateway database.
# 1.0.1
From b5da88506085a98aef15d84917f81a5dc03cffa3 Mon Sep 17 00:00:00 2001
From: spbsoluble <1661003+spbsoluble@users.noreply.github.com>
Date: Tue, 10 Nov 2026 12:12:45 -0800
Subject: [PATCH 3/5] fix(sync): skip unchanged orders on V1 incremental sync
(0093)
The V1 order report only filters by calendar day (orderDateFrom = yyyy-MM-dd), so
every incremental pass re-listed every order placed that day and refetched each
issued or revoked one (TrackOrder + DownloadCertificate), even when the gateway
already held it. A customer gateway with ~225 orders in the day made 451 serial
calls per 10-minute pass; when CERTInext latency rose, passes overran the interval
and ran back to back.
Follow the Entrust ECS / HashiCorp Vault / Nexus pattern: on incremental passes,
skip an order listed as GENERATED or REVOKED when
ICertificateDataReader.GetStatusByRequestID returns the same status and
GetExpirationDateByRequestId shows a stored body. New orders, status changes
(pending -> issued, issued -> revoked), rows without a body, no reader, and reader
errors are processed as before. Pending orders and full sync are unchanged. The
sync summary now reports UnchangedInGateway.
Not a regression: the date-only filter and per-order refetch date from the initial
implementation.
---
.../IncrementalSyncUnchangedTests.cs | 221 ++++++++++++++++++
CERTInext/CERTInextCAPlugin.cs | 57 ++++-
CHANGELOG.md | 1 +
3 files changed, 278 insertions(+), 1 deletion(-)
create mode 100644 CERTInext.Tests/IncrementalSyncUnchangedTests.cs
diff --git a/CERTInext.Tests/IncrementalSyncUnchangedTests.cs b/CERTInext.Tests/IncrementalSyncUnchangedTests.cs
new file mode 100644
index 0000000..a33098f
--- /dev/null
+++ b/CERTInext.Tests/IncrementalSyncUnchangedTests.cs
@@ -0,0 +1,221 @@
+// 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.API;
+using Keyfactor.Extensions.CAPlugin.CERTInext.Client;
+using Keyfactor.PKI.Enums.EJBCA;
+using Moq;
+using Xunit;
+
+namespace Keyfactor.Extensions.CAPlugin.CERTInext.Tests
+{
+ ///
+ /// Issue 0093: the V1 order listing filters by calendar day, so every incremental pass
+ /// re-lists every order placed that day. An issued or revoked order the gateway already holds
+ /// with the same status and a stored body must not be refetched (TrackOrder +
+ /// DownloadCertificate) or re-emitted on an incremental pass. Anything new, changed, body-less
+ /// or unverifiable is processed as before, and full sync is unaffected.
+ ///
+ public class IncrementalSyncUnchangedTests
+ {
+ private const string Id = MockCertificateData.CertId1;
+ private static readonly DateTime LastSync = new DateTime(2026, 11, 10, 10, 20, 0, DateTimeKind.Utc);
+
+ /// An order as the listing returns it: status and metadata, never a body.
+ private static LegacyGetCertificateResponse Listed(string status) =>
+ new LegacyGetCertificateResponse
+ {
+ Id = Id,
+ Status = status,
+ Certificate = null,
+ Subject = "test.example.com",
+ ProfileId = MockCertificateData.ProfileIdTls
+ };
+
+ private static async IAsyncEnumerable AsyncEnum(
+ IEnumerable items)
+ {
+ foreach (var item in items)
+ {
+ yield return item;
+ await Task.Yield();
+ }
+ }
+
+ private static Mock Client(LegacyGetCertificateResponse listed,
+ LegacyGetCertificateResponse refetched)
+ {
+ var mock = new Mock(MockBehavior.Loose);
+ mock.Setup(c => c.ListCertificatesAsync(
+ It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(AsyncEnum(new[] { listed }));
+ mock.Setup(c => c.GetCertificateAsync(Id, It.IsAny()))
+ .ReturnsAsync(refetched);
+ return mock;
+ }
+
+ private static async Task> RunSync(
+ ICERTInextClient client, ICertificateDataReader reader, bool fullSync = false)
+ {
+ var plugin = new CERTInextCAPlugin(client, reader, new CERTInextConfig());
+ var buffer = new BlockingCollection(10);
+ await plugin.Synchronize(buffer, lastSync: fullSync ? (DateTime?)null : LastSync,
+ fullSync: fullSync, cancelToken: CancellationToken.None);
+ return buffer.ToList();
+ }
+
+ private static Mock Reader(int dbStatus, DateTime? storedExpiry)
+ {
+ var reader = new Mock(MockBehavior.Strict);
+ reader.Setup(r => r.GetStatusByRequestID(Id)).ReturnsAsync(dbStatus);
+ reader.Setup(r => r.GetExpirationDateByRequestId(Id)).Returns(storedExpiry);
+ return reader;
+ }
+
+ [Fact]
+ public async Task Incremental_IssuedAndHeldUnchanged_IsNotRefetchedOrEmitted()
+ {
+ var client = Client(Listed("issued"), MockCertificateData.IssuedCertRecord(Id));
+ var reader = Reader((int)EndEntityStatus.GENERATED, new DateTime(2027, 1, 1));
+
+ var results = await RunSync(client.Object, reader.Object);
+
+ results.Should().BeEmpty("the gateway already holds this certificate as-is");
+ client.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Incremental_RevokedAndHeldAsRevoked_IsNotRefetchedOrEmitted()
+ {
+ var client = Client(Listed("revoked"), MockCertificateData.RevokedCertRecord(Id));
+ var reader = Reader((int)EndEntityStatus.REVOKED, new DateTime(2027, 1, 1));
+
+ var results = await RunSync(client.Object, reader.Object);
+
+ results.Should().BeEmpty();
+ client.Verify(c => c.GetCertificateAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Incremental_NewOrder_IsRefetchedAndEmitted()
+ {
+ var client = Client(Listed("issued"), MockCertificateData.IssuedCertRecord(Id));
+ var reader = new Mock(MockBehavior.Strict);
+ reader.Setup(r => r.GetStatusByRequestID(Id)).ThrowsAsync(new ArgumentException("no row"));
+
+ var results = await RunSync(client.Object, reader.Object);
+
+ results.Should().ContainSingle();
+ results[0].Status.Should().Be((int)EndEntityStatus.GENERATED);
+ results[0].Certificate.Should().NotBeNullOrWhiteSpace();
+ }
+
+ [Fact]
+ public async Task Incremental_StatusChangedIssuedToRevoked_IsRefetchedAndEmitted()
+ {
+ var client = Client(Listed("revoked"), MockCertificateData.RevokedCertRecord(Id));
+ var reader = Reader((int)EndEntityStatus.GENERATED, new DateTime(2027, 1, 1));
+
+ var results = await RunSync(client.Object, reader.Object);
+
+ results.Should().ContainSingle("an out-of-band revoke must still propagate");
+ results[0].Status.Should().Be((int)EndEntityStatus.REVOKED);
+ client.Verify(c => c.GetCertificateAsync(Id, It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task Incremental_StatusChangedPendingToIssued_IsRefetchedAndEmitted()
+ {
+ var client = Client(Listed("issued"), MockCertificateData.IssuedCertRecord(Id));
+ var reader = Reader((int)EndEntityStatus.EXTERNALVALIDATION, null);
+
+ var results = await RunSync(client.Object, reader.Object);
+
+ results.Should().ContainSingle("a DV order that issued after its first pass must be imported");
+ results[0].Status.Should().Be((int)EndEntityStatus.GENERATED);
+ results[0].Certificate.Should().NotBeNullOrWhiteSpace();
+ }
+
+ [Fact]
+ public async Task Incremental_HeldWithoutBody_IsRefetchedAndEmitted()
+ {
+ var client = Client(Listed("issued"), MockCertificateData.IssuedCertRecord(Id));
+ var reader = Reader((int)EndEntityStatus.GENERATED, null);
+
+ var results = await RunSync(client.Object, reader.Object);
+
+ results.Should().ContainSingle("a row whose earlier refetch failed must get its body");
+ results[0].Certificate.Should().NotBeNullOrWhiteSpace();
+ }
+
+ [Fact]
+ public async Task Incremental_ReaderFails_IsProcessedAsBefore()
+ {
+ var client = Client(Listed("issued"), MockCertificateData.IssuedCertRecord(Id));
+ var reader = new Mock(MockBehavior.Strict);
+ reader.Setup(r => r.GetStatusByRequestID(Id)).ThrowsAsync(new InvalidOperationException("db down"));
+
+ var results = await RunSync(client.Object, reader.Object);
+
+ results.Should().ContainSingle();
+ results[0].Status.Should().Be((int)EndEntityStatus.GENERATED);
+ }
+
+ [Fact]
+ public async Task Incremental_NoReaderInjected_IsProcessedAsBefore()
+ {
+ var client = Client(Listed("issued"), MockCertificateData.IssuedCertRecord(Id));
+
+ var results = await RunSync(client.Object, reader: null);
+
+ results.Should().ContainSingle();
+ results[0].Status.Should().Be((int)EndEntityStatus.GENERATED);
+ }
+
+ [Fact]
+ public async Task Incremental_PendingOrder_DoesNotConsultReader()
+ {
+ // Strict mock with no setups: any reader call fails the test.
+ var client = Client(MockCertificateData.PendingCertRecord(Id), MockCertificateData.PendingCertRecord(Id));
+ var reader = new Mock(MockBehavior.Strict);
+
+ var results = await RunSync(client.Object, reader.Object);
+
+ results.Should().ContainSingle();
+ results[0].Status.Should().Be((int)EndEntityStatus.EXTERNALVALIDATION);
+ }
+
+ [Fact]
+ public async Task FullSync_HeldUnchanged_IsStillRefetchedAndEmitted()
+ {
+ // Strict mock with no setups: full sync must not consult the reader for issued orders.
+ var client = Client(Listed("issued"), MockCertificateData.IssuedCertRecord(Id));
+ var reader = new Mock(MockBehavior.Strict);
+
+ var results = await RunSync(client.Object, reader.Object, fullSync: true);
+
+ results.Should().ContainSingle();
+ results[0].Certificate.Should().NotBeNullOrWhiteSpace();
+ client.Verify(c => c.GetCertificateAsync(Id, It.IsAny()), Times.Once);
+ }
+ }
+}
diff --git a/CERTInext/CERTInextCAPlugin.cs b/CERTInext/CERTInextCAPlugin.cs
index c72a690..acfe22a 100644
--- a/CERTInext/CERTInextCAPlugin.cs
+++ b/CERTInext/CERTInextCAPlugin.cs
@@ -860,6 +860,7 @@ public async Task Synchronize(
int skipped = 0;
int deferredRecentOrders = 0; // pending/issued orders withheld because they are younger than the grace window
int skippedWithBody = 0; // skipped records that nonetheless carried a cert body (should be 0)
+ int unchangedInGateway = 0; // incremental only: issued/revoked orders the gateway already holds as-is
int errors = 0;
#if SUPPORTS_DCV
@@ -932,6 +933,21 @@ public async Task Synchronize(
continue;
}
+ // Issue 0093: the listing filter is a calendar day, so an incremental pass
+ // re-lists every order placed that day. Without this check each issued or
+ // revoked one costs a TrackOrder + DownloadCertificate on every pass, even
+ // when the gateway already holds it unchanged. Same rule as the Entrust ECS,
+ // HashiCorp Vault and Nexus plugins: skip when the gateway's status matches.
+ // Full sync still refreshes everything.
+ if (!fullSync
+ && (status == (int)EndEntityStatus.GENERATED
+ || status == (int)EndEntityStatus.REVOKED)
+ && await IsUnchangedInGatewayAsync(current.Id, status))
+ {
+ unchangedInGateway++;
+ continue;
+ }
+
// Deferred DCV: pending orders (EXTERNALVALIDATION) often need DCV driven
// forward during sync — CERTInext parks fresh orders and exposes the DCV
// challenge minutes after enrollment, and scans are the only place that gets
@@ -1145,12 +1161,13 @@ public async Task Synchronize(
#endif
_logger.LogInformation(
"CERTInext synchronization complete. Synced={Synced}, Skipped={Skipped} (withBody={SkippedWithBody}), " +
+ "UnchangedInGateway={UnchangedInGateway} (incremental, already held — not refetched), " +
"Errors={Errors}. Emitted to gateway buffer: GeneratedWithBody={GenWithBody}, " +
"GeneratedNoBody={GenNoBody}, Revoked={Revoked}, Pending={Pending}, " +
"FailedFromBodylessRevoked={FailedFromBodylessRevoked}. " +
"BodylessRevokedSkipped={BodylessRevokedSkipped} (issue 0049 guard). " +
"RecentOrderDeferred={RecentOrderDeferred} (younger than {Grace}s, imported next pass). {DcvClause}",
- synced, skipped, skippedWithBody, errors,
+ synced, skipped, skippedWithBody, unchangedInGateway, errors,
emittedGeneratedWithBody, emittedGeneratedNoBody, emittedRevoked, emittedPending,
emittedFailedFromBodylessRevoked, skippedBodylessRevoked,
deferredRecentOrders, recentOrderGraceSeconds,
@@ -2736,6 +2753,44 @@ private enum BodylessRevokedDecision
/// null when the row exists but has no body, and throws
/// when there is no row at all.
///
+ ///
+ /// Issue 0093: true when the gateway already holds this order with the status the listing
+ /// reports and a stored certificate body, so an incremental pass can skip the TrackOrder +
+ /// DownloadCertificate refetch. A row whose status differs (e.g. issued → revoked), a row
+ /// with no body (an earlier refetch failed), no row at all (new order), no reader, or any
+ /// lookup failure all return false and the order is processed as before.
+ ///
+ private async Task IsUnchangedInGatewayAsync(string caRequestId, int listedStatus)
+ {
+ if (_certificateDataReader == null || string.IsNullOrWhiteSpace(caRequestId))
+ return false;
+
+ try
+ {
+ int dbStatus = await _certificateDataReader.GetStatusByRequestID(caRequestId);
+ if (dbStatus != listedStatus)
+ return false;
+
+ bool unchanged = _certificateDataReader.GetExpirationDateByRequestId(caRequestId).HasValue;
+ if (unchanged)
+ {
+ _logger.LogTrace(
+ "Sync: order '{Id}' is already held by the gateway with status {Status} — not refetching.",
+ caRequestId, listedStatus);
+ }
+ return unchanged;
+ }
+ catch (Exception ex)
+ {
+ // The reader throws when there is no gateway row yet (new order); treat that and any
+ // other failure as "changed" so the order is processed as before.
+ _logger.LogTrace(ex,
+ "Sync: could not confirm gateway state for order '{Id}'; processing it normally.",
+ caRequestId);
+ return false;
+ }
+ }
+
private BodylessRevokedDecision DecideBodylessRevokedRecord(string caRequestId)
{
if (_certificateDataReader == null)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4c50015..78ce571 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## Bug Fixes
- fix(sync): `Synchronize` and `GetSingleRecord` no longer send the gateway a revoked record without a certificate unless the gateway already stores that certificate. Such a record made the gateway's revoked-certificate search fail, which stopped every Command scan of the CA. A revoked order the gateway has without a certificate is now reported as failed; one the gateway has never seen is skipped. Rows already stored this way must be repaired in the gateway database.
+- fix(sync): Incremental sync no longer re-downloads every issued or revoked order placed that day on every pass. An order the gateway already stores with the same status and its certificate is skipped; new orders, status changes and orders stored without a certificate are fetched as before. Full sync is unchanged. On a day with many orders, incremental passes previously made two API calls per order and could run longer than the sync interval.
# 1.0.1
From 73bc621953e36bd204d1d4a41bba90c999d7e91b Mon Sep 17 00:00:00 2001
From: spbsoluble <1661003+spbsoluble@users.noreply.github.com>
Date: Tue, 10 Nov 2026 12:12:54 -0800
Subject: [PATCH 4/5] docs: change of issuing CA after eMudhra's exit from
public TLS
eMudhra is withdrawing from public SSL/TLS and the emSign roots are being phased
out (Chrome/Firefox: G1 certificates logged after 2026-11-16 not trusted; Apple:
G1 root removed 2026-11-30). CERTInext public TLS certificates move to another CA
(SSL.com for InCommon members; eMudhra offers Sectigo).
Document that the plugin needs no change (it stores only the end-entity
certificate and never reads the issuer), and the operator steps: import the new
chain on Command, the gateway and orchestrators, update the Gateway Registration CA
certificate, replace G1 certificates before Apple's removal, and confirm product
codes with CERTInext. Links the InCommon and eMudhra notices.
---
CHANGELOG.md | 3 +++
docsource/configuration.md | 20 ++++++++++++++++++++
docsource/overview.md | 2 ++
3 files changed, 25 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 78ce571..172d3bd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,9 @@
- fix(sync): `Synchronize` and `GetSingleRecord` no longer send the gateway a revoked record without a certificate unless the gateway already stores that certificate. Such a record made the gateway's revoked-certificate search fail, which stopped every Command scan of the CA. A revoked order the gateway has without a certificate is now reported as failed; one the gateway has never seen is skipped. Rows already stored this way must be repaired in the gateway database.
- fix(sync): Incremental sync no longer re-downloads every issued or revoked order placed that day on every pass. An order the gateway already stores with the same status and its certificate is skipped; new orders, status changes and orders stored without a certificate are fetched as before. Full sync is unchanged. On a day with many orders, incremental passes previously made two API calls per order and could run longer than the sync interval.
+## Documentation
+- docs: Added steps for the change of issuing CA after eMudhra's exit from public TLS (SSL.com for InCommon members, Sectigo offered by eMudhra), with the emSign G1 distrust dates. No plugin change is needed; Command, gateway and orchestrator trust stores and the gateway's CA certificate need the new chain.
+
# 1.0.1
## Bug Fixes
diff --git a/docsource/configuration.md b/docsource/configuration.md
index 03bca04..436b48b 100644
--- a/docsource/configuration.md
+++ b/docsource/configuration.md
@@ -91,6 +91,26 @@ Before enrolling certificates, the Keyfactor Command server must trust the CERTI
3. In the Keyfactor Command Management Portal, navigate to **CA Connectors** and add a new CA using the **CERTInext AnyCA REST Gateway Plugin**.
4. Complete the CA connector configuration fields described in the next section, then save and test the connection. The gateway performs a live connectivity test against the CERTInext `ValidateCredentials` endpoint during validation.
+### Change of issuing CA (eMudhra exit from public TLS)
+
+eMudhra, CERTInext's parent company, is withdrawing from the public SSL/TLS certificate business, and browser root programs are phasing out the emSign roots that CERTInext public TLS certificates have chained to. Public TLS certificates ordered through CERTInext will be issued under a different CA. Which CA depends on your service: InCommon members are moving to [SSL.com](https://incommon.org/about/news/incommon-certificate-service-update), and eMudhra offers [Sectigo](https://emudhra.com/en/post/certificate-security-incident) certificates to its customers. Confirm the new chain for your account with CERTInext.
+
+Dates published by eMudhra for the emSign G1 root (check the [eMudhra statement](https://emudhra.com/en/post/certificate-security-incident) for changes):
+
+| Root program | Effect |
+|---|---|
+| Chrome, Mozilla Firefox | Certificates logged after November 16, 2026 are not trusted. Certificates issued before then stay trusted until they expire. |
+| Apple | The root is removed on November 30, 2026. Every certificate issued under it stops being trusted on Apple platforms, including existing ones. |
+| Microsoft | No date announced. |
+
+The plugin does not need to change. It stores only the issued certificate and never reads or checks the issuer. Repeat the trust and registration steps above with the new chain:
+
+1. Get the new root and intermediate certificates for your account from CERTInext.
+2. Import them on the Keyfactor Command server and the AnyCA Gateway REST host, and on any orchestrator or client that builds or validates certificate chains. Use **Trusted Root Certification Authorities** for the root and **Intermediate Certification Authorities** for subordinates.
+3. Replace the CA certificate in this CA's **Gateway Registration** with the new issuing chain.
+4. Replace certificates that chain to the emSign G1 root before November 30, 2026 if Apple clients rely on them. Keep the emSign certificates in your trust stores until you have replaced every certificate issued under them.
+
+Also confirm with CERTInext whether your account's product codes change. If they do, update the `ProductCode` mapping on each template.
## Product Codes
diff --git a/docsource/overview.md b/docsource/overview.md
index 2da4d4c..6c1db9e 100644
--- a/docsource/overview.md
+++ b/docsource/overview.md
@@ -14,6 +14,8 @@ Before the gateway can register a CA backed by this plugin, the Keyfactor Comman
After signing in, navigate to the certificate-authority / chain download page in the portal, export each CA in the chain as PEM or DER, and import them into the appropriate Windows certificate stores on the gateway host (Trusted Root for the root CA, Intermediate Certification Authorities for any subordinates). See [configuration.md](configuration.md#gateway-registration) and the [README](../README.md#configuration) for the full Gateway Registration walkthrough.
+> Note: eMudhra is withdrawing from public TLS, and the emSign roots are being phased out by browser root programs (Chrome and Firefox after November 16, 2026; Apple on November 30, 2026). CERTInext public TLS certificates will be issued under a different CA, so the steps above need repeating with the new chain. See [Change of issuing CA](configuration.md#change-of-issuing-ca-emudhra-exit-from-public-tls).
+
## Troubleshooting
### `"Inactive Account User."` returned from `GenerateOrderSSL`
From e2154781fad1ddc4a3eb8a7c4ad72eaf457bb17c Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Tue, 10 Nov 2026 20:13:20 +0000
Subject: [PATCH 5/5] docs: auto-generate README and documentation [skip ci]
---
README.md | 21 +++++++++++++++++++++
1 file changed, 21 insertions(+)
diff --git a/README.md b/README.md
index 6ae147c..03b8180 100644
--- a/README.md
+++ b/README.md
@@ -112,6 +112,27 @@ CERTInext operates three separate environments. Use the sandbox environment for
2. On the Keyfactor Command server, import those certificates into the appropriate Windows certificate store — **Trusted Root Certification Authorities** for the root CA and **Intermediate Certification Authorities** for any subordinate CAs.
3. In the Keyfactor Command Management Portal, navigate to **CA Connectors** and add a new CA using the **CERTInext AnyCA REST Gateway Plugin**.
4. Complete the CA connector configuration fields described in the next section, then save and test the connection. The gateway performs a live connectivity test against the CERTInext `ValidateCredentials` endpoint during validation.
+
+ ### Change of issuing CA (eMudhra exit from public TLS)
+
+ eMudhra, CERTInext's parent company, is withdrawing from the public SSL/TLS certificate business, and browser root programs are phasing out the emSign roots that CERTInext public TLS certificates have chained to. Public TLS certificates ordered through CERTInext will be issued under a different CA. Which CA depends on your service: InCommon members are moving to [SSL.com](https://incommon.org/about/news/incommon-certificate-service-update), and eMudhra offers [Sectigo](https://emudhra.com/en/post/certificate-security-incident) certificates to its customers. Confirm the new chain for your account with CERTInext.
+
+ Dates published by eMudhra for the emSign G1 root (check the [eMudhra statement](https://emudhra.com/en/post/certificate-security-incident) for changes):
+
+ | Root program | Effect |
+ |---|---|
+ | Chrome, Mozilla Firefox | Certificates logged after November 16, 2026 are not trusted. Certificates issued before then stay trusted until they expire. |
+ | Apple | The root is removed on November 30, 2026. Every certificate issued under it stops being trusted on Apple platforms, including existing ones. |
+ | Microsoft | No date announced. |
+
+ The plugin does not need to change. It stores only the issued certificate and never reads or checks the issuer. Repeat the trust and registration steps above with the new chain:
+
+ 1. Get the new root and intermediate certificates for your account from CERTInext.
+ 2. Import them on the Keyfactor Command server and the AnyCA Gateway REST host, and on any orchestrator or client that builds or validates certificate chains. Use **Trusted Root Certification Authorities** for the root and **Intermediate Certification Authorities** for subordinates.
+ 3. Replace the CA certificate in this CA's **Gateway Registration** with the new issuing chain.
+ 4. Replace certificates that chain to the emSign G1 root before November 30, 2026 if Apple clients rely on them. Keep the emSign certificates in your trust stores until you have replaced every certificate issued under them.
+
+ Also confirm with CERTInext whether your account's product codes change. If they do, update the `ProductCode` mapping on each template.
* **CA Connection**