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");