diff --git a/cscglobal-caplugin.MockData/CSCGlobalCAPlugin.MockData.csproj b/cscglobal-caplugin.MockData/CSCGlobalCAPlugin.MockData.csproj
new file mode 100644
index 0000000..5532e69
--- /dev/null
+++ b/cscglobal-caplugin.MockData/CSCGlobalCAPlugin.MockData.csproj
@@ -0,0 +1,14 @@
+
+
+
+ net8.0
+ Keyfactor.Extensions.CAPlugin.CSCGlobal.MockData
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/cscglobal-caplugin.MockData/MidCycleReissueFixtures.cs b/cscglobal-caplugin.MockData/MidCycleReissueFixtures.cs
new file mode 100644
index 0000000..6c8a0cd
--- /dev/null
+++ b/cscglobal-caplugin.MockData/MidCycleReissueFixtures.cs
@@ -0,0 +1,144 @@
+// Copyright 2021 Keyfactor
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
+
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+using Keyfactor.Extensions.CAPlugin.CSCGlobal.Client.Models;
+
+namespace Keyfactor.Extensions.CAPlugin.CSCGlobal.MockData;
+
+///
+/// Mock CSC "mid-cycle reissue" API responses, reproducing a real-world scenario: CSC
+/// silently reissues a certificate partway through its order term - same uuid, re-signed
+/// with the *same* key pair as the original (new serial number, shifted validity dates),
+/// and isLifeCycleReIssueCertificate set to true on the next list/get response.
+///
+/// As of this fixture, the plugin does not special-case this flag anywhere - these fixtures
+/// exist to let that gap be exercised and tested before any handling is decided/implemented.
+///
+/// Metadata (uuid, commonName, certificateType, dates) is taken from real CSC API responses;
+/// only the certificate material is synthetic (freshly generated per test run), though the
+/// baseline and its reissue deliberately share one RSA key pair, re-signed twice.
+///
+public static class MidCycleReissueFixtures
+{
+ // Keyed by scenario uuid so the baseline and its mid-cycle reissue are re-signed from the
+ // same key pair, as they would be if CSC reissued the certificate without a new CSR.
+ private static readonly Dictionary ScenarioKeys = new();
+
+ private static RSA GetOrCreateKey(string uuid)
+ {
+ if (!ScenarioKeys.TryGetValue(uuid, out var key))
+ {
+ key = RSA.Create(2048);
+ ScenarioKeys[uuid] = key;
+ }
+ return key;
+ }
+
+ private static string MakeApiCertificate(string commonName, RSA key, DateTimeOffset notBefore, DateTimeOffset notAfter)
+ {
+ var req = new CertificateRequest($"CN={commonName}", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ using var cert = req.CreateSelfSigned(notBefore, notAfter);
+ var pem = "-----BEGIN CERTIFICATE-----\n" +
+ Convert.ToBase64String(cert.RawData, Base64FormattingOptions.InsertLineBreaks) +
+ "\n-----END CERTIFICATE-----\n";
+ return Convert.ToBase64String(Encoding.UTF8.GetBytes(pem));
+ }
+
+ ///
+ /// Single-name DV certificate (cnn.keyfactortestb.com). Real uuid/metadata from a CSC
+ /// sample response; standard mid-order-term sync, no reissue involved.
+ ///
+ public static CertificateResponse SingleNameBaseline()
+ {
+ const string uuid = "96d8802a-94f5-4616-a27d-bd51bb6eca18";
+ var key = GetOrCreateKey(uuid);
+ var apiCert = MakeApiCertificate("cnn.keyfactortestb.com", key,
+ DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(365));
+ return new CertificateResponse
+ {
+ Uuid = uuid,
+ CommonName = "cnn.keyfactortestb.com",
+ AdditionalNames = new List(),
+ CertificateType = "CSC TrustedSecure DV",
+ Status = "ACTIVE",
+ EffectiveDate = "21-Sep-2026",
+ ExpirationDate = "08-Apr-2027",
+ OrderValidityStartDate = "21-Sep-2026",
+ OrderValidityEndDate = "21-Sep-2027",
+ IsRenewed = false,
+ IsLifeCycleReIssueCertificate = false,
+ BusinessUnit = "Keyfactor API",
+ OrderedBy = "Brian Hill",
+ OrderDate = "21-Sep-2026",
+ ServerSoftware = "OTHER",
+ Certificate = apiCert
+ };
+ }
+
+ ///
+ /// Same uuid/order/key pair as , reappearing with a
+ /// mid-term reissue: isLifeCycleReIssueCertificate is true, effective/expiration shifted
+ /// forward to the reissue date, order validity window unchanged, and a *new certificate
+ /// re-signed from the same key* - a new serial number, not a new key pair.
+ ///
+ public static CertificateResponse SingleNameMidCycleReissue()
+ {
+ var baseline = SingleNameBaseline();
+ var key = GetOrCreateKey(baseline.Uuid);
+ baseline.Certificate = MakeApiCertificate(baseline.CommonName, key,
+ DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(366));
+ baseline.EffectiveDate = "05-Nov-2026";
+ baseline.ExpirationDate = "23-May-2027";
+ baseline.OrderDate = "05-Nov-2026";
+ baseline.IsLifeCycleReIssueCertificate = true;
+ return baseline;
+ }
+
+ ///
+ /// Wildcard, multi-name DV certificate (*.boingy.com). Real uuid/metadata from a CSC
+ /// sample response; covers the multi-name product type, not just single-name DV.
+ ///
+ public static CertificateResponse WildcardMultiNameBaseline()
+ {
+ const string uuid = "7db327f6-34a0-4919-9af1-1a76277cc2a7";
+ var key = GetOrCreateKey(uuid);
+ var apiCert = MakeApiCertificate("*.boingy.com", key,
+ DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(365));
+ return new CertificateResponse
+ {
+ Uuid = uuid,
+ CommonName = "*.boingy.com",
+ AdditionalNames = new List(),
+ CertificateType = "CSC TrustedSecure DV Wildcard, Multiple Names",
+ Status = "ACTIVE",
+ EffectiveDate = "16-Sep-2026",
+ ExpirationDate = "03-Apr-2027",
+ OrderValidityStartDate = "13-Sep-2026",
+ OrderValidityEndDate = "13-Sep-2027",
+ IsRenewed = false,
+ IsLifeCycleReIssueCertificate = false,
+ BusinessUnit = "Keyfactor API",
+ OrderedBy = "Brian Hill",
+ OrderDate = "17-Sep-2026",
+ ServerSoftware = "OTHER",
+ Certificate = apiCert
+ };
+ }
+
+ /// Mid-cycle reissue of , re-signed from the same key.
+ public static CertificateResponse WildcardMultiNameMidCycleReissue()
+ {
+ var baseline = WildcardMultiNameBaseline();
+ var key = GetOrCreateKey(baseline.Uuid);
+ baseline.Certificate = MakeApiCertificate(baseline.CommonName, key,
+ DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(366));
+ baseline.EffectiveDate = "02-Dec-2026";
+ baseline.ExpirationDate = "20-Jun-2027";
+ baseline.OrderDate = "02-Dec-2026";
+ baseline.IsLifeCycleReIssueCertificate = true;
+ return baseline;
+ }
+}
diff --git a/cscglobal-caplugin.MockServer/CSCGlobalCAPlugin.MockServer.csproj b/cscglobal-caplugin.MockServer/CSCGlobalCAPlugin.MockServer.csproj
new file mode 100644
index 0000000..c271d46
--- /dev/null
+++ b/cscglobal-caplugin.MockServer/CSCGlobalCAPlugin.MockServer.csproj
@@ -0,0 +1,20 @@
+
+
+
+ Exe
+ net8.0
+ Keyfactor.Extensions.CAPlugin.CSCGlobal.MockServer
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cscglobal-caplugin.MockServer/Program.cs b/cscglobal-caplugin.MockServer/Program.cs
new file mode 100644
index 0000000..6f40421
--- /dev/null
+++ b/cscglobal-caplugin.MockServer/Program.cs
@@ -0,0 +1,146 @@
+// Copyright 2021 Keyfactor
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
+
+using System.Collections.Concurrent;
+using System.Net;
+using System.Text;
+using Keyfactor.AnyGateway.Extensions;
+using Keyfactor.Extensions.CAPlugin.CSCGlobal;
+using Keyfactor.Extensions.CAPlugin.CSCGlobal.Client.Models;
+using Keyfactor.Extensions.CAPlugin.CSCGlobal.MockData;
+using Newtonsoft.Json;
+
+// Starts a real HTTP server on localhost implementing CSC's list/get certificate endpoints,
+// backed by MidCycleReissueFixtures, then runs the real (unmodified) CSCGlobalCAPlugin.Synchronize
+// against it over an actual HTTP connection - not a mocked ICscGlobalClient. This is the "does it
+// actually behave this way when sync runs for real" check that a unit test alone can't give you.
+
+const string baseUrl = "http://localhost:58242/";
+
+var results = new List
+{
+ MidCycleReissueFixtures.SingleNameBaseline(),
+ MidCycleReissueFixtures.SingleNameMidCycleReissue(),
+ MidCycleReissueFixtures.WildcardMultiNameBaseline(),
+ MidCycleReissueFixtures.WildcardMultiNameMidCycleReissue()
+};
+
+using var listener = new HttpListener();
+listener.Prefixes.Add(baseUrl);
+listener.Start();
+Console.WriteLine($"Mock CSC server listening on {baseUrl}");
+
+var serverCts = new CancellationTokenSource();
+var serverTask = Task.Run(() => RunServerAsync(listener, results, serverCts.Token));
+
+try
+{
+ var config = new MockConfigProvider
+ {
+ CAConnectionData = new Dictionary
+ {
+ [Constants.CscGlobalUrl] = baseUrl,
+ [Constants.CscGlobalApiKey] = "mock-api-key",
+ [Constants.BearerToken] = "mock-bearer-token"
+ }
+ };
+
+ var plugin = new CSCGlobalCAPlugin();
+ plugin.Initialize(config, new MockCertificateDataReader());
+
+ var buffer = new BlockingCollection();
+
+ Console.WriteLine("\nRunning a real Synchronize() call against the mock server...\n");
+ await plugin.Synchronize(buffer, null, true, CancellationToken.None);
+
+ var synced = buffer.ToList();
+ Console.WriteLine($"Synchronize queued {synced.Count} certificate(s):\n");
+ foreach (var cert in synced)
+ {
+ Console.WriteLine($" CARequestID: {cert.CARequestID}");
+ Console.WriteLine($" ProductID: {cert.ProductID}");
+ Console.WriteLine($" Status: {cert.Status}");
+ Console.WriteLine($" Certificate: {cert.Certificate?[..40]}...");
+ Console.WriteLine();
+ }
+
+ var byUuid = synced.GroupBy(c => c.CARequestID).Where(g => g.Count() > 1);
+ foreach (var group in byUuid)
+ {
+ var distinctCerts = group.Select(c => c.Certificate).Distinct().Count();
+ Console.WriteLine($"CARequestID {group.Key} appeared {group.Count()} time(s) " +
+ $"with {distinctCerts} distinct certificate value(s) - " +
+ "this is the mid-cycle-reissue collision: nothing above " +
+ "distinguishes the reissue from an ordinary re-sync.");
+ }
+}
+finally
+{
+ serverCts.Cancel();
+ listener.Stop();
+}
+
+static async Task RunServerAsync(HttpListener listener, List results, CancellationToken token)
+{
+ while (!token.IsCancellationRequested)
+ {
+ HttpListenerContext ctx;
+ try
+ {
+ ctx = await listener.GetContextAsync().WaitAsync(token);
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+ catch (ObjectDisposedException)
+ {
+ return;
+ }
+
+ var path = ctx.Request.Url?.AbsolutePath ?? "";
+ Console.WriteLine($"[mock] {ctx.Request.HttpMethod} {ctx.Request.Url}");
+
+ object responseBody;
+ if (path == "/dbs/api/v2/tls/certificate")
+ {
+ responseBody = new CertificateListResponse
+ {
+ Meta = new Meta { NumResults = results.Count },
+ Results = results
+ };
+ }
+ else if (path.StartsWith("/dbs/api/v2/tls/certificate/"))
+ {
+ var uuid = path[(path.LastIndexOf('/') + 1)..];
+ responseBody = results.FirstOrDefault(r => r.Uuid == uuid)
+ ?? (object)new { error = "not found" };
+ }
+ else
+ {
+ ctx.Response.StatusCode = 404;
+ ctx.Response.Close();
+ continue;
+ }
+
+ var json = JsonConvert.SerializeObject(responseBody);
+ var bytes = Encoding.UTF8.GetBytes(json);
+ ctx.Response.ContentType = "application/json";
+ ctx.Response.ContentLength64 = bytes.Length;
+ await ctx.Response.OutputStream.WriteAsync(bytes);
+ ctx.Response.Close();
+ }
+}
+
+internal sealed class MockConfigProvider : IAnyCAPluginConfigProvider
+{
+ public Dictionary CAConnectionData { get; set; } = new();
+}
+
+internal sealed class MockCertificateDataReader : ICertificateDataReader
+{
+ public Task GetStatusByRequestID(string caRequestID) => Task.FromResult(0);
+ public Task DoesCertExistForRequestID(string caRequestID) => Task.FromResult(false);
+ public Task GetRequestIDBySerialNumber(string serialNumber) => Task.FromResult(string.Empty);
+ public DateTime? GetExpirationDateByRequestId(string caRequestID) => null;
+}
diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPlugin.Tests.csproj b/cscglobal-caplugin.Tests/CSCGlobalCAPlugin.Tests.csproj
index b65ca5b..08f5ba0 100644
--- a/cscglobal-caplugin.Tests/CSCGlobalCAPlugin.Tests.csproj
+++ b/cscglobal-caplugin.Tests/CSCGlobalCAPlugin.Tests.csproj
@@ -25,6 +25,7 @@
+
diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs
index 4ae771b..c8047cc 100644
--- a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs
+++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs
@@ -9,6 +9,7 @@
using Keyfactor.Extensions.CAPlugin.CSCGlobal;
using Keyfactor.Extensions.CAPlugin.CSCGlobal.Client.Models;
using Keyfactor.Extensions.CAPlugin.CSCGlobal.Interfaces;
+using Keyfactor.Extensions.CAPlugin.CSCGlobal.MockData;
using Keyfactor.PKI.Enums.EJBCA;
using Moq;
using Xunit;
@@ -428,6 +429,249 @@ public async Task Synchronize_Disabled_CompletesBufferWithoutCallingClient()
mockClient.Verify(c => c.SubmitCertificateListRequestAsync(It.IsAny()), Times.Never);
}
+ // ---------------------------------------------------------------------
+ // Synchronize - mid-cycle reissue repro (CSC reissues a cert under the same uuid
+ // partway through its order term, with a key pair Keyfactor never held). The plugin
+ // does not special-case this today; these tests document and pin down that baseline
+ // so a fix can be verified against it later.
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public async Task Synchronize_MidCycleReissue_SameUuidQueuedTwiceWithDifferentCertificates()
+ {
+ var baseline = MidCycleReissueFixtures.SingleNameBaseline();
+ var reissue = MidCycleReissueFixtures.SingleNameMidCycleReissue();
+
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(null)).ReturnsAsync(new CertificateListResponse
+ {
+ Results = new List { baseline, reissue }
+ });
+
+ var plugin = MakePlugin(mockClient);
+ var buffer = new BlockingCollection();
+
+ await plugin.Synchronize(buffer, null, true, CancellationToken.None);
+
+ var items = buffer.ToList();
+
+ // Today: both records are queued independently under the same CARequestID (the shared
+ // uuid), with different certificate content, and nothing reflects that the second one
+ // was a mid-cycle reissue rather than an ordinary sync of the same certificate.
+ Assert.Equal(2, items.Count);
+ Assert.All(items, i => Assert.Equal(baseline.Uuid, i.CARequestID));
+ Assert.NotEqual(items[0].Certificate, items[1].Certificate);
+ }
+
+ [Fact]
+ public async Task Synchronize_MidCycleReissue_WildcardMultiName_SameUuidQueuedTwiceWithDifferentCertificates()
+ {
+ var baseline = MidCycleReissueFixtures.WildcardMultiNameBaseline();
+ var reissue = MidCycleReissueFixtures.WildcardMultiNameMidCycleReissue();
+
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(null)).ReturnsAsync(new CertificateListResponse
+ {
+ Results = new List { baseline, reissue }
+ });
+
+ var plugin = MakePlugin(mockClient);
+ var buffer = new BlockingCollection();
+
+ await plugin.Synchronize(buffer, null, true, CancellationToken.None);
+
+ var items = buffer.ToList();
+
+ Assert.Equal(2, items.Count);
+ Assert.All(items, i => Assert.Equal(baseline.Uuid, i.CARequestID));
+ Assert.All(items, i => Assert.Equal("CSC TrustedSecure DV Wildcard, Multiple Names", i.ProductID));
+ Assert.NotEqual(items[0].Certificate, items[1].Certificate);
+ }
+
+ [Fact]
+ public void Initialize_MockReissueRunMissing_DefaultsToFalse()
+ {
+ var plugin = MakePlugin();
+ Assert.False(plugin.MockReissueRun);
+ }
+
+ [Fact]
+ public void Initialize_MockReissueRunTrue_ParsesTrue()
+ {
+ var data = ValidConnectionData();
+ data[Constants.MockReissueRun] = "true";
+ var plugin = MakePlugin(connectionData: data);
+ Assert.True(plugin.MockReissueRun);
+ }
+
+ [Fact]
+ public async Task Synchronize_MockReissueRunEnabled_FirstSyncPassesRealCertThenReplacesOnLaterSyncs()
+ {
+ // Real CSC mid-cycle reissues aren't a second record - CSC returns the same UUID's single
+ // record with its certificate field swapped. Use fresh UUIDs per test so the static
+ // "already seen" tracking doesn't leak across test runs in the same process.
+ var activeUuid = Guid.NewGuid().ToString();
+ var revokedUuid = Guid.NewGuid().ToString();
+ var (_, pem) = MakeSelfSignedCert();
+ var apiCert = ToApiBase64(pem);
+
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(null)).ReturnsAsync(new CertificateListResponse
+ {
+ Results = new List
+ {
+ new CertificateResponse { Uuid = activeUuid, CommonName = "real1.example.com", Status = "ACTIVE", CertificateType = "CSC TrustedSecure DV", Certificate = apiCert },
+ new CertificateResponse { Uuid = revokedUuid, CommonName = "real2.example.com", Status = "REVOKED", CertificateType = "CSC TrustedSecure DV", Certificate = apiCert }
+ }
+ });
+
+ var data = ValidConnectionData();
+ data[Constants.MockReissueRun] = "true";
+
+ var firstSyncBuffer = new BlockingCollection();
+ await MakePlugin(mockClient, connectionData: data).Synchronize(firstSyncBuffer, null, true, CancellationToken.None);
+ var firstSyncItems = firstSyncBuffer.ToList();
+
+ // First sync: exactly one record per UUID.
+ Assert.Equal(2, firstSyncItems.Count);
+ var firstActive = Assert.Single(firstSyncItems, i => i.CARequestID == activeUuid);
+ var firstRevoked = Assert.Single(firstSyncItems, i => i.CARequestID == revokedUuid);
+
+ var secondSyncBuffer = new BlockingCollection();
+ await MakePlugin(mockClient, connectionData: data).Synchronize(secondSyncBuffer, null, true, CancellationToken.None);
+ var secondSyncItems = secondSyncBuffer.ToList();
+
+ // Second sync: still exactly one record per UUID (no pileup), but the ACTIVE cert's
+ // content is now the synthetic reissue replacement under the same CARequestID.
+ // REVOKED is never eligible for the swap, so its content is unchanged between syncs.
+ Assert.Equal(2, secondSyncItems.Count);
+ var secondActive = Assert.Single(secondSyncItems, i => i.CARequestID == activeUuid);
+ Assert.NotEqual(firstActive.Certificate, secondActive.Certificate);
+ var secondRevoked = Assert.Single(secondSyncItems, i => i.CARequestID == revokedUuid);
+ Assert.Equal(firstRevoked.Certificate, secondRevoked.Certificate);
+ }
+
+ [Fact]
+ public void Initialize_AppendThumbprintToCaIdMissing_DefaultsToFalse()
+ {
+ var plugin = MakePlugin();
+ Assert.False(plugin.AppendThumbprintToCaId);
+ }
+
+ [Fact]
+ public void Initialize_AppendThumbprintToCaIdTrue_ParsesTrue()
+ {
+ var data = ValidConnectionData();
+ data[Constants.AppendThumbprintToCaId] = "true";
+ var plugin = MakePlugin(connectionData: data);
+ Assert.True(plugin.AppendThumbprintToCaId);
+ }
+
+ [Fact]
+ public async Task Synchronize_AppendThumbprintToCaIdEnabled_AppendsThumbprintToCARequestID()
+ {
+ var uuid = Guid.NewGuid().ToString();
+ var (cert, pem) = MakeSelfSignedCert();
+ var apiCert = ToApiBase64(pem);
+ var expectedThumbprint = Convert.ToHexString(cert.GetCertHash());
+
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(null)).ReturnsAsync(new CertificateListResponse
+ {
+ Results = new List
+ {
+ new CertificateResponse { Uuid = uuid, CommonName = "real1.example.com", Status = "ACTIVE", CertificateType = "CSC TrustedSecure DV", Certificate = apiCert }
+ }
+ });
+
+ var data = ValidConnectionData();
+ data[Constants.AppendThumbprintToCaId] = "true";
+ var plugin = MakePlugin(mockClient, connectionData: data);
+ var buffer = new BlockingCollection();
+
+ await plugin.Synchronize(buffer, null, true, CancellationToken.None);
+
+ var item = Assert.Single(buffer);
+ Assert.Equal($"{uuid}-{expectedThumbprint}", item.CARequestID);
+ }
+
+ [Fact]
+ public async Task Synchronize_AppendThumbprintToCaIdDisabled_LeavesCARequestIDAsUuid()
+ {
+ var uuid = Guid.NewGuid().ToString();
+ var (_, pem) = MakeSelfSignedCert();
+ var apiCert = ToApiBase64(pem);
+
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(null)).ReturnsAsync(new CertificateListResponse
+ {
+ Results = new List
+ {
+ new CertificateResponse { Uuid = uuid, CommonName = "real1.example.com", Status = "ACTIVE", CertificateType = "CSC TrustedSecure DV", Certificate = apiCert }
+ }
+ });
+
+ var plugin = MakePlugin(mockClient);
+ var buffer = new BlockingCollection();
+
+ await plugin.Synchronize(buffer, null, true, CancellationToken.None);
+
+ var item = Assert.Single(buffer);
+ Assert.Equal(uuid, item.CARequestID);
+ }
+
+ [Fact]
+ public async Task Synchronize_MockReissueRunDisabled_DoesNotInjectDuplicates()
+ {
+ var (_, pem) = MakeSelfSignedCert();
+ var apiCert = ToApiBase64(pem);
+
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.SubmitCertificateListRequestAsync(null)).ReturnsAsync(new CertificateListResponse
+ {
+ Results = new List
+ {
+ new CertificateResponse { Uuid = "real-1", CommonName = "real1.example.com", Status = "ACTIVE", CertificateType = "CSC TrustedSecure DV", Certificate = apiCert }
+ }
+ });
+
+ var plugin = MakePlugin(mockClient);
+ var buffer = new BlockingCollection();
+
+ await plugin.Synchronize(buffer, null, true, CancellationToken.None);
+
+ Assert.Single(buffer);
+ }
+
+ [Fact]
+ public void MidCycleReissueFixtures_ReissueVariant_FlagsIsLifeCycleReIssueCertificate()
+ {
+ var baseline = MidCycleReissueFixtures.SingleNameBaseline();
+ var reissue = MidCycleReissueFixtures.SingleNameMidCycleReissue();
+
+ Assert.False(baseline.IsLifeCycleReIssueCertificate);
+ Assert.True(reissue.IsLifeCycleReIssueCertificate);
+ Assert.Equal(baseline.Uuid, reissue.Uuid);
+ Assert.Equal(baseline.OrderValidityStartDate, reissue.OrderValidityStartDate);
+ Assert.Equal(baseline.OrderValidityEndDate, reissue.OrderValidityEndDate);
+
+ // Different certificate (new serial/signature)...
+ Assert.NotEqual(baseline.Certificate, reissue.Certificate);
+ var baselineCert = DecodeApiCertificate(baseline.Certificate);
+ var reissueCert = DecodeApiCertificate(reissue.Certificate);
+ Assert.NotEqual(baselineCert.SerialNumber, reissueCert.SerialNumber);
+
+ // ...but re-signed from the same key pair, since CSC reissued without a new CSR.
+ Assert.Equal(baselineCert.GetPublicKey(), reissueCert.GetPublicKey());
+ }
+
+ private static X509Certificate2 DecodeApiCertificate(string apiBase64)
+ {
+ var pem = Encoding.UTF8.GetString(Convert.FromBase64String(apiBase64));
+ var body = pem.Replace("-----BEGIN CERTIFICATE-----", "").Replace("-----END CERTIFICATE-----", "");
+ return new X509Certificate2(Convert.FromBase64String(body));
+ }
+
// ---------------------------------------------------------------------
// Revoke
// ---------------------------------------------------------------------
@@ -633,6 +877,10 @@ public async Task Enroll_RenewOrReissue_NullExpirationDate_FallsBackToGetSingleR
var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo,
RequestFormat.PKCS10, EnrollmentType.RenewOrReissue);
+ Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result!.Status);
+ mockClient.Verify(c => c.SubmitReissueAsync(It.IsAny()), Times.Once);
+ }
+
[Fact]
public void GetProductIds_ReturnsCanonicalAndLegacyNames()
{
@@ -1143,14 +1391,16 @@ public void GetCAConnectorAnnotations_ContainsExpectedKeys()
var plugin = MakePlugin();
var annotations = plugin.GetCAConnectorAnnotations();
- Assert.Equal(7, annotations.Count);
+ Assert.Equal(9, annotations.Count);
Assert.Contains(Constants.Enabled, annotations.Keys);
Assert.Contains(Constants.CscGlobalUrl, annotations.Keys);
Assert.Contains(Constants.CscGlobalApiKey, annotations.Keys);
Assert.Contains(Constants.BearerToken, annotations.Keys);
Assert.Contains(Constants.DefaultPageSize, annotations.Keys);
+ Assert.Contains(Constants.AppendThumbprintToCaId, annotations.Keys);
Assert.Contains(Constants.SyncFilterDays, annotations.Keys);
Assert.Contains(Constants.RenewalWindowDays, annotations.Keys);
+ Assert.Contains(Constants.MockReissueRun, annotations.Keys);
Assert.True(annotations[Constants.CscGlobalApiKey].Hidden);
}
@@ -1165,17 +1415,6 @@ public void GetTemplateParameterAnnotations_ContainsExpectedKeys()
Assert.Contains(EnrollmentConfigConstants.AdditionalSansCommaSeparatedDcvEmails, annotations.Keys);
}
- [Fact]
- public void GetProductIds_ReturnsFullList()
- {
- var plugin = MakePlugin();
- var ids = plugin.GetProductIds();
-
- Assert.Equal(10, ids.Count);
- Assert.Contains("CSC TrustedSecure OV", ids);
- Assert.Contains("CSC TrustedSecure DV Wildcard, Multiple Names", ids);
- }
-
// ---------------------------------------------------------------------
// GetEndEntityCertificate
// ---------------------------------------------------------------------
diff --git a/cscglobal-caplugin.Tests/RequestManagerTests.cs b/cscglobal-caplugin.Tests/RequestManagerTests.cs
index ad82f3d..c3f2bc5 100644
--- a/cscglobal-caplugin.Tests/RequestManagerTests.cs
+++ b/cscglobal-caplugin.Tests/RequestManagerTests.cs
@@ -384,8 +384,8 @@ public void MapReturnStatus_MapsExpectedStatus(string? cscStatus, int expected)
[InlineData("CSC TrustedSecure UC Certificate", "2", true, false)]
[InlineData("CSC TrustedSecure EV Certificate", "3", false, true)]
[InlineData("CSC TrustedSecure Domain Validated SSL", "4", false, false)]
- [InlineData("CSC TrustedSecure Domain Validated Wildcard SSL", "5", false, false)]
- [InlineData("CSC TrustedSecure Domain Validated UC Certificate", "6", true, false)]
+ [InlineData("CSC Trusted Secure Domain Validated Wildcard SSL", "5", false, false)]
+ [InlineData("CSC Trusted Secure Domain Validated UC Certificate", "6", true, false)]
public void GetRegistrationRequest_RoutesCertificateTypeAndOptionalSections(
string productId, string expectedType, bool expectSans, bool expectEv)
{
@@ -684,7 +684,7 @@ public void GetRenewalRequest_LegacyProductName_ResolvesCertificateTypeAndSans()
// A Certificate Template in Command created before the 1.2.0 rename still carries the
// old product name; renewals against it must resolve the same as the canonical name.
var sans = new Dictionary { ["dnsname"] = new[] { "www.example.com" } };
- var productInfo = ProductInfo("CSC TrustedSecure Domain Validated UC Certificate", new Dictionary
+ var productInfo = ProductInfo("CSC Trusted Secure Domain Validated UC Certificate", new Dictionary
{
["Domain Control Validation Method"] = "CNAME"
});
diff --git a/cscglobal-caplugin.sln b/cscglobal-caplugin.sln
index 1fc79be..6c54c9b 100644
--- a/cscglobal-caplugin.sln
+++ b/cscglobal-caplugin.sln
@@ -1,7 +1,7 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 18
-VisualStudioVersion = 18.0.11217.181 d18.0
+VisualStudioVersion = 18.0.11217.181
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSCGlobalCAPlugin", "cscglobal-caplugin\CSCGlobalCAPlugin.csproj", "{01DDFD6F-275D-46E7-B522-E0C965D1BF9C}"
EndProject
@@ -14,29 +14,109 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
integration-manifest.json = integration-manifest.json
EndProjectSection
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "cscglobal-caplugin.MockData", "cscglobal-caplugin.MockData", "{D8EE054A-49B8-E9D0-41B0-50C454CAB470}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSCGlobalCAPlugin.MockData", "cscglobal-caplugin.MockData\CSCGlobalCAPlugin.MockData.csproj", "{2801F992-4B23-4D3F-84C6-5609AD64CCFD}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "cscglobal-caplugin", "cscglobal-caplugin", "{9EDFC34F-9707-CEB2-9158-E7368508D81D}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "cscglobal-caplugin.MockServer", "cscglobal-caplugin.MockServer", "{006DB98E-8D8C-125C-2D8C-8796C326F014}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSCGlobalCAPlugin.MockServer", "cscglobal-caplugin.MockServer\CSCGlobalCAPlugin.MockServer.csproj", "{F266BB60-211F-498A-8B09-B595A57CEF90}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
DebugAndPush|Any CPU = DebugAndPush|Any CPU
+ DebugAndPush|x64 = DebugAndPush|x64
+ DebugAndPush|x86 = DebugAndPush|x86
Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Debug|x64.Build.0 = Debug|Any CPU
+ {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Debug|x86.Build.0 = Debug|Any CPU
{01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|Any CPU.ActiveCfg = DebugAndPush|Any CPU
{01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|Any CPU.Build.0 = DebugAndPush|Any CPU
+ {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|x64.ActiveCfg = DebugAndPush|Any CPU
+ {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|x64.Build.0 = DebugAndPush|Any CPU
+ {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|x86.ActiveCfg = DebugAndPush|Any CPU
+ {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|x86.Build.0 = DebugAndPush|Any CPU
{01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Release|x64.ActiveCfg = Release|Any CPU
+ {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Release|x64.Build.0 = Release|Any CPU
+ {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Release|x86.ActiveCfg = Release|Any CPU
+ {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Release|x86.Build.0 = Release|Any CPU
{7B2A5E3C-9F4D-4A1B-8C6E-1D3F5A7B9C0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7B2A5E3C-9F4D-4A1B-8C6E-1D3F5A7B9C0D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7B2A5E3C-9F4D-4A1B-8C6E-1D3F5A7B9C0D}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {7B2A5E3C-9F4D-4A1B-8C6E-1D3F5A7B9C0D}.Debug|x64.Build.0 = Debug|Any CPU
+ {7B2A5E3C-9F4D-4A1B-8C6E-1D3F5A7B9C0D}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {7B2A5E3C-9F4D-4A1B-8C6E-1D3F5A7B9C0D}.Debug|x86.Build.0 = Debug|Any CPU
{7B2A5E3C-9F4D-4A1B-8C6E-1D3F5A7B9C0D}.DebugAndPush|Any CPU.ActiveCfg = Debug|Any CPU
{7B2A5E3C-9F4D-4A1B-8C6E-1D3F5A7B9C0D}.DebugAndPush|Any CPU.Build.0 = Debug|Any CPU
+ {7B2A5E3C-9F4D-4A1B-8C6E-1D3F5A7B9C0D}.DebugAndPush|x64.ActiveCfg = DebugAndPush|Any CPU
+ {7B2A5E3C-9F4D-4A1B-8C6E-1D3F5A7B9C0D}.DebugAndPush|x64.Build.0 = DebugAndPush|Any CPU
+ {7B2A5E3C-9F4D-4A1B-8C6E-1D3F5A7B9C0D}.DebugAndPush|x86.ActiveCfg = DebugAndPush|Any CPU
+ {7B2A5E3C-9F4D-4A1B-8C6E-1D3F5A7B9C0D}.DebugAndPush|x86.Build.0 = DebugAndPush|Any CPU
{7B2A5E3C-9F4D-4A1B-8C6E-1D3F5A7B9C0D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7B2A5E3C-9F4D-4A1B-8C6E-1D3F5A7B9C0D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7B2A5E3C-9F4D-4A1B-8C6E-1D3F5A7B9C0D}.Release|x64.ActiveCfg = Release|Any CPU
+ {7B2A5E3C-9F4D-4A1B-8C6E-1D3F5A7B9C0D}.Release|x64.Build.0 = Release|Any CPU
+ {7B2A5E3C-9F4D-4A1B-8C6E-1D3F5A7B9C0D}.Release|x86.ActiveCfg = Release|Any CPU
+ {7B2A5E3C-9F4D-4A1B-8C6E-1D3F5A7B9C0D}.Release|x86.Build.0 = Release|Any CPU
+ {2801F992-4B23-4D3F-84C6-5609AD64CCFD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2801F992-4B23-4D3F-84C6-5609AD64CCFD}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2801F992-4B23-4D3F-84C6-5609AD64CCFD}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {2801F992-4B23-4D3F-84C6-5609AD64CCFD}.Debug|x64.Build.0 = Debug|Any CPU
+ {2801F992-4B23-4D3F-84C6-5609AD64CCFD}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {2801F992-4B23-4D3F-84C6-5609AD64CCFD}.Debug|x86.Build.0 = Debug|Any CPU
+ {2801F992-4B23-4D3F-84C6-5609AD64CCFD}.DebugAndPush|Any CPU.ActiveCfg = Debug|Any CPU
+ {2801F992-4B23-4D3F-84C6-5609AD64CCFD}.DebugAndPush|Any CPU.Build.0 = Debug|Any CPU
+ {2801F992-4B23-4D3F-84C6-5609AD64CCFD}.DebugAndPush|x64.ActiveCfg = Debug|Any CPU
+ {2801F992-4B23-4D3F-84C6-5609AD64CCFD}.DebugAndPush|x64.Build.0 = Debug|Any CPU
+ {2801F992-4B23-4D3F-84C6-5609AD64CCFD}.DebugAndPush|x86.ActiveCfg = Debug|Any CPU
+ {2801F992-4B23-4D3F-84C6-5609AD64CCFD}.DebugAndPush|x86.Build.0 = Debug|Any CPU
+ {2801F992-4B23-4D3F-84C6-5609AD64CCFD}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2801F992-4B23-4D3F-84C6-5609AD64CCFD}.Release|Any CPU.Build.0 = Release|Any CPU
+ {2801F992-4B23-4D3F-84C6-5609AD64CCFD}.Release|x64.ActiveCfg = Release|Any CPU
+ {2801F992-4B23-4D3F-84C6-5609AD64CCFD}.Release|x64.Build.0 = Release|Any CPU
+ {2801F992-4B23-4D3F-84C6-5609AD64CCFD}.Release|x86.ActiveCfg = Release|Any CPU
+ {2801F992-4B23-4D3F-84C6-5609AD64CCFD}.Release|x86.Build.0 = Release|Any CPU
+ {F266BB60-211F-498A-8B09-B595A57CEF90}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F266BB60-211F-498A-8B09-B595A57CEF90}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F266BB60-211F-498A-8B09-B595A57CEF90}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {F266BB60-211F-498A-8B09-B595A57CEF90}.Debug|x64.Build.0 = Debug|Any CPU
+ {F266BB60-211F-498A-8B09-B595A57CEF90}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {F266BB60-211F-498A-8B09-B595A57CEF90}.Debug|x86.Build.0 = Debug|Any CPU
+ {F266BB60-211F-498A-8B09-B595A57CEF90}.DebugAndPush|Any CPU.ActiveCfg = Debug|Any CPU
+ {F266BB60-211F-498A-8B09-B595A57CEF90}.DebugAndPush|Any CPU.Build.0 = Debug|Any CPU
+ {F266BB60-211F-498A-8B09-B595A57CEF90}.DebugAndPush|x64.ActiveCfg = Debug|Any CPU
+ {F266BB60-211F-498A-8B09-B595A57CEF90}.DebugAndPush|x64.Build.0 = Debug|Any CPU
+ {F266BB60-211F-498A-8B09-B595A57CEF90}.DebugAndPush|x86.ActiveCfg = Debug|Any CPU
+ {F266BB60-211F-498A-8B09-B595A57CEF90}.DebugAndPush|x86.Build.0 = Debug|Any CPU
+ {F266BB60-211F-498A-8B09-B595A57CEF90}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F266BB60-211F-498A-8B09-B595A57CEF90}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F266BB60-211F-498A-8B09-B595A57CEF90}.Release|x64.ActiveCfg = Release|Any CPU
+ {F266BB60-211F-498A-8B09-B595A57CEF90}.Release|x64.Build.0 = Release|Any CPU
+ {F266BB60-211F-498A-8B09-B595A57CEF90}.Release|x86.ActiveCfg = Release|Any CPU
+ {F266BB60-211F-498A-8B09-B595A57CEF90}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {2801F992-4B23-4D3F-84C6-5609AD64CCFD} = {D8EE054A-49B8-E9D0-41B0-50C454CAB470}
+ {F266BB60-211F-498A-8B09-B595A57CEF90} = {006DB98E-8D8C-125C-2D8C-8796C326F014}
+ EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {8861D2F4-FEE8-4D70-8172-DF321704F12D}
EndGlobalSection
diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs
index c7c62cb..bf50215 100644
--- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs
+++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs
@@ -27,6 +27,12 @@ public class CSCGlobalCAPlugin : IAnyCAPlugin
private readonly ILogger Logger;
private ICertificateDataReader _certificateDataReader;
+ // Test/diagnostic only - see Constants.MockReissueRun. A new plugin instance is created per
+ // sync, so these need to be static to remember "have we already passed the real cert through
+ // for this UUID" across sync runs within the host process.
+ private static readonly ConcurrentDictionary MockReissueSeenUuids = new();
+ private static readonly ConcurrentDictionary MockReissueCertCache = new();
+
public CSCGlobalCAPlugin()
{
Logger = LogHandler.GetClassLogger();
@@ -56,6 +62,13 @@ public CSCGlobalCAPlugin()
// whether to submit a paid Renewal or a free Reissue under the still-active order.
public int RenewalWindowDays { get; set; }
+ // Test/diagnostic only - see Constants.MockReissueRun. Never set true against a production CA.
+ public bool MockReissueRun { get; set; }
+
+ // Test/diagnostic only - see Constants.AppendThumbprintToCaId. Never set true against a
+ // production CA.
+ public bool AppendThumbprintToCaId { get; set; }
+
//done
public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDataReader certificateDataReader)
{
@@ -98,6 +111,28 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa
}
Logger.LogDebug("RenewalWindowDays configured to {Days} days", RenewalWindowDays);
+ MockReissueRun = false; // default
+ if (configProvider.CAConnectionData.TryGetValue(Constants.MockReissueRun, out var mockReissueObj))
+ {
+ if (bool.TryParse(mockReissueObj?.ToString(), out var mockReissueParsed))
+ MockReissueRun = mockReissueParsed;
+ else
+ Logger.LogWarning($"Could not parse {Constants.MockReissueRun} value '{mockReissueObj}' as a bool; defaulting to false");
+ }
+ if (MockReissueRun)
+ Logger.LogWarning("MockReissueRun is enabled - Synchronize will inject synthetic mid-cycle-reissue certificates. Do not use against a production CA connection.");
+
+ AppendThumbprintToCaId = false; // default
+ if (configProvider.CAConnectionData.TryGetValue(Constants.AppendThumbprintToCaId, out var appendThumbprintObj))
+ {
+ if (bool.TryParse(appendThumbprintObj?.ToString(), out var appendThumbprintParsed))
+ AppendThumbprintToCaId = appendThumbprintParsed;
+ else
+ Logger.LogWarning($"Could not parse {Constants.AppendThumbprintToCaId} value '{appendThumbprintObj}' as a bool; defaulting to false");
+ }
+ if (AppendThumbprintToCaId)
+ Logger.LogWarning("AppendThumbprintToCaId is enabled - Synchronize will append the incoming certificate's thumbprint to each CARequestID. Do not use against a production CA connection.");
+
Logger.LogInformation("CSCGlobalCAPlugin initialized successfully");
Logger.MethodExit(LogLevel.Debug);
}
@@ -242,11 +277,48 @@ private async Task SyncCertificates(BlockingCollection b
var certString = GetEndEntityCertificate(certData);
if (certString.Length > 0)
{
+ // Test/diagnostic only - see Constants.MockReissueRun. A real CSC mid-cycle
+ // reissue isn't a second record - CSC just returns the same UUID's single
+ // record with its certificate field swapped for a new one. Reproduce that
+ // shape here: the first sync for a given UUID passes the real cert through
+ // untouched; every sync after that substitutes a synthetic replacement
+ // cert in its place, same CARequestID, no extra row. This also means the
+ // mock faithfully hits the same "existing certificate will be preserved"
+ // gateway behavior a real reissue would - that's expected, not a bug in
+ // this mock.
+ var certToQueue = certString;
+ if (MockReissueRun && certStatus == Convert.ToInt32(EndEntityStatus.GENERATED))
+ {
+ var uuid = currentResponseItem?.Uuid ?? string.Empty;
+ if (!MockReissueSeenUuids.TryAdd(uuid, true))
+ {
+ certToQueue = MockReissueCertCache.GetOrAdd(uuid, _ =>
+ {
+ var (realNotBefore, realNotAfter) = GetCertificateValidity(certString);
+ Logger.LogWarning(
+ "MockReissueRun: replacing certificate for {Uuid} ({CommonName}) with a synthetic mid-cycle-reissue cert",
+ currentResponseItem?.Uuid, currentResponseItem?.CommonName);
+ return GenerateMockReissueCertificate(
+ currentResponseItem?.CommonName ?? currentResponseItem?.Uuid ?? "mock-reissue.invalid",
+ realNotBefore, realNotAfter);
+ });
+ }
+ }
+
+ // Test/diagnostic only - see Constants.AppendThumbprintToCaId. Appending
+ // the thumbprint makes each distinct certificate version for this UUID
+ // land in its own gateway database row, instead of hitting
+ // AnyGatewayCore's existing-certificate-preserve behavior under the plain
+ // UUID - lets a sync be compared both ways.
+ var caRequestId = $"{currentResponseItem?.Uuid}";
+ if (AppendThumbprintToCaId)
+ caRequestId = $"{caRequestId}-{ComputeThumbprint(certToQueue)}";
+
Logger.LogTrace($"Queueing Certificate ID {currentResponseItem?.Uuid} with ProductID '{productId ?? "(null)"}'");
blockingBuffer.Add(new AnyCAPluginCertificate
{
- CARequestID = $"{currentResponseItem?.Uuid}",
- Certificate = certString,
+ CARequestID = caRequestId,
+ Certificate = certToQueue,
Status = certStatus,
ProductID = productId
}, cancelToken);
@@ -735,6 +807,20 @@ public Dictionary GetCAConnectorAnnotations()
Hidden = false,
DefaultValue = "30",
Type = "Number"
+ },
+ [Constants.MockReissueRun] = new()
+ {
+ Comments = "TEST/DIAGNOSTIC ONLY. When true, Synchronize substitutes a synthetic replacement certificate (freshly generated key, not CSC's) for each ACTIVE certificate starting on its second sync, matching CSC's real same-record reissue/renewal shape. Never enable against a production CA connection. Default is false.",
+ Hidden = false,
+ DefaultValue = "false",
+ Type = "String"
+ },
+ [Constants.AppendThumbprintToCaId] = new()
+ {
+ Comments = "TEST/DIAGNOSTIC ONLY. When true, Synchronize appends the incoming certificate's thumbprint to each CARequestID, so a changed certificate lands in its own gateway database row instead of being preserved under the existing row. Never enable against a production CA connection. Default is false.",
+ Hidden = false,
+ DefaultValue = "false",
+ Type = "String"
}
};
}
@@ -857,6 +943,35 @@ public List GetProductIds()
.ToList();
}
+ // Test/diagnostic only - see Constants.AppendThumbprintToCaId. SHA1-over-DER, matching
+ // AnyGatewayCore's own thumbprint convention (CertificateService.CreateOrUpdateCertificate).
+ private static string ComputeThumbprint(string base64DerCert)
+ {
+ using var sha1 = SHA1.Create();
+ var hash = sha1.ComputeHash(Convert.FromBase64String(base64DerCert));
+ return Convert.ToHexString(hash);
+ }
+
+ // Test/diagnostic only - see Constants.MockReissueRun. Reads the real leaf cert's own
+ // NotBefore/NotAfter so the synthetic mid-cycle-reissue duplicate can match them instead of
+ // using "now", keeping the mock cert's dates stable across sync runs.
+ private static (DateTimeOffset NotBefore, DateTimeOffset NotAfter) GetCertificateValidity(string base64DerCert)
+ {
+ using var cert = new X509Certificate2(Convert.FromBase64String(base64DerCert));
+ return (cert.NotBefore, cert.NotAfter);
+ }
+
+ // Test/diagnostic only - see Constants.MockReissueRun. Generates a self-signed leaf cert with
+ // a brand-new key pair for the given common name, standing in for a CSC mid-cycle reissue we
+ // never got a CSR for (so there's no real key to reuse, unlike a reissue we initiated).
+ private static string GenerateMockReissueCertificate(string commonName, DateTimeOffset notBefore, DateTimeOffset notAfter)
+ {
+ using var rsa = RSA.Create(2048);
+ var req = new CertificateRequest($"CN={commonName}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ using var cert = req.CreateSelfSigned(notBefore, notAfter);
+ return Convert.ToBase64String(cert.Export(X509ContentType.Cert));
+ }
+
#region PRIVATE
//Trying to fix leaf extraction
diff --git a/cscglobal-caplugin/Client/Models/CertificateResponse.cs b/cscglobal-caplugin/Client/Models/CertificateResponse.cs
index e1d5237..d1e7036 100644
--- a/cscglobal-caplugin/Client/Models/CertificateResponse.cs
+++ b/cscglobal-caplugin/Client/Models/CertificateResponse.cs
@@ -24,4 +24,18 @@ public class CertificateResponse : ICertificateResponse
[JsonProperty("orderDate")] public string OrderDate { get; set; }
[JsonProperty("serverSoftware")] public object ServerSoftware { get; set; }
[JsonProperty("certificate")] public string Certificate { get; set; }
+
+ // CSC's actual order term - not currently used for the renewal/reissue window calculation,
+ // which still assumes orderDate + 1 year (see CSCGlobalCAPlugin.RenewOrReissue). Null for most
+ // existing orders; populated on at least some newer ones.
+ [JsonProperty("orderValidityStartDate")] public string OrderValidityStartDate { get; set; }
+ [JsonProperty("orderValidityEndDate")] public string OrderValidityEndDate { get; set; }
+
+ [JsonProperty("isRenewed")] public bool IsRenewed { get; set; }
+
+ // True when CSC silently reissued this certificate mid-order-term (observed as the same
+ // Uuid reappearing in the list/get response with a new certificate - and no corresponding
+ // private key on Keyfactor's side, since CSC generates the reissued key pair itself). Not yet
+ // acted on anywhere in the plugin; see the mid-cycle-reissue mock fixtures in the test project.
+ [JsonProperty("isLifeCycleReIssueCertificate")] public bool IsLifeCycleReIssueCertificate { get; set; }
}
\ No newline at end of file
diff --git a/cscglobal-caplugin/Constants.cs b/cscglobal-caplugin/Constants.cs
index 982ed23..05ee541 100644
--- a/cscglobal-caplugin/Constants.cs
+++ b/cscglobal-caplugin/Constants.cs
@@ -16,6 +16,23 @@ public class Constants
public static string DefaultPageSize = "DefaultPageSize";
public static string SyncFilterDays = "SyncFilterDays";
public static string RenewalWindowDays = "RenewalWindowDays";
+
+ // When true, Synchronize substitutes a synthetic replacement certificate (same CARequestID,
+ // a freshly generated key - we never hold CSC's actual key) for every ACTIVE certificate that
+ // has already been synced once, starting on the second sync. Matches how CSC actually
+ // reports a mid-cycle reissue/renewal: a single record per UUID with its certificate field
+ // swapped in place, not a second record. Lets that scenario be reproduced live against a
+ // real CSC account instead of a separate mock server. Never enable this against a production
+ // CA connection.
+ public static string MockReissueRun = "MockReissueRun";
+
+ // Test/diagnostic only. When true, Synchronize appends the incoming certificate's thumbprint
+ // to the CARequestID it hands to the gateway, so each distinct certificate version for a UUID
+ // lands in its own row instead of hitting AnyGatewayCore's existing-certificate-preserve
+ // behavior. Lets a sync be compared both ways: new cert visible in the gateway database
+ // (true) vs. the real-world behavior of the existing cert being preserved (false, default).
+ // Never enable this against a production CA connection.
+ public static string AppendThumbprintToCaId = "AppendThumbprintToCaId";
}
public class ProductIDs
diff --git a/cscglobal-caplugin/Interfaces/ICertificateResponse.cs b/cscglobal-caplugin/Interfaces/ICertificateResponse.cs
index f4013f1..7a27bba 100644
--- a/cscglobal-caplugin/Interfaces/ICertificateResponse.cs
+++ b/cscglobal-caplugin/Interfaces/ICertificateResponse.cs
@@ -21,4 +21,8 @@ public interface ICertificateResponse
string OrderDate { get; set; }
object ServerSoftware { get; set; }
string Certificate { get; set; }
+ string OrderValidityStartDate { get; set; }
+ string OrderValidityEndDate { get; set; }
+ bool IsRenewed { get; set; }
+ bool IsLifeCycleReIssueCertificate { get; set; }
}
\ No newline at end of file
diff --git a/cscglobal-caplugin/RequestManager.cs b/cscglobal-caplugin/RequestManager.cs
index 0c485ff..1829e5e 100644
--- a/cscglobal-caplugin/RequestManager.cs
+++ b/cscglobal-caplugin/RequestManager.cs
@@ -23,6 +23,13 @@ public class RequestManager
public static Func Pemify = ss =>
ss.Length <= 64 ? ss : ss.Substring(0, 64) + "\n" + Pemify(ss.Substring(64));
+ // Certificate type codes (see ProductIdToCodeMap below) that carry multiple names
+ // and therefore need SubjectAlternativeNames populated on the registration request.
+ private static readonly HashSet MultiNameCertificateTypes = new() { "2", "6", "7", "8", "9" };
+
+ // Certificate type codes that require EvCertificateDetails (Organization Country, etc.).
+ private static readonly HashSet EvCertificateTypes = new() { "3", "7" };
+
private static string GetOptionalParam(EnrollmentProductInfo productInfo, string key)
{
return productInfo.ProductParameters != null &&
@@ -322,6 +329,22 @@ public RegistrationRequest GetRegistrationRequest(EnrollmentProductInfo productI
};
}
+ // Maps each canonical (1.2.0+) product ID to the numeric certificate type code CSC's API
+ // expects on enrollment. Order matches ProductIDs.productIds in Constants.cs.
+ private static readonly Dictionary ProductIdToCodeMap = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["CSC TrustedSecure OV"] = "0",
+ ["CSC TrustedSecure OV Wildcard"] = "1",
+ ["CSC TrustedSecure OV, Multiple Names"] = "2",
+ ["CSC TrustedSecure EV"] = "3",
+ ["CSC TrustedSecure DV"] = "4",
+ ["CSC TrustedSecure DV Wildcard"] = "5",
+ ["CSC TrustedSecure DV, Multiple Names"] = "6",
+ ["CSC TrustedSecure EV, Multiple Names"] = "7",
+ ["CSC TrustedSecure OV Wildcard, Multiple Names"] = "8",
+ ["CSC TrustedSecure DV Wildcard, Multiple Names"] = "9"
+ };
+
private string GetCertificateType(string productId)
{
if (productId != null && ProductIDs.legacyProductIdAliases.TryGetValue(productId, out var canonicalProductId))
@@ -329,6 +352,14 @@ private string GetCertificateType(string productId)
productId = canonicalProductId;
}
+ if (productId != null && ProductIdToCodeMap.TryGetValue(productId, out var code))
+ {
+ return code;
+ }
+
+ return "-1";
+ }
+
public Notifications GetNotifications(EnrollmentProductInfo productInfo)
{
var notificationEmails = GetOptionalParam(productInfo, "Notification Email(s) Comma Separated");