From 0f369b82587e662dd773c0e31e17b722178e8d5c Mon Sep 17 00:00:00 2001 From: Morgan Gangwere <470584+indrora@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:23:57 -0800 Subject: [PATCH 01/11] Merge 2.0.2 to main (#35) * Add custom field support * changelog * support cname return from enrollment * Update generated docs * feat: release 1.1.1 * Update generated docs * Fix for issues with * Test * Added template parameter configuration via REST gateway. Fixed bug with email used for verification. Changed docs and enrollment field/template parameter names. See changelog. * Update generated docs * Fixed broken logging. * Incremental sync support added using csc date filter so sync timing can run faster that default full sync periods * Update generated docs * Fixes for Incremental Sync * Update CHANGELOG.md --------- Co-authored-by: Mikey Henderson <4452096+fiddlermikey@users.noreply.github.com> Co-authored-by: Sean <1661003+spbsoluble@users.noreply.github.com> Co-authored-by: Keyfactor Co-authored-by: Brian Hill Co-authored-by: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> * Fix NullReferenceException in GetEnrollmentResult for null DCV email (#9) * Fix NullReferenceException in GetEnrollmentResult for null DCV email The condition for adding DCV email entries to the cnames dictionary was inverted (string.IsNullOrEmpty instead of !string.IsNullOrEmpty), causing cnames.Add(null, null) and an ArgumentNullException on every enrollment where CSC returned a DcvDetail with email=null (typical for EMAIL DCV orders that have actionNeeded=N, and for CNAME-only DCV). Inverts the condition and adds a ContainsKey guard to mirror the existing CName branch. * Update generated docs --------- Co-authored-by: Keyfactor * Feature/dns plugins (#12) * 200 day renewal fixes * Update generated docs * Improved logging .net 10 support * Removed Template Sync Logic * Update generated docs * fixed template mapping issue * product fixes * Update generated docs * fixed renewal issue * documentation fixes * Update generated docs * DNS Changes * Update generated docs * dns code updates * Update generated docs * change type * Update generated docs * fixed mismatch * Use 'cname' validation type for CSC CNAME DCV CSC DCV requires a CNAME record, so resolve a DNS provider advertising the 'cname' validation type (e.g. GoDaddyCnameDomainValidator) rather than the ACME 'dns-01'/TXT variant. Docs updated to call out the CNAME validator and warn against selecting the TXT validator for CSC domains. * Update generated docs * added polling to grab cert * Update generated docs * Update integration-manifest.json * Update CSCGlobalCAPlugin.csproj * Update CSCGlobalCAPlugin.csproj * Update keyfactor-bootstrap-workflow-v3.yml * docs: auto-generate README and documentation [skip ci] * Add Enabled CA connection flag Adds the standard Enabled boolean field to the CA Connection settings, matching the pattern used by every other Keyfactor CA plugin (SSL Store, Digicert, HydrantId, GCP CAS, Idnomic, etc.). Purpose: allow ops to create the CA record before all API credentials are available. When Enabled=false, the plugin short-circuits: - Initialize: skips CscGlobalClient construction (no valid creds needed) - Ping: no-op, logs a warning - ValidateCAConnectionInfo / ValidateProductInfo: skip validation - Synchronize: completes the buffer immediately - Enroll: returns FAILED with a clear message - Revoke: throws InvalidOperationException with a clear message Default is true so existing deployments that don't set the key continue to function without change. Reads the value from incoming connectionInfo in the Validate* methods so an operator editing the CA sees consistent behavior with the current form state. * docs: auto-generate README and documentation [skip ci] --------- Co-authored-by: Keyfactor Co-authored-by: github-actions[bot] * Update integration-manifest.json (#14) * Update integration-manifest.json (#29) * docs: auto-generate README and documentation [skip ci] * Port 1.2.0 product rename and fixes onto DNS plugin support (release-2.0) (#34) * 200 day renewal fixes * Update generated docs * Improved logging .net 10 support * Removed Template Sync Logic * Update generated docs * fixed template mapping issue * product fixes * Update generated docs * fixed renewal issue * documentation fixes * Update generated docs * DNS Changes * Update generated docs * dns code updates * Update generated docs * change type * Update generated docs * fixed mismatch * Use 'cname' validation type for CSC CNAME DCV CSC DCV requires a CNAME record, so resolve a DNS provider advertising the 'cname' validation type (e.g. GoDaddyCnameDomainValidator) rather than the ACME 'dns-01'/TXT variant. Docs updated to call out the CNAME validator and warn against selecting the TXT validator for CSC domains. * Update generated docs * added polling to grab cert * Update generated docs * Update integration-manifest.json * Update CSCGlobalCAPlugin.csproj * Update CSCGlobalCAPlugin.csproj * Update keyfactor-bootstrap-workflow-v3.yml * docs: auto-generate README and documentation [skip ci] * Add Enabled CA connection flag Adds the standard Enabled boolean field to the CA Connection settings, matching the pattern used by every other Keyfactor CA plugin (SSL Store, Digicert, HydrantId, GCP CAS, Idnomic, etc.). Purpose: allow ops to create the CA record before all API credentials are available. When Enabled=false, the plugin short-circuits: - Initialize: skips CscGlobalClient construction (no valid creds needed) - Ping: no-op, logs a warning - ValidateCAConnectionInfo / ValidateProductInfo: skip validation - Synchronize: completes the buffer immediately - Enroll: returns FAILED with a clear message - Revoke: throws InvalidOperationException with a clear message Default is true so existing deployments that don't set the key continue to function without change. Reads the value from incoming connectionInfo in the Validate* methods so an operator editing the CA sees consistent behavior with the current form state. * docs: auto-generate README and documentation [skip ci] * Port product rename, new certificate types, and bug fixes from feature/ev-ov-dv-multiname-certs Ports the following from feature/ev-ov-dv-multiname-certs, adapted to this branch's existing patterns (ProductIdToCodeMap/CodeToProductIdMap, structured logging style) rather than overwriting them: - Renamed all certificate product IDs to CSC's current certificate type names, with pre-1.2.0 legacy names still accepted (added as additional entries in the existing product-id maps, not a separate alias layer) so existing Certificate Templates in Command keep working. - Added the 3 new certificate products: CSC TrustedSecure EV, Multiple Names; OV Wildcard, Multiple Names; DV Wildcard, Multiple Names (types 7/8/9). - Replaced the hardcoded certificateType == "2"/"3" checks with MultiNameCertificateTypes/EvCertificateTypes sets covering all applicable types. - ValidateProductInfo now calls RequestManager.IsKnownProductId instead of checking a separate list, so accepted names can't drift out of sync with what GetCertificateType actually resolves. - Fixed the "Addtl Sans Comma Separated DCV Emails" field never being read (typo'd lookup key: "DVC" instead of "DCV"), and added a fallback to the primary CN's DCV email when no per-domain SAN email override matches (CSC Global rejects requests with a SAN missing domainControlValidation). - Fixed a case-sensitivity bug ("priorcertsn" vs "PriorCertSN") that silently prevented PriorCertSN from ever being read during Renew/Reissue. - Made Price.Total nullable to fix a JSON deserialization crash when CSC Global returns "price.total": null. - Updated integration-manifest.json and docsource/configuration.md to match. Explicitly NOT ported (per discussion - these don't fit this branch): - FlowLogger changes/redesign - this branch's FlowLogger has an incompatible tree-based design already wired into DNS-01 CNAME auto-publish call sites; left untouched. - The EnrollmentContext "Flow Summary" UX feature - this branch's TryPublishCnameDcvAsync treats EnrollmentContext entries as real DNS records to auto-publish; adding non-DNS entries there would be actively harmful. - .NET 6/8 multi-targeting - this branch already moved to net10.0-only with newer package versions; not reintroducing the older targets. - The Renew/Reissue GENERATED-vs-EXTERNALVALIDATION fix - already present independently on this branch. Also adds a new xUnit test project (this branch had none), with fresh tests written against this branch's actual code shape rather than adapted from the other branch's now-incompatible test suite: 40 tests covering certificate type/SAN/EV routing for all 10 canonical + 7 legacy product names, IsKnownProductId, the DCV email fallback fix, Price.Total null deserialization, and Renew/Reissue status codes. * docs: auto-generate README and documentation [skip ci] * Raise unit test coverage to ~95% line coverage Adds testability seams (matching the pattern already used for HTTP mocking in feature/ev-ov-dv-multiname-certs): - CSCGlobalCAPlugin.CscGlobalClient property is now internal instead of private, so tests can inject a mock ICscGlobalClient. - CscGlobalClient gets an internal HttpMessageHandler-accepting constructor overload, so tests can supply a fake handler instead of making real HTTP calls. - AssemblyInfo.cs adds InternalsVisibleTo("CSCGlobalCAPlugin.Tests"). Expands the test suite from 40 to 233 tests, covering: - RequestManager: all certificate-type/SAN/EV routing (canonical + legacy names), IsKnownProductId, DCV email fallback, GetRevokeResult, MapReturnStatus/MapCertificateTypeToProductId, custom fields, Price.Total null deserialization. - CscGlobalClient: all 7 API methods (success/400/error paths) via a fake HttpMessageHandler, plus constructor validation. - FlowLogger: Step/StepAsync/Fail/Skip/Branch/Dispose. - CSCGlobalCAPlugin: Initialize, GetSingleRecord, Synchronize/ SyncCertificates, Revoke, Ping, ValidateCAConnectionInfo/ ValidateProductInfo, Enroll (New and RenewOrReissue - renewal vs. reissue decision, DNS-01 CNAME auto-publish via IDomainValidatorFactory, synchronous issuance polling via DcvPollTimeoutSeconds, and the various failure branches), and the PEM/leaf-certificate parsing helpers (GetEndEntityCertificate/ExtractCertificates/FindLeaf). Line coverage: 15.57% -> 95.62%. Remaining gaps are dead code (ExportCollectionToPem, RetryCountExceededException - both already unused before this change) and a few defensive catch blocks for exception types (AggregateException, arbitrary X509 export failures) that aren't reachable through normal async/mock-based testing. * Raise unit test branch coverage from ~75% to ~80% Added targeted tests for previously-uncovered conditional branches across RequestManager, CSCGlobalCAPlugin, and CscGlobalClient - optional-field mapping in the registration/renewal/reissue request builders, null-valued config keys, null-object constructor inputs, and a few edge cases in the renewal/reissue decision and DNS auto-publish paths. Left purely diagnostic logging ternaries and practically-unreachable defensive branches alone. * Sync ProductID directly from CSC's certificateType, drop reverse-mapping table Synced DV/EV/UC/Premium certs weren't mapping to their Certificate Profile in Command because CodeToProductIdMap resolved them back to the old pre-1.2.0 legacy product names, which no longer match the canonical Template Short Names configured in Command. Follow the same fix already proven on feature/ev-ov-dv-multiname-certs: pass CSC's certificateType straight through as ProductID during sync instead of remapping it, since CSC's API already returns the current/canonical product name directly. * Fix Enroll failures reporting INPROCESS instead of FAILED, surface flow summary in StatusMessage Every early-return failure path in Enroll (plus the catch-all exception handler) set Status = 30 (INPROCESS) instead of FAILED, so Command treated these as still-pending rather than errors and never surfaced them in the UI. Follow the same fix already proven on feature/ev-ov-dv-multiname-certs: use EndEntityStatus.FAILED throughout, and add FlowLogger.GetSummary() (a concise step list, distinct from the ASCII-tree RenderFlow() used for Trace logs) to prepend what the plugin actually attempted ahead of the terse error text. Also fixes the reissue "one click not available" message incorrectly saying "Renew" instead of "Reissue". * Attach flow summary to EnrollmentContext on success, not just StatusMessage Command's enrollment UI doesn't surface StatusMessage on a successful/pending result at all - only EnrollmentContext is shown, so the flow summary added for failures was invisible on the success path. Add AttachFlowSummary (following the same fix already proven on feature/ev-ov-dv-multiname-certs) to also attach a "Flow Summary" entry to EnrollmentContext for success/pending results, alongside whatever DCV instructions came back, and use it for the FAILED results returned from GetEnrollmentResult/GetRenewResponse/ GetReIssueResult, which the earlier Status=30 fix didn't cover since they're already FAILED coming out of RequestManager. Called after TryPublishCnameDcvAsync in the New enrollment path so the "Flow Summary" entry is never present yet when DNS auto-publish walks EnrollmentContext looking for real CNAME records to publish. * Render flow summary as one bullet per step instead of one blob Matches the same fix already proven on feature/ev-ov-dv-multiname-certs: add FlowLogger.GetSummaryEntries(), which returns one dictionary entry per step (plus a header entry) instead of a single multi-line block. AttachFlowSummary now merges these into EnrollmentContext directly so Command's bulleted rendering shows a readable line per step, rather than one run-on entry with embedded newlines that don't render as separate bullets. * Rename PublishCnameDcv step and explain why it's a no-op for non-CNAME methods The DcvAutoPublish step (formerly PublishCnameDcv) ran unconditionally on every New enrollment and, for EMAIL-validated certs, silently no-op'd while still showing a bare [OK] under a CNAME-sounding step name in the flow summary - confusing when read back in Command's UI. Add a FlowLogger.StepAsync overload that uses the action's own return value as the step detail (the existing overload's detail parameter is evaluated before the action runs, so it can't reflect what the action decided), and have TryPublishCnameDcvAsync return why it skipped or how many records it published. --------- Co-authored-by: Keyfactor Co-authored-by: github-actions[bot] --------- Co-authored-by: David Galey Co-authored-by: Keyfactor Co-authored-by: Mark Kachkaev <37276742+mkachk@users.noreply.github.com> Co-authored-by: Mikey Henderson <4452096+fiddlermikey@users.noreply.github.com> Co-authored-by: Sean <1661003+spbsoluble@users.noreply.github.com> Co-authored-by: Brian Hill Co-authored-by: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Co-authored-by: github-actions[bot] --- .../CSCGlobalCAPluginTests.cs | 16 ++++++++-- cscglobal-caplugin/CSCGlobalCAPlugin.cs | 12 +++++-- cscglobal-caplugin/Constants.cs | 4 +-- cscglobal-caplugin/RequestManager.cs | 31 ------------------- integration-manifest.json | 10 +++++- 5 files changed, 35 insertions(+), 38 deletions(-) diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs index 16fd258..4ae771b 100644 --- a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs @@ -633,8 +633,20 @@ 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() + { + var plugin = MakePlugin(); + var ids = plugin.GetProductIds(); + + // Command uses this list to auto-resolve a CA for a template when none is explicitly + // selected, so pre-1.2.0 legacy-named templates need their names advertised here too, + // not just the 10 canonical names. + Assert.Equal(17, ids.Count); + Assert.Contains("CSC TrustedSecure OV", ids); + Assert.Contains("CSC TrustedSecure DV Wildcard, Multiple Names", ids); + Assert.Contains("CSC TrustedSecure EV Certificate", ids); + Assert.Contains("CSC TrustedSecure Domain Validated SSL", ids); } [Fact] diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index 33d2306..c7c62cb 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -845,8 +845,16 @@ public Dictionary GetTemplateParameterAnnotations() //done public List GetProductIds() { - - return ProductIDs.productIds; + // Command uses this list to auto-resolve a CA for a template when none is explicitly + // selected (matches a template's product ID against each CA's advertised list). Existing + // Certificate Templates created before the 1.2.0 product rename are still configured with + // the pre-1.2.0 legacy names, so those must be advertised here too, or auto-resolution + // fails for them with "No CA was found to issue the specified template" even though + // explicit CA selection still works (that path goes through the more lenient + // ValidateProductInfo, which already accepts legacy names via legacyProductIdAliases). + return ProductIDs.productIds + .Concat(ProductIDs.legacyProductIdAliases.Keys) + .ToList(); } #region PRIVATE diff --git a/cscglobal-caplugin/Constants.cs b/cscglobal-caplugin/Constants.cs index f013902..982ed23 100644 --- a/cscglobal-caplugin/Constants.cs +++ b/cscglobal-caplugin/Constants.cs @@ -44,8 +44,8 @@ public class ProductIDs ["CSC TrustedSecure UC Certificate"] = "CSC TrustedSecure OV, Multiple Names", ["CSC TrustedSecure EV Certificate"] = "CSC TrustedSecure EV", ["CSC TrustedSecure Domain Validated SSL"] = "CSC TrustedSecure DV", - ["CSC TrustedSecure Domain Validated Wildcard SSL"] = "CSC TrustedSecure DV Wildcard", - ["CSC TrustedSecure Domain Validated UC Certificate"] = "CSC TrustedSecure DV, Multiple Names" + ["CSC Trusted Secure Domain Validated Wildcard SSL"] = "CSC TrustedSecure DV Wildcard", + ["CSC Trusted Secure Domain Validated UC Certificate"] = "CSC TrustedSecure DV, Multiple Names" }; } diff --git a/cscglobal-caplugin/RequestManager.cs b/cscglobal-caplugin/RequestManager.cs index 06f0b41..0c485ff 100644 --- a/cscglobal-caplugin/RequestManager.cs +++ b/cscglobal-caplugin/RequestManager.cs @@ -329,37 +329,6 @@ private string GetCertificateType(string productId) productId = canonicalProductId; } - switch (productId) - { - case "CSC TrustedSecure OV": - return "0"; - case "CSC TrustedSecure OV Wildcard": - return "1"; - case "CSC TrustedSecure OV, Multiple Names": - return "2"; - case "CSC TrustedSecure EV": - return "3"; - case "CSC TrustedSecure DV": - return "4"; - case "CSC TrustedSecure DV Wildcard": - return "5"; - case "CSC TrustedSecure DV, Multiple Names": - return "6"; - case "CSC TrustedSecure EV, Multiple Names": - return "7"; - case "CSC TrustedSecure OV Wildcard, Multiple Names": - return "8"; - case "CSC TrustedSecure DV Wildcard, Multiple Names": - return "9"; - } - - Logger.LogWarning($"Unrecognized product ID '{productId}'; defaulting certificate type to -1"); - return "-1"; - } - - private static readonly HashSet MultiNameCertificateTypes = new() { "2", "6", "7", "8", "9" }; - private static readonly HashSet EvCertificateTypes = new() { "3", "7" }; - public Notifications GetNotifications(EnrollmentProductInfo productInfo) { var notificationEmails = GetOptionalParam(productInfo, "Notification Email(s) Comma Separated"); diff --git a/integration-manifest.json b/integration-manifest.json index 9ae70c7..5b44242 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -5,6 +5,7 @@ "support_level": "kf-supported", "status": "pilot", "link_github": true, + "status": "production", "update_catalog": true, "description": "CSCGlobal CAPlugin for the AnyCA REST Gateway framework", "gateway_framework": "24.2.0", @@ -94,7 +95,14 @@ "CSC TrustedSecure DV, Multiple Names", "CSC TrustedSecure EV, Multiple Names", "CSC TrustedSecure OV Wildcard, Multiple Names", - "CSC TrustedSecure DV Wildcard, Multiple Names" + "CSC TrustedSecure DV Wildcard, Multiple Names", + "CSC TrustedSecure Premium Certificate", + "CSC TrustedSecure EV Certificate", + "CSC TrustedSecure UC Certificate", + "CSC TrustedSecure Premium Wildcard Certificate", + "CSC TrustedSecure Domain Validated SSL", + "CSC Trusted Secure Domain Validated Wildcard SSL", + "CSC Trusted Secure Domain Validated UC Certificate" ] } } From 5d89183b565fd0d224a24f82132eff2c20854bb8 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 10 Nov 2026 10:18:31 -0500 Subject: [PATCH 02/11] Fix main build broken by bad merge-conflict resolution A "Merge branch 'main' into release-2.0" commit mangled RequestManager's GetCertificateType into a truncated method (missing body/closing brace, swallowing the next method's signature), and dropped the MultiNameCertificateTypes/EvCertificateTypes lookup sets it depends on. That broken state then flowed into main via the 2.0.2 merge. The actual published 2.0.2 release tag predates this and is unaffected. Also fixes a test file truncated by the same bad merge (a test body cut off mid-assertion, swallowing the next test's attribute/signature), two tests using the wrong ("TrustedSecure" vs "Trusted Secure") spelling for two legacy product names, and removes a stale duplicate GetProductIds test left over from before the product rename. --- .../CSCGlobalCAPluginTests.cs | 15 +++------ .../RequestManagerTests.cs | 6 ++-- cscglobal-caplugin/RequestManager.cs | 31 +++++++++++++++++++ 3 files changed, 38 insertions(+), 14 deletions(-) diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs index 4ae771b..1676e26 100644 --- a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs @@ -633,6 +633,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() { @@ -1165,17 +1169,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/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"); From 07cb45533945a40a31f5f1017456bb5cb6fe8f3f Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 10 Nov 2026 10:18:42 -0500 Subject: [PATCH 03/11] Deserialize orderValidityStartDate/EndDate, isRenewed, isLifeCycleReIssueCertificate CSC's API already returns these fields but the model silently dropped them. isLifeCycleReIssueCertificate in particular flags when CSC has mid-cycle reissued a certificate under the same uuid with a new key pair CSC generated itself - not yet acted on anywhere in the plugin, but needed to write any test against that scenario. See the mid-cycle-reissue mock fixtures. --- .../Client/Models/CertificateResponse.cs | 14 ++++++++++++++ .../Interfaces/ICertificateResponse.cs | 4 ++++ 2 files changed, 18 insertions(+) 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/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 From 4318ee76334f5d808022a7a3477720b0b4975c9d Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 10 Nov 2026 10:19:59 -0500 Subject: [PATCH 04/11] Add mock CSC data reproducing mid-cycle certificate reissue CSC can reissue a certificate partway through its order term under the same uuid, generating a new key pair itself rather than from our CSR, and flagging isLifeCycleReIssueCertificate on the next sync. Keyfactor never holds a matching private key for that reissued certificate. Adds MidCycleReissueFixtures with two real-world scenarios (single-name DV and wildcard multi-name DV, metadata taken from actual CSC API responses) each producing a baseline record and its mid-cycle-reissue counterpart, plus Synchronize tests pinning down current behavior: both records get queued independently under the same CARequestID with different certificate content, with nothing today reflecting that the second one is a reissue rather than an ordinary sync. This is a repro/test harness only - no handling of the flag has been implemented yet. --- .../CSCGlobalCAPluginTests.cs | 73 +++++++++++ .../MidCycleReissueFixtures.cs | 123 ++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 cscglobal-caplugin.Tests/MidCycleReissueFixtures.cs diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs index 1676e26..5dc90fe 100644 --- a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs @@ -428,6 +428,79 @@ 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 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); + Assert.NotEqual(baseline.Certificate, reissue.Certificate); + } + // --------------------------------------------------------------------- // Revoke // --------------------------------------------------------------------- diff --git a/cscglobal-caplugin.Tests/MidCycleReissueFixtures.cs b/cscglobal-caplugin.Tests/MidCycleReissueFixtures.cs new file mode 100644 index 0000000..651943b --- /dev/null +++ b/cscglobal-caplugin.Tests/MidCycleReissueFixtures.cs @@ -0,0 +1,123 @@ +// 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 CscGlobalCAPluginTests; + +/// +/// Mock CSC "mid-cycle reissue" API responses, reproducing a real-world scenario: CSC +/// silently reissues a certificate partway through its order term - same uuid, a new +/// certificate/key pair CSC generated itself (not from Keyfactor's CSR), and +/// isLifeCycleReIssueCertificate set to true on the next list/get response. Keyfactor never +/// holds the private key for the reissued certificate, since it didn't come from our CSR. +/// +/// 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/key material is synthetic (freshly generated per test run). +/// +public static class MidCycleReissueFixtures +{ + private static (string Pem, string ApiBase64) MakeApiCertificate(string commonName) + { + using var rsa = RSA.Create(2048); + var req = new CertificateRequest($"CN={commonName}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(365)); + var pem = "-----BEGIN CERTIFICATE-----\n" + + Convert.ToBase64String(cert.RawData, Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE-----\n"; + return (pem, 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() + { + var (_, apiCert) = MakeApiCertificate("cnn.keyfactortestb.com"); + return new CertificateResponse + { + Uuid = "96d8802a-94f5-4616-a27d-bd51bb6eca18", + 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 as , reappearing with a CSC-generated + /// reissue mid-term: isLifeCycleReIssueCertificate is true, effective/expiration shifted + /// forward to the reissue date, order validity window unchanged, and a certificate whose + /// key Keyfactor never saw. + /// + public static CertificateResponse SingleNameMidCycleReissue() + { + var baseline = SingleNameBaseline(); + var (_, apiCert) = MakeApiCertificate(baseline.CommonName); + baseline.Certificate = apiCert; + 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() + { + var (_, apiCert) = MakeApiCertificate("*.boingy.com"); + return new CertificateResponse + { + Uuid = "7db327f6-34a0-4919-9af1-1a76277cc2a7", + 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 . + public static CertificateResponse WildcardMultiNameMidCycleReissue() + { + var baseline = WildcardMultiNameBaseline(); + var (_, apiCert) = MakeApiCertificate(baseline.CommonName); + baseline.Certificate = apiCert; + baseline.EffectiveDate = "02-Dec-2026"; + baseline.ExpirationDate = "20-Jun-2027"; + baseline.OrderDate = "02-Dec-2026"; + baseline.IsLifeCycleReIssueCertificate = true; + return baseline; + } +} From ca72a729e8d2eed4eb6847ab2bdc7e37f02d3547 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 10 Nov 2026 15:20:35 +0000 Subject: [PATCH 05/11] docs: auto-generate README and documentation [skip ci] --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0250f2c..31c22e0 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

-Integration Status: pilot +Integration Status: production Release Issues GitHub Downloads (all assets, all releases) From 230fbedca95310906aa69eed57a9e3170de8046a Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 10 Nov 2026 11:06:37 -0500 Subject: [PATCH 06/11] Add a live mock CSC server to run Synchronize against real HTTP Moves MidCycleReissueFixtures into a new shared CSCGlobalCAPlugin.MockData project so it can be used outside the test project too. Adds CSCGlobalCAPlugin.MockServer, a runnable console app that starts a real HttpListener implementing CSC's list/get certificate endpoints (backed by the mid-cycle-reissue fixtures), then runs the real, unmodified CSCGlobalCAPlugin.Synchronize() against it over an actual HTTP connection - no mocked ICscGlobalClient. Confirms live what the xUnit tests already showed: both the baseline and its mid-cycle reissue come back under the same CARequestID with distinct certificate content, and nothing distinguishes the reissue from an ordinary re-sync. Run with: dotnet run --project cscglobal-caplugin.MockServer --- .../CSCGlobalCAPlugin.MockData.csproj | 14 ++ .../MidCycleReissueFixtures.cs | 69 ++++++--- .../CSCGlobalCAPlugin.MockServer.csproj | 20 +++ cscglobal-caplugin.MockServer/Program.cs | 146 ++++++++++++++++++ .../CSCGlobalCAPlugin.Tests.csproj | 1 + .../CSCGlobalCAPluginTests.cs | 16 ++ cscglobal-caplugin.sln | 82 +++++++++- 7 files changed, 323 insertions(+), 25 deletions(-) create mode 100644 cscglobal-caplugin.MockData/CSCGlobalCAPlugin.MockData.csproj rename {cscglobal-caplugin.Tests => cscglobal-caplugin.MockData}/MidCycleReissueFixtures.cs (61%) create mode 100644 cscglobal-caplugin.MockServer/CSCGlobalCAPlugin.MockServer.csproj create mode 100644 cscglobal-caplugin.MockServer/Program.cs 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.Tests/MidCycleReissueFixtures.cs b/cscglobal-caplugin.MockData/MidCycleReissueFixtures.cs similarity index 61% rename from cscglobal-caplugin.Tests/MidCycleReissueFixtures.cs rename to cscglobal-caplugin.MockData/MidCycleReissueFixtures.cs index 651943b..6c8a0cd 100644 --- a/cscglobal-caplugin.Tests/MidCycleReissueFixtures.cs +++ b/cscglobal-caplugin.MockData/MidCycleReissueFixtures.cs @@ -6,32 +6,45 @@ using System.Text; using Keyfactor.Extensions.CAPlugin.CSCGlobal.Client.Models; -namespace CscGlobalCAPluginTests; +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, a new -/// certificate/key pair CSC generated itself (not from Keyfactor's CSR), and -/// isLifeCycleReIssueCertificate set to true on the next list/get response. Keyfactor never -/// holds the private key for the reissued certificate, since it didn't come from our CSR. +/// 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/key material is synthetic (freshly generated per test run). +/// 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 { - private static (string Pem, string ApiBase64) MakeApiCertificate(string commonName) + // 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) { - using var rsa = RSA.Create(2048); - var req = new CertificateRequest($"CN={commonName}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(365)); + 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 (pem, Convert.ToBase64String(Encoding.UTF8.GetBytes(pem))); + return Convert.ToBase64String(Encoding.UTF8.GetBytes(pem)); } /// @@ -40,10 +53,13 @@ private static (string Pem, string ApiBase64) MakeApiCertificate(string commonNa /// public static CertificateResponse SingleNameBaseline() { - var (_, apiCert) = MakeApiCertificate("cnn.keyfactortestb.com"); + 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 = "96d8802a-94f5-4616-a27d-bd51bb6eca18", + Uuid = uuid, CommonName = "cnn.keyfactortestb.com", AdditionalNames = new List(), CertificateType = "CSC TrustedSecure DV", @@ -63,16 +79,17 @@ public static CertificateResponse SingleNameBaseline() } /// - /// Same uuid/order as , reappearing with a CSC-generated - /// reissue mid-term: isLifeCycleReIssueCertificate is true, effective/expiration shifted - /// forward to the reissue date, order validity window unchanged, and a certificate whose - /// key Keyfactor never saw. + /// 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 (_, apiCert) = MakeApiCertificate(baseline.CommonName); - baseline.Certificate = apiCert; + 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"; @@ -86,10 +103,13 @@ public static CertificateResponse SingleNameMidCycleReissue() /// public static CertificateResponse WildcardMultiNameBaseline() { - var (_, apiCert) = MakeApiCertificate("*.boingy.com"); + 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 = "7db327f6-34a0-4919-9af1-1a76277cc2a7", + Uuid = uuid, CommonName = "*.boingy.com", AdditionalNames = new List(), CertificateType = "CSC TrustedSecure DV Wildcard, Multiple Names", @@ -108,12 +128,13 @@ public static CertificateResponse WildcardMultiNameBaseline() }; } - /// Mid-cycle reissue of . + /// Mid-cycle reissue of , re-signed from the same key. public static CertificateResponse WildcardMultiNameMidCycleReissue() { var baseline = WildcardMultiNameBaseline(); - var (_, apiCert) = MakeApiCertificate(baseline.CommonName); - baseline.Certificate = apiCert; + 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"; 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 5dc90fe..f567517 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; @@ -498,7 +499,22 @@ public void MidCycleReissueFixtures_ReissueVariant_FlagsIsLifeCycleReIssueCertif 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)); } // --------------------------------------------------------------------- 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 From a7ce54a62f502ae57b2b9847acaaead6c989edeb Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 10 Nov 2026 12:45:48 -0500 Subject: [PATCH 07/11] Add MockReissueRun CA connection setting for live mid-cycle-reissue testing A separate local mock HTTP server works, but networking (Docker, remote Command instances) makes it awkward to point a real CA connection at it. This is simpler: a CA connection property that makes a real Synchronize() against the real CSC API also inject a synthetic mid-cycle-reissue duplicate for every ACTIVE certificate it gets back, using a freshly generated key (we never hold CSC's real key for an unsolicited reissue). Test/diagnostic only - defaults to false, logs a warning when enabled, and is documented as never-for-production in its connector annotation. --- .../CSCGlobalCAPluginTests.cs | 76 ++++++++++++++++++- cscglobal-caplugin/CSCGlobalCAPlugin.cs | 49 ++++++++++++ cscglobal-caplugin/Constants.cs | 7 ++ 3 files changed, 131 insertions(+), 1 deletion(-) diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs index f567517..fabac0b 100644 --- a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs @@ -488,6 +488,79 @@ public async Task Synchronize_MidCycleReissue_WildcardMultiName_SameUuidQueuedTw 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_InjectsSyntheticDuplicateForEachActiveCert() + { + 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 }, + new CertificateResponse { Uuid = "real-2", CommonName = "real2.example.com", Status = "REVOKED", CertificateType = "CSC TrustedSecure DV", Certificate = apiCert } + } + }); + + var data = ValidConnectionData(); + data[Constants.MockReissueRun] = "true"; + var plugin = MakePlugin(mockClient, connectionData: data); + var buffer = new BlockingCollection(); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var items = buffer.ToList(); + + // real-1 is ACTIVE -> gets a synthetic reissue duplicate injected. real-2 is REVOKED -> + // no injection, since there's nothing to "reissue mid-cycle" for a revoked certificate. + Assert.Equal(3, items.Count); + var real1Items = items.Where(i => i.CARequestID == "real-1").ToList(); + Assert.Equal(2, real1Items.Count); + Assert.NotEqual(real1Items[0].Certificate, real1Items[1].Certificate); + Assert.Single(items, i => i.CARequestID == "real-2"); + } + + [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() { @@ -1236,7 +1309,7 @@ public void GetCAConnectorAnnotations_ContainsExpectedKeys() var plugin = MakePlugin(); var annotations = plugin.GetCAConnectorAnnotations(); - Assert.Equal(7, annotations.Count); + Assert.Equal(8, annotations.Count); Assert.Contains(Constants.Enabled, annotations.Keys); Assert.Contains(Constants.CscGlobalUrl, annotations.Keys); Assert.Contains(Constants.CscGlobalApiKey, annotations.Keys); @@ -1244,6 +1317,7 @@ public void GetCAConnectorAnnotations_ContainsExpectedKeys() Assert.Contains(Constants.DefaultPageSize, 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); } diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index c7c62cb..11e6092 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -56,6 +56,9 @@ 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; } + //done public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDataReader certificateDataReader) { @@ -98,6 +101,17 @@ 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."); + Logger.LogInformation("CSCGlobalCAPlugin initialized successfully"); Logger.MethodExit(LogLevel.Debug); } @@ -251,6 +265,23 @@ private async Task SyncCertificates(BlockingCollection b ProductID = productId }, cancelToken); queuedCount++; + + if (MockReissueRun && certStatus == Convert.ToInt32(EndEntityStatus.GENERATED)) + { + var mockCertString = GenerateMockReissueCertificate( + currentResponseItem?.CommonName ?? currentResponseItem?.Uuid ?? "mock-reissue.invalid"); + Logger.LogWarning( + "MockReissueRun: injecting synthetic mid-cycle-reissue duplicate for {Uuid} ({CommonName})", + currentResponseItem?.Uuid, currentResponseItem?.CommonName); + blockingBuffer.Add(new AnyCAPluginCertificate + { + CARequestID = $"{currentResponseItem?.Uuid}", + Certificate = mockCertString, + Status = certStatus, + ProductID = productId + }, cancelToken); + queuedCount++; + } } else { @@ -735,6 +766,13 @@ public Dictionary GetCAConnectorAnnotations() Hidden = false, DefaultValue = "30", Type = "Number" + }, + [Constants.MockReissueRun] = new() + { + Comments = "TEST/DIAGNOSTIC ONLY. When true, Synchronize injects a synthetic mid-cycle-reissue duplicate (freshly generated key, not CSC's) for every real ACTIVE certificate returned. Never enable against a production CA connection. Default is false.", + Hidden = false, + DefaultValue = "false", + Type = "String" } }; } @@ -857,6 +895,17 @@ public List GetProductIds() .ToList(); } + // 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) + { + using var rsa = RSA.Create(2048); + var req = new CertificateRequest($"CN={commonName}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(365)); + return Convert.ToBase64String(cert.Export(X509ContentType.Cert)); + } + #region PRIVATE //Trying to fix leaf extraction diff --git a/cscglobal-caplugin/Constants.cs b/cscglobal-caplugin/Constants.cs index 982ed23..fb177f7 100644 --- a/cscglobal-caplugin/Constants.cs +++ b/cscglobal-caplugin/Constants.cs @@ -16,6 +16,13 @@ public class Constants public static string DefaultPageSize = "DefaultPageSize"; public static string SyncFilterDays = "SyncFilterDays"; public static string RenewalWindowDays = "RenewalWindowDays"; + + // When true, Synchronize injects a synthetic "mid-cycle reissue" duplicate for every real + // ACTIVE certificate CSC returns - same CARequestID, a freshly generated key/certificate (we + // never hold CSC's actual key), isLifeCycleReIssueCertificate-style scenario. Lets the reissue + // collision 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"; } public class ProductIDs From 765437d3082d037eb8b7ad1823011be5a19d3cb3 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Wed, 11 Nov 2026 11:31:15 -0500 Subject: [PATCH 08/11] Give MockReissueRun duplicate its own CARequestID so it lands as a new row AnyGatewayCore's CertificateService.CreateOrUpdateCertificate only writes a cert into a row the first time it is populated; once a row has a cert it logs "The existing certificate will be preserved" and leaves it alone, with no config to override that. Reusing the real cert's CARequestID for the synthetic duplicate collided with the real cert's row and was silently dropped - confirmed live, where a sync with MockReissueRun enabled queued 62 certs but the gateway database still held exactly the 31 real ones. Derive the mock duplicate's CARequestID from the real one plus the mock cert's serial number, so it gets its own row and that row differs on every sync run (a fixed suffix would collide with its own prior mock row on the next sync and hit the same preserve problem again). Diverges from CSC's real same-UUID reissue continuity, but makes the injected cert actually observable for testing. Rollback: git tag mock-reissue-same-careqid-backup points at the prior same-CARequestID behavior (a7ce54a). --- .../CSCGlobalCAPluginTests.cs | 16 ++++++++---- cscglobal-caplugin/CSCGlobalCAPlugin.cs | 26 ++++++++++++++----- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs index fabac0b..fa950bc 100644 --- a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs @@ -529,13 +529,19 @@ public async Task Synchronize_MockReissueRunEnabled_InjectsSyntheticDuplicateFor var items = buffer.ToList(); - // real-1 is ACTIVE -> gets a synthetic reissue duplicate injected. real-2 is REVOKED -> - // no injection, since there's nothing to "reissue mid-cycle" for a revoked certificate. + // real-1 is ACTIVE -> gets a synthetic reissue duplicate injected under a CARequestID + // derived from the mock cert's serial number (AnyGatewayCore's CertificateService + // preserves the first cert written to a row and silently drops any later cert for the + // same CARequestID, so the duplicate must land in its own row to be observable, and must + // vary per run so it doesn't collide with a prior sync's mock row). real-2 is REVOKED -> + // no injection. Assert.Equal(3, items.Count); - var real1Items = items.Where(i => i.CARequestID == "real-1").ToList(); - Assert.Equal(2, real1Items.Count); - Assert.NotEqual(real1Items[0].Certificate, real1Items[1].Certificate); + Assert.Single(items, i => i.CARequestID == "real-1"); Assert.Single(items, i => i.CARequestID == "real-2"); + var mockItem = Assert.Single(items, i => i.CARequestID != "real-1" && i.CARequestID != "real-2"); + Assert.StartsWith("real-1-mock-", mockItem.CARequestID); + var realItem = items.Single(i => i.CARequestID == "real-1"); + Assert.NotEqual(realItem.Certificate, mockItem.Certificate); } [Fact] diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index 11e6092..bce13be 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -268,14 +268,23 @@ private async Task SyncCertificates(BlockingCollection b if (MockReissueRun && certStatus == Convert.ToInt32(EndEntityStatus.GENERATED)) { - var mockCertString = GenerateMockReissueCertificate( + var (mockCertString, mockSerialNumber) = GenerateMockReissueCertificate( currentResponseItem?.CommonName ?? currentResponseItem?.Uuid ?? "mock-reissue.invalid"); + // AnyGatewayCore's CertificateService.CreateOrUpdateCertificate only writes a cert into a + // row the first time it's populated - if a row for this CARequestID already has a cert, + // it logs "The existing certificate will be preserved" and leaves it alone, with no + // config to override that. Reusing currentResponseItem.Uuid here would collide with the + // real cert's row and be silently dropped. Deriving the CARequestID from the mock cert's + // serial number (always different, since it's freshly generated each call) gives every + // sync run's duplicate its own new row, instead of colliding with a fixed suffix's row + // from a prior sync, at the cost of diverging from CSC's real same-UUID reissue continuity. + var mockCARequestId = $"{currentResponseItem?.Uuid}-mock-{mockSerialNumber}"; Logger.LogWarning( - "MockReissueRun: injecting synthetic mid-cycle-reissue duplicate for {Uuid} ({CommonName})", - currentResponseItem?.Uuid, currentResponseItem?.CommonName); + "MockReissueRun: injecting synthetic mid-cycle-reissue duplicate for {Uuid} ({CommonName}) under derived CARequestID {MockCARequestId}", + currentResponseItem?.Uuid, currentResponseItem?.CommonName, mockCARequestId); blockingBuffer.Add(new AnyCAPluginCertificate { - CARequestID = $"{currentResponseItem?.Uuid}", + CARequestID = mockCARequestId, Certificate = mockCertString, Status = certStatus, ProductID = productId @@ -897,13 +906,16 @@ public List GetProductIds() // 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) + // never got a CSR for (so there's no real key to reuse, unlike a reissue we initiated). Also + // returns the cert's serial number, which the caller uses to derive a CARequestID that's + // unique per sync run (a fixed suffix would collide with its own prior mock row on the next + // sync, hitting the same "existing certificate will be preserved" problem again). + private static (string CertString, string SerialNumber) GenerateMockReissueCertificate(string commonName) { using var rsa = RSA.Create(2048); var req = new CertificateRequest($"CN={commonName}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(365)); - return Convert.ToBase64String(cert.Export(X509ContentType.Cert)); + return (Convert.ToBase64String(cert.Export(X509ContentType.Cert)), cert.SerialNumber); } #region PRIVATE From 444d769622a1486b95f79540d85ccddc1ec08260 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Thu, 12 Nov 2026 09:51:56 -0500 Subject: [PATCH 09/11] Make MockReissueRun injection idempotent and use real cert's validity window Deriving the mock duplicate's CARequestID from its own serial number made every sync create a brand-new row, since a freshly generated self-signed cert gets a new random serial each call - confirmed live, where repeated syncs ballooned the gateway database to 4000+ records instead of one duplicate per real cert. Go back to a fixed, derived CARequestID (uuid + "-mock-reissue") so a sync only creates the mock row once; AnyGatewayCore's existing-cert-preserve behavior then does the right thing on every later sync - it finds the row and leaves it alone, instead of inserting a new one. Also set the mock cert's NotBefore/NotAfter from the real cert's own validity window instead of DateTimeOffset.UtcNow, since the real cert's dates don't drift between syncs the way "now" does, and that may be a factor in whether a downstream consumer treats a cert as the same vs. a replacement. --- .../CSCGlobalCAPluginTests.cs | 15 ++++---- cscglobal-caplugin/CSCGlobalCAPlugin.cs | 37 ++++++++++++------- 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs index fa950bc..a17a30f 100644 --- a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs @@ -529,17 +529,16 @@ public async Task Synchronize_MockReissueRunEnabled_InjectsSyntheticDuplicateFor var items = buffer.ToList(); - // real-1 is ACTIVE -> gets a synthetic reissue duplicate injected under a CARequestID - // derived from the mock cert's serial number (AnyGatewayCore's CertificateService - // preserves the first cert written to a row and silently drops any later cert for the - // same CARequestID, so the duplicate must land in its own row to be observable, and must - // vary per run so it doesn't collide with a prior sync's mock row). real-2 is REVOKED -> - // no injection. + // real-1 is ACTIVE -> gets a synthetic reissue duplicate injected under a derived, + // fixed CARequestID (AnyGatewayCore's CertificateService preserves the first cert written + // to a row and silently drops any later cert for the same CARequestID, so the duplicate + // must land in its own row to be observable; the ID is fixed, not varying per run, so a + // later sync finds the same row and leaves it alone instead of piling up new rows). + // real-2 is REVOKED -> no injection. Assert.Equal(3, items.Count); Assert.Single(items, i => i.CARequestID == "real-1"); Assert.Single(items, i => i.CARequestID == "real-2"); - var mockItem = Assert.Single(items, i => i.CARequestID != "real-1" && i.CARequestID != "real-2"); - Assert.StartsWith("real-1-mock-", mockItem.CARequestID); + var mockItem = Assert.Single(items, i => i.CARequestID == "real-1-mock-reissue"); var realItem = items.Single(i => i.CARequestID == "real-1"); Assert.NotEqual(realItem.Certificate, mockItem.Certificate); } diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index bce13be..1700875 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -268,17 +268,20 @@ private async Task SyncCertificates(BlockingCollection b if (MockReissueRun && certStatus == Convert.ToInt32(EndEntityStatus.GENERATED)) { - var (mockCertString, mockSerialNumber) = GenerateMockReissueCertificate( - currentResponseItem?.CommonName ?? currentResponseItem?.Uuid ?? "mock-reissue.invalid"); + var (realNotBefore, realNotAfter) = GetCertificateValidity(certString); + var mockCertString = GenerateMockReissueCertificate( + currentResponseItem?.CommonName ?? currentResponseItem?.Uuid ?? "mock-reissue.invalid", + realNotBefore, realNotAfter); // AnyGatewayCore's CertificateService.CreateOrUpdateCertificate only writes a cert into a // row the first time it's populated - if a row for this CARequestID already has a cert, // it logs "The existing certificate will be preserved" and leaves it alone, with no // config to override that. Reusing currentResponseItem.Uuid here would collide with the - // real cert's row and be silently dropped. Deriving the CARequestID from the mock cert's - // serial number (always different, since it's freshly generated each call) gives every - // sync run's duplicate its own new row, instead of colliding with a fixed suffix's row - // from a prior sync, at the cost of diverging from CSC's real same-UUID reissue continuity. - var mockCARequestId = $"{currentResponseItem?.Uuid}-mock-{mockSerialNumber}"; + // real cert's row and be silently dropped, so give the duplicate a derived, but fixed, + // CARequestID of its own. Fixed (not varying per sync) is deliberate: once the mock row + // exists, later syncs should just find it and leave it alone via the same preserve + // behavior, not keep creating new rows every sync. Matching the real cert's NotBefore/ + // NotAfter instead of "now" also keeps the mock cert's dates stable across sync runs. + var mockCARequestId = $"{currentResponseItem?.Uuid}-mock-reissue"; Logger.LogWarning( "MockReissueRun: injecting synthetic mid-cycle-reissue duplicate for {Uuid} ({CommonName}) under derived CARequestID {MockCARequestId}", currentResponseItem?.Uuid, currentResponseItem?.CommonName, mockCARequestId); @@ -904,18 +907,24 @@ public List GetProductIds() .ToList(); } + // 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). Also - // returns the cert's serial number, which the caller uses to derive a CARequestID that's - // unique per sync run (a fixed suffix would collide with its own prior mock row on the next - // sync, hitting the same "existing certificate will be preserved" problem again). - private static (string CertString, string SerialNumber) GenerateMockReissueCertificate(string commonName) + // 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(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(365)); - return (Convert.ToBase64String(cert.Export(X509ContentType.Cert)), cert.SerialNumber); + using var cert = req.CreateSelfSigned(notBefore, notAfter); + return Convert.ToBase64String(cert.Export(X509ContentType.Cert)); } #region PRIVATE From 751acd2036bb445aa21ee40fed27898dc734d2f9 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Thu, 12 Nov 2026 11:38:31 -0500 Subject: [PATCH 10/11] Match MockReissueRun to CSC's real same-record reissue/renewal shape Pulled two real CSC Certificates payloads (a renewal and an isLifeCycleReIssueCertificate case) and confirmed CSC never sends two records for one order - it's always a single record per UUID with the certificate field (and dates/flags) swapped in place. Replace the dual-queue, derived-CARequestID approach with an in-place swap: the first sync for a UUID passes the real cert through untouched; every sync after that substitutes a synthetic reissue cert for the same UUID/CARequestID, matching CSC's actual behavior exactly. This also means MockReissueRun now faithfully reproduces the 'existing certificate will be preserved' gateway behavior a real mid-cycle reissue would hit, instead of working around it - that's the point, not a bug in the mock. Tracking state (which UUIDs have already been shown once, and the cached replacement cert) has to be static since the plugin is reconstructed and re-initialized on every sync call. --- .../CSCGlobalCAPluginTests.cs | 50 ++++++++------ cscglobal-caplugin/CSCGlobalCAPlugin.cs | 65 ++++++++++--------- 2 files changed, 64 insertions(+), 51 deletions(-) diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs index a17a30f..219eb1c 100644 --- a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs @@ -505,8 +505,13 @@ public void Initialize_MockReissueRunTrue_ParsesTrue() } [Fact] - public async Task Synchronize_MockReissueRunEnabled_InjectsSyntheticDuplicateForEachActiveCert() + 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); @@ -515,32 +520,35 @@ public async Task Synchronize_MockReissueRunEnabled_InjectsSyntheticDuplicateFor { Results = new List { - new CertificateResponse { Uuid = "real-1", CommonName = "real1.example.com", Status = "ACTIVE", CertificateType = "CSC TrustedSecure DV", Certificate = apiCert }, - new CertificateResponse { Uuid = "real-2", CommonName = "real2.example.com", Status = "REVOKED", CertificateType = "CSC TrustedSecure DV", Certificate = apiCert } + 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 plugin = MakePlugin(mockClient, connectionData: data); - var buffer = new BlockingCollection(); - - await plugin.Synchronize(buffer, null, true, CancellationToken.None); - - var items = buffer.ToList(); - // real-1 is ACTIVE -> gets a synthetic reissue duplicate injected under a derived, - // fixed CARequestID (AnyGatewayCore's CertificateService preserves the first cert written - // to a row and silently drops any later cert for the same CARequestID, so the duplicate - // must land in its own row to be observable; the ID is fixed, not varying per run, so a - // later sync finds the same row and leaves it alone instead of piling up new rows). - // real-2 is REVOKED -> no injection. - Assert.Equal(3, items.Count); - Assert.Single(items, i => i.CARequestID == "real-1"); - Assert.Single(items, i => i.CARequestID == "real-2"); - var mockItem = Assert.Single(items, i => i.CARequestID == "real-1-mock-reissue"); - var realItem = items.Single(i => i.CARequestID == "real-1"); - Assert.NotEqual(realItem.Certificate, mockItem.Certificate); + 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] diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index 1700875..b90596d 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(); @@ -256,44 +262,43 @@ 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); + }); + } + } + Logger.LogTrace($"Queueing Certificate ID {currentResponseItem?.Uuid} with ProductID '{productId ?? "(null)"}'"); blockingBuffer.Add(new AnyCAPluginCertificate { CARequestID = $"{currentResponseItem?.Uuid}", - Certificate = certString, + Certificate = certToQueue, Status = certStatus, ProductID = productId }, cancelToken); queuedCount++; - - if (MockReissueRun && certStatus == Convert.ToInt32(EndEntityStatus.GENERATED)) - { - var (realNotBefore, realNotAfter) = GetCertificateValidity(certString); - var mockCertString = GenerateMockReissueCertificate( - currentResponseItem?.CommonName ?? currentResponseItem?.Uuid ?? "mock-reissue.invalid", - realNotBefore, realNotAfter); - // AnyGatewayCore's CertificateService.CreateOrUpdateCertificate only writes a cert into a - // row the first time it's populated - if a row for this CARequestID already has a cert, - // it logs "The existing certificate will be preserved" and leaves it alone, with no - // config to override that. Reusing currentResponseItem.Uuid here would collide with the - // real cert's row and be silently dropped, so give the duplicate a derived, but fixed, - // CARequestID of its own. Fixed (not varying per sync) is deliberate: once the mock row - // exists, later syncs should just find it and leave it alone via the same preserve - // behavior, not keep creating new rows every sync. Matching the real cert's NotBefore/ - // NotAfter instead of "now" also keeps the mock cert's dates stable across sync runs. - var mockCARequestId = $"{currentResponseItem?.Uuid}-mock-reissue"; - Logger.LogWarning( - "MockReissueRun: injecting synthetic mid-cycle-reissue duplicate for {Uuid} ({CommonName}) under derived CARequestID {MockCARequestId}", - currentResponseItem?.Uuid, currentResponseItem?.CommonName, mockCARequestId); - blockingBuffer.Add(new AnyCAPluginCertificate - { - CARequestID = mockCARequestId, - Certificate = mockCertString, - Status = certStatus, - ProductID = productId - }, cancelToken); - queuedCount++; - } } else { From 0e835af5f4ea241849f7598d8dad10a4dc64a4f5 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Thu, 12 Nov 2026 11:43:43 -0500 Subject: [PATCH 11/11] Add AppendThumbprintToCaId setting to test new-row vs preserved-row behavior MockReissueRun's cert-swap always lands under the same CARequestID, which hits AnyGatewayCore's existing-certificate-preserve behavior every time - useful for proving that behavior, but it means there's no way to also see what the gateway does with a genuinely new row for the same UUID. Add a second, independent test/diagnostic CA connection setting: AppendThumbprintToCaId. When true, Synchronize appends the incoming certificate's SHA1 thumbprint (matching AnyGatewayCore's own thumbprint convention) to every CARequestID it hands to the gateway. Combined with MockReissueRun, this lets the same scenario be synced both ways - new cert visible as its own row (true) vs. the real-world preserved-row behavior (false, default) - without changing any other code. --- .../CSCGlobalCAPluginTests.cs | 72 ++++++++++++++++++- cscglobal-caplugin/CSCGlobalCAPlugin.cs | 44 +++++++++++- cscglobal-caplugin/Constants.cs | 20 ++++-- 3 files changed, 128 insertions(+), 8 deletions(-) diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs index 219eb1c..c8047cc 100644 --- a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs @@ -551,6 +551,75 @@ public async Task Synchronize_MockReissueRunEnabled_FirstSyncPassesRealCertThenR 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() { @@ -1322,12 +1391,13 @@ public void GetCAConnectorAnnotations_ContainsExpectedKeys() var plugin = MakePlugin(); var annotations = plugin.GetCAConnectorAnnotations(); - Assert.Equal(8, 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); diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index b90596d..bf50215 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -65,6 +65,10 @@ public CSCGlobalCAPlugin() // 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) { @@ -118,6 +122,17 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa 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); } @@ -290,10 +305,19 @@ private async Task SyncCertificates(BlockingCollection b } } + // 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}", + CARequestID = caRequestId, Certificate = certToQueue, Status = certStatus, ProductID = productId @@ -786,7 +810,14 @@ public Dictionary GetCAConnectorAnnotations() }, [Constants.MockReissueRun] = new() { - Comments = "TEST/DIAGNOSTIC ONLY. When true, Synchronize injects a synthetic mid-cycle-reissue duplicate (freshly generated key, not CSC's) for every real ACTIVE certificate returned. Never enable against a production CA connection. Default is false.", + 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" @@ -912,6 +943,15 @@ 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. diff --git a/cscglobal-caplugin/Constants.cs b/cscglobal-caplugin/Constants.cs index fb177f7..05ee541 100644 --- a/cscglobal-caplugin/Constants.cs +++ b/cscglobal-caplugin/Constants.cs @@ -17,12 +17,22 @@ public class Constants public static string SyncFilterDays = "SyncFilterDays"; public static string RenewalWindowDays = "RenewalWindowDays"; - // When true, Synchronize injects a synthetic "mid-cycle reissue" duplicate for every real - // ACTIVE certificate CSC returns - same CARequestID, a freshly generated key/certificate (we - // never hold CSC's actual key), isLifeCycleReIssueCertificate-style scenario. Lets the reissue - // collision be reproduced live against a real CSC account instead of a separate mock server. - // Never enable this against a production CA connection. + // 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