From fc725c42956adcfbd714b7b99cda0b57463c26fe Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Wed, 9 Sep 2026 14:23:21 -0400 Subject: [PATCH 01/36] Add support for EV, OV Wildcard, and DV Wildcard multi-name certificate products CSC TrustedSecure EV, Multiple Names (7), CSC TrustedSecure OV Wildcard, Multiple Names (8), and CSC TrustedSecure DV Wildcard, Multiple Names (9) per CSC's certificate type table. Type 7 requires both EvCertificateDetails and SubjectAlternativeNames since it is EV plus multi-name; 8 and 9 only need SubjectAlternativeNames. --- CHANGELOG.md | 3 + cscglobal-caplugin/Constants.cs | 5 +- cscglobal-caplugin/RequestManager.cs | 21 +++++-- docsource/configuration.md | 88 ++++++++++++++++++++++++++++ integration-manifest.json | 5 +- 5 files changed, 114 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c6124b..367a89c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +v1.2.0 +- Added support for CSC TrustedSecure EV, Multiple Names; CSC TrustedSecure OV Wildcard, Multiple Names; and CSC TrustedSecure DV Wildcard, Multiple Names certificate products + v.1.1.1 - Added Incremental Sync that goes back X Number of days - Fixed issue with parsing certain certificates that were in zip format diff --git a/cscglobal-caplugin/Constants.cs b/cscglobal-caplugin/Constants.cs index 4d6b4da..fd79d7a 100644 --- a/cscglobal-caplugin/Constants.cs +++ b/cscglobal-caplugin/Constants.cs @@ -27,7 +27,10 @@ public class ProductIDs "CSC TrustedSecure Premium Wildcard Certificate", "CSC TrustedSecure Domain Validated SSL", "CSC TrustedSecure Domain Validated Wildcard SSL", - "CSC TrustedSecure Domain Validated UC Certificate" + "CSC TrustedSecure Domain Validated UC Certificate", + "CSC TrustedSecure EV, Multiple Names", + "CSC TrustedSecure OV Wildcard, Multiple Names", + "CSC TrustedSecure DV Wildcard, Multiple Names" }; } diff --git a/cscglobal-caplugin/RequestManager.cs b/cscglobal-caplugin/RequestManager.cs index 776902c..94ce704 100644 --- a/cscglobal-caplugin/RequestManager.cs +++ b/cscglobal-caplugin/RequestManager.cs @@ -177,8 +177,8 @@ public RegistrationRequest GetRegistrationRequest(EnrollmentProductInfo productI BusinessUnit = productInfo.ProductParameters["Business Unit"], ShowPrice = true, //User should not have to fill this out CustomFields = GetCustomFields(productInfo, customFields), - SubjectAlternativeNames = certificateType == "2" ? GetSubjectAlternativeNames(productInfo, sans) : null, - EvCertificateDetails = certificateType == "3" ? GetEvCertificateDetails(productInfo) : null + SubjectAlternativeNames = MultiNameCertificateTypes.Contains(certificateType) ? GetSubjectAlternativeNames(productInfo, sans) : null, + EvCertificateDetails = EvCertificateTypes.Contains(certificateType) ? GetEvCertificateDetails(productInfo) : null }; } @@ -206,11 +206,20 @@ private string GetCertificateType(string productId) return "5"; case "CSC TrustedSecure Domain Validated UC Certificate": 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"; } return "-1"; } + private static readonly HashSet MultiNameCertificateTypes = new() { "2", "7", "8", "9" }; + private static readonly HashSet EvCertificateTypes = new() { "3", "7" }; + public Notifications GetNotifications(EnrollmentProductInfo productInfo) { return new Notifications @@ -250,9 +259,9 @@ public RenewalRequest GetRenewalRequest(EnrollmentProductInfo productInfo, strin OrganizationContact = productInfo.ProductParameters["Organization Contact"], BusinessUnit = productInfo.ProductParameters["Business Unit"], ShowPrice = true, - SubjectAlternativeNames = certificateType == "2" ? GetSubjectAlternativeNames(productInfo, sans) : null, + SubjectAlternativeNames = MultiNameCertificateTypes.Contains(certificateType) ? GetSubjectAlternativeNames(productInfo, sans) : null, CustomFields = GetCustomFields(productInfo, customFields), - EvCertificateDetails = certificateType == "3" ? GetEvCertificateDetails(productInfo) : null + EvCertificateDetails = EvCertificateTypes.Contains(certificateType) ? GetEvCertificateDetails(productInfo) : null }; } @@ -308,9 +317,9 @@ public ReissueRequest GetReissueRequest(EnrollmentProductInfo productInfo, strin OrganizationContact = productInfo.ProductParameters["Organization Contact"], BusinessUnit = productInfo.ProductParameters["Business Unit"], ShowPrice = true, - SubjectAlternativeNames = certificateType == "2" ? GetSubjectAlternativeNames(productInfo, sans) : null, + SubjectAlternativeNames = MultiNameCertificateTypes.Contains(certificateType) ? GetSubjectAlternativeNames(productInfo, sans) : null, CustomFields = GetCustomFields(productInfo, customFields), - EvCertificateDetails = certificateType == "3" ? GetEvCertificateDetails(productInfo) : null + EvCertificateDetails = EvCertificateTypes.Contains(certificateType) ? GetEvCertificateDetails(productInfo) : null }; } diff --git a/docsource/configuration.md b/docsource/configuration.md index d8c196e..63cc066 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -216,3 +216,91 @@ Notification Email(s) Comma Separated | String | N/A CN DCV Email | String | N/A Addtl Sans Comma Separated DCV Emails | String | N/A +**CSC TrustedSecure EV, Multiple Names - Details Tab** + +CONFIG ELEMENT | DESCRIPTION +----------------------------|------------------ +Template Short Name | CSC TrustedSecure EV, Multiple Names +Template Display Name | CSC TrustedSecure EV, Multiple Names +Friendly Name | CSC TrustedSecure EV, Multiple Names +Keys Size | 2048 +Enforce RFC 2818 Compliance | True +CSR Enrollment | True +Pfx Enrollment | True + + +**CSC TrustedSecure EV, Multiple Names - Enrollment Fields** + +NAME | DATA TYPE | VALUES +-----|--------------|----------------- +Term | Multiple Choice | 12,24 +Applicant First Name | String | N/A +Applicant Last Name | String | N/A +Applicant Email Address | String | N/A +Applicant Phone | String | N/A +Domain Control Validation Method | Multiple Choice | EMAIL +Organization Contact | Multiple Choice | Get From CSC Differs For Clients +Business Unit | Multiple Choice | Get From CSC Differs For Clients +Notification Email(s) Comma Separated | String | N/A +CN DCV Email | String | N/A +Organization Country | String | N/A +Addtl Sans Comma Separated DCV Emails | String | N/A + +**CSC TrustedSecure OV Wildcard, Multiple Names - Details Tab** + +CONFIG ELEMENT | DESCRIPTION +----------------------------|------------------ +Template Short Name | CSC TrustedSecure OV Wildcard, Multiple Names +Template Display Name | CSC TrustedSecure OV Wildcard, Multiple Names +Friendly Name | CSC TrustedSecure OV Wildcard, Multiple Names +Keys Size | 2048 +Enforce RFC 2818 Compliance | True +CSR Enrollment | True +Pfx Enrollment | True + + +**CSC TrustedSecure OV Wildcard, Multiple Names - Enrollment Fields** + +NAME | DATA TYPE | VALUES +-----|--------------|----------------- +Term | Multiple Choice | 12,24 +Applicant First Name | String | N/A +Applicant Last Name | String | N/A +Applicant Email Address | String | N/A +Applicant Phone | String | N/A +Domain Control Validation Method | Multiple Choice | EMAIL +Organization Contact | Multiple Choice | Get From CSC Differs For Clients +Business Unit | Multiple Choice | Get From CSC Differs For Clients +Notification Email(s) Comma Separated | String | N/A +CN DCV Email | String | N/A +Addtl Sans Comma Separated DCV Emails | String | N/A + +**CSC TrustedSecure DV Wildcard, Multiple Names - Details Tab** + +CONFIG ELEMENT | DESCRIPTION +----------------------------|------------------ +Template Short Name | CSC TrustedSecure DV Wildcard, Multiple Names +Template Display Name | CSC TrustedSecure DV Wildcard, Multiple Names +Friendly Name | CSC TrustedSecure DV Wildcard, Multiple Names +Keys Size | 2048 +Enforce RFC 2818 Compliance | True +CSR Enrollment | True +Pfx Enrollment | True + + +**CSC TrustedSecure DV Wildcard, Multiple Names - Enrollment Fields** + +NAME | DATA TYPE | VALUES +-----|--------------|----------------- +Term | Multiple Choice | 12,24 +Applicant First Name | String | N/A +Applicant Last Name | String | N/A +Applicant Email Address | String | N/A +Applicant Phone | String | N/A +Domain Control Validation Method | Multiple Choice | EMAIL +Organization Contact | Multiple Choice | Get From CSC Differs For Clients +Business Unit | Multiple Choice | Get From CSC Differs For Clients +Notification Email(s) Comma Separated | String | N/A +CN DCV Email | String | N/A +Addtl Sans Comma Separated DCV Emails | String | N/A + diff --git a/integration-manifest.json b/integration-manifest.json index 2b4b8c4..756b19a 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -95,7 +95,10 @@ "CSC TrustedSecure Premium Wildcard Certificate", "CSC TrustedSecure Domain Validated SSL", "CSC TrustedSecure Domain Validated Wildcard SSL", - "CSC TrustedSecure Domain Validated UC Certificate" + "CSC TrustedSecure Domain Validated UC Certificate", + "CSC TrustedSecure EV, Multiple Names", + "CSC TrustedSecure OV Wildcard, Multiple Names", + "CSC TrustedSecure DV Wildcard, Multiple Names" ] } } From 916c16b395357ab315ff4f5ad6b0818ab3337ce2 Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Wed, 9 Sep 2026 18:24:58 +0000 Subject: [PATCH 02/36] Update generated docs --- README.md | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/README.md b/README.md index c68aac4..f61fd6d 100644 --- a/README.md +++ b/README.md @@ -293,6 +293,94 @@ This integration is tested and confirmed as working for Anygateway REST 24.2 and CN DCV Email | String | N/A Addtl Sans Comma Separated DCV Emails | String | N/A + **CSC TrustedSecure EV, Multiple Names - Details Tab** + + CONFIG ELEMENT | DESCRIPTION + ----------------------------|------------------ + Template Short Name | CSC TrustedSecure EV, Multiple Names + Template Display Name | CSC TrustedSecure EV, Multiple Names + Friendly Name | CSC TrustedSecure EV, Multiple Names + Keys Size | 2048 + Enforce RFC 2818 Compliance | True + CSR Enrollment | True + Pfx Enrollment | True + + + **CSC TrustedSecure EV, Multiple Names - Enrollment Fields** + + NAME | DATA TYPE | VALUES + -----|--------------|----------------- + Term | Multiple Choice | 12,24 + Applicant First Name | String | N/A + Applicant Last Name | String | N/A + Applicant Email Address | String | N/A + Applicant Phone | String | N/A + Domain Control Validation Method | Multiple Choice | EMAIL + Organization Contact | Multiple Choice | Get From CSC Differs For Clients + Business Unit | Multiple Choice | Get From CSC Differs For Clients + Notification Email(s) Comma Separated | String | N/A + CN DCV Email | String | N/A + Organization Country | String | N/A + Addtl Sans Comma Separated DCV Emails | String | N/A + + **CSC TrustedSecure OV Wildcard, Multiple Names - Details Tab** + + CONFIG ELEMENT | DESCRIPTION + ----------------------------|------------------ + Template Short Name | CSC TrustedSecure OV Wildcard, Multiple Names + Template Display Name | CSC TrustedSecure OV Wildcard, Multiple Names + Friendly Name | CSC TrustedSecure OV Wildcard, Multiple Names + Keys Size | 2048 + Enforce RFC 2818 Compliance | True + CSR Enrollment | True + Pfx Enrollment | True + + + **CSC TrustedSecure OV Wildcard, Multiple Names - Enrollment Fields** + + NAME | DATA TYPE | VALUES + -----|--------------|----------------- + Term | Multiple Choice | 12,24 + Applicant First Name | String | N/A + Applicant Last Name | String | N/A + Applicant Email Address | String | N/A + Applicant Phone | String | N/A + Domain Control Validation Method | Multiple Choice | EMAIL + Organization Contact | Multiple Choice | Get From CSC Differs For Clients + Business Unit | Multiple Choice | Get From CSC Differs For Clients + Notification Email(s) Comma Separated | String | N/A + CN DCV Email | String | N/A + Addtl Sans Comma Separated DCV Emails | String | N/A + + **CSC TrustedSecure DV Wildcard, Multiple Names - Details Tab** + + CONFIG ELEMENT | DESCRIPTION + ----------------------------|------------------ + Template Short Name | CSC TrustedSecure DV Wildcard, Multiple Names + Template Display Name | CSC TrustedSecure DV Wildcard, Multiple Names + Friendly Name | CSC TrustedSecure DV Wildcard, Multiple Names + Keys Size | 2048 + Enforce RFC 2818 Compliance | True + CSR Enrollment | True + Pfx Enrollment | True + + + **CSC TrustedSecure DV Wildcard, Multiple Names - Enrollment Fields** + + NAME | DATA TYPE | VALUES + -----|--------------|----------------- + Term | Multiple Choice | 12,24 + Applicant First Name | String | N/A + Applicant Last Name | String | N/A + Applicant Email Address | String | N/A + Applicant Phone | String | N/A + Domain Control Validation Method | Multiple Choice | EMAIL + Organization Contact | Multiple Choice | Get From CSC Differs For Clients + Business Unit | Multiple Choice | Get From CSC Differs For Clients + Notification Email(s) Comma Separated | String | N/A + CN DCV Email | String | N/A + Addtl Sans Comma Separated DCV Emails | String | N/A + 3. Follow the [official Keyfactor documentation](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCA-Keyfactor.htm) to add each defined Certificate Authority to Keyfactor Command and import the newly defined Certificate Templates. 4. In Keyfactor Command (v12.3+), for each imported Certificate Template, follow the [official documentation](https://software.keyfactor.com/Core-OnPrem/Current/Content/ReferenceGuide/Configuring%20Template%20Options.htm) to define enrollment fields for each of the following parameters: From 9c3b8241900ebf8a7f102fce9ac716c55907f396 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Wed, 9 Sep 2026 16:37:07 -0400 Subject: [PATCH 03/36] Add FlowLogger and expand error/warning/info logging Introduce a step-oriented FlowLogger for tracing Enroll, Revoke, and Synchronize pipelines end to end, and add logging throughout RequestManager (previously had none) and CscGlobalClient (missing error/warning branches, wrong logger category on CscGlobalClient). --- cscglobal-caplugin/CSCGlobalCAPlugin.cs | 309 +++++++++++++------ cscglobal-caplugin/Client/CscGlobalClient.cs | 60 +++- cscglobal-caplugin/FlowLogger.cs | 267 ++++++++++++++++ cscglobal-caplugin/RequestManager.cs | 27 ++ 4 files changed, 555 insertions(+), 108 deletions(-) create mode 100644 cscglobal-caplugin/FlowLogger.cs diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index e1af2f0..edf44bd 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -47,6 +47,7 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa CscGlobalClient = new CscGlobalClient(configProvider); var templateSync = configProvider.CAConnectionData["TemplateSync"].ToString(); if (templateSync.ToUpper() == "ON") EnableTemplateSync = true; + Logger.LogInformation($"Template sync is {(EnableTemplateSync ? "enabled" : "disabled")}"); if (configProvider.CAConnectionData.ContainsKey(Constants.SyncFilterDays)) { @@ -56,7 +57,13 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa SyncFilterDays = syncFilterDays; Logger.LogDebug($"SyncFilterDays configured to {SyncFilterDays} days"); } + else + { + Logger.LogWarning($"Could not parse {Constants.SyncFilterDays} value '{syncFilterDaysStr}' as an integer; using default"); + } } + + Logger.LogInformation("CSCGlobalCAPlugin initialized successfully"); Logger.MethodExit(LogLevel.Debug); } @@ -96,6 +103,7 @@ public async Task GetSingleRecord(string caRequestID) } catch (Exception e) { + Logger.LogError(e, "Error occurred getting single cert for CA request ID {CaRequestID}: {Message}", caRequestID, e.Message); throw new Exception($"Error Occurred getting single cert {e.Message}"); } } @@ -106,27 +114,39 @@ public async Task Synchronize(BlockingCollection blockin { Logger.LogTrace($"Full Sync? {fullSync.ToString()}"); Logger.MethodEntry(); + using var flow = new FlowLogger(Logger, "Synchronize"); try { if (fullSync) { - Logger.LogDebug("Performing full sync - no date filter applied"); - await SyncCertificates(blockingBuffer, cancelToken, null); + Logger.LogInformation("Performing full sync - no date filter applied"); + flow.Step("DetermineSyncMode", "Full sync - no date filter applied"); + await SyncCertificates(blockingBuffer, cancelToken, null, flow); } else { var filterDays = SyncFilterDays > 0 ? SyncFilterDays : 5; var filterDate = DateTime.Today.Subtract(TimeSpan.FromDays(filterDays)); var dateFilter = filterDate.ToString("yyyy/MM/dd"); - Logger.LogDebug($"Performing incremental sync with expiration date filter: {dateFilter}"); - await SyncCertificates(blockingBuffer, cancelToken, dateFilter); + Logger.LogInformation($"Performing incremental sync with expiration date filter: {dateFilter}"); + flow.Step("DetermineSyncMode", $"Incremental sync with expiration date filter: {dateFilter}"); + await SyncCertificates(blockingBuffer, cancelToken, dateFilter, flow); } blockingBuffer.CompleteAdding(); + Logger.LogInformation("Csc Global Synchronize Task completed successfully"); + } + catch (OperationCanceledException) + { + Logger.LogWarning("Csc Global Synchronize Task was cancelled"); + flow.Fail("Synchronize", "Task was cancelled"); + blockingBuffer.CompleteAdding(); + throw; } catch (Exception e) { - Logger.LogError($"Csc Global Synchronize Task failed! {LogHandler.FlattenException(e)}"); + Logger.LogError(e, $"Csc Global Synchronize Task failed! {LogHandler.FlattenException(e)}"); + flow.Fail("Synchronize", e.Message); Logger.MethodExit(); blockingBuffer.CompleteAdding(); throw; @@ -136,10 +156,15 @@ public async Task Synchronize(BlockingCollection blockin } private async Task SyncCertificates(BlockingCollection blockingBuffer, - CancellationToken cancelToken, string? dateFilter) + CancellationToken cancelToken, string? dateFilter, FlowLogger flow) { - var certs = await CscGlobalClient.SubmitCertificateListRequestAsync(dateFilter); + var certs = await flow.StepAsync("SubmitCertificateListRequest", + () => CscGlobalClient.SubmitCertificateListRequestAsync(dateFilter)); + + Logger.LogInformation($"Retrieved {certs?.Results?.Count ?? 0} certificate(s) from CSC Global for sync"); + var queuedCount = 0; + var skippedCount = 0; foreach (var currentResponseItem in certs.Results) { cancelToken.ThrowIfCancellationRequested(); @@ -164,6 +189,7 @@ private async Task SyncCertificates(BlockingCollection b var certData = fileContent.Replace("\r\n", string.Empty); var certString = GetEndEntityCertificate(certData); if (certString.Length > 0) + { blockingBuffer.Add(new AnyCAPluginCertificate { CARequestID = $"{currentResponseItem?.Uuid}", @@ -171,36 +197,70 @@ private async Task SyncCertificates(BlockingCollection b Status = certStatus, ProductID = productId }, cancelToken); + queuedCount++; + } + else + { + Logger.LogWarning($"Could not extract end-entity certificate for {currentResponseItem?.Uuid}; skipping sync of this record"); + skippedCount++; + } + } + else + { + Logger.LogWarning($"No certificate content returned by CSC Global for {currentResponseItem?.Uuid}; skipping sync of this record"); + skippedCount++; } } + else + { + Logger.LogTrace($"Skipping Certificate ID {currentResponseItem?.Uuid} - status {currentResponseItem?.Status} is not eligible for sync"); + skippedCount++; + } } + + flow.Step("QueueCertificates", $"Queued {queuedCount}, skipped {skippedCount}"); + Logger.LogInformation($"Sync queued {queuedCount} certificate(s), skipped {skippedCount}"); } //done public async Task Revoke(string caRequestID, string hexSerialNumber, uint revocationReason) { + Logger.MethodEntry(LogLevel.Debug); + using var flow = new FlowLogger(Logger, "Revoke"); try { - Logger.LogTrace("Staring Revoke Method"); - var revokeResponse = - Task.Run(async () => - await CscGlobalClient.SubmitRevokeCertificateAsync(caRequestID.Substring(0, 36))).Result - ; //todo fix to use pipe delimiter + Logger.LogInformation($"Starting Revoke for CA request ID {caRequestID}, reason {revocationReason}"); + var uuid = caRequestID.Substring(0, 36); //todo fix to use pipe delimiter + + var revokeResponse = await flow.StepAsync("SubmitRevokeCertificate", + () => CscGlobalClient.SubmitRevokeCertificateAsync(uuid)); Logger.LogTrace($"Revoke Response JSON: {JsonConvert.SerializeObject(revokeResponse)}"); - Logger.MethodExit(LogLevel.Debug); var revokeResult = _requestManager.GetRevokeResult(revokeResponse); if (revokeResult == (int)EndEntityStatus.FAILED) + { if (!string.IsNullOrEmpty(revokeResponse?.RegistrationError?.Description)) + { + flow.Fail("SubmitRevokeCertificate", revokeResponse?.RegistrationError?.Description ?? "Unknown error"); throw new HttpRequestException( $"Revoke Failed with message {revokeResponse?.RegistrationError?.Description}"); + } + Logger.LogWarning($"Revoke returned a failed status for CA request ID {caRequestID} with no error description"); + } + else + { + Logger.LogInformation($"Revoke succeeded for CA request ID {caRequestID}"); + } + + Logger.MethodExit(LogLevel.Debug); return revokeResult; } catch (Exception e) { + Logger.LogError(e, $"Revoke Failed for CA request ID {caRequestID} with message {e?.Message}"); throw new Exception($"Revoke Failed with message {e?.Message}"); } } @@ -210,114 +270,153 @@ public async Task Enroll(string csr, string subject, Dictionar EnrollmentProductInfo productInfo, RequestFormat requestFormat, EnrollmentType enrollmentType) { Logger.MethodEntry(LogLevel.Debug); + Logger.LogInformation($"Starting Enroll for product {productInfo.ProductID}, enrollment type {enrollmentType}"); + using var flow = new FlowLogger(Logger, "Enroll"); - RegistrationRequest enrollmentRequest; - var priorSn = ""; - ReissueRequest reissueRequest; - RenewalRequest renewRequest; - if (productInfo.ProductParameters.ContainsKey("priorcertsn")) + try { - priorSn = productInfo.ProductParameters["PriorCertSN"]; - Logger.LogDebug($"Prior cert sn: {priorSn}"); - } + RegistrationRequest enrollmentRequest; + var priorSn = ""; + ReissueRequest reissueRequest; + RenewalRequest renewRequest; + if (productInfo.ProductParameters.ContainsKey("priorcertsn")) + { + priorSn = productInfo.ProductParameters["PriorCertSN"]; + Logger.LogDebug($"Prior cert sn: {priorSn}"); + } - string uUId; - var customFields = await CscGlobalClient.SubmitGetCustomFields(); + string uUId; + var customFields = await flow.StepAsync("SubmitGetCustomFields", () => CscGlobalClient.SubmitGetCustomFields()); - switch (enrollmentType) - { - case EnrollmentType.New: - Logger.LogTrace("Entering New Enrollment"); - //If they renewed an expired cert it gets here and this will not be supported - IRegistrationResponse enrollmentResponse; - if (!productInfo.ProductParameters.ContainsKey("PriorCertSN")) - { - enrollmentRequest = _requestManager.GetRegistrationRequest(productInfo, csr, san, customFields); - Logger.LogTrace($"Enrollment Request JSON: {JsonConvert.SerializeObject(enrollmentRequest)}"); - enrollmentResponse = - Task.Run(async () => await CscGlobalClient.SubmitRegistrationAsync(enrollmentRequest)) - .Result; - Logger.LogTrace($"Enrollment Response JSON: {JsonConvert.SerializeObject(enrollmentResponse)}"); - } - else - { - return new EnrollmentResult + switch (enrollmentType) + { + case EnrollmentType.New: + flow.Branch("New Enrollment"); + //If they renewed an expired cert it gets here and this will not be supported + IRegistrationResponse enrollmentResponse; + if (!productInfo.ProductParameters.ContainsKey("PriorCertSN")) { - Status = 30, //failure - StatusMessage = "You cannot renew an expired cert please perform an new enrollment." - }; - } + enrollmentRequest = _requestManager.GetRegistrationRequest(productInfo, csr, san, customFields); + Logger.LogTrace($"Enrollment Request JSON: {JsonConvert.SerializeObject(enrollmentRequest)}"); + enrollmentResponse = await flow.StepAsync("SubmitRegistration", + () => CscGlobalClient.SubmitRegistrationAsync(enrollmentRequest)); + Logger.LogTrace($"Enrollment Response JSON: {JsonConvert.SerializeObject(enrollmentResponse)}"); + } + else + { + Logger.LogWarning("Cannot renew an expired cert via new enrollment; a new enrollment must be performed instead"); + flow.Fail("New Enrollment", "Attempted to renew an expired cert via new enrollment"); + flow.EndBranch(); + return new EnrollmentResult + { + Status = 30, //failure + StatusMessage = "You cannot renew an expired cert please perform an new enrollment." + }; + } - Logger.MethodExit(LogLevel.Debug); - return _requestManager.GetEnrollmentResult(enrollmentResponse); - case EnrollmentType.RenewOrReissue: - Logger.LogTrace("Entering Renew Enrollment"); - //Logic to determine renew vs reissue - var renewal = false; - var order_id = await _certificateDataReader.GetRequestIDBySerialNumber(priorSn); - var expirationDate = _certificateDataReader.GetExpirationDateByRequestId(order_id); - if (expirationDate == null) - { - var localcert = await GetSingleRecord(order_id); - expirationDate = localcert.RevocationDate; - } + flow.EndBranch(); + var newResult = _requestManager.GetEnrollmentResult(enrollmentResponse); + LogEnrollmentOutcome(newResult, "New Enrollment"); + Logger.MethodExit(LogLevel.Debug); + return newResult; + case EnrollmentType.RenewOrReissue: + flow.Branch("Renew Or Reissue"); + //Logic to determine renew vs reissue + var renewal = false; + var order_id = await _certificateDataReader.GetRequestIDBySerialNumber(priorSn); + var expirationDate = _certificateDataReader.GetExpirationDateByRequestId(order_id); + if (expirationDate == null) + { + var localcert = await GetSingleRecord(order_id); + expirationDate = localcert.RevocationDate; + } - if (expirationDate < DateTime.Now) renewal = true; - if (renewal) - { + if (expirationDate < DateTime.Now) renewal = true; + if (renewal) + { + flow.Step("DetermineRenewOrReissue", "Renewal - cert is expired"); + //One click won't work for this implementation b/c we are missing enrollment params + if (productInfo.ProductParameters.ContainsKey("Applicant Last Name")) + { + //priorCert = _certificateDataReader.get( + //DataConversion.HexToBytes(productInfo.ProductParameters["PriorCertSN"])); + //uUId = priorCert.CARequestID.Substring(0, 36); //uUId is a GUID + uUId = await _certificateDataReader.GetRequestIDBySerialNumber( + productInfo.ProductParameters["PriorCertSN"]); + Logger.LogTrace($"Renew uUId: {uUId}"); + renewRequest = _requestManager.GetRenewalRequest(productInfo, uUId, csr, san, customFields); + Logger.LogTrace($"Renewal Request JSON: {JsonConvert.SerializeObject(renewRequest)}"); + var renewResponse = await flow.StepAsync("SubmitRenewal", + () => CscGlobalClient.SubmitRenewalAsync(renewRequest)); + Logger.LogTrace($"Renewal Response JSON: {JsonConvert.SerializeObject(renewResponse)}"); + flow.EndBranch(); + var renewResult = _requestManager.GetRenewResponse(renewResponse); + LogEnrollmentOutcome(renewResult, "Renewal"); + Logger.MethodExit(LogLevel.Debug); + return renewResult; + } + + Logger.LogWarning($"One click renew is not available for product {productInfo.ProductID}; missing required enrollment parameters"); + flow.Fail("Renewal", "One click renew is not available; missing Applicant Last Name"); + flow.EndBranch(); + return new EnrollmentResult + { + Status = 30, //failure + StatusMessage = + "One click Renew Is Not Available for this Certificate Type. Use the configure button instead." + }; + } + + flow.Step("DetermineRenewOrReissue", "Reissue - cert is still valid"); //One click won't work for this implementation b/c we are missing enrollment params if (productInfo.ProductParameters.ContainsKey("Applicant Last Name")) { - //priorCert = _certificateDataReader.get( - //DataConversion.HexToBytes(productInfo.ProductParameters["PriorCertSN"])); - //uUId = priorCert.CARequestID.Substring(0, 36); //uUId is a GUID - uUId = await _certificateDataReader.GetRequestIDBySerialNumber( + var requestid = await _certificateDataReader.GetRequestIDBySerialNumber( productInfo.ProductParameters["PriorCertSN"]); - Logger.LogTrace($"Renew uUId: {uUId}"); - renewRequest = _requestManager.GetRenewalRequest(productInfo, uUId, csr, san, customFields); - Logger.LogTrace($"Renewal Request JSON: {JsonConvert.SerializeObject(renewRequest)}"); - var renewResponse = Task.Run(async () => await CscGlobalClient.SubmitRenewalAsync(renewRequest)) - .Result; - Logger.LogTrace($"Renewal Response JSON: {JsonConvert.SerializeObject(renewResponse)}"); + uUId = requestid.Substring(0, 36); //uUId is a GUID + Logger.LogTrace($"Reissue uUId: {uUId}"); + reissueRequest = _requestManager.GetReissueRequest(productInfo, uUId, csr, san, customFields); + Logger.LogTrace($"Reissue JSON: {JsonConvert.SerializeObject(reissueRequest)}"); + var reissueResponse = await flow.StepAsync("SubmitReissue", + () => CscGlobalClient.SubmitReissueAsync(reissueRequest)); + Logger.LogTrace($"Reissue Response JSON: {JsonConvert.SerializeObject(reissueResponse)}"); + flow.EndBranch(); + var reissueResult = _requestManager.GetReIssueResult(reissueResponse); + LogEnrollmentOutcome(reissueResult, "Reissue"); Logger.MethodExit(LogLevel.Debug); - return _requestManager.GetRenewResponse(renewResponse); + return reissueResult; } + Logger.LogWarning($"One click reissue is not available for product {productInfo.ProductID}; missing required enrollment parameters"); + flow.Fail("Reissue", "One click reissue is not available; missing Applicant Last Name"); + flow.EndBranch(); return new EnrollmentResult { Status = 30, //failure StatusMessage = "One click Renew Is Not Available for this Certificate Type. Use the configure button instead." }; - } - - Logger.LogTrace("Entering Reissue Enrollment"); - //One click won't work for this implementation b/c we are missing enrollment params - if (productInfo.ProductParameters.ContainsKey("Applicant Last Name")) - { - var requestid = await _certificateDataReader.GetRequestIDBySerialNumber( - productInfo.ProductParameters["PriorCertSN"]); - uUId = requestid.Substring(0, 36); //uUId is a GUID - Logger.LogTrace($"Reissue uUId: {uUId}"); - reissueRequest = _requestManager.GetReissueRequest(productInfo, uUId, csr, san, customFields); - Logger.LogTrace($"Reissue JSON: {JsonConvert.SerializeObject(reissueRequest)}"); - var reissueResponse = Task.Run(async () => await CscGlobalClient.SubmitReissueAsync(reissueRequest)) - .Result; - Logger.LogTrace($"Reissue Response JSON: {JsonConvert.SerializeObject(reissueResponse)}"); - Logger.MethodExit(LogLevel.Debug); - return _requestManager.GetReIssueResult(reissueResponse); - } + } - return new EnrollmentResult - { - Status = 30, //failure - StatusMessage = - "One click Renew Is Not Available for this Certificate Type. Use the configure button instead." - }; + Logger.LogWarning($"Unhandled enrollment type {enrollmentType} for product {productInfo.ProductID}"); + Logger.MethodExit(LogLevel.Debug); + return null; } + catch (Exception e) + { + Logger.LogError(e, $"Enroll failed for product {productInfo.ProductID}: {e.Message}"); + flow.Fail("Enroll", e.Message); + throw; + } + } - Logger.MethodExit(LogLevel.Debug); - return null; + private void LogEnrollmentOutcome(EnrollmentResult result, string operationName) + { + if (result == null) return; + if (result.Status == (int)EndEntityStatus.FAILED) + Logger.LogError($"{operationName} failed: {result.StatusMessage}"); + else + Logger.LogInformation($"{operationName} succeeded: {result.StatusMessage}"); } //done @@ -330,7 +429,7 @@ public async Task Ping() } catch (Exception e) { - Logger.LogError($"There was an error contacting CSCGlobal: {e.Message}."); + Logger.LogError(e, $"There was an error contacting CSCGlobal: {e.Message}."); throw new Exception($"Error attempting to ping CSCGlobal: {e.Message}.", e); } @@ -340,19 +439,27 @@ public async Task Ping() //do public async Task ValidateCAConnectionInfo(Dictionary connectionInfo) { + Logger.MethodEntry(LogLevel.Debug); + Logger.LogDebug($"Validating CA connection info with {connectionInfo?.Count ?? 0} entries"); + Logger.MethodExit(LogLevel.Debug); } //do public async Task ValidateProductInfo(EnrollmentProductInfo productInfo, Dictionary connectionInfo) { + Logger.MethodEntry(LogLevel.Debug); var certType = ProductIDs.productIds.Find(x => x.Equals(productInfo.ProductID, StringComparison.InvariantCultureIgnoreCase)); - if (certType == null) throw new ArgumentException($"Cannot find {productInfo.ProductID}", "ProductId"); + if (certType == null) + { + Logger.LogError($"Cannot find product ID {productInfo.ProductID} in the list of supported CSC Global products"); + throw new ArgumentException($"Cannot find {productInfo.ProductID}", "ProductId"); + } Logger.LogInformation($"Validated {certType} ({certType})configured for AnyGateway"); - + Logger.MethodExit(LogLevel.Debug); } //done diff --git a/cscglobal-caplugin/Client/CscGlobalClient.cs b/cscglobal-caplugin/Client/CscGlobalClient.cs index 0a5c7c5..3429dc5 100644 --- a/cscglobal-caplugin/Client/CscGlobalClient.cs +++ b/cscglobal-caplugin/Client/CscGlobalClient.cs @@ -23,13 +23,18 @@ public sealed class CscGlobalClient : ICscGlobalClient public CscGlobalClient(IAnyCAPluginConfigProvider config) { - Logger = LogHandler.GetClassLogger(); + Logger = LogHandler.GetClassLogger(); if (config.CAConnectionData.ContainsKey(Constants.CscGlobalApiKey)) { BaseUrl = new Uri(config.CAConnectionData[Constants.CscGlobalUrl].ToString()); ApiKey = config.CAConnectionData[Constants.CscGlobalApiKey].ToString(); Authorization = config.CAConnectionData[Constants.BearerToken].ToString(); RestClient = ConfigureRestClient(); + Logger.LogDebug($"CscGlobalClient configured for base URL {BaseUrl}"); + } + else + { + Logger.LogError($"CA connection data is missing required key '{Constants.CscGlobalApiKey}'; client will not be able to call the CSC Global API"); } } @@ -41,6 +46,7 @@ public CscGlobalClient(IAnyCAPluginConfigProvider config) public async Task SubmitRegistrationAsync( RegistrationRequest registerRequest) { + Logger.MethodEntry(LogLevel.Debug); using (var resp = await RestClient.PostAsync("/dbs/api/v2/tls/registration", new StringContent( JsonConvert.SerializeObject(registerRequest), Encoding.ASCII, "application/json"))) { @@ -48,18 +54,23 @@ public async Task SubmitRegistrationAsync( var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; if (resp.StatusCode == HttpStatusCode.BadRequest) //Csc Sends Errors back in 400 Json Response { + var rawErrorResponse = await resp.Content.ReadAsStringAsync(); var errorResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync(), - settings); + JsonConvert.DeserializeObject(rawErrorResponse, settings); + Logger.LogWarning($"Registration request rejected by CSC Global: {errorResponse?.Description ?? rawErrorResponse}"); var response = new RegistrationResponse(); response.RegistrationError = errorResponse; response.Result = null; return response; } + if (!resp.IsSuccessStatusCode) + Logger.LogError($"Registration request failed with status code {resp.StatusCode}"); + var registrationResponse = JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync(), settings); + Logger.MethodExit(LogLevel.Debug); return registrationResponse; } } @@ -81,12 +92,16 @@ public async Task SubmitRenewalAsync( var errorResponse = JsonConvert.DeserializeObject(rawErrorResponse, settings); + Logger.LogWarning($"Renewal request rejected by CSC Global: {errorResponse?.Description ?? rawErrorResponse}"); var response = new RenewalResponse(); response.RegistrationError = errorResponse; response.Result = null; return response; } + if (!resp.IsSuccessStatusCode) + Logger.LogError($"Renewal request failed with status code {resp.StatusCode}"); + var rawRenewResponse = await resp.Content.ReadAsStringAsync(); Logger.LogTrace("Logging Success Response Raw"); Logger.LogTrace(rawRenewResponse); @@ -107,15 +122,19 @@ public async Task SubmitReissueAsync( var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; if (resp.StatusCode == HttpStatusCode.BadRequest) //Csc Sends Errors back in 400 Json Response { + var rawErrorResponse = await resp.Content.ReadAsStringAsync(); var errorResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync(), - settings); + JsonConvert.DeserializeObject(rawErrorResponse, settings); + Logger.LogWarning($"Reissue request rejected by CSC Global: {errorResponse?.Description ?? rawErrorResponse}"); var response = new ReissueResponse(); response.RegistrationError = errorResponse; response.Result = null; return response; } + if (!resp.IsSuccessStatusCode) + Logger.LogError($"Reissue request failed with status code {resp.StatusCode}"); + var reissueResponse = JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); return reissueResponse; @@ -124,44 +143,69 @@ public async Task SubmitReissueAsync( public async Task SubmitGetCertificateAsync(string certificateId) { + Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace($"Getting certificate with ID {certificateId}"); using (var resp = await RestClient.GetAsync($"/dbs/api/v2/tls/certificate/{certificateId}")) { + if (!resp.IsSuccessStatusCode) + { + var errorBody = await resp.Content.ReadAsStringAsync(); + Logger.LogError($"Failed to get certificate {certificateId}. Status code {resp.StatusCode} | Message: {errorBody}"); + } + resp.EnsureSuccessStatusCode(); var getCertificateResponse = JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + Logger.MethodExit(LogLevel.Debug); return getCertificateResponse; } } public async Task> SubmitGetCustomFields() { + Logger.MethodEntry(LogLevel.Debug); using (var resp = await RestClient.GetAsync("/dbs/api/v2/admin/customfields")) { + if (!resp.IsSuccessStatusCode) + { + var errorBody = await resp.Content.ReadAsStringAsync(); + Logger.LogError($"Failed to get custom fields. Status code {resp.StatusCode} | Message: {errorBody}"); + } + resp.EnsureSuccessStatusCode(); var getCustomFieldsResponse = JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + Logger.LogTrace($"Retrieved {getCustomFieldsResponse.CustomFields?.Count ?? 0} custom field(s)"); + Logger.MethodExit(LogLevel.Debug); return getCustomFieldsResponse.CustomFields; } } public async Task SubmitRevokeCertificateAsync(string uuId) { + Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace($"Revoking certificate with UUID {uuId}"); using (var resp = await RestClient.PutAsync($"/dbs/api/v2/tls/revoke/{uuId}", new StringContent(""))) { var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; if (resp.StatusCode == HttpStatusCode.BadRequest) //Csc Sends Errors back in 400 Json Response { + var rawErrorResponse = await resp.Content.ReadAsStringAsync(); var errorResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync(), - settings); + JsonConvert.DeserializeObject(rawErrorResponse, settings); + Logger.LogWarning($"Revoke request rejected by CSC Global for UUID {uuId}: {errorResponse?.Description ?? rawErrorResponse}"); var response = new RevokeResponse(); response.RegistrationError = errorResponse; response.RevokeSuccess = null; return response; } + if (!resp.IsSuccessStatusCode) + Logger.LogError($"Revoke request for UUID {uuId} failed with status code {resp.StatusCode}"); + var getRevokeResponse = JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + Logger.MethodExit(LogLevel.Debug); return getRevokeResponse; } } @@ -186,6 +230,8 @@ public async Task SubmitCertificateListRequestAsync(str var certificateListResponse = JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + Logger.LogInformation($"Certificate list request returned {certificateListResponse?.Results?.Count ?? 0} result(s)"); + Logger.MethodExit(LogLevel.Debug); return certificateListResponse; } diff --git a/cscglobal-caplugin/FlowLogger.cs b/cscglobal-caplugin/FlowLogger.cs new file mode 100644 index 0000000..711cac9 --- /dev/null +++ b/cscglobal-caplugin/FlowLogger.cs @@ -0,0 +1,267 @@ +// 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. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using System.Diagnostics; +using System.Text; +using Microsoft.Extensions.Logging; + +namespace Keyfactor.Extensions.CAPlugin.CSCGlobal; + +/// +/// Step-oriented trace logger for CA plugin pipelines. Wraps a sequence of named steps with +/// timings, branches, and outcomes into a single appended summary block that is attached +/// to on both success and failure, giving +/// operators a single scannable breadcrumb trail per plugin operation (Enroll, Revoke, Sync). +/// +public class FlowLogger : IDisposable +{ + private readonly ILogger _logger; + private readonly string _flowName; + private readonly Stopwatch _overallStopwatch; + private readonly List _steps = new(); + private readonly Stack _branchStack = new(); + + public FlowLogger(ILogger logger, string flowName) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _flowName = flowName ?? throw new ArgumentNullException(nameof(flowName)); + _overallStopwatch = Stopwatch.StartNew(); + _logger.LogTrace("[FLOW:{FlowName}] === BEGIN ===", _flowName); + } + + public void Step(string name, string detail = null) + { + var step = new FlowStep { Name = name, Detail = detail, Status = StepStatus.Success }; + _steps.Add(step); + var prefix = GetPrefix(); + if (detail != null) + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[OK] {StepName} - {Detail}", _flowName, prefix, name, detail); + else + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[OK] {StepName}", _flowName, prefix, name); + } + + public void Step(string name, Action action, string detail = null) + { + var sw = Stopwatch.StartNew(); + var step = new FlowStep { Name = name, Detail = detail }; + try + { + action(); + sw.Stop(); + step.Status = StepStatus.Success; + step.ElapsedMs = sw.ElapsedMilliseconds; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[OK] {StepName} ({Elapsed}ms){DetailSuffix}", + _flowName, prefix, name, sw.ElapsedMilliseconds, FormatDetail(detail)); + } + catch (Exception ex) + { + sw.Stop(); + step.Status = StepStatus.Failed; + step.ElapsedMs = sw.ElapsedMilliseconds; + step.ErrorMessage = ex.Message; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[FAIL] {StepName} ({Elapsed}ms) - {Error}", + _flowName, prefix, name, sw.ElapsedMilliseconds, ex.Message); + throw; + } + } + + public async Task StepAsync(string name, Func action, string detail = null) + { + var sw = Stopwatch.StartNew(); + var step = new FlowStep { Name = name, Detail = detail }; + try + { + await action(); + sw.Stop(); + step.Status = StepStatus.Success; + step.ElapsedMs = sw.ElapsedMilliseconds; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[OK] {StepName} ({Elapsed}ms){DetailSuffix}", + _flowName, prefix, name, sw.ElapsedMilliseconds, FormatDetail(detail)); + } + catch (Exception ex) + { + sw.Stop(); + step.Status = StepStatus.Failed; + step.ElapsedMs = sw.ElapsedMilliseconds; + step.ErrorMessage = ex.Message; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[FAIL] {StepName} ({Elapsed}ms) - {Error}", + _flowName, prefix, name, sw.ElapsedMilliseconds, ex.Message); + throw; + } + } + + public async Task StepAsync(string name, Func> action, string detail = null) + { + var sw = Stopwatch.StartNew(); + var step = new FlowStep { Name = name, Detail = detail }; + try + { + var result = await action(); + sw.Stop(); + step.Status = StepStatus.Success; + step.ElapsedMs = sw.ElapsedMilliseconds; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[OK] {StepName} ({Elapsed}ms){DetailSuffix}", + _flowName, prefix, name, sw.ElapsedMilliseconds, FormatDetail(detail)); + return result; + } + catch (Exception ex) + { + sw.Stop(); + step.Status = StepStatus.Failed; + step.ElapsedMs = sw.ElapsedMilliseconds; + step.ErrorMessage = ex.Message; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[FAIL] {StepName} ({Elapsed}ms) - {Error}", + _flowName, prefix, name, sw.ElapsedMilliseconds, ex.Message); + throw; + } + } + + public T Step(string name, Func action, string detail = null) + { + var sw = Stopwatch.StartNew(); + var step = new FlowStep { Name = name, Detail = detail }; + try + { + var result = action(); + sw.Stop(); + step.Status = StepStatus.Success; + step.ElapsedMs = sw.ElapsedMilliseconds; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[OK] {StepName} ({Elapsed}ms){DetailSuffix}", + _flowName, prefix, name, sw.ElapsedMilliseconds, FormatDetail(detail)); + return result; + } + catch (Exception ex) + { + sw.Stop(); + step.Status = StepStatus.Failed; + step.ElapsedMs = sw.ElapsedMilliseconds; + step.ErrorMessage = ex.Message; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[FAIL] {StepName} ({Elapsed}ms) - {Error}", + _flowName, prefix, name, sw.ElapsedMilliseconds, ex.Message); + throw; + } + } + + public void Fail(string name, string reason) + { + var step = new FlowStep { Name = name, Status = StepStatus.Failed, ErrorMessage = reason }; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[FAIL] {StepName} - {Reason}", _flowName, prefix, name, reason); + } + + public void Skip(string name, string reason) + { + var step = new FlowStep { Name = name, Status = StepStatus.Skipped, Detail = reason }; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[SKIP] {StepName} - {Reason}", _flowName, prefix, name, reason); + } + + public void Branch(string name) + { + _branchStack.Push(name); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}>> {BranchName}", _flowName, prefix, name); + } + + public void EndBranch() + { + if (_branchStack.Count > 0) + { + var name = _branchStack.Pop(); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}<< {BranchName}", _flowName, prefix, name); + } + } + + public bool HasFailures => _steps.Any(s => s.Status == StepStatus.Failed); + + public string GetSummary() + { + var hasFailures = HasFailures; + var overallStatus = hasFailures ? "FAILED" : "OK"; + var total = _steps.Count; + var succeeded = _steps.Count(s => s.Status == StepStatus.Success); + var failed = _steps.Count(s => s.Status == StepStatus.Failed); + var skipped = _steps.Count(s => s.Status == StepStatus.Skipped); + var elapsed = _overallStopwatch.ElapsedMilliseconds; + + var sb = new StringBuilder(); + sb.AppendLine($"Flow: {_flowName} [{overallStatus}] Total: {elapsed}ms"); + sb.AppendLine($"Steps: {total} total, {succeeded} ok, {failed} failed, {skipped} skipped"); + sb.AppendLine("----------------------------------------"); + foreach (var step in _steps) + { + var icon = step.Status == StepStatus.Success ? "[OK] " + : step.Status == StepStatus.Failed ? "[FAIL]" + : step.Status == StepStatus.Skipped ? "[SKIP]" + : "[...]"; + var time = step.ElapsedMs.HasValue ? $" ({step.ElapsedMs}ms)" : ""; + var detail = !string.IsNullOrEmpty(step.ErrorMessage) + ? $" - {step.ErrorMessage}" + : !string.IsNullOrEmpty(step.Detail) + ? $" - {step.Detail}" + : ""; + sb.AppendLine($" {icon} {step.Name}{time}{detail}"); + } + sb.Append("----------------------------------------"); + + return sb.ToString(); + } + + public void Dispose() + { + _overallStopwatch.Stop(); + var summary = GetSummary(); + _logger.LogTrace("[FLOW:{FlowName}] === END ===\n{Summary}", _flowName, summary); + } + + private string GetPrefix() + { + if (_branchStack.Count == 0) return ""; + return new string(' ', _branchStack.Count * 2) + "| "; + } + + private static string FormatDetail(string detail) + { + return string.IsNullOrEmpty(detail) ? "" : $" - {detail}"; + } + + private enum StepStatus + { + Success, + Failed, + Skipped, + InProgress + } + + private class FlowStep + { + public string Name { get; set; } + public string Detail { get; set; } + public StepStatus Status { get; set; } = StepStatus.InProgress; + public long? ElapsedMs { get; set; } + public string ErrorMessage { get; set; } + } +} diff --git a/cscglobal-caplugin/RequestManager.cs b/cscglobal-caplugin/RequestManager.cs index 94ce704..14859cd 100644 --- a/cscglobal-caplugin/RequestManager.cs +++ b/cscglobal-caplugin/RequestManager.cs @@ -10,12 +10,16 @@ using Keyfactor.AnyGateway.Extensions; using Keyfactor.Extensions.CAPlugin.CSCGlobal.Client.Models; using Keyfactor.Extensions.CAPlugin.CSCGlobal.Interfaces; +using Keyfactor.Logging; using Keyfactor.PKI.Enums.EJBCA; +using Microsoft.Extensions.Logging; namespace Keyfactor.Extensions.CAPlugin.CSCGlobal; public class RequestManager { + private readonly ILogger Logger = LogHandler.GetClassLogger(); + public static Func Pemify = ss => ss.Length <= 64 ? ss : ss.Substring(0, 64) + "\n" + Pemify(ss.Substring(64)); @@ -34,23 +38,29 @@ private List GetCustomFields(EnrollmentProductInfo productInfo, Lis } else if (field.Mandatory) { + Logger.LogError($"Custom field {field.Label} is marked as mandatory, but was not supplied in the request."); throw new Exception( $"Custom field {field.Label} is marked as mandatory, but was not supplied in the request."); } + Logger.LogTrace($"Mapped {customFieldList.Count} custom field(s) for request"); return customFieldList; } public EnrollmentResult GetRenewResponse(RenewalResponse renewResponse) { if (renewResponse.RegistrationError != null) + { + Logger.LogError($"Renewal failed: {renewResponse.RegistrationError.Description}"); return new EnrollmentResult { Status = (int)EndEntityStatus.FAILED, //failure CARequestID = renewResponse?.Result?.Status?.Uuid, StatusMessage = renewResponse.RegistrationError.Description }; + } + Logger.LogInformation($"Renewal successfully completed for {renewResponse.Result.CommonName}"); return new EnrollmentResult { Status = (int)EndEntityStatus.GENERATED, //success @@ -65,11 +75,14 @@ public EnrollmentResult IRegistrationResponse registrationResponse) { if (registrationResponse.RegistrationError != null) + { + Logger.LogError($"Enrollment failed: {registrationResponse.RegistrationError.Description}"); return new EnrollmentResult { Status = (int)EndEntityStatus.FAILED, //failure StatusMessage = registrationResponse.RegistrationError.Description }; + } var cnames = new Dictionary(); if (registrationResponse.Result.DcvDetails != null && registrationResponse.Result.DcvDetails.Count > 0) @@ -86,6 +99,7 @@ public EnrollmentResult } } + Logger.LogInformation($"Order successfully created with order number {registrationResponse.Result.CommonName}"); return new EnrollmentResult { Status = (int)EndEntityStatus.EXTERNALVALIDATION, //success @@ -99,7 +113,10 @@ public EnrollmentResult public int GetRevokeResult(IRevokeResponse revokeResponse) { if (revokeResponse.RegistrationError != null) + { + Logger.LogError($"Revoke failed: {revokeResponse.RegistrationError.Description}"); return (int)EndEntityStatus.FAILED; + } return (int)EndEntityStatus.REVOKED; } @@ -107,12 +124,16 @@ public int GetRevokeResult(IRevokeResponse revokeResponse) public EnrollmentResult GetReIssueResult(IReissueResponse reissueResponse) { if (reissueResponse.RegistrationError != null) + { + Logger.LogError($"Reissue failed: {reissueResponse.RegistrationError.Description}"); return new EnrollmentResult { Status = (int)EndEntityStatus.FAILED, //failure StatusMessage = reissueResponse.RegistrationError.Description }; + } + Logger.LogInformation($"Reissue successfully completed for {reissueResponse.Result.CommonName}"); return new EnrollmentResult { Status = (int)EndEntityStatus.GENERATED, //success @@ -135,6 +156,7 @@ public DomainControlValidation GetDomainControlValidation(string methodType, str }; } + Logger.LogWarning($"No matching DCV email address found for domain {domainName}"); return null; } @@ -150,6 +172,7 @@ public DomainControlValidation GetDomainControlValidation(string methodType, str public RegistrationRequest GetRegistrationRequest(EnrollmentProductInfo productInfo, string csr, Dictionary sans, List customFields) { + Logger.LogTrace($"Building registration request for product {productInfo.ProductID}"); //var cert = "-----BEGIN CERTIFICATE REQUEST-----\r\n"; var cert = Pemify(csr); //cert = cert + "\r\n-----END CERTIFICATE REQUEST-----"; @@ -214,6 +237,7 @@ private string GetCertificateType(string productId) return "9"; } + Logger.LogWarning($"Unrecognized product ID '{productId}'; defaulting certificate type to -1"); return "-1"; } @@ -233,6 +257,7 @@ public Notifications GetNotifications(EnrollmentProductInfo productInfo) public RenewalRequest GetRenewalRequest(EnrollmentProductInfo productInfo, string uUId, string csr, Dictionary sans, List customFields) { + Logger.LogTrace($"Building renewal request for product {productInfo.ProductID}, UUID {uUId}"); //var cert = "-----BEGIN CERTIFICATE REQUEST-----\r\n"; var cert = Pemify(csr); //cert = cert + "\r\n-----END CERTIFICATE REQUEST-----"; @@ -291,6 +316,7 @@ private List GetSubjectAlternativeNames(EnrollmentProduc public ReissueRequest GetReissueRequest(EnrollmentProductInfo productInfo, string uUId, string csr, Dictionary sans, List customFields) { + Logger.LogTrace($"Building reissue request for product {productInfo.ProductID}, UUID {uUId}"); //var cert = "-----BEGIN CERTIFICATE REQUEST-----\r\n"; var cert = Pemify(csr); //cert = cert + "\r\n-----END CERTIFICATE REQUEST-----"; @@ -349,6 +375,7 @@ public int MapReturnStatus(string cscGlobalStatus) returnStatus = (int)EndEntityStatus.REVOKED; break; default: + Logger.LogWarning($"Unrecognized CSC Global status '{cscGlobalStatus}'; mapping to FAILED"); returnStatus = (int)EndEntityStatus.FAILED; break; } From 2488f3544b14c8385e359e44d1bfd1be999a6448 Mon Sep 17 00:00:00 2001 From: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:20:06 -0400 Subject: [PATCH 04/36] Update keyfactor-bootstrap-workflow-v3.yml --- .github/workflows/keyfactor-bootstrap-workflow-v3.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/keyfactor-bootstrap-workflow-v3.yml b/.github/workflows/keyfactor-bootstrap-workflow-v3.yml index 042ba5a..0f3d3ae 100644 --- a/.github/workflows/keyfactor-bootstrap-workflow-v3.yml +++ b/.github/workflows/keyfactor-bootstrap-workflow-v3.yml @@ -11,10 +11,17 @@ on: jobs: call-starter-workflow: - uses: keyfactor/actions/.github/workflows/starter.yml@v3.1.2 + uses: keyfactor/actions/.github/workflows/starter.yml@v5 + with: + command_token_url: ${{ vars.COMMAND_TOKEN_URL }} + command_hostname: ${{ vars.COMMAND_HOSTNAME }} + command_base_api_path: ${{ vars.COMMAND_API_PATH }} secrets: token: ${{ secrets.V2BUILDTOKEN}} - APPROVE_README_PUSH: ${{ secrets.APPROVE_README_PUSH}} gpg_key: ${{ secrets.KF_GPG_PRIVATE_KEY }} gpg_pass: ${{ secrets.KF_GPG_PASSPHRASE }} scan_token: ${{ secrets.SAST_TOKEN }} + entra_username: ${{ secrets.DOCTOOL_ENTRA_USERNAME }} + entra_password: ${{ secrets.DOCTOOL_ENTRA_PASSWD }} + command_client_id: ${{ secrets.COMMAND_CLIENT_ID }} + command_client_secret: ${{ secrets.COMMAND_CLIENT_SECRET }} From bb6b6589c8939c208bda3232565b4968ff7b237f Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Fri, 28 Aug 2026 09:09:03 -0400 Subject: [PATCH 05/36] Fix KeyNotFoundException when Addtl Sans Comma Separated DVC Emails is unset Only look up the optional additional-SAN-emails field when the domain control validation method is EMAIL, and use TryGetValue instead of the indexer so a missing/blank value no longer throws. --- cscglobal-caplugin/RequestManager.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cscglobal-caplugin/RequestManager.cs b/cscglobal-caplugin/RequestManager.cs index 14859cd..8557672 100644 --- a/cscglobal-caplugin/RequestManager.cs +++ b/cscglobal-caplugin/RequestManager.cs @@ -301,9 +301,14 @@ private List GetSubjectAlternativeNames(EnrollmentProduc var domainName = v; var san = new SubjectAlternativeName(); san.DomainName = domainName; - var emailAddresses = productInfo.ProductParameters["Addtl Sans Comma Separated DVC Emails"].Split(','); if (methodType.ToUpper() == "EMAIL") + { + productInfo.ProductParameters.TryGetValue("Addtl Sans Comma Separated DVC Emails", out var addtlSansEmails); + var emailAddresses = string.IsNullOrWhiteSpace(addtlSansEmails) + ? Array.Empty() + : addtlSansEmails.Split(','); san.DomainControlValidation = GetDomainControlValidation(methodType, emailAddresses, domainName); + } else //it is a CNAME validation so no email is needed san.DomainControlValidation = GetDomainControlValidation(methodType, ""); From b9d0bdd1136b1df187c54d4b00b64634dce5b918 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Fri, 28 Aug 2026 10:51:09 -0400 Subject: [PATCH 06/36] Fix KeyNotFoundException in GetSubjectAlternativeNames when sans has no 'dnsname' key UC certificate enrollments with zero SANs supplied threw KeyNotFoundException from the sans["dnsname"] indexer. Use TryGetValue and treat a missing key as no SANs instead of throwing. --- cscglobal-caplugin/RequestManager.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cscglobal-caplugin/RequestManager.cs b/cscglobal-caplugin/RequestManager.cs index 8557672..72f9272 100644 --- a/cscglobal-caplugin/RequestManager.cs +++ b/cscglobal-caplugin/RequestManager.cs @@ -296,7 +296,8 @@ private List GetSubjectAlternativeNames(EnrollmentProduc var subjectNameList = new List(); var methodType = productInfo.ProductParameters["Domain Control Validation Method"]; - foreach (var v in sans["dnsname"]) + sans.TryGetValue("dnsname", out var dnsNames); + foreach (var v in dnsNames ?? Array.Empty()) { var domainName = v; var san = new SubjectAlternativeName(); From 35e488e388c5d29e1ad498651c7b5a83cbc27a67 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 9 Sep 2026 20:49:56 +0000 Subject: [PATCH 07/36] docs: auto-generate README and documentation [skip ci] --- README.md | 623 +++++++++++++++++++++++++++--------------------------- 1 file changed, 310 insertions(+), 313 deletions(-) diff --git a/README.md b/README.md index f61fd6d..3ffe2b0 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Support - + · Requirements @@ -33,7 +33,6 @@

- This integration allows for the Synchronization, Enrollment, and Revocation of certificates from the CSCGlobal. This is the AnyGateway REST version. ## Compatibility @@ -41,7 +40,7 @@ This integration allows for the Synchronization, Enrollment, and Revocation of c The CSCGlobal CAPlugin AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 24.2.0 and later. ## Support -The CSCGlobal CAPlugin AnyCA Gateway REST plugin is supported by Keyfactor for Keyfactor customers. If you have a support issue, please open a support ticket with your Keyfactor representative. If you have a support issue, please open a support ticket via the Keyfactor Support Portal at https://support.keyfactor.com. +The CSCGlobal CAPlugin AnyCA Gateway REST plugin is supported by Keyfactor for Keyfactor customers. If you have a support issue, please open a support ticket via the Keyfactor Support Portal at https://support.keyfactor.com. > To report a problem or suggest a new feature, use the **[Issues](../../issues)** tab. If you want to contribute actual bug fixes or proposed enhancements, use the **[Pull requests](../../pulls)** tab. @@ -82,323 +81,321 @@ This integration is tested and confirmed as working for Anygateway REST 24.2 and Populate using the configuration fields collected in the [requirements](#requirements) section. - * **CscGlobalUrl** - CSCGlobal API URL - * **ApiKey** - CSCGlobal API Key - * **BearerToken** - CSCGlobal Bearer Token - * **DefaultPageSize** - Default page size for use with the API. Default is 100 - * **TemplateSync** - Enable template sync. - * **SyncFilterDays** - Number of days from today to filter certificates by expiration date during incremental sync. + * **CscGlobalUrl** - CSCGlobal API URL + * **ApiKey** - CSCGlobal API Key + * **BearerToken** - CSCGlobal Bearer Token + * **DefaultPageSize** - Default page size for use with the API. Default is 100 + * **TemplateSync** - Enable template sync. + * **SyncFilterDays** - Number of days from today to filter certificates by expiration date during incremental sync. 2. PLEASE NOTE, AT THIS TIME THE RAPID_SSL TEMPLATE IS NOT SUPPORTED BY THE CSC API AND WILL NOT WORK WITH THIS INTEGRATION - The following certificate templates are supported. Please set up the key sizes accordingly in the Certificate Profile menu of Anygateway REST, then enter the remaining details - and the Enrollment Fields for each Template accordingly using the Certificate Templates section in Command. If you would like to set up default values for enrollment parameters, you can do so the in the Certificate Template Menu of Anygateway REST. - If a field value is specified as both an Enrollment Field in Command and in the Certificate Template Menu in the REST Gateway, the value in the Enrollment Field will take precedence. - - CONFIG ELEMENT | DESCRIPTION - ----------------------------|------------------ - Template Short Name | CSC TrustedSecure Premium Certificate - Template Display Name | CSC TrustedSecure Premium Certificate - Friendly Name | CSC TrustedSecure Premium Certificate - Keys Size | 2048 - Enforce RFC 2818 Compliance | True - CSR Enrollment | True - Pfx Enrollment | True - - - **CSC TrustedSecure Premium Certificate - Enrollment Fields** - - NAME | DATA TYPE | VALUES - -----|--------------|----------------- - Term | Multiple Choice | 12,24 - Applicant First Name | String | N/A - Applicant Last Name | String | N/A - Applicant Email Address | String | N/A - Applicant Phone | String | N/A - Domain Control Validation Method | Multiple Choice | EMAIL - Organization Contact | Multiple Choice | Get From CSC Differs For Clients - Business Unit | Multiple Choice | Get From CSC Differs For Clients - Notification Email(s) Comma Separated | String | N/A - CN DCV Email | String | N/A - - **CSC TrustedSecure EV Certificate - Details Tab** - - CONFIG ELEMENT | DESCRIPTION - ----------------------------|------------------ - Template Short Name | CSC TrustedSecure EV Certificate - Template Display Name | CSC TrustedSecure EV Certificate - Friendly Name | CSC TrustedSecure EV Certificate - Keys Size | 2048 - Enforce RFC 2818 Compliance | True - CSR Enrollment | True - Pfx Enrollment | True - - - **CSC TrustedSecure EV Certificate - Enrollment Fields** - - NAME | DATA TYPE | VALUES - -----|--------------|----------------- - Term | Multiple Choice | 12,24 - Applicant First Name | String | N/A - Applicant Last Name | String | N/A - Applicant Email Address | String | N/A - Applicant Phone | String | N/A - Domain Control Validation Method | Multiple Choice | EMAIL - Organization Contact | Multiple Choice | Get From CSC Differs For Clients - Business Unit | Multiple Choice | Get From CSC Differs For Clients - Notification Email(s) Comma Separated | String | N/A - CN DCV Email | String | N/A - Organization Country | String | N/A - - **CSC TrustedSecure UC Certificate - Details Tab** - - CONFIG ELEMENT | DESCRIPTION - ----------------------------|------------------ - Template Short Name | CSC TrustedSecure UC Certificate - Template Display Name | CSC TrustedSecure UC Certificate - Friendly Name | CSC TrustedSecure UC Certificate - Keys Size | 2048 - Enforce RFC 2818 Compliance | True - CSR Enrollment | True - Pfx Enrollment | True - - - **CSC TrustedSecure UC Certificate - Enrollment Fields** - - NAME | DATA TYPE | VALUES - -----|--------------|----------------- - Term | Multiple Choice | 12,24 - Applicant First Name | String | N/A - Applicant Last Name | String | N/A - Applicant Email Address | String | N/A - Applicant Phone | String | N/A - Domain Control Validation Method | Multiple Choice | EMAIL - Organization Contact | Multiple Choice | Get From CSC Differs For Clients - Business Unit | Multiple Choice | Get From CSC Differs For Clients - Notification Email(s) Comma Separated | String | N/A - CN DCV Email | String | N/A - Addtl Sans Comma Separated DCV Emails | String | N/A - - - **CSC TrustedSecure Premium Wildcard Certificate - Details Tab** - - CONFIG ELEMENT | DESCRIPTION - ----------------------------|------------------ - Template Short Name | CSC TrustedSecure Premium Wildcard Certificate - Template Display Name | CSC TrustedSecure Premium Wildcard Certificate - Friendly Name | CSC TrustedSecure Premium Wildcard Certificate - Keys Size | 2048 - Enforce RFC 2818 Compliance | True - CSR Enrollment | True - Pfx Enrollment | True - - - **CSC TrustedSecure Premium Wildcard Certificate - Enrollment Fields** - - NAME | DATA TYPE | VALUES - -----|--------------|----------------- - Term | Multiple Choice | 12,24 - Applicant First Name | String | N/A - Applicant Last Name | String | N/A - Applicant Email Address | String | N/A - Applicant Phone | String | N/A - Domain Control Validation Method | Multiple Choice | EMAIL - Organization Contact | Multiple Choice | Get From CSC Differs For Clients - Business Unit | Multiple Choice | Get From CSC Differs For Clients - Notification Email(s) Comma Separated | String | N/A - CN DCV Email | String | N/A - - **CSC TrustedSecure Domain Validated SSL - Details Tab** - - CONFIG ELEMENT | DESCRIPTION - ----------------------------|------------------ - Template Short Name | CSC TrustedSecure Domain Validated SSL - Template Display Name | CSC TrustedSecure Domain Validated SSL - Friendly Name | CSC TrustedSecure Domain Validated SSL - Keys Size | 2048 - Enforce RFC 2818 Compliance | True - CSR Enrollment | True - Pfx Enrollment | True - - - **CSC TrustedSecure Domain Validated SSL - Enrollment Fields** - - NAME | DATA TYPE | VALUES - -----|--------------|----------------- - Term | Multiple Choice | 12,24 - Applicant First Name | String | N/A - Applicant Last Name | String | N/A - Applicant Email Address | String | N/A - Applicant Phone | String | N/A - Domain Control Validation Method | Multiple Choice | EMAIL - Organization Contact | Multiple Choice | Get From CSC Differs For Clients - Business Unit | Multiple Choice | Get From CSC Differs For Clients - Notification Email(s) Comma Separated | String | N/A - CN DCV Email | String | N/A - - **CSC TrustedSecure Domain Validated Wildcard SSL - Details Tab** - - CONFIG ELEMENT | DESCRIPTION - ----------------------------|------------------ - Template Short Name | CSC TrustedSecure Domain Validated Wildcard SSL - Template Display Name | CSC TrustedSecure Domain Validated Wildcard SSL - Friendly Name | CSC TrustedSecure Domain Validated Wildcard SSL - Keys Size | 2048 - Enforce RFC 2818 Compliance | True - CSR Enrollment | True - Pfx Enrollment | True - - - **CSC TrustedSecure Domain Validated Wildcard SSL - Enrollment Fields** - - NAME | DATA TYPE | VALUES - -----|--------------|----------------- - Term | Multiple Choice | 12,24 - Applicant First Name | String | N/A - Applicant Last Name | String | N/A - Applicant Email Address | String | N/A - Applicant Phone | String | N/A - Domain Control Validation Method | Multiple Choice | EMAIL - Organization Contact | Multiple Choice | Get From CSC Differs For Clients - Business Unit | Multiple Choice | Get From CSC Differs For Clients - Notification Email(s) Comma Separated | String | N/A - CN DCV Email | String | N/A - - **CSC TrustedSecure Domain Validated UC Certificate - Details Tab** - - CONFIG ELEMENT | DESCRIPTION - ----------------------------|------------------ - Template Short Name | CSC TrustedSecure Domain Validated UC Certificate - Template Display Name | CSC TrustedSecure Domain Validated UC Certificate - Friendly Name | CSC TrustedSecure Domain Validated UC Certificate - Keys Size | 2048 - Enforce RFC 2818 Compliance | True - CSR Enrollment | True - Pfx Enrollment | True - - - **CSC TrustedSecure Domain Validated UC Certificate - Enrollment Fields** - - NAME | DATA TYPE | VALUES - -----|--------------|----------------- - Term | Multiple Choice | 12,24 - Applicant First Name | String | N/A - Applicant Last Name | String | N/A - Applicant Email Address | String | N/A - Applicant Phone | String | N/A - Domain Control Validation Method | Multiple Choice | EMAIL - Organization Contact | Multiple Choice | Get From CSC Differs For Clients - Business Unit | Multiple Choice | Get From CSC Differs For Clients - Notification Email(s) Comma Separated | String | N/A - CN DCV Email | String | N/A - Addtl Sans Comma Separated DCV Emails | String | N/A - - **CSC TrustedSecure EV, Multiple Names - Details Tab** - - CONFIG ELEMENT | DESCRIPTION - ----------------------------|------------------ - Template Short Name | CSC TrustedSecure EV, Multiple Names - Template Display Name | CSC TrustedSecure EV, Multiple Names - Friendly Name | CSC TrustedSecure EV, Multiple Names - Keys Size | 2048 - Enforce RFC 2818 Compliance | True - CSR Enrollment | True - Pfx Enrollment | True - - - **CSC TrustedSecure EV, Multiple Names - Enrollment Fields** - - NAME | DATA TYPE | VALUES - -----|--------------|----------------- - Term | Multiple Choice | 12,24 - Applicant First Name | String | N/A - Applicant Last Name | String | N/A - Applicant Email Address | String | N/A - Applicant Phone | String | N/A - Domain Control Validation Method | Multiple Choice | EMAIL - Organization Contact | Multiple Choice | Get From CSC Differs For Clients - Business Unit | Multiple Choice | Get From CSC Differs For Clients - Notification Email(s) Comma Separated | String | N/A - CN DCV Email | String | N/A - Organization Country | String | N/A - Addtl Sans Comma Separated DCV Emails | String | N/A - - **CSC TrustedSecure OV Wildcard, Multiple Names - Details Tab** - - CONFIG ELEMENT | DESCRIPTION - ----------------------------|------------------ - Template Short Name | CSC TrustedSecure OV Wildcard, Multiple Names - Template Display Name | CSC TrustedSecure OV Wildcard, Multiple Names - Friendly Name | CSC TrustedSecure OV Wildcard, Multiple Names - Keys Size | 2048 - Enforce RFC 2818 Compliance | True - CSR Enrollment | True - Pfx Enrollment | True - - - **CSC TrustedSecure OV Wildcard, Multiple Names - Enrollment Fields** - - NAME | DATA TYPE | VALUES - -----|--------------|----------------- - Term | Multiple Choice | 12,24 - Applicant First Name | String | N/A - Applicant Last Name | String | N/A - Applicant Email Address | String | N/A - Applicant Phone | String | N/A - Domain Control Validation Method | Multiple Choice | EMAIL - Organization Contact | Multiple Choice | Get From CSC Differs For Clients - Business Unit | Multiple Choice | Get From CSC Differs For Clients - Notification Email(s) Comma Separated | String | N/A - CN DCV Email | String | N/A - Addtl Sans Comma Separated DCV Emails | String | N/A - - **CSC TrustedSecure DV Wildcard, Multiple Names - Details Tab** - - CONFIG ELEMENT | DESCRIPTION - ----------------------------|------------------ - Template Short Name | CSC TrustedSecure DV Wildcard, Multiple Names - Template Display Name | CSC TrustedSecure DV Wildcard, Multiple Names - Friendly Name | CSC TrustedSecure DV Wildcard, Multiple Names - Keys Size | 2048 - Enforce RFC 2818 Compliance | True - CSR Enrollment | True - Pfx Enrollment | True - - - **CSC TrustedSecure DV Wildcard, Multiple Names - Enrollment Fields** - - NAME | DATA TYPE | VALUES - -----|--------------|----------------- - Term | Multiple Choice | 12,24 - Applicant First Name | String | N/A - Applicant Last Name | String | N/A - Applicant Email Address | String | N/A - Applicant Phone | String | N/A - Domain Control Validation Method | Multiple Choice | EMAIL - Organization Contact | Multiple Choice | Get From CSC Differs For Clients - Business Unit | Multiple Choice | Get From CSC Differs For Clients - Notification Email(s) Comma Separated | String | N/A - CN DCV Email | String | N/A - Addtl Sans Comma Separated DCV Emails | String | N/A +The following certificate templates are supported. Please set up the key sizes accordingly in the Certificate Profile menu of Anygateway REST, then enter the remaining details +and the Enrollment Fields for each Template accordingly using the Certificate Templates section in Command. If you would like to set up default values for enrollment parameters, you can do so the in the Certificate Template Menu of Anygateway REST. +If a field value is specified as both an Enrollment Field in Command and in the Certificate Template Menu in the REST Gateway, the value in the Enrollment Field will take precedence. + +CONFIG ELEMENT | DESCRIPTION +----------------------------|------------------ +Template Short Name | CSC TrustedSecure Premium Certificate +Template Display Name | CSC TrustedSecure Premium Certificate +Friendly Name | CSC TrustedSecure Premium Certificate +Keys Size | 2048 +Enforce RFC 2818 Compliance | True +CSR Enrollment | True +Pfx Enrollment | True + + +**CSC TrustedSecure Premium Certificate - Enrollment Fields** + +NAME | DATA TYPE | VALUES +-----|--------------|----------------- +Term | Multiple Choice | 12,24 +Applicant First Name | String | N/A +Applicant Last Name | String | N/A +Applicant Email Address | String | N/A +Applicant Phone | String | N/A +Domain Control Validation Method | Multiple Choice | EMAIL +Organization Contact | Multiple Choice | Get From CSC Differs For Clients +Business Unit | Multiple Choice | Get From CSC Differs For Clients +Notification Email(s) Comma Separated | String | N/A +CN DCV Email | String | N/A + +**CSC TrustedSecure EV Certificate - Details Tab** + +CONFIG ELEMENT | DESCRIPTION +----------------------------|------------------ +Template Short Name | CSC TrustedSecure EV Certificate +Template Display Name | CSC TrustedSecure EV Certificate +Friendly Name | CSC TrustedSecure EV Certificate +Keys Size | 2048 +Enforce RFC 2818 Compliance | True +CSR Enrollment | True +Pfx Enrollment | True + + +**CSC TrustedSecure EV Certificate - Enrollment Fields** + +NAME | DATA TYPE | VALUES +-----|--------------|----------------- +Term | Multiple Choice | 12,24 +Applicant First Name | String | N/A +Applicant Last Name | String | N/A +Applicant Email Address | String | N/A +Applicant Phone | String | N/A +Domain Control Validation Method | Multiple Choice | EMAIL +Organization Contact | Multiple Choice | Get From CSC Differs For Clients +Business Unit | Multiple Choice | Get From CSC Differs For Clients +Notification Email(s) Comma Separated | String | N/A +CN DCV Email | String | N/A +Organization Country | String | N/A + +**CSC TrustedSecure UC Certificate - Details Tab** + +CONFIG ELEMENT | DESCRIPTION +----------------------------|------------------ +Template Short Name | CSC TrustedSecure UC Certificate +Template Display Name | CSC TrustedSecure UC Certificate +Friendly Name | CSC TrustedSecure UC Certificate +Keys Size | 2048 +Enforce RFC 2818 Compliance | True +CSR Enrollment | True +Pfx Enrollment | True + + +**CSC TrustedSecure UC Certificate - Enrollment Fields** + +NAME | DATA TYPE | VALUES +-----|--------------|----------------- +Term | Multiple Choice | 12,24 +Applicant First Name | String | N/A +Applicant Last Name | String | N/A +Applicant Email Address | String | N/A +Applicant Phone | String | N/A +Domain Control Validation Method | Multiple Choice | EMAIL +Organization Contact | Multiple Choice | Get From CSC Differs For Clients +Business Unit | Multiple Choice | Get From CSC Differs For Clients +Notification Email(s) Comma Separated | String | N/A +CN DCV Email | String | N/A +Addtl Sans Comma Separated DCV Emails | String | N/A + + +**CSC TrustedSecure Premium Wildcard Certificate - Details Tab** + +CONFIG ELEMENT | DESCRIPTION +----------------------------|------------------ +Template Short Name | CSC TrustedSecure Premium Wildcard Certificate +Template Display Name | CSC TrustedSecure Premium Wildcard Certificate +Friendly Name | CSC TrustedSecure Premium Wildcard Certificate +Keys Size | 2048 +Enforce RFC 2818 Compliance | True +CSR Enrollment | True +Pfx Enrollment | True + + +**CSC TrustedSecure Premium Wildcard Certificate - Enrollment Fields** + +NAME | DATA TYPE | VALUES +-----|--------------|----------------- +Term | Multiple Choice | 12,24 +Applicant First Name | String | N/A +Applicant Last Name | String | N/A +Applicant Email Address | String | N/A +Applicant Phone | String | N/A +Domain Control Validation Method | Multiple Choice | EMAIL +Organization Contact | Multiple Choice | Get From CSC Differs For Clients +Business Unit | Multiple Choice | Get From CSC Differs For Clients +Notification Email(s) Comma Separated | String | N/A +CN DCV Email | String | N/A + +**CSC TrustedSecure Domain Validated SSL - Details Tab** + +CONFIG ELEMENT | DESCRIPTION +----------------------------|------------------ +Template Short Name | CSC TrustedSecure Domain Validated SSL +Template Display Name | CSC TrustedSecure Domain Validated SSL +Friendly Name | CSC TrustedSecure Domain Validated SSL +Keys Size | 2048 +Enforce RFC 2818 Compliance | True +CSR Enrollment | True +Pfx Enrollment | True + + +**CSC TrustedSecure Domain Validated SSL - Enrollment Fields** + +NAME | DATA TYPE | VALUES +-----|--------------|----------------- +Term | Multiple Choice | 12,24 +Applicant First Name | String | N/A +Applicant Last Name | String | N/A +Applicant Email Address | String | N/A +Applicant Phone | String | N/A +Domain Control Validation Method | Multiple Choice | EMAIL +Organization Contact | Multiple Choice | Get From CSC Differs For Clients +Business Unit | Multiple Choice | Get From CSC Differs For Clients +Notification Email(s) Comma Separated | String | N/A +CN DCV Email | String | N/A + +**CSC TrustedSecure Domain Validated Wildcard SSL - Details Tab** + +CONFIG ELEMENT | DESCRIPTION +----------------------------|------------------ +Template Short Name | CSC TrustedSecure Domain Validated Wildcard SSL +Template Display Name | CSC TrustedSecure Domain Validated Wildcard SSL +Friendly Name | CSC TrustedSecure Domain Validated Wildcard SSL +Keys Size | 2048 +Enforce RFC 2818 Compliance | True +CSR Enrollment | True +Pfx Enrollment | True + + +**CSC TrustedSecure Domain Validated Wildcard SSL - Enrollment Fields** + +NAME | DATA TYPE | VALUES +-----|--------------|----------------- +Term | Multiple Choice | 12,24 +Applicant First Name | String | N/A +Applicant Last Name | String | N/A +Applicant Email Address | String | N/A +Applicant Phone | String | N/A +Domain Control Validation Method | Multiple Choice | EMAIL +Organization Contact | Multiple Choice | Get From CSC Differs For Clients +Business Unit | Multiple Choice | Get From CSC Differs For Clients +Notification Email(s) Comma Separated | String | N/A +CN DCV Email | String | N/A + +**CSC TrustedSecure Domain Validated UC Certificate - Details Tab** + +CONFIG ELEMENT | DESCRIPTION +----------------------------|------------------ +Template Short Name | CSC TrustedSecure Domain Validated UC Certificate +Template Display Name | CSC TrustedSecure Domain Validated UC Certificate +Friendly Name | CSC TrustedSecure Domain Validated UC Certificate +Keys Size | 2048 +Enforce RFC 2818 Compliance | True +CSR Enrollment | True +Pfx Enrollment | True + + +**CSC TrustedSecure Domain Validated UC Certificate - Enrollment Fields** + +NAME | DATA TYPE | VALUES +-----|--------------|----------------- +Term | Multiple Choice | 12,24 +Applicant First Name | String | N/A +Applicant Last Name | String | N/A +Applicant Email Address | String | N/A +Applicant Phone | String | N/A +Domain Control Validation Method | Multiple Choice | EMAIL +Organization Contact | Multiple Choice | Get From CSC Differs For Clients +Business Unit | Multiple Choice | Get From CSC Differs For Clients +Notification Email(s) Comma Separated | String | N/A +CN DCV Email | String | N/A +Addtl Sans Comma Separated DCV Emails | String | N/A + +**CSC TrustedSecure EV, Multiple Names - Details Tab** + +CONFIG ELEMENT | DESCRIPTION +----------------------------|------------------ +Template Short Name | CSC TrustedSecure EV, Multiple Names +Template Display Name | CSC TrustedSecure EV, Multiple Names +Friendly Name | CSC TrustedSecure EV, Multiple Names +Keys Size | 2048 +Enforce RFC 2818 Compliance | True +CSR Enrollment | True +Pfx Enrollment | True + + +**CSC TrustedSecure EV, Multiple Names - Enrollment Fields** + +NAME | DATA TYPE | VALUES +-----|--------------|----------------- +Term | Multiple Choice | 12,24 +Applicant First Name | String | N/A +Applicant Last Name | String | N/A +Applicant Email Address | String | N/A +Applicant Phone | String | N/A +Domain Control Validation Method | Multiple Choice | EMAIL +Organization Contact | Multiple Choice | Get From CSC Differs For Clients +Business Unit | Multiple Choice | Get From CSC Differs For Clients +Notification Email(s) Comma Separated | String | N/A +CN DCV Email | String | N/A +Organization Country | String | N/A +Addtl Sans Comma Separated DCV Emails | String | N/A + +**CSC TrustedSecure OV Wildcard, Multiple Names - Details Tab** + +CONFIG ELEMENT | DESCRIPTION +----------------------------|------------------ +Template Short Name | CSC TrustedSecure OV Wildcard, Multiple Names +Template Display Name | CSC TrustedSecure OV Wildcard, Multiple Names +Friendly Name | CSC TrustedSecure OV Wildcard, Multiple Names +Keys Size | 2048 +Enforce RFC 2818 Compliance | True +CSR Enrollment | True +Pfx Enrollment | True + + +**CSC TrustedSecure OV Wildcard, Multiple Names - Enrollment Fields** + +NAME | DATA TYPE | VALUES +-----|--------------|----------------- +Term | Multiple Choice | 12,24 +Applicant First Name | String | N/A +Applicant Last Name | String | N/A +Applicant Email Address | String | N/A +Applicant Phone | String | N/A +Domain Control Validation Method | Multiple Choice | EMAIL +Organization Contact | Multiple Choice | Get From CSC Differs For Clients +Business Unit | Multiple Choice | Get From CSC Differs For Clients +Notification Email(s) Comma Separated | String | N/A +CN DCV Email | String | N/A +Addtl Sans Comma Separated DCV Emails | String | N/A + +**CSC TrustedSecure DV Wildcard, Multiple Names - Details Tab** + +CONFIG ELEMENT | DESCRIPTION +----------------------------|------------------ +Template Short Name | CSC TrustedSecure DV Wildcard, Multiple Names +Template Display Name | CSC TrustedSecure DV Wildcard, Multiple Names +Friendly Name | CSC TrustedSecure DV Wildcard, Multiple Names +Keys Size | 2048 +Enforce RFC 2818 Compliance | True +CSR Enrollment | True +Pfx Enrollment | True + + +**CSC TrustedSecure DV Wildcard, Multiple Names - Enrollment Fields** + +NAME | DATA TYPE | VALUES +-----|--------------|----------------- +Term | Multiple Choice | 12,24 +Applicant First Name | String | N/A +Applicant Last Name | String | N/A +Applicant Email Address | String | N/A +Applicant Phone | String | N/A +Domain Control Validation Method | Multiple Choice | EMAIL +Organization Contact | Multiple Choice | Get From CSC Differs For Clients +Business Unit | Multiple Choice | Get From CSC Differs For Clients +Notification Email(s) Comma Separated | String | N/A +CN DCV Email | String | N/A +Addtl Sans Comma Separated DCV Emails | String | N/A 3. Follow the [official Keyfactor documentation](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCA-Keyfactor.htm) to add each defined Certificate Authority to Keyfactor Command and import the newly defined Certificate Templates. 4. In Keyfactor Command (v12.3+), for each imported Certificate Template, follow the [official documentation](https://software.keyfactor.com/Core-OnPrem/Current/Content/ReferenceGuide/Configuring%20Template%20Options.htm) to define enrollment fields for each of the following parameters: - * **Term** - OPTIONAL: Certificate term (e.g. 12 or 24 months) - * **Applicant First Name** - OPTIONAL: Applicant First Name - * **Applicant Last Name** - OPTIONAL: Applicant Last Name - * **Applicant Email Address** - OPTIONAL: Applicant Email Address - * **Applicant Phone** - OPTIONAL: Applicant Phone (+nn.nnnnnnnn) - * **Domain Control Validation Method** - OPTIONAL: Domain Control Validation Method (e.g. EMAIL) - * **Organization Contact** - OPTIONAL: Organization Contact (selected from CSC configuration) - * **Business Unit** - OPTIONAL: Business Unit (selected from CSC configuration) - * **Notification Email(s) Comma Separated** - OPTIONAL: Notification Email(s), comma separated - * **CN DCV Email** - OPTIONAL: CN DCV Email (e.g. admin@yourdomain.com) - * **Organization Country** - OPTIONAL: Organization Country - * **Addtl Sans Comma Separated DCV Emails** - OPTIONAL: Additional SANs DCV Emails, comma separated - - + * **Term** - OPTIONAL: Certificate term (e.g. 12 or 24 months) + * **Applicant First Name** - OPTIONAL: Applicant First Name + * **Applicant Last Name** - OPTIONAL: Applicant Last Name + * **Applicant Email Address** - OPTIONAL: Applicant Email Address + * **Applicant Phone** - OPTIONAL: Applicant Phone (+nn.nnnnnnnn) + * **Domain Control Validation Method** - OPTIONAL: Domain Control Validation Method (e.g. EMAIL) + * **Organization Contact** - OPTIONAL: Organization Contact (selected from CSC configuration) + * **Business Unit** - OPTIONAL: Business Unit (selected from CSC configuration) + * **Notification Email(s) Comma Separated** - OPTIONAL: Notification Email(s), comma separated + * **CN DCV Email** - OPTIONAL: CN DCV Email (e.g. admin@yourdomain.com) + * **Organization Country** - OPTIONAL: Organization Country + * **Addtl Sans Comma Separated DCV Emails** - OPTIONAL: Additional SANs DCV Emails, comma separated ## License @@ -406,4 +403,4 @@ Apache License 2.0, see [LICENSE](LICENSE). ## Related Integrations -See all [Keyfactor Any CA Gateways (REST)](https://github.com/orgs/Keyfactor/repositories?q=anycagateway). \ No newline at end of file +See all [Keyfactor Any CA Gateways (REST)](https://github.com/orgs/Keyfactor/repositories?q=anycagateway). From cba26dc36b3c08be371bb63d8487c787d62b62b7 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Wed, 9 Sep 2026 16:59:11 -0400 Subject: [PATCH 08/36] Throw on non-BadRequest HTTP failures in CscGlobalClient Registration/Renewal/Reissue/Revoke calls only handled CSC Global's 400 error-body case; any other failure status (e.g. 404) fell through to deserializing an empty/unexpected body, leaving Result and RegistrationError both null and causing a downstream NullReferenceException in RequestManager instead of a clear error. --- cscglobal-caplugin/Client/CscGlobalClient.cs | 24 ++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/cscglobal-caplugin/Client/CscGlobalClient.cs b/cscglobal-caplugin/Client/CscGlobalClient.cs index 3429dc5..f9b7fb2 100644 --- a/cscglobal-caplugin/Client/CscGlobalClient.cs +++ b/cscglobal-caplugin/Client/CscGlobalClient.cs @@ -65,7 +65,11 @@ public async Task SubmitRegistrationAsync( } if (!resp.IsSuccessStatusCode) - Logger.LogError($"Registration request failed with status code {resp.StatusCode}"); + { + var errorBody = await resp.Content.ReadAsStringAsync(); + Logger.LogError($"Registration request failed with status code {resp.StatusCode} | Message: {errorBody}"); + throw new HttpRequestException($"Registration request failed with status code {resp.StatusCode}: {errorBody}"); + } var registrationResponse = JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync(), @@ -100,7 +104,11 @@ public async Task SubmitRenewalAsync( } if (!resp.IsSuccessStatusCode) - Logger.LogError($"Renewal request failed with status code {resp.StatusCode}"); + { + var errorBody = await resp.Content.ReadAsStringAsync(); + Logger.LogError($"Renewal request failed with status code {resp.StatusCode} | Message: {errorBody}"); + throw new HttpRequestException($"Renewal request failed with status code {resp.StatusCode}: {errorBody}"); + } var rawRenewResponse = await resp.Content.ReadAsStringAsync(); Logger.LogTrace("Logging Success Response Raw"); @@ -133,7 +141,11 @@ public async Task SubmitReissueAsync( } if (!resp.IsSuccessStatusCode) - Logger.LogError($"Reissue request failed with status code {resp.StatusCode}"); + { + var errorBody = await resp.Content.ReadAsStringAsync(); + Logger.LogError($"Reissue request failed with status code {resp.StatusCode} | Message: {errorBody}"); + throw new HttpRequestException($"Reissue request failed with status code {resp.StatusCode}: {errorBody}"); + } var reissueResponse = JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); @@ -201,7 +213,11 @@ public async Task SubmitRevokeCertificateAsync(string uuId) } if (!resp.IsSuccessStatusCode) - Logger.LogError($"Revoke request for UUID {uuId} failed with status code {resp.StatusCode}"); + { + var errorBody = await resp.Content.ReadAsStringAsync(); + Logger.LogError($"Revoke request for UUID {uuId} failed with status code {resp.StatusCode} | Message: {errorBody}"); + throw new HttpRequestException($"Revoke request failed with status code {resp.StatusCode}: {errorBody}"); + } var getRevokeResponse = JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); From 36e2c5ab43e2be7495038eb9cfc34d745bd9f769 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Wed, 9 Sep 2026 17:07:45 -0400 Subject: [PATCH 09/36] Harden CA plugin against null/short/missing inputs Add null and bounds guards throughout the enrollment, revoke, and sync paths: CSC Global responses (Result/RegistrationError/Status may be null even on a 200), optional ProductParameters lookups that were using the strict indexer, CARequestID substring truncation, and config/dictionary lookups in Initialize and the CscGlobalClient constructor. Replaces silent NREs with clear, logged failures. --- cscglobal-caplugin/CSCGlobalCAPlugin.cs | 81 +++++++-- cscglobal-caplugin/Client/CscGlobalClient.cs | 38 +++- cscglobal-caplugin/RequestManager.cs | 180 +++++++++++++++---- 3 files changed, 243 insertions(+), 56 deletions(-) diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index edf44bd..f5ab830 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -43,10 +43,14 @@ public CSCGlobalCAPlugin() public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDataReader certificateDataReader) { Logger.MethodEntry(LogLevel.Debug); - _certificateDataReader = certificateDataReader; + if (configProvider == null) throw new ArgumentNullException(nameof(configProvider)); + _certificateDataReader = certificateDataReader ?? throw new ArgumentNullException(nameof(certificateDataReader)); CscGlobalClient = new CscGlobalClient(configProvider); - var templateSync = configProvider.CAConnectionData["TemplateSync"].ToString(); - if (templateSync.ToUpper() == "ON") EnableTemplateSync = true; + + if (configProvider.CAConnectionData.TryGetValue("TemplateSync", out var templateSyncValue) && + templateSyncValue != null && + string.Equals(templateSyncValue.ToString(), "ON", StringComparison.OrdinalIgnoreCase)) + EnableTemplateSync = true; Logger.LogInformation($"Template sync is {(EnableTemplateSync ? "enabled" : "disabled")}"); if (configProvider.CAConnectionData.ContainsKey(Constants.SyncFilterDays)) @@ -73,7 +77,10 @@ public async Task GetSingleRecord(string caRequestID) try { Logger.MethodEntry(LogLevel.Debug); - var keyfactorCaId = caRequestID?.Substring(0, 36); //todo fix to use pipe delimiter + if (string.IsNullOrEmpty(caRequestID) || caRequestID.Length < 36) + throw new ArgumentException($"CA request ID '{caRequestID}' is missing or too short to contain a valid UUID", nameof(caRequestID)); + + var keyfactorCaId = caRequestID.Substring(0, 36); //todo fix to use pipe delimiter Logger.LogTrace($"Keyfactor Ca Id: {keyfactorCaId}"); var certificateResponse = Task.Run(async () => await CscGlobalClient.SubmitGetCertificateAsync(keyfactorCaId)) @@ -163,6 +170,13 @@ private async Task SyncCertificates(BlockingCollection b Logger.LogInformation($"Retrieved {certs?.Results?.Count ?? 0} certificate(s) from CSC Global for sync"); + if (certs?.Results == null) + { + Logger.LogWarning("Certificate list request returned no results collection; nothing to sync"); + flow.Step("QueueCertificates", "No results collection returned; nothing to sync"); + return; + } + var queuedCount = 0; var skippedCount = 0; foreach (var currentResponseItem in certs.Results) @@ -230,6 +244,9 @@ public async Task Revoke(string caRequestID, string hexSerialNumber, uint r try { Logger.LogInformation($"Starting Revoke for CA request ID {caRequestID}, reason {revocationReason}"); + if (string.IsNullOrEmpty(caRequestID) || caRequestID.Length < 36) + throw new ArgumentException($"CA request ID '{caRequestID}' is missing or too short to contain a valid UUID", nameof(caRequestID)); + var uuid = caRequestID.Substring(0, 36); //todo fix to use pipe delimiter var revokeResponse = await flow.StepAsync("SubmitRevokeCertificate", @@ -269,6 +286,8 @@ public async Task Revoke(string caRequestID, string hexSerialNumber, uint r public async Task Enroll(string csr, string subject, Dictionary san, EnrollmentProductInfo productInfo, RequestFormat requestFormat, EnrollmentType enrollmentType) { + if (productInfo == null) throw new ArgumentNullException(nameof(productInfo)); + Logger.MethodEntry(LogLevel.Debug); Logger.LogInformation($"Starting Enroll for product {productInfo.ProductID}, enrollment type {enrollmentType}"); using var flow = new FlowLogger(Logger, "Enroll"); @@ -279,9 +298,11 @@ public async Task Enroll(string csr, string subject, Dictionar var priorSn = ""; ReissueRequest reissueRequest; RenewalRequest renewRequest; - if (productInfo.ProductParameters.ContainsKey("priorcertsn")) + var productParameters = productInfo.ProductParameters ?? new Dictionary(); + if (productParameters.ContainsKey("priorcertsn")) { - priorSn = productInfo.ProductParameters["PriorCertSN"]; + productParameters.TryGetValue("PriorCertSN", out priorSn); + priorSn ??= ""; Logger.LogDebug($"Prior cert sn: {priorSn}"); } @@ -294,7 +315,7 @@ public async Task Enroll(string csr, string subject, Dictionar flow.Branch("New Enrollment"); //If they renewed an expired cert it gets here and this will not be supported IRegistrationResponse enrollmentResponse; - if (!productInfo.ProductParameters.ContainsKey("PriorCertSN")) + if (!productParameters.ContainsKey("PriorCertSN")) { enrollmentRequest = _requestManager.GetRegistrationRequest(productInfo, csr, san, customFields); Logger.LogTrace($"Enrollment Request JSON: {JsonConvert.SerializeObject(enrollmentRequest)}"); @@ -321,14 +342,38 @@ public async Task Enroll(string csr, string subject, Dictionar return newResult; case EnrollmentType.RenewOrReissue: flow.Branch("Renew Or Reissue"); + if (string.IsNullOrEmpty(priorSn)) + { + Logger.LogWarning($"Renew/Reissue requested for product {productInfo.ProductID} but no prior certificate serial number was supplied"); + flow.Fail("Renew Or Reissue", "Missing prior certificate serial number"); + flow.EndBranch(); + return new EnrollmentResult + { + Status = 30, //failure + StatusMessage = "Cannot renew or reissue: no prior certificate serial number was supplied." + }; + } + //Logic to determine renew vs reissue var renewal = false; var order_id = await _certificateDataReader.GetRequestIDBySerialNumber(priorSn); + if (string.IsNullOrEmpty(order_id)) + { + Logger.LogWarning($"Could not find a Keyfactor request ID for prior certificate serial number {priorSn}"); + flow.Fail("Renew Or Reissue", $"No request ID found for prior certificate serial number {priorSn}"); + flow.EndBranch(); + return new EnrollmentResult + { + Status = 30, //failure + StatusMessage = $"Cannot renew or reissue: no prior request found for serial number {priorSn}." + }; + } + var expirationDate = _certificateDataReader.GetExpirationDateByRequestId(order_id); if (expirationDate == null) { var localcert = await GetSingleRecord(order_id); - expirationDate = localcert.RevocationDate; + expirationDate = localcert?.RevocationDate; } if (expirationDate < DateTime.Now) renewal = true; @@ -336,13 +381,13 @@ public async Task Enroll(string csr, string subject, Dictionar { flow.Step("DetermineRenewOrReissue", "Renewal - cert is expired"); //One click won't work for this implementation b/c we are missing enrollment params - if (productInfo.ProductParameters.ContainsKey("Applicant Last Name")) + if (productParameters.ContainsKey("Applicant Last Name")) { //priorCert = _certificateDataReader.get( //DataConversion.HexToBytes(productInfo.ProductParameters["PriorCertSN"])); //uUId = priorCert.CARequestID.Substring(0, 36); //uUId is a GUID uUId = await _certificateDataReader.GetRequestIDBySerialNumber( - productInfo.ProductParameters["PriorCertSN"]); + productParameters.GetValueOrDefault("PriorCertSN", "")); Logger.LogTrace($"Renew uUId: {uUId}"); renewRequest = _requestManager.GetRenewalRequest(productInfo, uUId, csr, san, customFields); Logger.LogTrace($"Renewal Request JSON: {JsonConvert.SerializeObject(renewRequest)}"); @@ -369,10 +414,22 @@ public async Task Enroll(string csr, string subject, Dictionar flow.Step("DetermineRenewOrReissue", "Reissue - cert is still valid"); //One click won't work for this implementation b/c we are missing enrollment params - if (productInfo.ProductParameters.ContainsKey("Applicant Last Name")) + if (productParameters.ContainsKey("Applicant Last Name")) { var requestid = await _certificateDataReader.GetRequestIDBySerialNumber( - productInfo.ProductParameters["PriorCertSN"]); + productParameters.GetValueOrDefault("PriorCertSN", "")); + if (string.IsNullOrEmpty(requestid) || requestid.Length < 36) + { + Logger.LogWarning($"Could not find a valid Keyfactor request ID for prior certificate serial number for product {productInfo.ProductID}"); + flow.Fail("Reissue", "No valid request ID found for prior certificate serial number"); + flow.EndBranch(); + return new EnrollmentResult + { + Status = 30, //failure + StatusMessage = "Cannot reissue: no prior request found for the supplied certificate serial number." + }; + } + uUId = requestid.Substring(0, 36); //uUId is a GUID Logger.LogTrace($"Reissue uUId: {uUId}"); reissueRequest = _requestManager.GetReissueRequest(productInfo, uUId, csr, san, customFields); diff --git a/cscglobal-caplugin/Client/CscGlobalClient.cs b/cscglobal-caplugin/Client/CscGlobalClient.cs index f9b7fb2..ca692e2 100644 --- a/cscglobal-caplugin/Client/CscGlobalClient.cs +++ b/cscglobal-caplugin/Client/CscGlobalClient.cs @@ -24,6 +24,13 @@ public sealed class CscGlobalClient : ICscGlobalClient public CscGlobalClient(IAnyCAPluginConfigProvider config) { Logger = LogHandler.GetClassLogger(); + if (config == null) throw new ArgumentNullException(nameof(config)); + if (config.CAConnectionData == null) + { + Logger.LogError("CA connection data is null; client will not be able to call the CSC Global API"); + return; + } + if (config.CAConnectionData.ContainsKey(Constants.CscGlobalApiKey)) { BaseUrl = new Uri(config.CAConnectionData[Constants.CscGlobalUrl].ToString()); @@ -74,6 +81,9 @@ public async Task SubmitRegistrationAsync( var registrationResponse = JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync(), settings); + if (registrationResponse == null) + throw new InvalidOperationException("Registration request succeeded but the response body could not be parsed"); + Logger.MethodExit(LogLevel.Debug); return registrationResponse; } @@ -115,6 +125,9 @@ public async Task SubmitRenewalAsync( Logger.LogTrace(rawRenewResponse); var renewalResponse = JsonConvert.DeserializeObject(rawRenewResponse); + if (renewalResponse == null) + throw new InvalidOperationException("Renewal request succeeded but the response body could not be parsed"); + return renewalResponse; } } @@ -149,6 +162,9 @@ public async Task SubmitReissueAsync( var reissueResponse = JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + if (reissueResponse == null) + throw new InvalidOperationException("Reissue request succeeded but the response body could not be parsed"); + return reissueResponse; } } @@ -168,6 +184,9 @@ public async Task SubmitGetCertificateAsync(string certific resp.EnsureSuccessStatusCode(); var getCertificateResponse = JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + if (getCertificateResponse == null) + throw new InvalidOperationException($"Get certificate request for {certificateId} succeeded but the response body could not be parsed"); + Logger.MethodExit(LogLevel.Debug); return getCertificateResponse; } @@ -187,9 +206,12 @@ public async Task> SubmitGetCustomFields() resp.EnsureSuccessStatusCode(); var getCustomFieldsResponse = JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + if (getCustomFieldsResponse == null) + throw new InvalidOperationException("Get custom fields request succeeded but the response body could not be parsed"); + Logger.LogTrace($"Retrieved {getCustomFieldsResponse.CustomFields?.Count ?? 0} custom field(s)"); Logger.MethodExit(LogLevel.Debug); - return getCustomFieldsResponse.CustomFields; + return getCustomFieldsResponse.CustomFields ?? new List(); } } @@ -221,6 +243,9 @@ public async Task SubmitRevokeCertificateAsync(string uuId) var getRevokeResponse = JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + if (getRevokeResponse == null) + throw new InvalidOperationException("Revoke request succeeded but the response body could not be parsed"); + Logger.MethodExit(LogLevel.Debug); return getRevokeResponse; } @@ -239,14 +264,17 @@ public async Task SubmitCertificateListRequestAsync(str if (!resp.IsSuccessStatusCode) { - var responseMessage = resp.Content.ReadAsStringAsync().Result; - Logger.LogError( - $"Failed Request to Keyfactor. Retrying request. Status Code {resp.StatusCode} | Message: {responseMessage}"); + var responseMessage = await resp.Content.ReadAsStringAsync(); + Logger.LogError($"Certificate list request failed. Status Code {resp.StatusCode} | Message: {responseMessage}"); + throw new HttpRequestException($"Certificate list request failed with status code {resp.StatusCode}: {responseMessage}"); } var certificateListResponse = JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); - Logger.LogInformation($"Certificate list request returned {certificateListResponse?.Results?.Count ?? 0} result(s)"); + if (certificateListResponse == null) + throw new InvalidOperationException("Certificate list request succeeded but the response body could not be parsed"); + + Logger.LogInformation($"Certificate list request returned {certificateListResponse.Results?.Count ?? 0} result(s)"); Logger.MethodExit(LogLevel.Debug); return certificateListResponse; } diff --git a/cscglobal-caplugin/RequestManager.cs b/cscglobal-caplugin/RequestManager.cs index 72f9272..b580e71 100644 --- a/cscglobal-caplugin/RequestManager.cs +++ b/cscglobal-caplugin/RequestManager.cs @@ -23,9 +23,23 @@ public class RequestManager public static Func Pemify = ss => ss.Length <= 64 ? ss : ss.Substring(0, 64) + "\n" + Pemify(ss.Substring(64)); + private static string GetOptionalParam(EnrollmentProductInfo productInfo, string key) + { + return productInfo.ProductParameters != null && + productInfo.ProductParameters.TryGetValue(key, out var value) + ? value + : string.Empty; + } + private List GetCustomFields(EnrollmentProductInfo productInfo, List customFields) { var customFieldList = new List(); + if (customFields == null) + { + Logger.LogTrace("No custom field definitions supplied; skipping custom field mapping"); + return customFieldList; + } + foreach (var field in customFields) if (productInfo.ProductParameters.ContainsKey(field.Label)) { @@ -49,17 +63,37 @@ private List GetCustomFields(EnrollmentProductInfo productInfo, Lis public EnrollmentResult GetRenewResponse(RenewalResponse renewResponse) { + if (renewResponse == null) + { + Logger.LogError("Renewal failed: CSC Global returned no response"); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, //failure + StatusMessage = "CSC Global returned no response for the renewal request" + }; + } + if (renewResponse.RegistrationError != null) { Logger.LogError($"Renewal failed: {renewResponse.RegistrationError.Description}"); return new EnrollmentResult { Status = (int)EndEntityStatus.FAILED, //failure - CARequestID = renewResponse?.Result?.Status?.Uuid, + CARequestID = renewResponse.Result?.Status?.Uuid, StatusMessage = renewResponse.RegistrationError.Description }; } + if (renewResponse.Result == null) + { + Logger.LogError("Renewal failed: CSC Global reported success but returned no result"); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, //failure + StatusMessage = "CSC Global reported success but returned no result" + }; + } + Logger.LogInformation($"Renewal successfully completed for {renewResponse.Result.CommonName}"); return new EnrollmentResult { @@ -74,6 +108,16 @@ public EnrollmentResult GetEnrollmentResult( IRegistrationResponse registrationResponse) { + if (registrationResponse == null) + { + Logger.LogError("Enrollment failed: CSC Global returned no response"); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, //failure + StatusMessage = "CSC Global returned no response for the registration request" + }; + } + if (registrationResponse.RegistrationError != null) { Logger.LogError($"Enrollment failed: {registrationResponse.RegistrationError.Description}"); @@ -84,6 +128,16 @@ public EnrollmentResult }; } + if (registrationResponse.Result == null) + { + Logger.LogError("Enrollment failed: CSC Global reported success but returned no result"); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, //failure + StatusMessage = "CSC Global reported success but returned no result" + }; + } + var cnames = new Dictionary(); if (registrationResponse.Result.DcvDetails != null && registrationResponse.Result.DcvDetails.Count > 0) foreach (var dcv in registrationResponse.Result.DcvDetails) @@ -103,7 +157,7 @@ public EnrollmentResult return new EnrollmentResult { Status = (int)EndEntityStatus.EXTERNALVALIDATION, //success - CARequestID = registrationResponse.Result.Status.Uuid, + CARequestID = registrationResponse.Result.Status?.Uuid, StatusMessage = $"Order Successfully Created With Order Number {registrationResponse.Result.CommonName}", EnrollmentContext = cnames.Count > 0 ? cnames : null @@ -112,6 +166,12 @@ public EnrollmentResult public int GetRevokeResult(IRevokeResponse revokeResponse) { + if (revokeResponse == null) + { + Logger.LogError("Revoke failed: CSC Global returned no response"); + return (int)EndEntityStatus.FAILED; + } + if (revokeResponse.RegistrationError != null) { Logger.LogError($"Revoke failed: {revokeResponse.RegistrationError.Description}"); @@ -123,6 +183,16 @@ public int GetRevokeResult(IRevokeResponse revokeResponse) public EnrollmentResult GetReIssueResult(IReissueResponse reissueResponse) { + if (reissueResponse == null) + { + Logger.LogError("Reissue failed: CSC Global returned no response"); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, //failure + StatusMessage = "CSC Global returned no response for the reissue request" + }; + } + if (reissueResponse.RegistrationError != null) { Logger.LogError($"Reissue failed: {reissueResponse.RegistrationError.Description}"); @@ -133,11 +203,21 @@ public EnrollmentResult GetReIssueResult(IReissueResponse reissueResponse) }; } + if (reissueResponse.Result == null) + { + Logger.LogError("Reissue failed: CSC Global reported success but returned no result"); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, //failure + StatusMessage = "CSC Global reported success but returned no result" + }; + } + Logger.LogInformation($"Reissue successfully completed for {reissueResponse.Result.CommonName}"); return new EnrollmentResult { Status = (int)EndEntityStatus.GENERATED, //success - CARequestID = reissueResponse.Result.Status.Uuid, + CARequestID = reissueResponse.Result.Status?.Uuid, StatusMessage = $"Reissue Successfully Completed For {reissueResponse.Result.CommonName}" }; } @@ -145,10 +225,29 @@ public EnrollmentResult GetReIssueResult(IReissueResponse reissueResponse) public DomainControlValidation GetDomainControlValidation(string methodType, string[] emailAddress, string domainName) { - foreach (var address in emailAddress) + if (string.IsNullOrWhiteSpace(domainName)) + { + Logger.LogWarning("GetDomainControlValidation called with an empty domain name"); + return null; + } + + foreach (var address in emailAddress ?? Array.Empty()) { - var email = new MailAddress(address); - if (domainName.Contains(email.Host.Split('.')[0])) + if (string.IsNullOrWhiteSpace(address)) continue; + + MailAddress email; + try + { + email = new MailAddress(address.Trim()); + } + catch (FormatException fex) + { + Logger.LogWarning(fex, $"Skipping malformed DCV email address '{address}'"); + continue; + } + + var hostLabels = email.Host.Split('.'); + if (hostLabels.Length > 0 && domainName.Contains(hostLabels[0])) return new DomainControlValidation { MethodType = methodType, @@ -180,8 +279,8 @@ public RegistrationRequest GetRegistrationRequest(EnrollmentProductInfo productI var bytes = Encoding.UTF8.GetBytes(cert); var encodedString = Convert.ToBase64String(bytes); - var commonNameValidationEmail = productInfo.ProductParameters["CN DCV Email"]; - var methodType = productInfo.ProductParameters["Domain Control Validation Method"]; + var commonNameValidationEmail = GetOptionalParam(productInfo, "CN DCV Email"); + var methodType = GetOptionalParam(productInfo, "Domain Control Validation Method"); var certificateType = GetCertificateType(productInfo.ProductID); return new RegistrationRequest @@ -189,15 +288,15 @@ public RegistrationRequest GetRegistrationRequest(EnrollmentProductInfo productI Csr = encodedString, ServerSoftware = "-1", //Just default to other, user does not need to fill this in CertificateType = certificateType, - Term = productInfo.ProductParameters["Term"], - ApplicantFirstName = productInfo.ProductParameters["Applicant First Name"], - ApplicantLastName = productInfo.ProductParameters["Applicant Last Name"], - ApplicantEmailAddress = productInfo.ProductParameters["Applicant Email Address"], - ApplicantPhoneNumber = productInfo.ProductParameters["Applicant Phone"], + Term = GetOptionalParam(productInfo, "Term"), + ApplicantFirstName = GetOptionalParam(productInfo, "Applicant First Name"), + ApplicantLastName = GetOptionalParam(productInfo, "Applicant Last Name"), + ApplicantEmailAddress = GetOptionalParam(productInfo, "Applicant Email Address"), + ApplicantPhoneNumber = GetOptionalParam(productInfo, "Applicant Phone"), DomainControlValidation = GetDomainControlValidation(methodType, commonNameValidationEmail), Notifications = GetNotifications(productInfo), - OrganizationContact = productInfo.ProductParameters["Organization Contact"], - BusinessUnit = productInfo.ProductParameters["Business Unit"], + OrganizationContact = GetOptionalParam(productInfo, "Organization Contact"), + BusinessUnit = GetOptionalParam(productInfo, "Business Unit"), ShowPrice = true, //User should not have to fill this out CustomFields = GetCustomFields(productInfo, customFields), SubjectAlternativeNames = MultiNameCertificateTypes.Contains(certificateType) ? GetSubjectAlternativeNames(productInfo, sans) : null, @@ -246,11 +345,13 @@ private string GetCertificateType(string productId) public Notifications GetNotifications(EnrollmentProductInfo productInfo) { + var notificationEmails = GetOptionalParam(productInfo, "Notification Email(s) Comma Separated"); return new Notifications { Enabled = true, - AdditionalNotificationEmails = productInfo.ProductParameters["Notification Email(s) Comma Separated"] - .Split(',').ToList() + AdditionalNotificationEmails = string.IsNullOrWhiteSpace(notificationEmails) + ? new List() + : notificationEmails.Split(',').ToList() }; } @@ -264,8 +365,8 @@ public RenewalRequest GetRenewalRequest(EnrollmentProductInfo productInfo, strin var bytes = Encoding.UTF8.GetBytes(cert); var encodedString = Convert.ToBase64String(bytes); - var commonNameValidationEmail = productInfo.ProductParameters["CN DCV Email"]; - var methodType = productInfo.ProductParameters["Domain Control Validation Method"]; + var commonNameValidationEmail = GetOptionalParam(productInfo, "CN DCV Email"); + var methodType = GetOptionalParam(productInfo, "Domain Control Validation Method"); var certificateType = GetCertificateType(productInfo.ProductID); return new RenewalRequest @@ -274,15 +375,15 @@ public RenewalRequest GetRenewalRequest(EnrollmentProductInfo productInfo, strin Csr = encodedString, ServerSoftware = "-1", CertificateType = certificateType, - Term = productInfo.ProductParameters["Term"], - ApplicantFirstName = productInfo.ProductParameters["Applicant First Name"], - ApplicantLastName = productInfo.ProductParameters["Applicant Last Name"], - ApplicantEmailAddress = productInfo.ProductParameters["Applicant Email Address"], - ApplicantPhoneNumber = productInfo.ProductParameters["Applicant Phone"], + Term = GetOptionalParam(productInfo, "Term"), + ApplicantFirstName = GetOptionalParam(productInfo, "Applicant First Name"), + ApplicantLastName = GetOptionalParam(productInfo, "Applicant Last Name"), + ApplicantEmailAddress = GetOptionalParam(productInfo, "Applicant Email Address"), + ApplicantPhoneNumber = GetOptionalParam(productInfo, "Applicant Phone"), DomainControlValidation = GetDomainControlValidation(methodType, commonNameValidationEmail), Notifications = GetNotifications(productInfo), - OrganizationContact = productInfo.ProductParameters["Organization Contact"], - BusinessUnit = productInfo.ProductParameters["Business Unit"], + OrganizationContact = GetOptionalParam(productInfo, "Organization Contact"), + BusinessUnit = GetOptionalParam(productInfo, "Business Unit"), ShowPrice = true, SubjectAlternativeNames = MultiNameCertificateTypes.Contains(certificateType) ? GetSubjectAlternativeNames(productInfo, sans) : null, CustomFields = GetCustomFields(productInfo, customFields), @@ -294,9 +395,10 @@ private List GetSubjectAlternativeNames(EnrollmentProduc Dictionary sans) { var subjectNameList = new List(); - var methodType = productInfo.ProductParameters["Domain Control Validation Method"]; + var methodType = GetOptionalParam(productInfo, "Domain Control Validation Method"); - sans.TryGetValue("dnsname", out var dnsNames); + string[] dnsNames = null; + sans?.TryGetValue("dnsname", out dnsNames); foreach (var v in dnsNames ?? Array.Empty()) { var domainName = v; @@ -329,8 +431,8 @@ public ReissueRequest GetReissueRequest(EnrollmentProductInfo productInfo, strin var bytes = Encoding.UTF8.GetBytes(cert); var encodedString = Convert.ToBase64String(bytes); - var commonNameValidationEmail = productInfo.ProductParameters["CN DCV Email"]; - var methodType = productInfo.ProductParameters["Domain Control Validation Method"]; + var commonNameValidationEmail = GetOptionalParam(productInfo, "CN DCV Email"); + var methodType = GetOptionalParam(productInfo, "Domain Control Validation Method"); var certificateType = GetCertificateType(productInfo.ProductID); return new ReissueRequest @@ -338,16 +440,16 @@ public ReissueRequest GetReissueRequest(EnrollmentProductInfo productInfo, strin Uuid = uUId, Csr = encodedString, ServerSoftware = "-1", - CertificateType = GetCertificateType(productInfo.ProductID), - Term = productInfo.ProductParameters["Term"], - ApplicantFirstName = productInfo.ProductParameters["Applicant First Name"], - ApplicantLastName = productInfo.ProductParameters["Applicant Last Name"], - ApplicantEmailAddress = productInfo.ProductParameters["Applicant Email Address"], - ApplicantPhoneNumber = productInfo.ProductParameters["Applicant Phone"], + CertificateType = certificateType, + Term = GetOptionalParam(productInfo, "Term"), + ApplicantFirstName = GetOptionalParam(productInfo, "Applicant First Name"), + ApplicantLastName = GetOptionalParam(productInfo, "Applicant Last Name"), + ApplicantEmailAddress = GetOptionalParam(productInfo, "Applicant Email Address"), + ApplicantPhoneNumber = GetOptionalParam(productInfo, "Applicant Phone"), DomainControlValidation = GetDomainControlValidation(methodType, commonNameValidationEmail), Notifications = GetNotifications(productInfo), - OrganizationContact = productInfo.ProductParameters["Organization Contact"], - BusinessUnit = productInfo.ProductParameters["Business Unit"], + OrganizationContact = GetOptionalParam(productInfo, "Organization Contact"), + BusinessUnit = GetOptionalParam(productInfo, "Business Unit"), ShowPrice = true, SubjectAlternativeNames = MultiNameCertificateTypes.Contains(certificateType) ? GetSubjectAlternativeNames(productInfo, sans) : null, CustomFields = GetCustomFields(productInfo, customFields), @@ -358,7 +460,7 @@ public ReissueRequest GetReissueRequest(EnrollmentProductInfo productInfo, strin private EvCertificateDetails GetEvCertificateDetails(EnrollmentProductInfo productInfo) { var evDetails = new EvCertificateDetails(); - evDetails.Country = productInfo.ProductParameters["Organization Country"]; + evDetails.Country = GetOptionalParam(productInfo, "Organization Country"); return evDetails; } From fa1b23e2aa8d854a2cce1fae8772640feac9b6a4 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Mon, 14 Sep 2026 15:47:06 -0400 Subject: [PATCH 10/36] Sync all cert request statuses back to Command, not just issued/revoked Previously the CSC Global list request filtered to status=in=(ACTIVE,REVOKED) and SyncCertificates only forwarded GENERATED/REVOKED records, so any pending or failed request was never reported back to Command on any sync cycle. Command eventually treats a request it hasn't seen in recent syncs as outdated and tries to prune it, which can trigger an internal Command cleanup bug for requests with no staged private key. Now every request CSC Global returns is forwarded to Command each sync, with certificate content attached only when actually issued or revoked. --- cscglobal-caplugin/CSCGlobalCAPlugin.cs | 66 ++++++++++---------- cscglobal-caplugin/Client/CscGlobalClient.cs | 10 +-- 2 files changed, 37 insertions(+), 39 deletions(-) diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index f5ab830..91bd8a5 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -178,21 +178,25 @@ private async Task SyncCertificates(BlockingCollection b } var queuedCount = 0; - var skippedCount = 0; + var queuedWithoutCertCount = 0; foreach (var currentResponseItem in certs.Results) { cancelToken.ThrowIfCancellationRequested(); Logger.LogTrace($"Took Certificate ID {currentResponseItem?.Uuid} from Queue"); var certStatus = _requestManager.MapReturnStatus(currentResponseItem?.Status); - //Keyfactor sync only seems to work when there is a valid cert and I can only get Active valid certs from Csc Global - if (certStatus == Convert.ToInt32(EndEntityStatus.GENERATED) || - certStatus == Convert.ToInt32(EndEntityStatus.REVOKED)) - { - //One click renewal/reissue won't work for this implementation so there is an option to disable it by not syncing back template - var productId = "CscGlobal"; - if (EnableTemplateSync) productId = currentResponseItem?.CertificateType; + //Every known request is always reported back to Command, even without a certificate, + //so Command never considers a still-pending or failed request "outdated" and tries to + //prune it (which can hit an internal Command bug for requests with no staged private key). + var productId = "CscGlobal"; + if (EnableTemplateSync) productId = currentResponseItem?.CertificateType; + + var certString = string.Empty; + var hasIssuedOrRevokedCert = certStatus == Convert.ToInt32(EndEntityStatus.GENERATED) || + certStatus == Convert.ToInt32(EndEntityStatus.REVOKED); + if (hasIssuedOrRevokedCert) + { var fileContent = PreparePemTextFromApi( currentResponseItem?.Certificate ?? string.Empty); @@ -201,39 +205,33 @@ private async Task SyncCertificates(BlockingCollection b { Logger.LogTrace($"File Content {fileContent}"); var certData = fileContent.Replace("\r\n", string.Empty); - var certString = GetEndEntityCertificate(certData); - if (certString.Length > 0) - { - blockingBuffer.Add(new AnyCAPluginCertificate - { - CARequestID = $"{currentResponseItem?.Uuid}", - Certificate = certString, - Status = certStatus, - ProductID = productId - }, cancelToken); - queuedCount++; - } - else - { - Logger.LogWarning($"Could not extract end-entity certificate for {currentResponseItem?.Uuid}; skipping sync of this record"); - skippedCount++; - } - } - else - { - Logger.LogWarning($"No certificate content returned by CSC Global for {currentResponseItem?.Uuid}; skipping sync of this record"); - skippedCount++; + certString = GetEndEntityCertificate(certData); } + + if (string.IsNullOrEmpty(certString)) + Logger.LogWarning($"Could not extract end-entity certificate for {currentResponseItem?.Uuid} (status {currentResponseItem?.Status}); syncing status only"); } else { - Logger.LogTrace($"Skipping Certificate ID {currentResponseItem?.Uuid} - status {currentResponseItem?.Status} is not eligible for sync"); - skippedCount++; + Logger.LogTrace($"Certificate ID {currentResponseItem?.Uuid} - status {currentResponseItem?.Status} has no certificate content yet; syncing status only"); } + + blockingBuffer.Add(new AnyCAPluginCertificate + { + CARequestID = $"{currentResponseItem?.Uuid}", + Certificate = certString, + Status = certStatus, + ProductID = productId + }, cancelToken); + + if (string.IsNullOrEmpty(certString)) + queuedWithoutCertCount++; + else + queuedCount++; } - flow.Step("QueueCertificates", $"Queued {queuedCount}, skipped {skippedCount}"); - Logger.LogInformation($"Sync queued {queuedCount} certificate(s), skipped {skippedCount}"); + flow.Step("QueueCertificates", $"Queued {queuedCount} with certificates, {queuedWithoutCertCount} status-only"); + Logger.LogInformation($"Sync queued {queuedCount} certificate(s) with content, {queuedWithoutCertCount} status-only record(s)"); } //done diff --git a/cscglobal-caplugin/Client/CscGlobalClient.cs b/cscglobal-caplugin/Client/CscGlobalClient.cs index ca692e2..e036d10 100644 --- a/cscglobal-caplugin/Client/CscGlobalClient.cs +++ b/cscglobal-caplugin/Client/CscGlobalClient.cs @@ -254,11 +254,11 @@ public async Task SubmitRevokeCertificateAsync(string uuId) public async Task SubmitCertificateListRequestAsync(string? dateFilter = null) { Logger.MethodEntry(LogLevel.Debug); - var filterQuery = "filter=status=in=(ACTIVE,REVOKED)"; - if (!string.IsNullOrEmpty(dateFilter)) - { - filterQuery += $";effectiveDate=ge={dateFilter}"; - } + // Intentionally not filtering by status here: Command's sync considers any request that + // isn't returned in a sync cycle as "outdated" and tries to prune it, which can hit an + // internal Command bug for requests with no staged private key. Returning every status + // (not just ACTIVE/REVOKED) keeps every known request visible to Command on every sync. + var filterQuery = !string.IsNullOrEmpty(dateFilter) ? $"filter=effectiveDate=ge={dateFilter}" : string.Empty; Logger.LogTrace($"Certificate list filter query: {filterQuery}"); var resp = RestClient.GetAsync($"/dbs/api/v2/tls/certificate?{filterQuery}").Result; From 11e27470111e7503f50518b97d012876a4e15add Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 15 Sep 2026 13:03:13 -0400 Subject: [PATCH 11/36] Revert "Sync all cert request statuses back to Command, not just issued/revoked" This reverts commit fa1b23e2aa8d854a2cce1fae8772640feac9b6a4. --- cscglobal-caplugin/CSCGlobalCAPlugin.cs | 66 ++++++++++---------- cscglobal-caplugin/Client/CscGlobalClient.cs | 10 +-- 2 files changed, 39 insertions(+), 37 deletions(-) diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index 91bd8a5..f5ab830 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -178,25 +178,21 @@ private async Task SyncCertificates(BlockingCollection b } var queuedCount = 0; - var queuedWithoutCertCount = 0; + var skippedCount = 0; foreach (var currentResponseItem in certs.Results) { cancelToken.ThrowIfCancellationRequested(); Logger.LogTrace($"Took Certificate ID {currentResponseItem?.Uuid} from Queue"); var certStatus = _requestManager.MapReturnStatus(currentResponseItem?.Status); - //Every known request is always reported back to Command, even without a certificate, - //so Command never considers a still-pending or failed request "outdated" and tries to - //prune it (which can hit an internal Command bug for requests with no staged private key). - var productId = "CscGlobal"; - if (EnableTemplateSync) productId = currentResponseItem?.CertificateType; - - var certString = string.Empty; - var hasIssuedOrRevokedCert = certStatus == Convert.ToInt32(EndEntityStatus.GENERATED) || - certStatus == Convert.ToInt32(EndEntityStatus.REVOKED); - - if (hasIssuedOrRevokedCert) + //Keyfactor sync only seems to work when there is a valid cert and I can only get Active valid certs from Csc Global + if (certStatus == Convert.ToInt32(EndEntityStatus.GENERATED) || + certStatus == Convert.ToInt32(EndEntityStatus.REVOKED)) { + //One click renewal/reissue won't work for this implementation so there is an option to disable it by not syncing back template + var productId = "CscGlobal"; + if (EnableTemplateSync) productId = currentResponseItem?.CertificateType; + var fileContent = PreparePemTextFromApi( currentResponseItem?.Certificate ?? string.Empty); @@ -205,33 +201,39 @@ private async Task SyncCertificates(BlockingCollection b { Logger.LogTrace($"File Content {fileContent}"); var certData = fileContent.Replace("\r\n", string.Empty); - certString = GetEndEntityCertificate(certData); + var certString = GetEndEntityCertificate(certData); + if (certString.Length > 0) + { + blockingBuffer.Add(new AnyCAPluginCertificate + { + CARequestID = $"{currentResponseItem?.Uuid}", + Certificate = certString, + Status = certStatus, + ProductID = productId + }, cancelToken); + queuedCount++; + } + else + { + Logger.LogWarning($"Could not extract end-entity certificate for {currentResponseItem?.Uuid}; skipping sync of this record"); + skippedCount++; + } + } + else + { + Logger.LogWarning($"No certificate content returned by CSC Global for {currentResponseItem?.Uuid}; skipping sync of this record"); + skippedCount++; } - - if (string.IsNullOrEmpty(certString)) - Logger.LogWarning($"Could not extract end-entity certificate for {currentResponseItem?.Uuid} (status {currentResponseItem?.Status}); syncing status only"); } else { - Logger.LogTrace($"Certificate ID {currentResponseItem?.Uuid} - status {currentResponseItem?.Status} has no certificate content yet; syncing status only"); + Logger.LogTrace($"Skipping Certificate ID {currentResponseItem?.Uuid} - status {currentResponseItem?.Status} is not eligible for sync"); + skippedCount++; } - - blockingBuffer.Add(new AnyCAPluginCertificate - { - CARequestID = $"{currentResponseItem?.Uuid}", - Certificate = certString, - Status = certStatus, - ProductID = productId - }, cancelToken); - - if (string.IsNullOrEmpty(certString)) - queuedWithoutCertCount++; - else - queuedCount++; } - flow.Step("QueueCertificates", $"Queued {queuedCount} with certificates, {queuedWithoutCertCount} status-only"); - Logger.LogInformation($"Sync queued {queuedCount} certificate(s) with content, {queuedWithoutCertCount} status-only record(s)"); + flow.Step("QueueCertificates", $"Queued {queuedCount}, skipped {skippedCount}"); + Logger.LogInformation($"Sync queued {queuedCount} certificate(s), skipped {skippedCount}"); } //done diff --git a/cscglobal-caplugin/Client/CscGlobalClient.cs b/cscglobal-caplugin/Client/CscGlobalClient.cs index e036d10..ca692e2 100644 --- a/cscglobal-caplugin/Client/CscGlobalClient.cs +++ b/cscglobal-caplugin/Client/CscGlobalClient.cs @@ -254,11 +254,11 @@ public async Task SubmitRevokeCertificateAsync(string uuId) public async Task SubmitCertificateListRequestAsync(string? dateFilter = null) { Logger.MethodEntry(LogLevel.Debug); - // Intentionally not filtering by status here: Command's sync considers any request that - // isn't returned in a sync cycle as "outdated" and tries to prune it, which can hit an - // internal Command bug for requests with no staged private key. Returning every status - // (not just ACTIVE/REVOKED) keeps every known request visible to Command on every sync. - var filterQuery = !string.IsNullOrEmpty(dateFilter) ? $"filter=effectiveDate=ge={dateFilter}" : string.Empty; + var filterQuery = "filter=status=in=(ACTIVE,REVOKED)"; + if (!string.IsNullOrEmpty(dateFilter)) + { + filterQuery += $";effectiveDate=ge={dateFilter}"; + } Logger.LogTrace($"Certificate list filter query: {filterQuery}"); var resp = RestClient.GetAsync($"/dbs/api/v2/tls/certificate?{filterQuery}").Result; From 41a2e2261b7f4172bf775abec0c031bea6d9fd0b Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 15 Sep 2026 14:41:40 -0400 Subject: [PATCH 12/36] Map CSC Global's current product names back to plugin ProductIDs during sync CSC Global renamed its product line at some point; its sync/list API now returns current names (e.g. "CSC TrustedSecure DV") while this plugin's ProductIDs/GetCertificateType still use the legacy names it shipped with (e.g. "CSC TrustedSecure Domain Validated SSL"). Syncing a cert back set ProductID directly from CSC's current name, which never matched Command's Certificate Profile/Template mappings (keyed on the legacy names) for 7 of the 10 product types. Add a reverse lookup so synced certificates resolve to the same ProductID used during enrollment. --- cscglobal-caplugin/CSCGlobalCAPlugin.cs | 2 +- cscglobal-caplugin/RequestManager.cs | 30 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index f5ab830..ac23102 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -191,7 +191,7 @@ private async Task SyncCertificates(BlockingCollection b { //One click renewal/reissue won't work for this implementation so there is an option to disable it by not syncing back template var productId = "CscGlobal"; - if (EnableTemplateSync) productId = currentResponseItem?.CertificateType; + if (EnableTemplateSync) productId = _requestManager.MapCertificateTypeToProductId(currentResponseItem?.CertificateType); var fileContent = PreparePemTextFromApi( diff --git a/cscglobal-caplugin/RequestManager.cs b/cscglobal-caplugin/RequestManager.cs index b580e71..b69f6b4 100644 --- a/cscglobal-caplugin/RequestManager.cs +++ b/cscglobal-caplugin/RequestManager.cs @@ -340,6 +340,36 @@ private string GetCertificateType(string productId) return "-1"; } + // CSC Global's API returns its current product names in sync/list responses (e.g. "CSC TrustedSecure DV"), + // which differ from the legacy names this plugin's ProductIDs/GetCertificateType use as Command's ProductID + // (e.g. "CSC TrustedSecure Domain Validated SSL"). Map back to our legacy names so Command's Certificate + // Profile/Template mapping (keyed on our ProductID) can resolve synced certificates. + private static readonly Dictionary CscCertificateTypeToProductId = new(StringComparer.OrdinalIgnoreCase) + { + ["CSC TrustedSecure OV"] = "CSC TrustedSecure Premium Certificate", + ["CSC TrustedSecure OV Wildcard"] = "CSC TrustedSecure Premium Wildcard Certificate", + ["CSC TrustedSecure OV, Multiple Names"] = "CSC TrustedSecure UC Certificate", + ["CSC TrustedSecure EV"] = "CSC TrustedSecure EV Certificate", + ["CSC TrustedSecure DV"] = "CSC TrustedSecure Domain Validated SSL", + ["CSC TrustedSecure DV Wildcard"] = "CSC TrustedSecure Domain Validated Wildcard SSL", + ["CSC TrustedSecure DV, Multiple Names"] = "CSC TrustedSecure Domain Validated UC Certificate", + ["CSC TrustedSecure EV, Multiple Names"] = "CSC TrustedSecure EV, Multiple Names", + ["CSC TrustedSecure OV Wildcard, Multiple Names"] = "CSC TrustedSecure OV Wildcard, Multiple Names", + ["CSC TrustedSecure DV Wildcard, Multiple Names"] = "CSC TrustedSecure DV Wildcard, Multiple Names" + }; + + public string MapCertificateTypeToProductId(string cscCertificateType) + { + if (string.IsNullOrEmpty(cscCertificateType)) + return cscCertificateType; + + if (CscCertificateTypeToProductId.TryGetValue(cscCertificateType, out var productId)) + return productId; + + Logger.LogWarning($"Unrecognized CSC Global certificate type '{cscCertificateType}' returned during sync; using it as-is for ProductID"); + return cscCertificateType; + } + private static readonly HashSet MultiNameCertificateTypes = new() { "2", "7", "8", "9" }; private static readonly HashSet EvCertificateTypes = new() { "3", "7" }; From 3d4f832ce859b3b9200371146b1ff7e8b81e2caf Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 15 Sep 2026 15:48:33 -0400 Subject: [PATCH 13/36] Rename all CSC certificate products to match CSC's current naming Drop the legacy product-name compatibility layer (GetCertificateType's duplicate/old-name cases, the CscCertificateTypeToProductId sync mapping) and use CSC's certificate type names directly everywhere: CSC TrustedSecure OV/OV Wildcard/OV Multiple Names/EV/DV/DV Wildcard/DV Multiple Names plus the three new EV/OV Wildcard/DV Wildcard Multiple Names products. There is no template mapping persisted in the gateway database, so no migration is needed, but existing Command Certificate Templates must have their Template Short Name updated to the new names. --- CHANGELOG.md | 1 + cscglobal-caplugin/CSCGlobalCAPlugin.cs | 2 +- cscglobal-caplugin/Constants.cs | 14 ++--- cscglobal-caplugin/RequestManager.cs | 54 ++++--------------- docsource/configuration.md | 71 ++++++++++++------------- integration-manifest.json | 14 ++--- 6 files changed, 60 insertions(+), 96 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 367a89c..aa4a9f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ v1.2.0 - Added support for CSC TrustedSecure EV, Multiple Names; CSC TrustedSecure OV Wildcard, Multiple Names; and CSC TrustedSecure DV Wildcard, Multiple Names certificate products +- BREAKING: Renamed all certificate template product IDs to match CSC's current certificate type names (e.g. "CSC TrustedSecure Premium Certificate" is now "CSC TrustedSecure OV", "CSC TrustedSecure Domain Validated SSL" is now "CSC TrustedSecure DV"). Existing Certificate Templates in Command must have their Template Short Name updated to match. v.1.1.1 - Added Incremental Sync that goes back X Number of days diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index ac23102..e88d7d4 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -191,7 +191,7 @@ private async Task SyncCertificates(BlockingCollection b { //One click renewal/reissue won't work for this implementation so there is an option to disable it by not syncing back template var productId = "CscGlobal"; - if (EnableTemplateSync) productId = _requestManager.MapCertificateTypeToProductId(currentResponseItem?.CertificateType); + if (EnableTemplateSync) productId = currentResponseItem?.CertificateType ?? productId; var fileContent = PreparePemTextFromApi( diff --git a/cscglobal-caplugin/Constants.cs b/cscglobal-caplugin/Constants.cs index fd79d7a..e8dc28a 100644 --- a/cscglobal-caplugin/Constants.cs +++ b/cscglobal-caplugin/Constants.cs @@ -21,13 +21,13 @@ public class ProductIDs { public static List productIds = new List() { - "CSC TrustedSecure Premium Certificate", - "CSC TrustedSecure EV Certificate", - "CSC TrustedSecure UC Certificate", - "CSC TrustedSecure Premium Wildcard Certificate", - "CSC TrustedSecure Domain Validated SSL", - "CSC TrustedSecure Domain Validated Wildcard SSL", - "CSC TrustedSecure Domain Validated UC Certificate", + "CSC TrustedSecure OV", + "CSC TrustedSecure OV Wildcard", + "CSC TrustedSecure OV, Multiple Names", + "CSC TrustedSecure EV", + "CSC TrustedSecure DV", + "CSC TrustedSecure DV Wildcard", + "CSC TrustedSecure DV, Multiple Names", "CSC TrustedSecure EV, Multiple Names", "CSC TrustedSecure OV Wildcard, Multiple Names", "CSC TrustedSecure DV Wildcard, Multiple Names" diff --git a/cscglobal-caplugin/RequestManager.cs b/cscglobal-caplugin/RequestManager.cs index b69f6b4..d689e66 100644 --- a/cscglobal-caplugin/RequestManager.cs +++ b/cscglobal-caplugin/RequestManager.cs @@ -308,25 +308,19 @@ private string GetCertificateType(string productId) { switch (productId) { - case "CSC TrustedSecure Premium Certificate": + case "CSC TrustedSecure OV": return "0"; - case "CSC TrustedSecure EV Certificate": - return "3"; - case "CSC TrustedSecure UC Certificate": - return "2"; - case "CSC TrustedSecure Premium Wildcard Certificate": + case "CSC TrustedSecure OV Wildcard": return "1"; - case "CSC Trusted Secure Domain Validated SSL": - return "4"; - case "CSC Trusted Secure Domain Validated Wildcard SSL": - return "5"; - case "CSC Trusted Secure Domain Validated UC Certificate": - return "6"; - case "CSC TrustedSecure Domain Validated SSL": + case "CSC TrustedSecure OV, Multiple Names": + return "2"; + case "CSC TrustedSecure EV": + return "3"; + case "CSC TrustedSecure DV": return "4"; - case "CSC TrustedSecure Domain Validated Wildcard SSL": + case "CSC TrustedSecure DV Wildcard": return "5"; - case "CSC TrustedSecure Domain Validated UC Certificate": + case "CSC TrustedSecure DV, Multiple Names": return "6"; case "CSC TrustedSecure EV, Multiple Names": return "7"; @@ -340,36 +334,6 @@ private string GetCertificateType(string productId) return "-1"; } - // CSC Global's API returns its current product names in sync/list responses (e.g. "CSC TrustedSecure DV"), - // which differ from the legacy names this plugin's ProductIDs/GetCertificateType use as Command's ProductID - // (e.g. "CSC TrustedSecure Domain Validated SSL"). Map back to our legacy names so Command's Certificate - // Profile/Template mapping (keyed on our ProductID) can resolve synced certificates. - private static readonly Dictionary CscCertificateTypeToProductId = new(StringComparer.OrdinalIgnoreCase) - { - ["CSC TrustedSecure OV"] = "CSC TrustedSecure Premium Certificate", - ["CSC TrustedSecure OV Wildcard"] = "CSC TrustedSecure Premium Wildcard Certificate", - ["CSC TrustedSecure OV, Multiple Names"] = "CSC TrustedSecure UC Certificate", - ["CSC TrustedSecure EV"] = "CSC TrustedSecure EV Certificate", - ["CSC TrustedSecure DV"] = "CSC TrustedSecure Domain Validated SSL", - ["CSC TrustedSecure DV Wildcard"] = "CSC TrustedSecure Domain Validated Wildcard SSL", - ["CSC TrustedSecure DV, Multiple Names"] = "CSC TrustedSecure Domain Validated UC Certificate", - ["CSC TrustedSecure EV, Multiple Names"] = "CSC TrustedSecure EV, Multiple Names", - ["CSC TrustedSecure OV Wildcard, Multiple Names"] = "CSC TrustedSecure OV Wildcard, Multiple Names", - ["CSC TrustedSecure DV Wildcard, Multiple Names"] = "CSC TrustedSecure DV Wildcard, Multiple Names" - }; - - public string MapCertificateTypeToProductId(string cscCertificateType) - { - if (string.IsNullOrEmpty(cscCertificateType)) - return cscCertificateType; - - if (CscCertificateTypeToProductId.TryGetValue(cscCertificateType, out var productId)) - return productId; - - Logger.LogWarning($"Unrecognized CSC Global certificate type '{cscCertificateType}' returned during sync; using it as-is for ProductID"); - return cscCertificateType; - } - private static readonly HashSet MultiNameCertificateTypes = new() { "2", "7", "8", "9" }; private static readonly HashSet EvCertificateTypes = new() { "3", "7" }; diff --git a/docsource/configuration.md b/docsource/configuration.md index 63cc066..20053a4 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -20,16 +20,16 @@ If a field value is specified as both an Enrollment Field in Command and in the CONFIG ELEMENT | DESCRIPTION ----------------------------|------------------ -Template Short Name | CSC TrustedSecure Premium Certificate -Template Display Name | CSC TrustedSecure Premium Certificate -Friendly Name | CSC TrustedSecure Premium Certificate +Template Short Name | CSC TrustedSecure OV +Template Display Name | CSC TrustedSecure OV +Friendly Name | CSC TrustedSecure OV Keys Size | 2048 Enforce RFC 2818 Compliance | True CSR Enrollment | True Pfx Enrollment | True -**CSC TrustedSecure Premium Certificate - Enrollment Fields** +**CSC TrustedSecure OV - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -44,20 +44,20 @@ Business Unit | Multiple Choice | Get From CSC Differs For Clients Notification Email(s) Comma Separated | String | N/A CN DCV Email | String | N/A -**CSC TrustedSecure EV Certificate - Details Tab** +**CSC TrustedSecure OV Wildcard - Details Tab** CONFIG ELEMENT | DESCRIPTION ----------------------------|------------------ -Template Short Name | CSC TrustedSecure EV Certificate -Template Display Name | CSC TrustedSecure EV Certificate -Friendly Name | CSC TrustedSecure EV Certificate +Template Short Name | CSC TrustedSecure OV Wildcard +Template Display Name | CSC TrustedSecure OV Wildcard +Friendly Name | CSC TrustedSecure OV Wildcard Keys Size | 2048 Enforce RFC 2818 Compliance | True CSR Enrollment | True Pfx Enrollment | True -**CSC TrustedSecure EV Certificate - Enrollment Fields** +**CSC TrustedSecure OV Wildcard - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -71,22 +71,21 @@ Organization Contact | Multiple Choice | Get From CSC Differs For Clients Business Unit | Multiple Choice | Get From CSC Differs For Clients Notification Email(s) Comma Separated | String | N/A CN DCV Email | String | N/A -Organization Country | String | N/A -**CSC TrustedSecure UC Certificate - Details Tab** +**CSC TrustedSecure OV, Multiple Names - Details Tab** CONFIG ELEMENT | DESCRIPTION ----------------------------|------------------ -Template Short Name | CSC TrustedSecure UC Certificate -Template Display Name | CSC TrustedSecure UC Certificate -Friendly Name | CSC TrustedSecure UC Certificate +Template Short Name | CSC TrustedSecure OV, Multiple Names +Template Display Name | CSC TrustedSecure OV, Multiple Names +Friendly Name | CSC TrustedSecure OV, Multiple Names Keys Size | 2048 Enforce RFC 2818 Compliance | True CSR Enrollment | True Pfx Enrollment | True -**CSC TrustedSecure UC Certificate - Enrollment Fields** +**CSC TrustedSecure OV, Multiple Names - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -101,22 +100,21 @@ Business Unit | Multiple Choice | Get From CSC Differs For Clients Notification Email(s) Comma Separated | String | N/A CN DCV Email | String | N/A Addtl Sans Comma Separated DCV Emails | String | N/A - -**CSC TrustedSecure Premium Wildcard Certificate - Details Tab** +**CSC TrustedSecure EV - Details Tab** CONFIG ELEMENT | DESCRIPTION ----------------------------|------------------ -Template Short Name | CSC TrustedSecure Premium Wildcard Certificate -Template Display Name | CSC TrustedSecure Premium Wildcard Certificate -Friendly Name | CSC TrustedSecure Premium Wildcard Certificate +Template Short Name | CSC TrustedSecure EV +Template Display Name | CSC TrustedSecure EV +Friendly Name | CSC TrustedSecure EV Keys Size | 2048 Enforce RFC 2818 Compliance | True CSR Enrollment | True Pfx Enrollment | True -**CSC TrustedSecure Premium Wildcard Certificate - Enrollment Fields** +**CSC TrustedSecure EV - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -130,21 +128,22 @@ Organization Contact | Multiple Choice | Get From CSC Differs For Clients Business Unit | Multiple Choice | Get From CSC Differs For Clients Notification Email(s) Comma Separated | String | N/A CN DCV Email | String | N/A +Organization Country | String | N/A -**CSC TrustedSecure Domain Validated SSL - Details Tab** +**CSC TrustedSecure DV - Details Tab** CONFIG ELEMENT | DESCRIPTION ----------------------------|------------------ -Template Short Name | CSC TrustedSecure Domain Validated SSL -Template Display Name | CSC TrustedSecure Domain Validated SSL -Friendly Name | CSC TrustedSecure Domain Validated SSL +Template Short Name | CSC TrustedSecure DV +Template Display Name | CSC TrustedSecure DV +Friendly Name | CSC TrustedSecure DV Keys Size | 2048 Enforce RFC 2818 Compliance | True CSR Enrollment | True Pfx Enrollment | True -**CSC TrustedSecure Domain Validated SSL - Enrollment Fields** +**CSC TrustedSecure DV - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -159,20 +158,20 @@ Business Unit | Multiple Choice | Get From CSC Differs For Clients Notification Email(s) Comma Separated | String | N/A CN DCV Email | String | N/A -**CSC TrustedSecure Domain Validated Wildcard SSL - Details Tab** +**CSC TrustedSecure DV Wildcard - Details Tab** CONFIG ELEMENT | DESCRIPTION ----------------------------|------------------ -Template Short Name | CSC TrustedSecure Domain Validated Wildcard SSL -Template Display Name | CSC TrustedSecure Domain Validated Wildcard SSL -Friendly Name | CSC TrustedSecure Domain Validated Wildcard SSL +Template Short Name | CSC TrustedSecure DV Wildcard +Template Display Name | CSC TrustedSecure DV Wildcard +Friendly Name | CSC TrustedSecure DV Wildcard Keys Size | 2048 Enforce RFC 2818 Compliance | True CSR Enrollment | True Pfx Enrollment | True -**CSC TrustedSecure Domain Validated Wildcard SSL - Enrollment Fields** +**CSC TrustedSecure DV Wildcard - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -187,20 +186,20 @@ Business Unit | Multiple Choice | Get From CSC Differs For Clients Notification Email(s) Comma Separated | String | N/A CN DCV Email | String | N/A -**CSC TrustedSecure Domain Validated UC Certificate - Details Tab** +**CSC TrustedSecure DV, Multiple Names - Details Tab** CONFIG ELEMENT | DESCRIPTION ----------------------------|------------------ -Template Short Name | CSC TrustedSecure Domain Validated UC Certificate -Template Display Name | CSC TrustedSecure Domain Validated UC Certificate -Friendly Name | CSC TrustedSecure Domain Validated UC Certificate +Template Short Name | CSC TrustedSecure DV, Multiple Names +Template Display Name | CSC TrustedSecure DV, Multiple Names +Friendly Name | CSC TrustedSecure DV, Multiple Names Keys Size | 2048 Enforce RFC 2818 Compliance | True CSR Enrollment | True Pfx Enrollment | True -**CSC TrustedSecure Domain Validated UC Certificate - Enrollment Fields** +**CSC TrustedSecure DV, Multiple Names - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- diff --git a/integration-manifest.json b/integration-manifest.json index 756b19a..44f826d 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -89,13 +89,13 @@ } ], "product_ids": [ - "CSC TrustedSecure Premium Certificate", - "CSC TrustedSecure EV Certificate", - "CSC TrustedSecure UC Certificate", - "CSC TrustedSecure Premium Wildcard Certificate", - "CSC TrustedSecure Domain Validated SSL", - "CSC TrustedSecure Domain Validated Wildcard SSL", - "CSC TrustedSecure Domain Validated UC Certificate", + "CSC TrustedSecure OV", + "CSC TrustedSecure OV Wildcard", + "CSC TrustedSecure OV, Multiple Names", + "CSC TrustedSecure EV", + "CSC TrustedSecure DV", + "CSC TrustedSecure DV Wildcard", + "CSC TrustedSecure DV, Multiple Names", "CSC TrustedSecure EV, Multiple Names", "CSC TrustedSecure OV Wildcard, Multiple Names", "CSC TrustedSecure DV Wildcard, Multiple Names" From 08681db3964fd8b84a25509c482da3d8d832d1d9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 15 Sep 2026 19:49:10 +0000 Subject: [PATCH 14/36] docs: auto-generate README and documentation [skip ci] --- README.md | 71 +++++++++++++++++++++++++++---------------------------- 1 file changed, 35 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 3ffe2b0..be687f8 100644 --- a/README.md +++ b/README.md @@ -96,16 +96,16 @@ If a field value is specified as both an Enrollment Field in Command and in the CONFIG ELEMENT | DESCRIPTION ----------------------------|------------------ -Template Short Name | CSC TrustedSecure Premium Certificate -Template Display Name | CSC TrustedSecure Premium Certificate -Friendly Name | CSC TrustedSecure Premium Certificate +Template Short Name | CSC TrustedSecure OV +Template Display Name | CSC TrustedSecure OV +Friendly Name | CSC TrustedSecure OV Keys Size | 2048 Enforce RFC 2818 Compliance | True CSR Enrollment | True Pfx Enrollment | True -**CSC TrustedSecure Premium Certificate - Enrollment Fields** +**CSC TrustedSecure OV - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -120,20 +120,20 @@ Business Unit | Multiple Choice | Get From CSC Differs For Clients Notification Email(s) Comma Separated | String | N/A CN DCV Email | String | N/A -**CSC TrustedSecure EV Certificate - Details Tab** +**CSC TrustedSecure OV Wildcard - Details Tab** CONFIG ELEMENT | DESCRIPTION ----------------------------|------------------ -Template Short Name | CSC TrustedSecure EV Certificate -Template Display Name | CSC TrustedSecure EV Certificate -Friendly Name | CSC TrustedSecure EV Certificate +Template Short Name | CSC TrustedSecure OV Wildcard +Template Display Name | CSC TrustedSecure OV Wildcard +Friendly Name | CSC TrustedSecure OV Wildcard Keys Size | 2048 Enforce RFC 2818 Compliance | True CSR Enrollment | True Pfx Enrollment | True -**CSC TrustedSecure EV Certificate - Enrollment Fields** +**CSC TrustedSecure OV Wildcard - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -147,22 +147,21 @@ Organization Contact | Multiple Choice | Get From CSC Differs For Clients Business Unit | Multiple Choice | Get From CSC Differs For Clients Notification Email(s) Comma Separated | String | N/A CN DCV Email | String | N/A -Organization Country | String | N/A -**CSC TrustedSecure UC Certificate - Details Tab** +**CSC TrustedSecure OV, Multiple Names - Details Tab** CONFIG ELEMENT | DESCRIPTION ----------------------------|------------------ -Template Short Name | CSC TrustedSecure UC Certificate -Template Display Name | CSC TrustedSecure UC Certificate -Friendly Name | CSC TrustedSecure UC Certificate +Template Short Name | CSC TrustedSecure OV, Multiple Names +Template Display Name | CSC TrustedSecure OV, Multiple Names +Friendly Name | CSC TrustedSecure OV, Multiple Names Keys Size | 2048 Enforce RFC 2818 Compliance | True CSR Enrollment | True Pfx Enrollment | True -**CSC TrustedSecure UC Certificate - Enrollment Fields** +**CSC TrustedSecure OV, Multiple Names - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -177,22 +176,21 @@ Business Unit | Multiple Choice | Get From CSC Differs For Clients Notification Email(s) Comma Separated | String | N/A CN DCV Email | String | N/A Addtl Sans Comma Separated DCV Emails | String | N/A - -**CSC TrustedSecure Premium Wildcard Certificate - Details Tab** +**CSC TrustedSecure EV - Details Tab** CONFIG ELEMENT | DESCRIPTION ----------------------------|------------------ -Template Short Name | CSC TrustedSecure Premium Wildcard Certificate -Template Display Name | CSC TrustedSecure Premium Wildcard Certificate -Friendly Name | CSC TrustedSecure Premium Wildcard Certificate +Template Short Name | CSC TrustedSecure EV +Template Display Name | CSC TrustedSecure EV +Friendly Name | CSC TrustedSecure EV Keys Size | 2048 Enforce RFC 2818 Compliance | True CSR Enrollment | True Pfx Enrollment | True -**CSC TrustedSecure Premium Wildcard Certificate - Enrollment Fields** +**CSC TrustedSecure EV - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -206,21 +204,22 @@ Organization Contact | Multiple Choice | Get From CSC Differs For Clients Business Unit | Multiple Choice | Get From CSC Differs For Clients Notification Email(s) Comma Separated | String | N/A CN DCV Email | String | N/A +Organization Country | String | N/A -**CSC TrustedSecure Domain Validated SSL - Details Tab** +**CSC TrustedSecure DV - Details Tab** CONFIG ELEMENT | DESCRIPTION ----------------------------|------------------ -Template Short Name | CSC TrustedSecure Domain Validated SSL -Template Display Name | CSC TrustedSecure Domain Validated SSL -Friendly Name | CSC TrustedSecure Domain Validated SSL +Template Short Name | CSC TrustedSecure DV +Template Display Name | CSC TrustedSecure DV +Friendly Name | CSC TrustedSecure DV Keys Size | 2048 Enforce RFC 2818 Compliance | True CSR Enrollment | True Pfx Enrollment | True -**CSC TrustedSecure Domain Validated SSL - Enrollment Fields** +**CSC TrustedSecure DV - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -235,20 +234,20 @@ Business Unit | Multiple Choice | Get From CSC Differs For Clients Notification Email(s) Comma Separated | String | N/A CN DCV Email | String | N/A -**CSC TrustedSecure Domain Validated Wildcard SSL - Details Tab** +**CSC TrustedSecure DV Wildcard - Details Tab** CONFIG ELEMENT | DESCRIPTION ----------------------------|------------------ -Template Short Name | CSC TrustedSecure Domain Validated Wildcard SSL -Template Display Name | CSC TrustedSecure Domain Validated Wildcard SSL -Friendly Name | CSC TrustedSecure Domain Validated Wildcard SSL +Template Short Name | CSC TrustedSecure DV Wildcard +Template Display Name | CSC TrustedSecure DV Wildcard +Friendly Name | CSC TrustedSecure DV Wildcard Keys Size | 2048 Enforce RFC 2818 Compliance | True CSR Enrollment | True Pfx Enrollment | True -**CSC TrustedSecure Domain Validated Wildcard SSL - Enrollment Fields** +**CSC TrustedSecure DV Wildcard - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -263,20 +262,20 @@ Business Unit | Multiple Choice | Get From CSC Differs For Clients Notification Email(s) Comma Separated | String | N/A CN DCV Email | String | N/A -**CSC TrustedSecure Domain Validated UC Certificate - Details Tab** +**CSC TrustedSecure DV, Multiple Names - Details Tab** CONFIG ELEMENT | DESCRIPTION ----------------------------|------------------ -Template Short Name | CSC TrustedSecure Domain Validated UC Certificate -Template Display Name | CSC TrustedSecure Domain Validated UC Certificate -Friendly Name | CSC TrustedSecure Domain Validated UC Certificate +Template Short Name | CSC TrustedSecure DV, Multiple Names +Template Display Name | CSC TrustedSecure DV, Multiple Names +Friendly Name | CSC TrustedSecure DV, Multiple Names Keys Size | 2048 Enforce RFC 2818 Compliance | True CSR Enrollment | True Pfx Enrollment | True -**CSC TrustedSecure Domain Validated UC Certificate - Enrollment Fields** +**CSC TrustedSecure DV, Multiple Names - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- From f3319784b0f565753878fe9f6716ad6565b444fa Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 15 Sep 2026 16:13:41 -0400 Subject: [PATCH 15/36] Always sync certificate template mapping, remove TemplateSync setting TemplateSync defaulted to off and was never turned on, so template sync never ran. There is no reason to keep it opt-in, so always resolve synced certificates back to their CSC certificate type/template and drop the gateway registration setting. --- CHANGELOG.md | 1 + cscglobal-caplugin/CSCGlobalCAPlugin.cs | 19 +------------------ cscglobal-caplugin/Constants.cs | 1 - integration-manifest.json | 4 ---- 4 files changed, 2 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa4a9f3..ed77d06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ v1.2.0 - Added support for CSC TrustedSecure EV, Multiple Names; CSC TrustedSecure OV Wildcard, Multiple Names; and CSC TrustedSecure DV Wildcard, Multiple Names certificate products - BREAKING: Renamed all certificate template product IDs to match CSC's current certificate type names (e.g. "CSC TrustedSecure Premium Certificate" is now "CSC TrustedSecure OV", "CSC TrustedSecure Domain Validated SSL" is now "CSC TrustedSecure DV"). Existing Certificate Templates in Command must have their Template Short Name updated to match. +- Removed the TemplateSync gateway registration setting. Certificate sync now always maps synced certificates back to their CSC certificate type/template. v.1.1.1 - Added Incremental Sync that goes back X Number of days diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index e88d7d4..21149d2 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -35,8 +35,6 @@ public CSCGlobalCAPlugin() private ICscGlobalClient CscGlobalClient { get; set; } - public bool EnableTemplateSync { get; set; } - public int SyncFilterDays { get; set; } //done @@ -47,12 +45,6 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa _certificateDataReader = certificateDataReader ?? throw new ArgumentNullException(nameof(certificateDataReader)); CscGlobalClient = new CscGlobalClient(configProvider); - if (configProvider.CAConnectionData.TryGetValue("TemplateSync", out var templateSyncValue) && - templateSyncValue != null && - string.Equals(templateSyncValue.ToString(), "ON", StringComparison.OrdinalIgnoreCase)) - EnableTemplateSync = true; - Logger.LogInformation($"Template sync is {(EnableTemplateSync ? "enabled" : "disabled")}"); - if (configProvider.CAConnectionData.ContainsKey(Constants.SyncFilterDays)) { var syncFilterDaysStr = configProvider.CAConnectionData[Constants.SyncFilterDays]?.ToString(); @@ -189,9 +181,7 @@ private async Task SyncCertificates(BlockingCollection b if (certStatus == Convert.ToInt32(EndEntityStatus.GENERATED) || certStatus == Convert.ToInt32(EndEntityStatus.REVOKED)) { - //One click renewal/reissue won't work for this implementation so there is an option to disable it by not syncing back template - var productId = "CscGlobal"; - if (EnableTemplateSync) productId = currentResponseItem?.CertificateType ?? productId; + var productId = currentResponseItem?.CertificateType ?? "CscGlobal"; var fileContent = PreparePemTextFromApi( @@ -552,13 +542,6 @@ public Dictionary GetCAConnectorAnnotations() DefaultValue = "100", Type = "String" }, - [Constants.TemplateSync] = new() - { - Comments = "Enable template sync.", - Hidden = false, - DefaultValue = "false", - Type = "Bool" - }, [Constants.SyncFilterDays] = new() { Comments = "Number of days from today to filter certificates by expiration date during incremental sync.", diff --git a/cscglobal-caplugin/Constants.cs b/cscglobal-caplugin/Constants.cs index e8dc28a..557dee4 100644 --- a/cscglobal-caplugin/Constants.cs +++ b/cscglobal-caplugin/Constants.cs @@ -13,7 +13,6 @@ public class Constants public static string CscGlobalApiKey = "ApiKey"; public static string BearerToken = "BearerToken"; public static string DefaultPageSize = "DefaultPageSize"; - public static string TemplateSync = "TemplateSync"; public static string SyncFilterDays = "SyncFilterDays"; } diff --git a/integration-manifest.json b/integration-manifest.json index 44f826d..17315d8 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -29,10 +29,6 @@ "name": "DefaultPageSize", "description": "Default page size for use with the API. Default is 100" }, - { - "name": "TemplateSync", - "description": "Enable template sync." - }, { "name": "SyncFilterDays", "description": "Number of days from today to filter certificates by expiration date during incremental sync." From 10f090f5a3465d89d4338b57cedba49c7ff22439 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 15 Sep 2026 20:14:27 +0000 Subject: [PATCH 16/36] docs: auto-generate README and documentation [skip ci] --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index be687f8..77cc6c6 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,6 @@ This integration is tested and confirmed as working for Anygateway REST 24.2 and * **ApiKey** - CSCGlobal API Key * **BearerToken** - CSCGlobal Bearer Token * **DefaultPageSize** - Default page size for use with the API. Default is 100 - * **TemplateSync** - Enable template sync. * **SyncFilterDays** - Number of days from today to filter certificates by expiration date during incremental sync. 2. PLEASE NOTE, AT THIS TIME THE RAPID_SSL TEMPLATE IS NOT SUPPORTED BY THE CSC API AND WILL NOT WORK WITH THIS INTEGRATION From fd22d7eae806c7df636018cc1bb1226411cc2543 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 15 Sep 2026 16:15:31 -0400 Subject: [PATCH 17/36] Log the resolved ProductID when queueing a synced certificate --- cscglobal-caplugin/CSCGlobalCAPlugin.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index 21149d2..1cc9455 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -194,6 +194,7 @@ private async Task SyncCertificates(BlockingCollection b var certString = GetEndEntityCertificate(certData); if (certString.Length > 0) { + Logger.LogTrace($"Queueing Certificate ID {currentResponseItem?.Uuid} with ProductID '{productId}'"); blockingBuffer.Add(new AnyCAPluginCertificate { CARequestID = $"{currentResponseItem?.Uuid}", From 34d0542449982ddd71994231ca2a67fe6a55ae34 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 15 Sep 2026 16:29:58 -0400 Subject: [PATCH 18/36] Leave ProductID null when CSC returns no certificateType during sync Matches the pattern used in hydrantid-caplugin: pass the resolved value through as-is rather than substituting a fallback string that looks like a real product name. --- cscglobal-caplugin/CSCGlobalCAPlugin.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index 1cc9455..d2ae007 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -181,7 +181,7 @@ private async Task SyncCertificates(BlockingCollection b if (certStatus == Convert.ToInt32(EndEntityStatus.GENERATED) || certStatus == Convert.ToInt32(EndEntityStatus.REVOKED)) { - var productId = currentResponseItem?.CertificateType ?? "CscGlobal"; + var productId = currentResponseItem?.CertificateType; var fileContent = PreparePemTextFromApi( @@ -194,7 +194,7 @@ private async Task SyncCertificates(BlockingCollection b var certString = GetEndEntityCertificate(certData); if (certString.Length > 0) { - Logger.LogTrace($"Queueing Certificate ID {currentResponseItem?.Uuid} with ProductID '{productId}'"); + Logger.LogTrace($"Queueing Certificate ID {currentResponseItem?.Uuid} with ProductID '{productId ?? "(null)"}'"); blockingBuffer.Add(new AnyCAPluginCertificate { CARequestID = $"{currentResponseItem?.Uuid}", From 71bf6343d7b55b5ba0ff47d19f4f905c2f31f6a8 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 15 Sep 2026 16:57:01 -0400 Subject: [PATCH 19/36] Add xUnit test suite (158 tests, ~96% line coverage) Add a CSCGlobalCAPlugin.Tests project modeled on hydrantid-caplugin's test setup (xUnit, Moq, coverlet). Covers RequestManager, FlowLogger, CscGlobalClient (via a fake HttpMessageHandler), and CSCGlobalCAPlugin's orchestration logic (Synchronize, Enroll, Revoke, GetSingleRecord, GetEndEntityCertificate, annotations). Rework needed for testability: - CSCGlobalCAPlugin now resolves its ICscGlobalClient through an internal ClientFactory delegate instead of constructing one eagerly in Initialize, so tests can substitute a mock. - CscGlobalClient gained an internal constructor overload that accepts an HttpMessageHandler, so HTTP calls can be intercepted without a real network client. - Added AssemblyInfo.cs with InternalsVisibleTo for the test project. Bugs found and fixed while writing tests: - MultiNameCertificateTypes was missing certificate type "6" (CSC TrustedSecure DV, Multiple Names), so that product never got SubjectAlternativeNames populated despite its name. - The prior-cert-serial-number extraction in Enroll() gated on the lowercase key "priorcertsn" but read the properly-cased "PriorCertSN", so priorSn was effectively never populated and Renew/Reissue enrollments always failed with "no prior certificate serial number was supplied". Also fixed a blocking .Result call in CscGlobalClient.SubmitCertificateListRequestAsync to use await. --- .../CSCGlobalCAPlugin.Tests.csproj | 30 + .../CSCGlobalCAPluginTests.cs | 866 ++++++++++++++++++ .../CscGlobalClientTests.cs | 385 ++++++++ cscglobal-caplugin.Tests/FlowLoggerTests.cs | 194 ++++ .../RequestManagerTests.cs | 547 +++++++++++ cscglobal-caplugin.Tests/coverlet.runsettings | 12 + cscglobal-caplugin.sln | 8 + cscglobal-caplugin/AssemblyInfo.cs | 10 + cscglobal-caplugin/CSCGlobalCAPlugin.cs | 11 +- cscglobal-caplugin/Client/CscGlobalClient.cs | 15 +- cscglobal-caplugin/RequestManager.cs | 2 +- 11 files changed, 2070 insertions(+), 10 deletions(-) create mode 100644 cscglobal-caplugin.Tests/CSCGlobalCAPlugin.Tests.csproj create mode 100644 cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs create mode 100644 cscglobal-caplugin.Tests/CscGlobalClientTests.cs create mode 100644 cscglobal-caplugin.Tests/FlowLoggerTests.cs create mode 100644 cscglobal-caplugin.Tests/RequestManagerTests.cs create mode 100644 cscglobal-caplugin.Tests/coverlet.runsettings create mode 100644 cscglobal-caplugin/AssemblyInfo.cs diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPlugin.Tests.csproj b/cscglobal-caplugin.Tests/CSCGlobalCAPlugin.Tests.csproj new file mode 100644 index 0000000..b65ca5b --- /dev/null +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPlugin.Tests.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + enable + enable + false + true + CSCGlobalCAPlugin.Tests + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs new file mode 100644 index 0000000..a2e40eb --- /dev/null +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs @@ -0,0 +1,866 @@ +// 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.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CSCGlobal; +using Keyfactor.Extensions.CAPlugin.CSCGlobal.Client.Models; +using Keyfactor.Extensions.CAPlugin.CSCGlobal.Interfaces; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using Xunit; + +namespace CscGlobalCAPluginTests; + +public class CSCGlobalCAPluginTests +{ + private sealed class FakeConfigProvider : IAnyCAPluginConfigProvider + { + public Dictionary CAConnectionData { get; set; } = new(); + } + + private static Dictionary ValidConnectionData() => new() + { + [Constants.CscGlobalUrl] = "https://api.csc.test", + [Constants.CscGlobalApiKey] = "test-api-key", + [Constants.BearerToken] = "test-bearer-token" + }; + + private static CSCGlobalCAPlugin MakePlugin(Mock? client = null, + Mock? certDataReader = null, Dictionary? connectionData = null) + { + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(new FakeConfigProvider { CAConnectionData = connectionData ?? ValidConnectionData() }, + (certDataReader ?? new Mock()).Object); + if (client != null) + plugin.ClientFactory = _ => client.Object; + return plugin; + } + + private static EnrollmentProductInfo ProductInfo(string productId = "CSC TrustedSecure OV", + Dictionary? parameters = null) => new EnrollmentProductInfo + { + ProductID = productId, + ProductParameters = parameters ?? new Dictionary() + }; + + private static (X509Certificate2 Cert, string Pem) MakeSelfSignedCert(string cn = "test.example.com", bool isCa = false) + { + using var rsa = RSA.Create(2048); + var req = new CertificateRequest($"CN={cn}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + req.CertificateExtensions.Add(new X509BasicConstraintsExtension(isCa, false, 0, true)); + 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 (cert, pem); + } + + private static string ToApiBase64(string pemText) => Convert.ToBase64String(Encoding.UTF8.GetBytes(pemText)); + + // --------------------------------------------------------------------- + // Initialize + // --------------------------------------------------------------------- + + [Fact] + public void Initialize_NullConfigProvider_Throws() + { + var plugin = new CSCGlobalCAPlugin(); + Assert.Throws(() => plugin.Initialize(null!, Mock.Of())); + } + + [Fact] + public void Initialize_NullCertDataReader_Throws() + { + var plugin = new CSCGlobalCAPlugin(); + Assert.Throws(() => + plugin.Initialize(new FakeConfigProvider { CAConnectionData = ValidConnectionData() }, null!)); + } + + [Fact] + public void Initialize_ValidSyncFilterDays_ParsesValue() + { + var data = ValidConnectionData(); + data[Constants.SyncFilterDays] = "10"; + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(new FakeConfigProvider { CAConnectionData = data }, Mock.Of()); + Assert.Equal(10, plugin.SyncFilterDays); + } + + [Fact] + public void Initialize_UnparsableSyncFilterDays_LeavesDefault() + { + var data = ValidConnectionData(); + data[Constants.SyncFilterDays] = "not-a-number"; + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(new FakeConfigProvider { CAConnectionData = data }, Mock.Of()); + Assert.Equal(0, plugin.SyncFilterDays); + } + + [Fact] + public void Initialize_MissingSyncFilterDays_LeavesDefault() + { + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(new FakeConfigProvider { CAConnectionData = ValidConnectionData() }, Mock.Of()); + Assert.Equal(0, plugin.SyncFilterDays); + } + + // --------------------------------------------------------------------- + // GetSingleRecord + // --------------------------------------------------------------------- + + [Fact] + public async Task GetSingleRecord_ShortCaRequestId_ThrowsWrappedException() + { + var plugin = MakePlugin(); + await Assert.ThrowsAsync(() => plugin.GetSingleRecord("too-short")); + } + + [Fact] + public async Task GetSingleRecord_NullCaRequestId_ThrowsWrappedException() + { + var plugin = MakePlugin(); + await Assert.ThrowsAsync(() => plugin.GetSingleRecord(null!)); + } + + [Fact] + public async Task GetSingleRecord_ValidId_ReturnsMappedCertificate() + { + var uuid = Guid.NewGuid().ToString(); + var (cert, pem) = MakeSelfSignedCert(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ReturnsAsync(new CertificateResponse + { + Certificate = Convert.ToBase64String(Encoding.ASCII.GetBytes(pem)), + Status = "ACTIVE" + }); + + var plugin = MakePlugin(mockClient); + var result = await plugin.GetSingleRecord(uuid); + + Assert.Equal(uuid, result.CARequestID); + Assert.Equal((int)EndEntityStatus.GENERATED, result.Status); + Assert.Equal(Convert.ToBase64String(cert.RawData), result.Certificate); + } + + [Fact] + public async Task GetSingleRecord_ClientThrows_WrapsException() + { + var uuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ThrowsAsync(new InvalidOperationException("boom")); + + var plugin = MakePlugin(mockClient); + await Assert.ThrowsAsync(() => plugin.GetSingleRecord(uuid)); + } + + // --------------------------------------------------------------------- + // Synchronize + // --------------------------------------------------------------------- + + [Fact] + public async Task Synchronize_FullSync_QueuesActiveAndRevokedOnly() + { + var (cert, 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 = "cert-1", Status = "ACTIVE", CertificateType = "CSC TrustedSecure OV", Certificate = apiCert }, + new CertificateResponse { Uuid = "cert-2", Status = "REVOKED", CertificateType = "CSC TrustedSecure DV", Certificate = apiCert }, + new CertificateResponse { Uuid = "cert-3", Status = "Pending", CertificateType = "CSC TrustedSecure OV", Certificate = apiCert } + } + }); + + 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.Contains(items, i => i.CARequestID == "cert-1" && i.ProductID == "CSC TrustedSecure OV"); + Assert.Contains(items, i => i.CARequestID == "cert-2" && i.ProductID == "CSC TrustedSecure DV"); + } + + [Fact] + public async Task Synchronize_IncrementalSync_UsesConfiguredFilterDays() + { + var mockClient = new Mock(); + string? capturedFilter = null; + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())) + .Callback(f => capturedFilter = f) + .ReturnsAsync(new CertificateListResponse { Results = new List() }); + + var data = ValidConnectionData(); + data[Constants.SyncFilterDays] = "10"; + var plugin = MakePlugin(mockClient, connectionData: data); + var buffer = new BlockingCollection(); + + await plugin.Synchronize(buffer, DateTime.UtcNow, false, CancellationToken.None); + + Assert.NotNull(capturedFilter); + Assert.Equal(DateTime.Today.Subtract(TimeSpan.FromDays(10)).ToString("yyyy/MM/dd"), capturedFilter); + } + + [Fact] + public async Task Synchronize_IncrementalSync_DefaultsToFiveDaysWhenUnset() + { + var mockClient = new Mock(); + string? capturedFilter = null; + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())) + .Callback(f => capturedFilter = f) + .ReturnsAsync(new CertificateListResponse { Results = new List() }); + + var plugin = MakePlugin(mockClient); + var buffer = new BlockingCollection(); + + await plugin.Synchronize(buffer, DateTime.UtcNow, false, CancellationToken.None); + + Assert.Equal(DateTime.Today.Subtract(TimeSpan.FromDays(5)).ToString("yyyy/MM/dd"), capturedFilter); + } + + [Fact] + public async Task Synchronize_NullResultsCollection_CompletesWithoutQueueing() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())) + .ReturnsAsync(new CertificateListResponse { Results = null }); + + var plugin = MakePlugin(mockClient); + var buffer = new BlockingCollection(); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + Assert.Empty(buffer.ToList()); + Assert.True(buffer.IsAddingCompleted); + } + + [Fact] + public async Task Synchronize_EmptyCertificateContent_SkipsRecord() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())) + .ReturnsAsync(new CertificateListResponse + { + Results = new List + { + new CertificateResponse { Uuid = "cert-empty", Status = "ACTIVE", Certificate = "" } + } + }); + + var plugin = MakePlugin(mockClient); + var buffer = new BlockingCollection(); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + Assert.Empty(buffer.ToList()); + } + + [Fact] + public async Task Synchronize_UnparsableCertificateContent_SkipsRecord() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())) + .ReturnsAsync(new CertificateListResponse + { + Results = new List + { + new CertificateResponse + { + Uuid = "cert-bad", + Status = "ACTIVE", + Certificate = ToApiBase64("not a valid pem block at all") + } + } + }); + + var plugin = MakePlugin(mockClient); + var buffer = new BlockingCollection(); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + Assert.Empty(buffer.ToList()); + } + + [Fact] + public async Task Synchronize_MissingProductIdFromCsc_LeavesProductIdNull() + { + var (_, pem) = MakeSelfSignedCert(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())) + .ReturnsAsync(new CertificateListResponse + { + Results = new List + { + new CertificateResponse { Uuid = "cert-1", Status = "ACTIVE", CertificateType = null, Certificate = ToApiBase64(pem) } + } + }); + + var plugin = MakePlugin(mockClient); + var buffer = new BlockingCollection(); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var item = Assert.Single(buffer.ToList()); + Assert.Null(item.ProductID); + } + + [Fact] + public async Task Synchronize_CancellationRequested_ThrowsAndCompletesBuffer() + { + var (_, pem) = MakeSelfSignedCert(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())) + .ReturnsAsync(new CertificateListResponse + { + Results = new List + { + new CertificateResponse { Uuid = "cert-1", Status = "ACTIVE", Certificate = ToApiBase64(pem) } + } + }); + + var plugin = MakePlugin(mockClient); + var buffer = new BlockingCollection(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAsync(() => + plugin.Synchronize(buffer, null, true, cts.Token)); + + Assert.True(buffer.IsAddingCompleted); + } + + [Fact] + public async Task Synchronize_ClientThrows_CompletesBufferAndRethrows() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("network down")); + + var plugin = MakePlugin(mockClient); + var buffer = new BlockingCollection(); + + await Assert.ThrowsAsync(() => + plugin.Synchronize(buffer, null, true, CancellationToken.None)); + + Assert.True(buffer.IsAddingCompleted); + } + + // --------------------------------------------------------------------- + // Revoke + // --------------------------------------------------------------------- + + [Fact] + public async Task Revoke_ShortCaRequestId_ThrowsWrappedException() + { + var plugin = MakePlugin(); + await Assert.ThrowsAsync(() => plugin.Revoke("short", "AB12", 0)); + } + + [Fact] + public async Task Revoke_Success_ReturnsRevokedStatus() + { + var uuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitRevokeCertificateAsync(uuid)).ReturnsAsync(new RevokeResponse()); + + var plugin = MakePlugin(mockClient); + var status = await plugin.Revoke(uuid, "AB12", 0); + + Assert.Equal((int)EndEntityStatus.REVOKED, status); + } + + [Fact] + public async Task Revoke_ErrorWithDescription_ThrowsWrappedException() + { + var uuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitRevokeCertificateAsync(uuid)).ReturnsAsync(new RevokeResponse + { + RegistrationError = new RegistrationError { Description = "already revoked" } + }); + + var plugin = MakePlugin(mockClient); + await Assert.ThrowsAsync(() => plugin.Revoke(uuid, "AB12", 0)); + } + + [Fact] + public async Task Revoke_FailedWithNoErrorDescription_ReturnsFailedStatus() + { + var uuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitRevokeCertificateAsync(uuid)).ReturnsAsync((RevokeResponse)null!); + + var plugin = MakePlugin(mockClient); + var status = await plugin.Revoke(uuid, "AB12", 0); + + Assert.Equal((int)EndEntityStatus.FAILED, status); + } + + // --------------------------------------------------------------------- + // Enroll + // --------------------------------------------------------------------- + + [Fact] + public async Task Enroll_NullProductInfo_ThrowsArgumentNullException() + { + var plugin = MakePlugin(); + await Assert.ThrowsAsync(() => + plugin.Enroll("csr", "subject", new Dictionary(), null!, RequestFormat.PKCS10, EnrollmentType.New)); + } + + [Fact] + public async Task Enroll_New_Success_ReturnsExternalValidation() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse + { + Result = new Result { CommonName = "order-1", Status = new Status { Uuid = "uuid-1" } } + }); + + var plugin = MakePlugin(mockClient); + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result!.Status); + Assert.Equal("uuid-1", result.CARequestID); + } + + [Fact] + public async Task Enroll_New_CscReturnsError_ReturnsFailedStatus() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse + { + RegistrationError = new RegistrationError { Description = "duplicate order" } + }); + + var plugin = MakePlugin(mockClient); + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), + RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.FAILED, result!.Status); + Assert.Equal("duplicate order", result.StatusMessage); + } + + [Fact] + public async Task Enroll_NewWithPriorCertSn_ReturnsFailureWithoutCallingClient() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + + var plugin = MakePlugin(mockClient); + var productInfo = ProductInfo(parameters: new Dictionary { ["PriorCertSN"] = "ABC123" }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.INPROCESS, result!.Status); + mockClient.Verify(c => c.SubmitRegistrationAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Enroll_RenewOrReissue_MissingPriorCertSn_ReturnsFailure() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + + var plugin = MakePlugin(mockClient); + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.Equal((int)EndEntityStatus.INPROCESS, result!.Status); + Assert.Contains("no prior certificate serial number", result.StatusMessage); + } + + [Fact] + public async Task Enroll_RenewOrReissue_NoRequestIdFoundForSerial_ReturnsFailure() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(string.Empty); + + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary { ["PriorCertSN"] = "ABC123" }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.Equal((int)EndEntityStatus.INPROCESS, result!.Status); + Assert.Contains("no prior request found", result.StatusMessage); + } + + [Fact] + public async Task Enroll_RenewOrReissue_NullExpirationDate_FallsBackToGetSingleRecordThenReissues() + { + var orderUuid = Guid.NewGuid().ToString(); + var (_, pem) = MakeSelfSignedCert(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + Certificate = Convert.ToBase64String(Encoding.ASCII.GetBytes(pem)), + Status = "ACTIVE" + }); + mockClient.Setup(c => c.SubmitReissueAsync(It.IsAny())).ReturnsAsync(new ReissueResponse + { + Result = new Result { CommonName = "reissued.example.com" } + }); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); + certDataReader.Setup(r => r.GetExpirationDateByRequestId(orderUuid)).Returns((DateTime?)null); + + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary + { + ["PriorCertSN"] = "ABC123", + ["Applicant Last Name"] = "Doe" + }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.Equal((int)EndEntityStatus.GENERATED, result!.Status); + mockClient.Verify(c => c.SubmitReissueAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Enroll_Renewal_ExpiredCertWithApplicantLastName_Succeeds() + { + var uuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitRenewalAsync(It.IsAny())).ReturnsAsync(new RenewalResponse + { + Result = new Result { CommonName = "renewed.example.com" } + }); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(uuid); + certDataReader.Setup(r => r.GetExpirationDateByRequestId(uuid)).Returns(DateTime.Now.AddDays(-1)); + + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary + { + ["PriorCertSN"] = "ABC123", + ["Applicant Last Name"] = "Doe" + }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.Equal((int)EndEntityStatus.GENERATED, result!.Status); + } + + [Fact] + public async Task Enroll_Renewal_ExpiredCertMissingApplicantLastName_ReturnsFailure() + { + var uuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(uuid); + certDataReader.Setup(r => r.GetExpirationDateByRequestId(uuid)).Returns(DateTime.Now.AddDays(-1)); + + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary { ["PriorCertSN"] = "ABC123" }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.Equal((int)EndEntityStatus.INPROCESS, result!.Status); + Assert.Contains("One click Renew Is Not Available", result.StatusMessage); + } + + [Fact] + public async Task Enroll_Reissue_ValidCertWithApplicantLastName_Succeeds() + { + var uuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitReissueAsync(It.IsAny())).ReturnsAsync(new ReissueResponse + { + Result = new Result { CommonName = "reissued.example.com", Status = new Status { Uuid = uuid } } + }); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(uuid); + certDataReader.Setup(r => r.GetExpirationDateByRequestId(uuid)).Returns(DateTime.Now.AddDays(30)); + + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary + { + ["PriorCertSN"] = "ABC123", + ["Applicant Last Name"] = "Doe" + }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.Equal((int)EndEntityStatus.GENERATED, result!.Status); + } + + [Fact] + public async Task Enroll_Reissue_MissingApplicantLastName_ReturnsFailure() + { + var uuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(uuid); + certDataReader.Setup(r => r.GetExpirationDateByRequestId(uuid)).Returns(DateTime.Now.AddDays(30)); + + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary { ["PriorCertSN"] = "ABC123" }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.Equal((int)EndEntityStatus.INPROCESS, result!.Status); + Assert.Contains("One click Renew Is Not Available", result.StatusMessage); + } + + [Fact] + public async Task Enroll_Reissue_RequestIdTooShort_ReturnsFailure() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync("short-id"); + certDataReader.Setup(r => r.GetExpirationDateByRequestId("short-id")).Returns(DateTime.Now.AddDays(30)); + + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary + { + ["PriorCertSN"] = "ABC123", + ["Applicant Last Name"] = "Doe" + }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.Equal((int)EndEntityStatus.INPROCESS, result!.Status); + Assert.Contains("no prior request found", result.StatusMessage); + } + + [Fact] + public async Task Enroll_UnhandledEnrollmentType_ReturnsNull() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + + var plugin = MakePlugin(mockClient); + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), + RequestFormat.PKCS10, EnrollmentType.Renew); + + Assert.Null(result); + } + + [Fact] + public async Task Enroll_ClientThrows_Rethrows() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ThrowsAsync(new InvalidOperationException("boom")); + + var plugin = MakePlugin(mockClient); + await Assert.ThrowsAsync(() => + plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), RequestFormat.PKCS10, EnrollmentType.New)); + } + + // --------------------------------------------------------------------- + // Ping / ValidateCAConnectionInfo / ValidateProductInfo + // --------------------------------------------------------------------- + + [Fact] + public async Task Ping_DoesNotThrow() + { + var plugin = MakePlugin(); + await plugin.Ping(); + } + + [Fact] + public async Task ValidateCAConnectionInfo_NullConnectionInfo_DoesNotThrow() + { + var plugin = MakePlugin(); + await plugin.ValidateCAConnectionInfo(null!); + } + + [Fact] + public async Task ValidateCAConnectionInfo_WithConnectionInfo_DoesNotThrow() + { + var plugin = MakePlugin(); + await plugin.ValidateCAConnectionInfo(new Dictionary { ["Key"] = "Value" }); + } + + [Fact] + public async Task ValidateProductInfo_KnownProduct_DoesNotThrow() + { + var plugin = MakePlugin(); + await plugin.ValidateProductInfo(ProductInfo("CSC TrustedSecure OV"), new Dictionary()); + } + + [Fact] + public async Task ValidateProductInfo_KnownProductDifferentCase_DoesNotThrow() + { + var plugin = MakePlugin(); + await plugin.ValidateProductInfo(ProductInfo("csc trustedsecure ov"), new Dictionary()); + } + + [Fact] + public async Task ValidateProductInfo_UnknownProduct_Throws() + { + var plugin = MakePlugin(); + await Assert.ThrowsAsync(() => + plugin.ValidateProductInfo(ProductInfo("Not A Real Product"), new Dictionary())); + } + + // --------------------------------------------------------------------- + // GetCAConnectorAnnotations / GetTemplateParameterAnnotations / GetProductIds + // --------------------------------------------------------------------- + + [Fact] + public void GetCAConnectorAnnotations_ContainsExpectedKeys() + { + var plugin = MakePlugin(); + var annotations = plugin.GetCAConnectorAnnotations(); + + Assert.Equal(5, annotations.Count); + 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.SyncFilterDays, annotations.Keys); + Assert.True(annotations[Constants.CscGlobalApiKey].Hidden); + } + + [Fact] + public void GetTemplateParameterAnnotations_ContainsExpectedKeys() + { + var plugin = MakePlugin(); + var annotations = plugin.GetTemplateParameterAnnotations(); + + Assert.Equal(12, annotations.Count); + Assert.Contains(EnrollmentConfigConstants.Term, annotations.Keys); + 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 + // --------------------------------------------------------------------- + + [Fact] + public void GetEndEntityCertificate_EmptyInput_ReturnsEmptyString() + { + var plugin = MakePlugin(); + Assert.Equal(string.Empty, plugin.GetEndEntityCertificate("")); + Assert.Equal(string.Empty, plugin.GetEndEntityCertificate(" ")); + Assert.Equal(string.Empty, plugin.GetEndEntityCertificate(null!)); + } + + [Fact] + public void GetEndEntityCertificate_NoValidPemBlocks_ReturnsEmptyString() + { + var plugin = MakePlugin(); + Assert.Equal(string.Empty, plugin.GetEndEntityCertificate("this is not a certificate")); + } + + [Fact] + public void GetEndEntityCertificate_SingleLeafCert_ReturnsBase64Der() + { + var (cert, pem) = MakeSelfSignedCert(); + var plugin = MakePlugin(); + + var result = plugin.GetEndEntityCertificate(pem); + + Assert.Equal(Convert.ToBase64String(cert.RawData), result); + } + + [Fact] + public void GetEndEntityCertificate_MalformedBase64Block_SkipsBlockReturnsEmpty() + { + var pem = "-----BEGIN CERTIFICATE-----\nNOT-VALID-BASE64!!!\n-----END CERTIFICATE-----\n"; + var plugin = MakePlugin(); + + Assert.Equal(string.Empty, plugin.GetEndEntityCertificate(pem)); + } + + [Fact] + public void GetEndEntityCertificate_EmptyBlockContent_Skipped() + { + var pem = "-----BEGIN CERTIFICATE-----\n\n-----END CERTIFICATE-----\n"; + var plugin = MakePlugin(); + + Assert.Equal(string.Empty, plugin.GetEndEntityCertificate(pem)); + } + + [Fact] + public void GetEndEntityCertificate_ValidBase64ButNotACertificate_SkipsBlockReturnsEmpty() + { + var notACert = Convert.ToBase64String(Encoding.UTF8.GetBytes("this is not a certificate, just text")); + var pem = $"-----BEGIN CERTIFICATE-----\n{notACert}\n-----END CERTIFICATE-----\n"; + var plugin = MakePlugin(); + + Assert.Equal(string.Empty, plugin.GetEndEntityCertificate(pem)); + } + + [Fact] + public void GetEndEntityCertificate_TwoIndependentLeafCerts_ReturnsOneOfThem() + { + var (certA, pemA) = MakeSelfSignedCert("a.example.com"); + var (certB, pemB) = MakeSelfSignedCert("b.example.com"); + var plugin = MakePlugin(); + + var result = plugin.GetEndEntityCertificate(pemA + pemB); + + Assert.True(result == Convert.ToBase64String(certA.RawData) || result == Convert.ToBase64String(certB.RawData)); + } + + [Fact] + public void GetEndEntityCertificate_LeafAndCaChain_ReturnsLeafOnly() + { + using var rsaCa = RSA.Create(2048); + var caReq = new CertificateRequest("CN=Test CA", rsaCa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + caReq.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + var caCert = caReq.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(365)); + + using var rsaLeaf = RSA.Create(2048); + var leafReq = new CertificateRequest("CN=leaf.example.com", rsaLeaf, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + leafReq.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); + var leafCert = leafReq.Create(caCert, DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(365), + Guid.NewGuid().ToByteArray()); + + string ToPemBlock(X509Certificate2 c) => "-----BEGIN CERTIFICATE-----\n" + + Convert.ToBase64String(c.RawData, Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE-----\n"; + + var chainPem = ToPemBlock(caCert) + ToPemBlock(leafCert); + var plugin = MakePlugin(); + + var result = plugin.GetEndEntityCertificate(chainPem); + + Assert.Equal(Convert.ToBase64String(leafCert.RawData), result); + } +} diff --git a/cscglobal-caplugin.Tests/CscGlobalClientTests.cs b/cscglobal-caplugin.Tests/CscGlobalClientTests.cs new file mode 100644 index 0000000..2879dbe --- /dev/null +++ b/cscglobal-caplugin.Tests/CscGlobalClientTests.cs @@ -0,0 +1,385 @@ +// 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.Net; +using System.Net.Http; +using System.Text; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CSCGlobal; +using Keyfactor.Extensions.CAPlugin.CSCGlobal.Client; +using Keyfactor.Extensions.CAPlugin.CSCGlobal.Client.Models; +using Xunit; + +namespace CscGlobalCAPluginTests; + +public class CscGlobalClientTests +{ + private sealed class FakeConfigProvider : IAnyCAPluginConfigProvider + { + public Dictionary CAConnectionData { get; set; } = new(); + } + + private sealed class FakeHttpMessageHandler : HttpMessageHandler + { + private readonly Func _responder; + + public FakeHttpMessageHandler(Func responder) + { + _responder = responder; + } + + public HttpRequestMessage? LastRequest { get; private set; } + public string? LastRequestBody { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + LastRequestBody = request.Content?.ReadAsStringAsync(cancellationToken).GetAwaiter().GetResult(); + return Task.FromResult(_responder(request)); + } + } + + private static HttpResponseMessage JsonResponse(HttpStatusCode status, string json) => + new HttpResponseMessage(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") }; + + private static IAnyCAPluginConfigProvider ValidConfig() => new FakeConfigProvider + { + CAConnectionData = new Dictionary + { + [Constants.CscGlobalUrl] = "https://api.csc.test", + [Constants.CscGlobalApiKey] = "test-api-key", + [Constants.BearerToken] = "test-bearer-token" + } + }; + + private static CscGlobalClient MakeClient(Func responder, + out FakeHttpMessageHandler handler, IAnyCAPluginConfigProvider? config = null) + { + handler = new FakeHttpMessageHandler(responder); + return new CscGlobalClient(config ?? ValidConfig(), handler); + } + + // --------------------------------------------------------------------- + // Constructor validation + // --------------------------------------------------------------------- + + [Fact] + public void Constructor_NullConfig_Throws() + { + Assert.Throws(() => new CscGlobalClient(null!)); + } + + [Fact] + public async Task Constructor_NullConnectionData_DoesNotThrowButClientIsInert() + { + var config = new FakeConfigProvider { CAConnectionData = null! }; + var client = new CscGlobalClient(config); + + await Assert.ThrowsAsync(() => client.SubmitGetCustomFields()); + } + + [Fact] + public async Task Constructor_MissingApiKeyEntry_DoesNotThrowButClientIsInert() + { + var config = new FakeConfigProvider { CAConnectionData = new Dictionary() }; + var client = new CscGlobalClient(config); + + await Assert.ThrowsAsync(() => client.SubmitGetCustomFields()); + } + + // --------------------------------------------------------------------- + // SubmitRegistrationAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task SubmitRegistrationAsync_Success_ReturnsParsedResponse() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, + "{\"result\":{\"commonName\":\"order-1\",\"price\":{\"currency\":\"USD\",\"total\":99.5}," + + "\"dcvDetails\":[{\"domainName\":\"example.com\",\"actionNeeded\":\"N\"}]}}"), out var handler); + + var response = await client.SubmitRegistrationAsync(new RegistrationRequest()); + + Assert.NotNull(response.Result); + Assert.Equal("order-1", response.Result.CommonName); + Assert.Equal("USD", response.Result.Price.Currency); + Assert.Equal(99.5m, response.Result.Price.Total); + Assert.Equal("example.com", response.Result.DcvDetails[0].DomainName); + Assert.Equal("N", response.Result.DcvDetails[0].ActionNeeded); + Assert.Contains("/dbs/api/v2/tls/registration", handler.LastRequest!.RequestUri!.ToString()); + } + + [Fact] + public async Task SubmitRegistrationAsync_BadRequest_ReturnsRegistrationError() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.BadRequest, + "{\"description\":\"invalid csr\"}"), out _); + + var response = await client.SubmitRegistrationAsync(new RegistrationRequest()); + + Assert.NotNull(response.RegistrationError); + Assert.Equal("invalid csr", response.RegistrationError.Description); + Assert.Null(response.Result); + } + + [Fact] + public async Task SubmitRegistrationAsync_OtherError_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.InternalServerError, "boom"), out _); + + await Assert.ThrowsAsync(() => client.SubmitRegistrationAsync(new RegistrationRequest())); + } + + [Fact] + public async Task SubmitRegistrationAsync_UnparsableSuccessBody_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "null"), out _); + + await Assert.ThrowsAsync(() => client.SubmitRegistrationAsync(new RegistrationRequest())); + } + + // --------------------------------------------------------------------- + // SubmitRenewalAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task SubmitRenewalAsync_Success_ReturnsParsedResponse() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, + "{\"result\":{\"commonName\":\"renew-1\"}}"), out var handler); + + var response = await client.SubmitRenewalAsync(new RenewalRequest()); + + Assert.Equal("renew-1", response.Result.CommonName); + Assert.Contains("/dbs/api/v2/tls/renewal", handler.LastRequest!.RequestUri!.ToString()); + } + + [Fact] + public async Task SubmitRenewalAsync_BadRequest_ReturnsRegistrationError() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.BadRequest, "{\"description\":\"nope\"}"), out _); + + var response = await client.SubmitRenewalAsync(new RenewalRequest()); + + Assert.Equal("nope", response.RegistrationError.Description); + } + + [Fact] + public async Task SubmitRenewalAsync_OtherError_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.InternalServerError, "boom"), out _); + + await Assert.ThrowsAsync(() => client.SubmitRenewalAsync(new RenewalRequest())); + } + + [Fact] + public async Task SubmitRenewalAsync_UnparsableSuccessBody_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "null"), out _); + + await Assert.ThrowsAsync(() => client.SubmitRenewalAsync(new RenewalRequest())); + } + + // --------------------------------------------------------------------- + // SubmitReissueAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task SubmitReissueAsync_Success_ReturnsParsedResponse() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, + "{\"result\":{\"commonName\":\"reissue-1\"}}"), out var handler); + + var response = await client.SubmitReissueAsync(new ReissueRequest()); + + Assert.Equal("reissue-1", response.Result.CommonName); + Assert.Contains("/dbs/api/v2/tls/reissue", handler.LastRequest!.RequestUri!.ToString()); + } + + [Fact] + public async Task SubmitReissueAsync_BadRequest_ReturnsRegistrationError() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.BadRequest, "{\"description\":\"denied\"}"), out _); + + var response = await client.SubmitReissueAsync(new ReissueRequest()); + + Assert.Equal("denied", response.RegistrationError.Description); + } + + [Fact] + public async Task SubmitReissueAsync_OtherError_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.InternalServerError, "boom"), out _); + + await Assert.ThrowsAsync(() => client.SubmitReissueAsync(new ReissueRequest())); + } + + [Fact] + public async Task SubmitReissueAsync_UnparsableSuccessBody_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "null"), out _); + + await Assert.ThrowsAsync(() => client.SubmitReissueAsync(new ReissueRequest())); + } + + // --------------------------------------------------------------------- + // SubmitGetCertificateAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task SubmitGetCertificateAsync_Success_ReturnsParsedResponse() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{\"certificate\":\"abc123\"}"), out var handler); + + var response = await client.SubmitGetCertificateAsync("cert-uuid"); + + Assert.Equal("abc123", response.Certificate); + Assert.Contains("/dbs/api/v2/tls/certificate/cert-uuid", handler.LastRequest!.RequestUri!.ToString()); + } + + [Fact] + public async Task SubmitGetCertificateAsync_Failure_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.NotFound, "not found"), out _); + + await Assert.ThrowsAsync(() => client.SubmitGetCertificateAsync("missing-uuid")); + } + + [Fact] + public async Task SubmitGetCertificateAsync_UnparsableSuccessBody_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "null"), out _); + + await Assert.ThrowsAsync(() => client.SubmitGetCertificateAsync("cert-uuid")); + } + + // --------------------------------------------------------------------- + // SubmitGetCustomFields + // --------------------------------------------------------------------- + + [Fact] + public async Task SubmitGetCustomFields_Success_ReturnsList() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, + "{\"customFields\":[{\"label\":\"Field1\",\"mandatory\":true}]}"), out var handler); + + var fields = await client.SubmitGetCustomFields(); + + Assert.Single(fields); + Assert.Equal("Field1", fields[0].Label); + Assert.Contains("/dbs/api/v2/admin/customfields", handler.LastRequest!.RequestUri!.ToString()); + } + + [Fact] + public async Task SubmitGetCustomFields_NullCustomFieldsArray_ReturnsEmptyList() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{}"), out _); + + var fields = await client.SubmitGetCustomFields(); + + Assert.Empty(fields); + } + + [Fact] + public async Task SubmitGetCustomFields_Failure_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.InternalServerError, "boom"), out _); + + await Assert.ThrowsAsync(() => client.SubmitGetCustomFields()); + } + + [Fact] + public async Task SubmitGetCustomFields_UnparsableBody_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "null"), out _); + + await Assert.ThrowsAsync(() => client.SubmitGetCustomFields()); + } + + // --------------------------------------------------------------------- + // SubmitRevokeCertificateAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task SubmitRevokeCertificateAsync_Success_ReturnsParsedResponse() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, + "{\"revokeSuccess\":{\"commonName\":\"revoked.example.com\",\"certificateType\":\"4\",\"status\":\"REVOKED\"}}"), out var handler); + + var response = await client.SubmitRevokeCertificateAsync("revoke-uuid"); + + Assert.Equal("revoked.example.com", response.RevokeSuccess.CommonName); + Assert.Equal("4", response.RevokeSuccess.CertificateType); + Assert.Equal("REVOKED", response.RevokeSuccess.Status); + Assert.Contains("/dbs/api/v2/tls/revoke/revoke-uuid", handler.LastRequest!.RequestUri!.ToString()); + } + + [Fact] + public async Task SubmitRevokeCertificateAsync_BadRequest_ReturnsRegistrationError() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.BadRequest, "{\"description\":\"already revoked\"}"), out _); + + var response = await client.SubmitRevokeCertificateAsync("revoke-uuid"); + + Assert.Equal("already revoked", response.RegistrationError.Description); + } + + [Fact] + public async Task SubmitRevokeCertificateAsync_OtherError_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.InternalServerError, "boom"), out _); + + await Assert.ThrowsAsync(() => client.SubmitRevokeCertificateAsync("revoke-uuid")); + } + + [Fact] + public async Task SubmitRevokeCertificateAsync_UnparsableSuccessBody_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "null"), out _); + + await Assert.ThrowsAsync(() => client.SubmitRevokeCertificateAsync("revoke-uuid")); + } + + // --------------------------------------------------------------------- + // SubmitCertificateListRequestAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task SubmitCertificateListRequestAsync_NoDateFilter_Success() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, + "{\"meta\":{\"numResults\":1},\"results\":[{\"uuid\":\"cert-1\"}]}"), out var handler); + + var response = await client.SubmitCertificateListRequestAsync(); + + Assert.Single(response.Results); + Assert.Equal(1, response.Meta.NumResults); + Assert.DoesNotContain("effectiveDate", handler.LastRequest!.RequestUri!.ToString()); + } + + [Fact] + public async Task SubmitCertificateListRequestAsync_WithDateFilter_IncludesFilterInQuery() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{\"results\":[]}"), out var handler); + + await client.SubmitCertificateListRequestAsync("2026/01/01"); + + Assert.Contains("effectiveDate=ge=2026/01/01", handler.LastRequest!.RequestUri!.ToString()); + } + + [Fact] + public async Task SubmitCertificateListRequestAsync_Failure_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.InternalServerError, "boom"), out _); + + await Assert.ThrowsAsync(() => client.SubmitCertificateListRequestAsync()); + } + + [Fact] + public async Task SubmitCertificateListRequestAsync_UnparsableBody_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "null"), out _); + + await Assert.ThrowsAsync(() => client.SubmitCertificateListRequestAsync()); + } +} diff --git a/cscglobal-caplugin.Tests/FlowLoggerTests.cs b/cscglobal-caplugin.Tests/FlowLoggerTests.cs new file mode 100644 index 0000000..15b7f5e --- /dev/null +++ b/cscglobal-caplugin.Tests/FlowLoggerTests.cs @@ -0,0 +1,194 @@ +// 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 Keyfactor.Extensions.CAPlugin.CSCGlobal; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace CscGlobalCAPluginTests; + +public class FlowLoggerTests +{ + private static Mock NewLoggerMock() + { + var mock = new Mock(); + mock.Setup(l => l.IsEnabled(It.IsAny())).Returns(true); + return mock; + } + + [Fact] + public void Constructor_NullLogger_Throws() + { + Assert.Throws(() => new FlowLogger(null!, "Flow")); + } + + [Fact] + public void Constructor_NullFlowName_Throws() + { + Assert.Throws(() => new FlowLogger(NewLoggerMock().Object, null!)); + } + + [Fact] + public void Step_NoDetail_DoesNotThrowAndMarksNoFailure() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Step("StepOne"); + Assert.False(flow.HasFailures); + } + + [Fact] + public void Step_WithDetail_DoesNotThrow() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Step("StepOne", "some detail"); + Assert.False(flow.HasFailures); + } + + [Fact] + public void Step_Action_Success_RecordsSuccess() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + var ran = false; + flow.Step("Action", () => ran = true); + Assert.True(ran); + Assert.False(flow.HasFailures); + } + + [Fact] + public void Step_Action_Throws_RecordsFailureAndRethrows() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + Assert.Throws(() => + flow.Step("Action", () => throw new InvalidOperationException("boom"))); + Assert.True(flow.HasFailures); + } + + [Fact] + public void Step_ActionWithDetail_Success() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Step("Action", () => { }, "detail"); + Assert.False(flow.HasFailures); + } + + [Fact] + public async Task StepAsync_NoReturnValue_Success() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + await flow.StepAsync("AsyncStep", () => Task.CompletedTask); + Assert.False(flow.HasFailures); + } + + [Fact] + public async Task StepAsync_NoReturnValue_Throws_RecordsFailureAndRethrows() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + await Assert.ThrowsAsync(() => + flow.StepAsync("AsyncStep", () => throw new InvalidOperationException("boom"))); + Assert.True(flow.HasFailures); + } + + [Fact] + public async Task StepAsync_WithReturnValue_Success_ReturnsResult() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + var result = await flow.StepAsync("AsyncStep", () => Task.FromResult(42)); + Assert.Equal(42, result); + Assert.False(flow.HasFailures); + } + + [Fact] + public async Task StepAsync_WithReturnValue_Throws_RecordsFailureAndRethrows() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + await Assert.ThrowsAsync(() => + flow.StepAsync("AsyncStep", () => throw new InvalidOperationException("boom"))); + Assert.True(flow.HasFailures); + } + + [Fact] + public void StepFunc_Success_ReturnsResult() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + var result = flow.Step("Func", () => 99); + Assert.Equal(99, result); + Assert.False(flow.HasFailures); + } + + [Fact] + public void StepFunc_Throws_RecordsFailureAndRethrows() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + Assert.Throws(() => + flow.Step("Func", () => throw new InvalidOperationException("boom"))); + Assert.True(flow.HasFailures); + } + + [Fact] + public void Fail_RecordsFailure() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Fail("StepOne", "reason"); + Assert.True(flow.HasFailures); + } + + [Fact] + public void Skip_DoesNotRecordFailure() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Skip("StepOne", "not applicable"); + Assert.False(flow.HasFailures); + } + + [Fact] + public void Branch_EndBranch_RoundTrips() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Branch("Inner"); + flow.Step("NestedStep"); + flow.EndBranch(); + Assert.False(flow.HasFailures); + } + + [Fact] + public void EndBranch_WithoutBranch_DoesNotThrow() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.EndBranch(); + } + + [Fact] + public void GetSummary_IncludesAllStepKindsAndCounts() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Step("Ok"); + flow.Skip("Skipped", "n/a"); + flow.Fail("Failed", "bad"); + + var summary = flow.GetSummary(); + + Assert.Contains("FAILED", summary); + Assert.Contains("Steps: 3 total, 1 ok, 1 failed, 1 skipped", summary); + Assert.Contains("[OK]", summary); + Assert.Contains("[FAIL]", summary); + Assert.Contains("[SKIP]", summary); + } + + [Fact] + public void GetSummary_NoFailures_ReportsOk() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Step("Ok"); + Assert.Contains("[OK]", flow.GetSummary()); + Assert.DoesNotContain("FAILED", flow.GetSummary()); + } + + [Fact] + public void Dispose_DoesNotThrow() + { + var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Step("Ok"); + flow.Dispose(); + } +} diff --git a/cscglobal-caplugin.Tests/RequestManagerTests.cs b/cscglobal-caplugin.Tests/RequestManagerTests.cs new file mode 100644 index 0000000..fa1437b --- /dev/null +++ b/cscglobal-caplugin.Tests/RequestManagerTests.cs @@ -0,0 +1,547 @@ +// 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 Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CSCGlobal; +using Keyfactor.Extensions.CAPlugin.CSCGlobal.Client.Models; +using Keyfactor.PKI.Enums.EJBCA; +using Xunit; + +namespace CscGlobalCAPluginTests; + +public class RequestManagerTests +{ + private const string SampleCsr = "sample-csr-body"; + + private static EnrollmentProductInfo ProductInfo(string productId, Dictionary? parameters = null) => + new EnrollmentProductInfo + { + ProductID = productId, + ProductParameters = parameters ?? new Dictionary() + }; + + private static RequestManager Manager => new RequestManager(); + + // --------------------------------------------------------------------- + // GetRenewResponse + // --------------------------------------------------------------------- + + [Fact] + public void GetRenewResponse_NullResponse_ReturnsFailed() + { + var result = Manager.GetRenewResponse(null); + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + Assert.Contains("no response", result.StatusMessage); + } + + [Fact] + public void GetRenewResponse_RegistrationError_ReturnsFailedWithDescription() + { + var response = new RenewalResponse + { + RegistrationError = new RegistrationError { Description = "boom" }, + Result = new Result { Status = new Status { Uuid = "abc-123" } } + }; + + var result = Manager.GetRenewResponse(response); + + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + Assert.Equal("boom", result.StatusMessage); + Assert.Equal("abc-123", result.CARequestID); + } + + [Fact] + public void GetRenewResponse_NullResult_ReturnsFailed() + { + var response = new RenewalResponse { Result = null }; + var result = Manager.GetRenewResponse(response); + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + Assert.Contains("no result", result.StatusMessage); + } + + [Fact] + public void GetRenewResponse_Success_ReturnsGenerated() + { + var response = new RenewalResponse { Result = new Result { CommonName = "renewed.example.com" } }; + var result = Manager.GetRenewResponse(response); + Assert.Equal((int)EndEntityStatus.GENERATED, result.Status); + Assert.Contains("renewed.example.com", result.StatusMessage); + } + + // --------------------------------------------------------------------- + // GetEnrollmentResult + // --------------------------------------------------------------------- + + [Fact] + public void GetEnrollmentResult_NullResponse_ReturnsFailed() + { + var result = Manager.GetEnrollmentResult(null); + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + } + + [Fact] + public void GetEnrollmentResult_RegistrationError_ReturnsFailed() + { + var response = new RegistrationResponse { RegistrationError = new RegistrationError { Description = "bad request" } }; + var result = Manager.GetEnrollmentResult(response); + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + Assert.Equal("bad request", result.StatusMessage); + } + + [Fact] + public void GetEnrollmentResult_NullResult_ReturnsFailed() + { + var response = new RegistrationResponse { Result = null }; + var result = Manager.GetEnrollmentResult(response); + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + } + + [Fact] + public void GetEnrollmentResult_SuccessNoDcvDetails_ReturnsExternalValidationWithNullContext() + { + var response = new RegistrationResponse + { + Result = new Result { CommonName = "order-1", Status = new Status { Uuid = "uuid-1" } } + }; + + var result = Manager.GetEnrollmentResult(response); + + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Equal("uuid-1", result.CARequestID); + Assert.Null(result.EnrollmentContext); + } + + [Fact] + public void GetEnrollmentResult_WithCNameAndEmailDcvDetails_PopulatesEnrollmentContext() + { + var response = new RegistrationResponse + { + Result = new Result + { + CommonName = "order-2", + Status = new Status { Uuid = "uuid-2" }, + DcvDetails = new List + { + new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "token" } }, + new DcvDetail { Email = "admin@example.com" }, + // Duplicate email key should not throw and should not be added twice. + new DcvDetail { Email = "admin@example.com" }, + // Entry with neither CName nor Email contributes nothing. + new DcvDetail() + } + } + }; + + var result = Manager.GetEnrollmentResult(response); + + Assert.NotNull(result.EnrollmentContext); + Assert.Equal("token", result.EnrollmentContext["_dnsauth.example.com"]); + Assert.Equal("admin@example.com", result.EnrollmentContext["admin@example.com"]); + Assert.Equal(2, result.EnrollmentContext.Count); + } + + // --------------------------------------------------------------------- + // GetRevokeResult + // --------------------------------------------------------------------- + + [Fact] + public void GetRevokeResult_NullResponse_ReturnsFailed() + { + Assert.Equal((int)EndEntityStatus.FAILED, Manager.GetRevokeResult(null)); + } + + [Fact] + public void GetRevokeResult_RegistrationError_ReturnsFailed() + { + var response = new RevokeResponse { RegistrationError = new RegistrationError { Description = "nope" } }; + Assert.Equal((int)EndEntityStatus.FAILED, Manager.GetRevokeResult(response)); + } + + [Fact] + public void GetRevokeResult_Success_ReturnsRevoked() + { + var response = new RevokeResponse(); + Assert.Equal((int)EndEntityStatus.REVOKED, Manager.GetRevokeResult(response)); + } + + // --------------------------------------------------------------------- + // GetReIssueResult + // --------------------------------------------------------------------- + + [Fact] + public void GetReIssueResult_NullResponse_ReturnsFailed() + { + var result = Manager.GetReIssueResult(null); + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + } + + [Fact] + public void GetReIssueResult_RegistrationError_ReturnsFailed() + { + var response = new ReissueResponse { RegistrationError = new RegistrationError { Description = "rejected" } }; + var result = Manager.GetReIssueResult(response); + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + Assert.Equal("rejected", result.StatusMessage); + } + + [Fact] + public void GetReIssueResult_NullResult_ReturnsFailed() + { + var response = new ReissueResponse { Result = null }; + var result = Manager.GetReIssueResult(response); + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + } + + [Fact] + public void GetReIssueResult_Success_ReturnsGenerated() + { + var response = new ReissueResponse + { + Result = new Result { CommonName = "reissued.example.com", Status = new Status { Uuid = "uuid-3" } } + }; + var result = Manager.GetReIssueResult(response); + Assert.Equal((int)EndEntityStatus.GENERATED, result.Status); + Assert.Equal("uuid-3", result.CARequestID); + } + + // --------------------------------------------------------------------- + // GetDomainControlValidation (email-list overload) + // --------------------------------------------------------------------- + + [Fact] + public void GetDomainControlValidation_EmptyDomainName_ReturnsNull() + { + var result = Manager.GetDomainControlValidation("EMAIL", new[] { "admin@example.com" }, ""); + Assert.Null(result); + } + + [Fact] + public void GetDomainControlValidation_NullEmailArray_ReturnsNull() + { + var result = Manager.GetDomainControlValidation("EMAIL", null!, "example.com"); + Assert.Null(result); + } + + [Fact] + public void GetDomainControlValidation_MalformedEmailSkipped_NoMatchReturnsNull() + { + var result = Manager.GetDomainControlValidation("EMAIL", new[] { "not-an-email", " " }, "example.com"); + Assert.Null(result); + } + + [Fact] + public void GetDomainControlValidation_MatchingHostFound_ReturnsValidation() + { + var result = Manager.GetDomainControlValidation("EMAIL", new[] { "not-an-email", "admin@example.com" }, "www.example.com"); + Assert.NotNull(result); + Assert.Equal("EMAIL", result.MethodType); + Assert.Contains("admin@example.com", result.EmailAddress); + } + + [Fact] + public void GetDomainControlValidation_NoMatchingHost_ReturnsNull() + { + var result = Manager.GetDomainControlValidation("EMAIL", new[] { "admin@other.com" }, "www.example.com"); + Assert.Null(result); + } + + // --------------------------------------------------------------------- + // GetDomainControlValidation (single-email overload) + // --------------------------------------------------------------------- + + [Fact] + public void GetDomainControlValidation_SingleEmail_ReturnsValidationVerbatim() + { + var result = Manager.GetDomainControlValidation("CNAME", "admin@example.com"); + Assert.Equal("CNAME", result.MethodType); + Assert.Equal("admin@example.com", result.EmailAddress); + } + + // --------------------------------------------------------------------- + // MapReturnStatus + // --------------------------------------------------------------------- + + public static IEnumerable MapReturnStatusCases() + { + yield return new object?[] { "ACTIVE", (int)EndEntityStatus.GENERATED }; + yield return new object?[] { "Initial", (int)EndEntityStatus.INITIALIZED }; + yield return new object?[] { "Pending", (int)EndEntityStatus.INPROCESS }; + yield return new object?[] { "REVOKED", (int)EndEntityStatus.REVOKED }; + yield return new object?[] { "SOMETHING_ELSE", (int)EndEntityStatus.FAILED }; + yield return new object?[] { null, (int)EndEntityStatus.FAILED }; + } + + [Theory] + [MemberData(nameof(MapReturnStatusCases))] + public void MapReturnStatus_MapsExpectedStatus(string? cscStatus, int expected) + { + Assert.Equal(expected, Manager.MapReturnStatus(cscStatus!)); + } + + // --------------------------------------------------------------------- + // GetRegistrationRequest - certificate type routing + // --------------------------------------------------------------------- + + [Theory] + [InlineData("CSC TrustedSecure OV", "0", false, false)] + [InlineData("CSC TrustedSecure OV Wildcard", "1", false, false)] + [InlineData("CSC TrustedSecure OV, Multiple Names", "2", true, false)] + [InlineData("CSC TrustedSecure EV", "3", false, true)] + [InlineData("CSC TrustedSecure DV", "4", false, false)] + [InlineData("CSC TrustedSecure DV Wildcard", "5", false, false)] + [InlineData("CSC TrustedSecure DV, Multiple Names", "6", true, false)] + [InlineData("CSC TrustedSecure EV, Multiple Names", "7", true, true)] + [InlineData("CSC TrustedSecure OV Wildcard, Multiple Names", "8", true, false)] + [InlineData("CSC TrustedSecure DV Wildcard, Multiple Names", "9", true, false)] + [InlineData("Some Unknown Product", "-1", false, false)] + public void GetRegistrationRequest_RoutesCertificateTypeAndOptionalSections( + string productId, string expectedType, bool expectSans, bool expectEv) + { + var sans = new Dictionary { ["dnsname"] = new[] { "www.example.com" } }; + var productInfo = ProductInfo(productId, new Dictionary + { + ["Domain Control Validation Method"] = "CNAME", + ["Organization Country"] = "US" + }); + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, sans, new List()); + + Assert.Equal(expectedType, request.CertificateType); + Assert.Equal(expectSans, request.SubjectAlternativeNames != null); + Assert.Equal(expectEv, request.EvCertificateDetails != null); + if (expectEv) + Assert.Equal("US", request.EvCertificateDetails.Country); + } + + [Fact] + public void GetRegistrationRequest_EncodesCsrAsBase64() + { + var productInfo = ProductInfo("CSC TrustedSecure OV"); + var request = Manager.GetRegistrationRequest(productInfo, "hello", new Dictionary(), new List()); + + var decoded = Convert.FromBase64String(request.Csr); + Assert.Equal("hello", System.Text.Encoding.UTF8.GetString(decoded)); + } + + [Fact] + public void GetRegistrationRequest_MandatoryCustomFieldMissing_Throws() + { + var productInfo = ProductInfo("CSC TrustedSecure OV"); + var customFields = new List { new GetCustomField { Label = "Required Field", Mandatory = true } }; + + Assert.Throws(() => + Manager.GetRegistrationRequest(productInfo, SampleCsr, new Dictionary(), customFields)); + } + + [Fact] + public void GetRegistrationRequest_NullCustomFields_ReturnsEmptyList() + { + var productInfo = ProductInfo("CSC TrustedSecure OV"); + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, new Dictionary(), null!); + + Assert.Empty(request.CustomFields); + } + + [Fact] + public void GetRegistrationRequest_OptionalCustomFieldMissing_Skipped() + { + var productInfo = ProductInfo("CSC TrustedSecure OV"); + var customFields = new List { new GetCustomField { Label = "Optional Field", Mandatory = false } }; + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, new Dictionary(), customFields); + + Assert.Empty(request.CustomFields); + } + + [Fact] + public void GetRegistrationRequest_CustomFieldPresent_IsMapped() + { + var productInfo = ProductInfo("CSC TrustedSecure OV", new Dictionary { ["Custom Field"] = "value" }); + var customFields = new List { new GetCustomField { Label = "Custom Field", Mandatory = false } }; + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, new Dictionary(), customFields); + + Assert.Single(request.CustomFields); + Assert.Equal("value", request.CustomFields[0].Value); + } + + // --------------------------------------------------------------------- + // GetSubjectAlternativeNames (exercised via GetRegistrationRequest) + // --------------------------------------------------------------------- + + [Fact] + public void GetRegistrationRequest_MultiNameEmailMethod_MatchesAdditionalSanEmail() + { + var sans = new Dictionary { ["dnsname"] = new[] { "www.example.com" } }; + var productInfo = ProductInfo("CSC TrustedSecure OV, Multiple Names", new Dictionary + { + ["Domain Control Validation Method"] = "EMAIL", + ["Addtl Sans Comma Separated DVC Emails"] = "admin@example.com,admin@other.com" + }); + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, sans, new List()); + + Assert.Single(request.SubjectAlternativeNames); + var san = request.SubjectAlternativeNames[0]; + Assert.Equal("www.example.com", san.DomainName); + Assert.NotNull(san.DomainControlValidation); + Assert.Equal("admin@example.com", san.DomainControlValidation.EmailAddress); + } + + [Fact] + public void GetRegistrationRequest_MultiNameEmailMethodNoAddtlEmailsConfigured_NoDcvMatch() + { + var sans = new Dictionary { ["dnsname"] = new[] { "www.example.com" } }; + var productInfo = ProductInfo("CSC TrustedSecure OV, Multiple Names", new Dictionary + { + ["Domain Control Validation Method"] = "EMAIL" + }); + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, sans, new List()); + + Assert.Single(request.SubjectAlternativeNames); + Assert.Null(request.SubjectAlternativeNames[0].DomainControlValidation); + } + + [Fact] + public void GetRegistrationRequest_MultiNameCnameMethod_UsesEmptyEmailValidation() + { + var sans = new Dictionary { ["dnsname"] = new[] { "www.example.com" } }; + var productInfo = ProductInfo("CSC TrustedSecure OV, Multiple Names", new Dictionary + { + ["Domain Control Validation Method"] = "CNAME" + }); + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, sans, new List()); + + Assert.Single(request.SubjectAlternativeNames); + var san = request.SubjectAlternativeNames[0]; + Assert.NotNull(san.DomainControlValidation); + Assert.Equal(string.Empty, san.DomainControlValidation.EmailAddress); + } + + [Fact] + public void GetRegistrationRequest_MultiNameProductWithNoDnsNameKey_ReturnsEmptySanList() + { + var productInfo = ProductInfo("CSC TrustedSecure OV, Multiple Names", new Dictionary + { + ["Domain Control Validation Method"] = "CNAME" + }); + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, new Dictionary(), new List()); + + Assert.NotNull(request.SubjectAlternativeNames); + Assert.Empty(request.SubjectAlternativeNames); + } + + [Fact] + public void GetRegistrationRequest_MultiNameProductWithNullSans_ReturnsEmptySanList() + { + var productInfo = ProductInfo("CSC TrustedSecure OV, Multiple Names", new Dictionary + { + ["Domain Control Validation Method"] = "CNAME" + }); + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, null!, new List()); + + Assert.NotNull(request.SubjectAlternativeNames); + Assert.Empty(request.SubjectAlternativeNames); + } + + // --------------------------------------------------------------------- + // GetNotifications + // --------------------------------------------------------------------- + + [Fact] + public void GetNotifications_NoEmailsConfigured_ReturnsEmptyList() + { + var productInfo = ProductInfo("CSC TrustedSecure OV"); + var notifications = Manager.GetNotifications(productInfo); + Assert.True(notifications.Enabled); + Assert.Empty(notifications.AdditionalNotificationEmails); + } + + [Fact] + public void GetNotifications_EmailsConfigured_SplitsOnComma() + { + var productInfo = ProductInfo("CSC TrustedSecure OV", + new Dictionary { ["Notification Email(s) Comma Separated"] = "a@example.com,b@example.com" }); + + var notifications = Manager.GetNotifications(productInfo); + + Assert.Equal(2, notifications.AdditionalNotificationEmails.Count); + Assert.Contains("a@example.com", notifications.AdditionalNotificationEmails); + } + + // --------------------------------------------------------------------- + // GetRenewalRequest / GetReissueRequest - basic parity with GetRegistrationRequest + // --------------------------------------------------------------------- + + [Fact] + public void GetRenewalRequest_MultiNameProduct_PopulatesUuidAndSans() + { + var sans = new Dictionary { ["dnsname"] = new[] { "www.example.com" } }; + var productInfo = ProductInfo("CSC TrustedSecure DV, Multiple Names", new Dictionary + { + ["Domain Control Validation Method"] = "CNAME" + }); + + var request = Manager.GetRenewalRequest(productInfo, "uuid-123", SampleCsr, sans, new List()); + + Assert.Equal("uuid-123", request.Uuid); + Assert.Equal("6", request.CertificateType); + Assert.Single(request.SubjectAlternativeNames); + Assert.Null(request.EvCertificateDetails); + } + + [Fact] + public void GetRenewalRequest_EvProduct_PopulatesEvDetailsNoSans() + { + var productInfo = ProductInfo("CSC TrustedSecure EV", new Dictionary + { + ["Organization Country"] = "CA" + }); + + var request = Manager.GetRenewalRequest(productInfo, "uuid-456", SampleCsr, new Dictionary(), new List()); + + Assert.Equal("3", request.CertificateType); + Assert.Null(request.SubjectAlternativeNames); + Assert.NotNull(request.EvCertificateDetails); + Assert.Equal("CA", request.EvCertificateDetails.Country); + } + + [Fact] + public void GetReissueRequest_MultiNameProduct_PopulatesUuidAndSans() + { + var sans = new Dictionary { ["dnsname"] = new[] { "www.example.com" } }; + var productInfo = ProductInfo("CSC TrustedSecure OV Wildcard, Multiple Names", new Dictionary + { + ["Domain Control Validation Method"] = "CNAME" + }); + + var request = Manager.GetReissueRequest(productInfo, "uuid-789", SampleCsr, sans, new List()); + + Assert.Equal("uuid-789", request.Uuid); + Assert.Equal("8", request.CertificateType); + Assert.Single(request.SubjectAlternativeNames); + } + + [Fact] + public void GetReissueRequest_EvMultiNameProduct_PopulatesBothSansAndEvDetails() + { + var sans = new Dictionary { ["dnsname"] = new[] { "www.example.com" } }; + var productInfo = ProductInfo("CSC TrustedSecure EV, Multiple Names", new Dictionary + { + ["Domain Control Validation Method"] = "CNAME", + ["Organization Country"] = "GB" + }); + + var request = Manager.GetReissueRequest(productInfo, "uuid-000", SampleCsr, sans, new List()); + + Assert.Equal("7", request.CertificateType); + Assert.Single(request.SubjectAlternativeNames); + Assert.NotNull(request.EvCertificateDetails); + Assert.Equal("GB", request.EvCertificateDetails.Country); + } +} diff --git a/cscglobal-caplugin.Tests/coverlet.runsettings b/cscglobal-caplugin.Tests/coverlet.runsettings new file mode 100644 index 0000000..f1d0827 --- /dev/null +++ b/cscglobal-caplugin.Tests/coverlet.runsettings @@ -0,0 +1,12 @@ + + + + + + + cobertura + + + + + diff --git a/cscglobal-caplugin.sln b/cscglobal-caplugin.sln index 220a2cd..1fc79be 100644 --- a/cscglobal-caplugin.sln +++ b/cscglobal-caplugin.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 18.0.11217.181 d18.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSCGlobalCAPlugin", "cscglobal-caplugin\CSCGlobalCAPlugin.csproj", "{01DDFD6F-275D-46E7-B522-E0C965D1BF9C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSCGlobalCAPlugin.Tests", "cscglobal-caplugin.Tests\CSCGlobalCAPlugin.Tests.csproj", "{7B2A5E3C-9F4D-4A1B-8C6E-1D3F5A7B9C0D}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}" ProjectSection(SolutionItems) = preProject CHANGELOG.md = CHANGELOG.md @@ -25,6 +27,12 @@ Global {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|Any CPU.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 + {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}.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}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7B2A5E3C-9F4D-4A1B-8C6E-1D3F5A7B9C0D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/cscglobal-caplugin/AssemblyInfo.cs b/cscglobal-caplugin/AssemblyInfo.cs new file mode 100644 index 0000000..dadad5f --- /dev/null +++ b/cscglobal-caplugin/AssemblyInfo.cs @@ -0,0 +1,10 @@ +// 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. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CSCGlobalCAPlugin.Tests")] diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index d2ae007..f9a3240 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -33,7 +33,12 @@ public CSCGlobalCAPlugin() _requestManager = new RequestManager(); } - private ICscGlobalClient CscGlobalClient { get; set; } + internal Func ClientFactory { get; set; } + = config => new CscGlobalClient(config); + + private IAnyCAPluginConfigProvider Config { get; set; } + + private ICscGlobalClient CscGlobalClient => ClientFactory(Config); public int SyncFilterDays { get; set; } @@ -43,7 +48,7 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa Logger.MethodEntry(LogLevel.Debug); if (configProvider == null) throw new ArgumentNullException(nameof(configProvider)); _certificateDataReader = certificateDataReader ?? throw new ArgumentNullException(nameof(certificateDataReader)); - CscGlobalClient = new CscGlobalClient(configProvider); + Config = configProvider; if (configProvider.CAConnectionData.ContainsKey(Constants.SyncFilterDays)) { @@ -290,7 +295,7 @@ public async Task Enroll(string csr, string subject, Dictionar ReissueRequest reissueRequest; RenewalRequest renewRequest; var productParameters = productInfo.ProductParameters ?? new Dictionary(); - if (productParameters.ContainsKey("priorcertsn")) + if (productParameters.ContainsKey("PriorCertSN")) { productParameters.TryGetValue("PriorCertSN", out priorSn); priorSn ??= ""; diff --git a/cscglobal-caplugin/Client/CscGlobalClient.cs b/cscglobal-caplugin/Client/CscGlobalClient.cs index ca692e2..43de107 100644 --- a/cscglobal-caplugin/Client/CscGlobalClient.cs +++ b/cscglobal-caplugin/Client/CscGlobalClient.cs @@ -21,7 +21,11 @@ public sealed class CscGlobalClient : ICscGlobalClient { private readonly ILogger Logger; - public CscGlobalClient(IAnyCAPluginConfigProvider config) + public CscGlobalClient(IAnyCAPluginConfigProvider config) : this(config, null) + { + } + + internal CscGlobalClient(IAnyCAPluginConfigProvider config, HttpMessageHandler? handler) { Logger = LogHandler.GetClassLogger(); if (config == null) throw new ArgumentNullException(nameof(config)); @@ -36,7 +40,7 @@ public CscGlobalClient(IAnyCAPluginConfigProvider config) BaseUrl = new Uri(config.CAConnectionData[Constants.CscGlobalUrl].ToString()); ApiKey = config.CAConnectionData[Constants.CscGlobalApiKey].ToString(); Authorization = config.CAConnectionData[Constants.BearerToken].ToString(); - RestClient = ConfigureRestClient(); + RestClient = ConfigureRestClient(handler); Logger.LogDebug($"CscGlobalClient configured for base URL {BaseUrl}"); } else @@ -260,7 +264,7 @@ public async Task SubmitCertificateListRequestAsync(str filterQuery += $";effectiveDate=ge={dateFilter}"; } Logger.LogTrace($"Certificate list filter query: {filterQuery}"); - var resp = RestClient.GetAsync($"/dbs/api/v2/tls/certificate?{filterQuery}").Result; + var resp = await RestClient.GetAsync($"/dbs/api/v2/tls/certificate?{filterQuery}"); if (!resp.IsSuccessStatusCode) { @@ -279,10 +283,9 @@ public async Task SubmitCertificateListRequestAsync(str return certificateListResponse; } - private HttpClient ConfigureRestClient() + private HttpClient ConfigureRestClient(HttpMessageHandler? handler = null) { - var clientHandler = new HttpClientHandler(); - var returnClient = new HttpClient(clientHandler, true) + var returnClient = new HttpClient(handler ?? new HttpClientHandler(), true) { BaseAddress = BaseUrl }; diff --git a/cscglobal-caplugin/RequestManager.cs b/cscglobal-caplugin/RequestManager.cs index d689e66..312d0bd 100644 --- a/cscglobal-caplugin/RequestManager.cs +++ b/cscglobal-caplugin/RequestManager.cs @@ -334,7 +334,7 @@ private string GetCertificateType(string productId) return "-1"; } - private static readonly HashSet MultiNameCertificateTypes = new() { "2", "7", "8", "9" }; + private static readonly HashSet MultiNameCertificateTypes = new() { "2", "6", "7", "8", "9" }; private static readonly HashSet EvCertificateTypes = new() { "3", "7" }; public Notifications GetNotifications(EnrollmentProductInfo productInfo) From 77c888c4e9229d414e2fd9cba208bd9b0b9bbc00 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Wed, 16 Sep 2026 09:59:21 -0400 Subject: [PATCH 20/36] Add .NET 10 as a supported target framework The gateway host now runs on .NET 10 as well as .NET 6/8, so build the plugin against all three. The net8.0 package set (Keyfactor.PKI 8.1.1, IAnyCAPlugin 3.1.0) is reused for net10.0 since those libraries are forward-compatible with the newer runtime. --- CHANGELOG.md | 1 + cscglobal-caplugin/CSCGlobalCAPlugin.csproj | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed77d06..c93cdfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ v1.2.0 - Added support for CSC TrustedSecure EV, Multiple Names; CSC TrustedSecure OV Wildcard, Multiple Names; and CSC TrustedSecure DV Wildcard, Multiple Names certificate products - BREAKING: Renamed all certificate template product IDs to match CSC's current certificate type names (e.g. "CSC TrustedSecure Premium Certificate" is now "CSC TrustedSecure OV", "CSC TrustedSecure Domain Validated SSL" is now "CSC TrustedSecure DV"). Existing Certificate Templates in Command must have their Template Short Name updated to match. - Removed the TemplateSync gateway registration setting. Certificate sync now always maps synced certificates back to their CSC certificate type/template. +- Added .NET 10 as a supported target framework, alongside .NET 6 and .NET 8. v.1.1.1 - Added Incremental Sync that goes back X Number of days diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.csproj b/cscglobal-caplugin/CSCGlobalCAPlugin.csproj index 5118677..a8ab8fd 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.csproj +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.csproj @@ -3,7 +3,7 @@ true - net6.0;net8.0 + net6.0;net8.0;net10.0 Keyfactor.Extensions.CAPlugin.CSCGlobal true enable @@ -17,7 +17,7 @@ - + From 632b8695e0a1014ae9d00c3a4f846dbdc3b395e7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 16 Sep 2026 14:00:10 +0000 Subject: [PATCH 21/36] docs: auto-generate README and documentation [skip ci] --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 77cc6c6..0250f2c 100644 --- a/README.md +++ b/README.md @@ -54,16 +54,17 @@ This integration is tested and confirmed as working for Anygateway REST 24.2 and 2. On the server hosting the AnyCA Gateway REST, download and unzip the latest [CSCGlobal CAPlugin AnyCA Gateway REST plugin](https://github.com/Keyfactor/cscglobal-caplugin/releases/latest) from GitHub. -3. Copy the unzipped directory (usually called `net6.0` or `net8.0`) to the Extensions directory: +3. Copy the unzipped directory (usually called `net6.0` or `net8.0` or `net10.0`) to the Extensions directory: ```shell Depending on your AnyCA Gateway REST version, copy the unzipped directory to one of the following locations: Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net6.0\Extensions Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net8.0\Extensions + Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net10.0\Extensions ``` - > The directory containing the CSCGlobal CAPlugin AnyCA Gateway REST plugin DLLs (`net6.0` or `net8.0`) can be named anything, as long as it is unique within the `Extensions` directory. + > The directory containing the CSCGlobal CAPlugin AnyCA Gateway REST plugin DLLs (`net6.0` or `net8.0` or `net10.0`) can be named anything, as long as it is unique within the `Extensions` directory. 4. Restart the AnyCA Gateway REST service. From ee437a5245c0343809ae5c2515851ec30b54daa6 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Wed, 16 Sep 2026 15:37:35 -0400 Subject: [PATCH 22/36] Accept legacy CSC product/template names for backward compatibility Existing Certificate Templates in Command may still reference the pre-1.2.0 CSC product names (e.g. "CSC TrustedSecure UC Certificate"). Add a legacy name -> canonical name alias table and resolve it in ValidateProductInfo and RequestManager.GetCertificateType so both old and new names continue to work during enrollment. --- CHANGELOG.md | 2 +- cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs | 9 ++++++++- cscglobal-caplugin.Tests/RequestManagerTests.cs | 8 ++++++++ cscglobal-caplugin/CSCGlobalCAPlugin.cs | 7 +++++-- cscglobal-caplugin/Constants.cs | 14 ++++++++++++++ cscglobal-caplugin/RequestManager.cs | 5 +++++ 6 files changed, 41 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c93cdfc..b3f223f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ v1.2.0 - Added support for CSC TrustedSecure EV, Multiple Names; CSC TrustedSecure OV Wildcard, Multiple Names; and CSC TrustedSecure DV Wildcard, Multiple Names certificate products -- BREAKING: Renamed all certificate template product IDs to match CSC's current certificate type names (e.g. "CSC TrustedSecure Premium Certificate" is now "CSC TrustedSecure OV", "CSC TrustedSecure Domain Validated SSL" is now "CSC TrustedSecure DV"). Existing Certificate Templates in Command must have their Template Short Name updated to match. +- Renamed all certificate template product IDs to match CSC's current certificate type names (e.g. "CSC TrustedSecure Premium Certificate" is now "CSC TrustedSecure OV", "CSC TrustedSecure Domain Validated SSL" is now "CSC TrustedSecure DV"). Existing Certificate Templates in Command using the old names continue to work; new Templates should use the new names. - Removed the TemplateSync gateway registration setting. Certificate sync now always maps synced certificates back to their CSC certificate type/template. - Added .NET 10 as a supported target framework, alongside .NET 6 and .NET 8. diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs index a2e40eb..e2438d9 100644 --- a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs @@ -726,6 +726,13 @@ await Assert.ThrowsAsync(() => plugin.ValidateProductInfo(ProductInfo("Not A Real Product"), new Dictionary())); } + [Fact] + public async Task ValidateProductInfo_LegacyProductName_DoesNotThrow() + { + var plugin = MakePlugin(); + await plugin.ValidateProductInfo(ProductInfo("CSC TrustedSecure UC Certificate"), new Dictionary()); + } + // --------------------------------------------------------------------- // GetCAConnectorAnnotations / GetTemplateParameterAnnotations / GetProductIds // --------------------------------------------------------------------- @@ -849,7 +856,7 @@ public void GetEndEntityCertificate_LeafAndCaChain_ReturnsLeafOnly() using var rsaLeaf = RSA.Create(2048); var leafReq = new CertificateRequest("CN=leaf.example.com", rsaLeaf, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); leafReq.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); - var leafCert = leafReq.Create(caCert, DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(365), + var leafCert = leafReq.Create(caCert, DateTimeOffset.UtcNow.AddDays(-1), caCert.NotAfter.AddDays(-1), Guid.NewGuid().ToByteArray()); string ToPemBlock(X509Certificate2 c) => "-----BEGIN CERTIFICATE-----\n" + diff --git a/cscglobal-caplugin.Tests/RequestManagerTests.cs b/cscglobal-caplugin.Tests/RequestManagerTests.cs index fa1437b..01cd56a 100644 --- a/cscglobal-caplugin.Tests/RequestManagerTests.cs +++ b/cscglobal-caplugin.Tests/RequestManagerTests.cs @@ -294,6 +294,14 @@ public void MapReturnStatus_MapsExpectedStatus(string? cscStatus, int expected) [InlineData("CSC TrustedSecure OV Wildcard, Multiple Names", "8", true, false)] [InlineData("CSC TrustedSecure DV Wildcard, Multiple Names", "9", true, false)] [InlineData("Some Unknown Product", "-1", false, false)] + // Legacy (pre-1.2.0) product names, kept as aliases for backward compatibility. + [InlineData("CSC TrustedSecure Premium Certificate", "0", false, false)] + [InlineData("CSC TrustedSecure Premium Wildcard Certificate", "1", false, false)] + [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)] public void GetRegistrationRequest_RoutesCertificateTypeAndOptionalSections( string productId, string expectedType, bool expectSans, bool expectEv) { diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index f9a3240..3cc3ec0 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -505,13 +505,16 @@ public async Task ValidateProductInfo(EnrollmentProductInfo productInfo, var certType = ProductIDs.productIds.Find(x => x.Equals(productInfo.ProductID, StringComparison.InvariantCultureIgnoreCase)); - if (certType == null) + var isLegacyProductId = certType == null && productInfo.ProductID != null && + ProductIDs.legacyProductIdAliases.ContainsKey(productInfo.ProductID); + + if (certType == null && !isLegacyProductId) { Logger.LogError($"Cannot find product ID {productInfo.ProductID} in the list of supported CSC Global products"); throw new ArgumentException($"Cannot find {productInfo.ProductID}", "ProductId"); } - Logger.LogInformation($"Validated {certType} ({certType})configured for AnyGateway"); + Logger.LogInformation($"Validated product ID '{productInfo.ProductID}' configured for AnyGateway"); Logger.MethodExit(LogLevel.Debug); } diff --git a/cscglobal-caplugin/Constants.cs b/cscglobal-caplugin/Constants.cs index 557dee4..69e07e4 100644 --- a/cscglobal-caplugin/Constants.cs +++ b/cscglobal-caplugin/Constants.cs @@ -31,6 +31,20 @@ public class ProductIDs "CSC TrustedSecure OV Wildcard, Multiple Names", "CSC TrustedSecure DV Wildcard, Multiple Names" }; + + // Pre-1.2.0 template names. Existing Certificate Templates in Command may still + // reference these, so they're accepted as aliases for their canonical replacement. + public static Dictionary legacyProductIdAliases = + new(StringComparer.InvariantCultureIgnoreCase) + { + ["CSC TrustedSecure Premium Certificate"] = "CSC TrustedSecure OV", + ["CSC TrustedSecure Premium Wildcard Certificate"] = "CSC TrustedSecure OV Wildcard", + ["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" + }; } public class EnrollmentConfigConstants diff --git a/cscglobal-caplugin/RequestManager.cs b/cscglobal-caplugin/RequestManager.cs index 312d0bd..ddbbeaf 100644 --- a/cscglobal-caplugin/RequestManager.cs +++ b/cscglobal-caplugin/RequestManager.cs @@ -306,6 +306,11 @@ public RegistrationRequest GetRegistrationRequest(EnrollmentProductInfo productI private string GetCertificateType(string productId) { + if (productId != null && ProductIDs.legacyProductIdAliases.TryGetValue(productId, out var canonicalProductId)) + { + productId = canonicalProductId; + } + switch (productId) { case "CSC TrustedSecure OV": From c4cf8e64fd8469767a6f8f067890e3de0b5c36fc Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Wed, 16 Sep 2026 19:44:42 -0400 Subject: [PATCH 23/36] Fall back to primary CN's DCV email for unmatched SAN entries CSC Global rejects a multi-name registration if any subjectAlternativeNames entry is missing domainControlValidation. GetSubjectAlternativeNames could return a SAN with a null DomainControlValidation when the domain didn't match any address in "Addtl Sans Comma Separated DVC Emails" (or when that field wasn't set at all), causing enrollment to fail. Both the EMAIL no-match case and the CNAME case now mirror the primary CN's DCV method/ email instead of returning null or a hardcoded empty value. --- cscglobal-caplugin.Tests/RequestManagerTests.cs | 14 ++++++++++---- cscglobal-caplugin/RequestManager.cs | 11 ++++++++--- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/cscglobal-caplugin.Tests/RequestManagerTests.cs b/cscglobal-caplugin.Tests/RequestManagerTests.cs index 01cd56a..522b5e2 100644 --- a/cscglobal-caplugin.Tests/RequestManagerTests.cs +++ b/cscglobal-caplugin.Tests/RequestManagerTests.cs @@ -398,22 +398,27 @@ public void GetRegistrationRequest_MultiNameEmailMethod_MatchesAdditionalSanEmai } [Fact] - public void GetRegistrationRequest_MultiNameEmailMethodNoAddtlEmailsConfigured_NoDcvMatch() + public void GetRegistrationRequest_MultiNameEmailMethodNoAddtlSanMatch_FallsBackToCommonNameDcvEmail() { + // CSC Global rejects the request if a SAN entry has no domainControlValidation, so a SAN + // domain with no matching "Addtl Sans" email must fall back to the primary CN's DCV email. var sans = new Dictionary { ["dnsname"] = new[] { "www.example.com" } }; var productInfo = ProductInfo("CSC TrustedSecure OV, Multiple Names", new Dictionary { - ["Domain Control Validation Method"] = "EMAIL" + ["Domain Control Validation Method"] = "EMAIL", + ["CN DCV Email"] = "cn@example.com" }); var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, sans, new List()); Assert.Single(request.SubjectAlternativeNames); - Assert.Null(request.SubjectAlternativeNames[0].DomainControlValidation); + var san = request.SubjectAlternativeNames[0]; + Assert.NotNull(san.DomainControlValidation); + Assert.Equal("cn@example.com", san.DomainControlValidation.EmailAddress); } [Fact] - public void GetRegistrationRequest_MultiNameCnameMethod_UsesEmptyEmailValidation() + public void GetRegistrationRequest_MultiNameCnameMethod_MirrorsCommonNameDcv() { var sans = new Dictionary { ["dnsname"] = new[] { "www.example.com" } }; var productInfo = ProductInfo("CSC TrustedSecure OV, Multiple Names", new Dictionary @@ -426,6 +431,7 @@ public void GetRegistrationRequest_MultiNameCnameMethod_UsesEmptyEmailValidation Assert.Single(request.SubjectAlternativeNames); var san = request.SubjectAlternativeNames[0]; Assert.NotNull(san.DomainControlValidation); + Assert.Equal("CNAME", san.DomainControlValidation.MethodType); Assert.Equal(string.Empty, san.DomainControlValidation.EmailAddress); } diff --git a/cscglobal-caplugin/RequestManager.cs b/cscglobal-caplugin/RequestManager.cs index ddbbeaf..4415697 100644 --- a/cscglobal-caplugin/RequestManager.cs +++ b/cscglobal-caplugin/RequestManager.cs @@ -395,6 +395,7 @@ private List GetSubjectAlternativeNames(EnrollmentProduc { var subjectNameList = new List(); var methodType = GetOptionalParam(productInfo, "Domain Control Validation Method"); + var commonNameValidationEmail = GetOptionalParam(productInfo, "CN DCV Email"); string[] dnsNames = null; sans?.TryGetValue("dnsname", out dnsNames); @@ -409,10 +410,14 @@ private List GetSubjectAlternativeNames(EnrollmentProduc var emailAddresses = string.IsNullOrWhiteSpace(addtlSansEmails) ? Array.Empty() : addtlSansEmails.Split(','); - san.DomainControlValidation = GetDomainControlValidation(methodType, emailAddresses, domainName); + + // Fall back to the primary CN's DCV email when no per-domain override matches; + // CSC Global rejects the request if a SAN entry is missing domainControlValidation. + san.DomainControlValidation = GetDomainControlValidation(methodType, emailAddresses, domainName) + ?? GetDomainControlValidation(methodType, commonNameValidationEmail); } - else //it is a CNAME validation so no email is needed - san.DomainControlValidation = GetDomainControlValidation(methodType, ""); + else //it is a CNAME validation - mirror the primary CN's DCV, no email is needed + san.DomainControlValidation = GetDomainControlValidation(methodType, commonNameValidationEmail); subjectNameList.Add(san); } From ab33dad52cbeaad57035c92c2576b67c3ef5ad60 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Thu, 17 Sep 2026 09:53:15 -0400 Subject: [PATCH 24/36] push new test --- .../RequestManagerTests.cs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/cscglobal-caplugin.Tests/RequestManagerTests.cs b/cscglobal-caplugin.Tests/RequestManagerTests.cs index 522b5e2..7d7c16a 100644 --- a/cscglobal-caplugin.Tests/RequestManagerTests.cs +++ b/cscglobal-caplugin.Tests/RequestManagerTests.cs @@ -140,6 +140,44 @@ public void GetEnrollmentResult_WithCNameAndEmailDcvDetails_PopulatesEnrollmentC Assert.Equal(2, result.EnrollmentContext.Count); } + [Fact] + public void GetEnrollmentResult_CnameMultiSanSharingBaseDomain_SingleDcvDetailCoversAllNames() + { + // Real CSC Global behavior: a wildcard CN plus two SAN entries all under the same + // registrable base domain ("boingy.com") only produces one dcvDetails entry for that + // base domain - CSC does not return one entry per requested SAN name. + var response = new RegistrationResponse + { + Result = new Result + { + CommonName = "*.boingy.com", + Status = new Status { Uuid = "b7094337-3fbb-4a3d-9a5c-3c2ea3394e92" }, + DcvDetails = new List + { + new DcvDetail + { + DomainName = "boingy.com", + ActionNeeded = "Y", + Email = null, + CName = new CName + { + Name = "_489058F15A9A86BCDE6FAE1835C64424.boingy.com.", + Value = "87754be597cffd626f3fd036ba2d21c0.1f782fab3e3a78e17fb68b2efcf391f6.0189912763034de0b9bc.sectigo.com." + } + } + } + } + }; + + var result = Manager.GetEnrollmentResult(response); + + Assert.NotNull(result.EnrollmentContext); + Assert.Single(result.EnrollmentContext); + Assert.Equal( + "87754be597cffd626f3fd036ba2d21c0.1f782fab3e3a78e17fb68b2efcf391f6.0189912763034de0b9bc.sectigo.com.", + result.EnrollmentContext["_489058F15A9A86BCDE6FAE1835C64424.boingy.com."]); + } + // --------------------------------------------------------------------- // GetRevokeResult // --------------------------------------------------------------------- From e2d2e093b3b5b0b3bde4e4fb65071c2eff290038 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Thu, 17 Sep 2026 10:55:35 -0400 Subject: [PATCH 25/36] Surface FlowLogger step summary ahead of the error on Enroll failures Previously, Enroll failures gave the requester only a terse message (e.g. CSC Global's "Open order in progress"), with no indication of what the plugin actually attempted before hitting that error. Every failure path in Enroll now prepends flow.GetSummary() to StatusMessage, matching the pattern used in acme-provider-caplugin. Also: - An exception thrown mid-enrollment is now caught and returned as a failed EnrollmentResult (with flow summary + error detail) instead of bubbling up as an unhandled exception that Command has to wrap itself. - Fixed several early-return failure branches reporting Status = 30 (INPROCESS) instead of the correct FAILED status. - Fixed the reissue "one click not available" message incorrectly saying "Renew" instead of "Reissue". --- CHANGELOG.md | 1 + .../CSCGlobalCAPluginTests.cs | 27 +++++----- cscglobal-caplugin/CSCGlobalCAPlugin.cs | 49 +++++++++++++------ 3 files changed, 51 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3f223f..c91212b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ v1.2.0 - Renamed all certificate template product IDs to match CSC's current certificate type names (e.g. "CSC TrustedSecure Premium Certificate" is now "CSC TrustedSecure OV", "CSC TrustedSecure Domain Validated SSL" is now "CSC TrustedSecure DV"). Existing Certificate Templates in Command using the old names continue to work; new Templates should use the new names. - Removed the TemplateSync gateway registration setting. Certificate sync now always maps synced certificates back to their CSC certificate type/template. - Added .NET 10 as a supported target framework, alongside .NET 6 and .NET 8. +- Enrollment/renewal/reissue failures now include the FlowLogger step-by-step summary ahead of the underlying error message in StatusMessage, instead of just a terse error. Also fixed enrollment failures reporting Status 30 (INPROCESS) instead of the correct FAILED status, and an exception thrown mid-enrollment no longer bubbles up as an unhandled error - it's now returned as a proper failed EnrollmentResult with full flow context. v.1.1.1 - Added Incremental Sync that goes back X Number of days diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs index e2438d9..37c242b 100644 --- a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs @@ -448,7 +448,8 @@ public async Task Enroll_New_CscReturnsError_ReturnsFailedStatus() RequestFormat.PKCS10, EnrollmentType.New); Assert.Equal((int)EndEntityStatus.FAILED, result!.Status); - Assert.Equal("duplicate order", result.StatusMessage); + Assert.Contains("Flow: Enroll", result.StatusMessage); + Assert.Contains("duplicate order", result.StatusMessage); } [Fact] @@ -462,7 +463,7 @@ public async Task Enroll_NewWithPriorCertSn_ReturnsFailureWithoutCallingClient() var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, RequestFormat.PKCS10, EnrollmentType.New); - Assert.Equal((int)EndEntityStatus.INPROCESS, result!.Status); + Assert.Equal((int)EndEntityStatus.FAILED, result!.Status); mockClient.Verify(c => c.SubmitRegistrationAsync(It.IsAny()), Times.Never); } @@ -476,7 +477,7 @@ public async Task Enroll_RenewOrReissue_MissingPriorCertSn_ReturnsFailure() var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); - Assert.Equal((int)EndEntityStatus.INPROCESS, result!.Status); + Assert.Equal((int)EndEntityStatus.FAILED, result!.Status); Assert.Contains("no prior certificate serial number", result.StatusMessage); } @@ -494,7 +495,7 @@ public async Task Enroll_RenewOrReissue_NoRequestIdFoundForSerial_ReturnsFailure var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); - Assert.Equal((int)EndEntityStatus.INPROCESS, result!.Status); + Assert.Equal((int)EndEntityStatus.FAILED, result!.Status); Assert.Contains("no prior request found", result.StatusMessage); } @@ -578,7 +579,7 @@ public async Task Enroll_Renewal_ExpiredCertMissingApplicantLastName_ReturnsFail var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); - Assert.Equal((int)EndEntityStatus.INPROCESS, result!.Status); + Assert.Equal((int)EndEntityStatus.FAILED, result!.Status); Assert.Contains("One click Renew Is Not Available", result.StatusMessage); } @@ -627,8 +628,8 @@ public async Task Enroll_Reissue_MissingApplicantLastName_ReturnsFailure() var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); - Assert.Equal((int)EndEntityStatus.INPROCESS, result!.Status); - Assert.Contains("One click Renew Is Not Available", result.StatusMessage); + Assert.Equal((int)EndEntityStatus.FAILED, result!.Status); + Assert.Contains("One click Reissue Is Not Available", result.StatusMessage); } [Fact] @@ -651,7 +652,7 @@ public async Task Enroll_Reissue_RequestIdTooShort_ReturnsFailure() var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); - Assert.Equal((int)EndEntityStatus.INPROCESS, result!.Status); + Assert.Equal((int)EndEntityStatus.FAILED, result!.Status); Assert.Contains("no prior request found", result.StatusMessage); } @@ -669,14 +670,18 @@ public async Task Enroll_UnhandledEnrollmentType_ReturnsNull() } [Fact] - public async Task Enroll_ClientThrows_Rethrows() + public async Task Enroll_ClientThrows_ReturnsFailureWithFlowSummaryAndErrorDetail() { var mockClient = new Mock(); mockClient.Setup(c => c.SubmitGetCustomFields()).ThrowsAsync(new InvalidOperationException("boom")); var plugin = MakePlugin(mockClient); - await Assert.ThrowsAsync(() => - plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), RequestFormat.PKCS10, EnrollmentType.New)); + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.FAILED, result!.Status); + Assert.Contains("Flow: Enroll", result.StatusMessage); + Assert.Contains("SubmitGetCustomFields", result.StatusMessage); + Assert.Contains("boom", result.StatusMessage); } // --------------------------------------------------------------------- diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index 3cc3ec0..6d99b9c 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -326,13 +326,14 @@ public async Task Enroll(string csr, string subject, Dictionar flow.EndBranch(); return new EnrollmentResult { - Status = 30, //failure - StatusMessage = "You cannot renew an expired cert please perform an new enrollment." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nYou cannot renew an expired cert please perform an new enrollment." }; } flow.EndBranch(); var newResult = _requestManager.GetEnrollmentResult(enrollmentResponse); + EnrichFailureWithFlowSummary(newResult, flow); LogEnrollmentOutcome(newResult, "New Enrollment"); Logger.MethodExit(LogLevel.Debug); return newResult; @@ -345,8 +346,8 @@ public async Task Enroll(string csr, string subject, Dictionar flow.EndBranch(); return new EnrollmentResult { - Status = 30, //failure - StatusMessage = "Cannot renew or reissue: no prior certificate serial number was supplied." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nCannot renew or reissue: no prior certificate serial number was supplied." }; } @@ -360,8 +361,8 @@ public async Task Enroll(string csr, string subject, Dictionar flow.EndBranch(); return new EnrollmentResult { - Status = 30, //failure - StatusMessage = $"Cannot renew or reissue: no prior request found for serial number {priorSn}." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nCannot renew or reissue: no prior request found for serial number {priorSn}." }; } @@ -392,6 +393,7 @@ public async Task Enroll(string csr, string subject, Dictionar Logger.LogTrace($"Renewal Response JSON: {JsonConvert.SerializeObject(renewResponse)}"); flow.EndBranch(); var renewResult = _requestManager.GetRenewResponse(renewResponse); + EnrichFailureWithFlowSummary(renewResult, flow); LogEnrollmentOutcome(renewResult, "Renewal"); Logger.MethodExit(LogLevel.Debug); return renewResult; @@ -402,9 +404,9 @@ public async Task Enroll(string csr, string subject, Dictionar flow.EndBranch(); return new EnrollmentResult { - Status = 30, //failure + Status = (int)EndEntityStatus.FAILED, StatusMessage = - "One click Renew Is Not Available for this Certificate Type. Use the configure button instead." + $"{flow.GetSummary()}\n\nOne click Renew Is Not Available for this Certificate Type. Use the configure button instead." }; } @@ -421,8 +423,8 @@ public async Task Enroll(string csr, string subject, Dictionar flow.EndBranch(); return new EnrollmentResult { - Status = 30, //failure - StatusMessage = "Cannot reissue: no prior request found for the supplied certificate serial number." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nCannot reissue: no prior request found for the supplied certificate serial number." }; } @@ -435,6 +437,7 @@ public async Task Enroll(string csr, string subject, Dictionar Logger.LogTrace($"Reissue Response JSON: {JsonConvert.SerializeObject(reissueResponse)}"); flow.EndBranch(); var reissueResult = _requestManager.GetReIssueResult(reissueResponse); + EnrichFailureWithFlowSummary(reissueResult, flow); LogEnrollmentOutcome(reissueResult, "Reissue"); Logger.MethodExit(LogLevel.Debug); return reissueResult; @@ -445,9 +448,9 @@ public async Task Enroll(string csr, string subject, Dictionar flow.EndBranch(); return new EnrollmentResult { - Status = 30, //failure + Status = (int)EndEntityStatus.FAILED, StatusMessage = - "One click Renew Is Not Available for this Certificate Type. Use the configure button instead." + $"{flow.GetSummary()}\n\nOne click Reissue Is Not Available for this Certificate Type. Use the configure button instead." }; } @@ -457,9 +460,15 @@ public async Task Enroll(string csr, string subject, Dictionar } catch (Exception e) { - Logger.LogError(e, $"Enroll failed for product {productInfo.ProductID}: {e.Message}"); - flow.Fail("Enroll", e.Message); - throw; + var detail = LogHandler.FlattenException(e); + Logger.LogError(e, $"Enroll failed for product {productInfo.ProductID}: {detail}"); + flow.Fail("Enroll", detail); + Logger.MethodExit(LogLevel.Debug); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nEnrollment failed: {detail}" + }; } } @@ -472,6 +481,16 @@ private void LogEnrollmentOutcome(EnrollmentResult result, string operationName) Logger.LogInformation($"{operationName} succeeded: {result.StatusMessage}"); } + // CSC Global business-level failures (e.g. "Open order in progress") come back from + // RequestManager as a terse StatusMessage with no context on what the plugin actually did + // before hitting that error. Prepend the flow's step-by-step summary so the message shown + // to the requester in Command explains what ran, not just how it ended. + private static void EnrichFailureWithFlowSummary(EnrollmentResult result, FlowLogger flow) + { + if (result?.Status == (int)EndEntityStatus.FAILED) + result.StatusMessage = $"{flow.GetSummary()}\n\n{result.StatusMessage}"; + } + //done public async Task Ping() { From 995c642c2d33aa0ded24623fe7d40c32a7ca512b Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Thu, 17 Sep 2026 11:20:22 -0400 Subject: [PATCH 26/36] Drop stack trace from Enroll failure StatusMessage LogHandler.FlattenException includes the full stack trace, which was getting embedded both in the flow summary's failed step line and in the trailing error text, making the message shown to the requester in Command needlessly long. Use e.Message for the flow step and the final error line; the full flattened exception still goes to the log. --- cscglobal-caplugin/CSCGlobalCAPlugin.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index 6d99b9c..7ee9d80 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -460,14 +460,16 @@ public async Task Enroll(string csr, string subject, Dictionar } catch (Exception e) { - var detail = LogHandler.FlattenException(e); - Logger.LogError(e, $"Enroll failed for product {productInfo.ProductID}: {detail}"); - flow.Fail("Enroll", detail); + // Log the full exception (with stack trace) for diagnostics, but keep the + // user-facing StatusMessage to just the exception's message - the flow summary + // already shows which step failed, so the stack trace would only add noise. + Logger.LogError(e, $"Enroll failed for product {productInfo.ProductID}: {LogHandler.FlattenException(e)}"); + flow.Fail("Enroll", e.Message); Logger.MethodExit(LogLevel.Debug); return new EnrollmentResult { Status = (int)EndEntityStatus.FAILED, - StatusMessage = $"{flow.GetSummary()}\n\nEnrollment failed: {detail}" + StatusMessage = $"{flow.GetSummary()}\n\nEnrollment failed: {e.Message}" }; } } From 3e96102fc1642f9bce3fcd47dd95ecfb6f2357a6 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Thu, 17 Sep 2026 11:33:43 -0400 Subject: [PATCH 27/36] Make Price.Total nullable to fix reissue deserialization crash CSC Global returns "price.total": null when a certificate cannot be reissued (e.g. it's not in an active status). Price.Total being a non-nullable decimal caused Newtonsoft to throw a JsonSerializationException while parsing the response, which surfaced to the requester as a raw deserialization error instead of the actual RegistrationError/order status CSC was trying to report. --- CHANGELOG.md | 1 + .../CscGlobalClientTests.cs | 18 +++++++++++++++++- cscglobal-caplugin/Client/Models/Price.cs | 2 +- cscglobal-caplugin/Interfaces/IPrice.cs | 2 +- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c91212b..ad6c7aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ v1.2.0 - Removed the TemplateSync gateway registration setting. Certificate sync now always maps synced certificates back to their CSC certificate type/template. - Added .NET 10 as a supported target framework, alongside .NET 6 and .NET 8. - Enrollment/renewal/reissue failures now include the FlowLogger step-by-step summary ahead of the underlying error message in StatusMessage, instead of just a terse error. Also fixed enrollment failures reporting Status 30 (INPROCESS) instead of the correct FAILED status, and an exception thrown mid-enrollment no longer bubbles up as an unhandled error - it's now returned as a proper failed EnrollmentResult with full flow context. +- Fixed a crash when CSC Global returns a null "price.total" (e.g. reissuing a certificate that is not in an active status) - Price.Total is now nullable instead of causing a JSON deserialization exception. v.1.1.1 - Added Incremental Sync that goes back X Number of days diff --git a/cscglobal-caplugin.Tests/CscGlobalClientTests.cs b/cscglobal-caplugin.Tests/CscGlobalClientTests.cs index 2879dbe..2dbeeae 100644 --- a/cscglobal-caplugin.Tests/CscGlobalClientTests.cs +++ b/cscglobal-caplugin.Tests/CscGlobalClientTests.cs @@ -103,7 +103,7 @@ public async Task SubmitRegistrationAsync_Success_ReturnsParsedResponse() Assert.NotNull(response.Result); Assert.Equal("order-1", response.Result.CommonName); Assert.Equal("USD", response.Result.Price.Currency); - Assert.Equal(99.5m, response.Result.Price.Total); + Assert.Equal(99.5m, response.Result.Price.Total!.Value); Assert.Equal("example.com", response.Result.DcvDetails[0].DomainName); Assert.Equal("N", response.Result.DcvDetails[0].ActionNeeded); Assert.Contains("/dbs/api/v2/tls/registration", handler.LastRequest!.RequestUri!.ToString()); @@ -196,6 +196,22 @@ public async Task SubmitReissueAsync_Success_ReturnsParsedResponse() Assert.Contains("/dbs/api/v2/tls/reissue", handler.LastRequest!.RequestUri!.ToString()); } + [Fact] + public async Task SubmitReissueAsync_NullPriceTotal_DoesNotThrow() + { + // Real CSC Global response observed in production: "price.total" comes back null + // for a reissue where the certificate is not in a reissuable status. Price.Total + // must be nullable or this throws a JsonSerializationException instead of letting + // the caller see the RegistrationError/order status. + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, + "{\"result\":{\"commonName\":\"reissue-2\",\"price\":{\"currency\":\"USD\",\"total\":null}}}"), out _); + + var response = await client.SubmitReissueAsync(new ReissueRequest()); + + Assert.Equal("reissue-2", response.Result.CommonName); + Assert.Null(response.Result.Price.Total); + } + [Fact] public async Task SubmitReissueAsync_BadRequest_ReturnsRegistrationError() { diff --git a/cscglobal-caplugin/Client/Models/Price.cs b/cscglobal-caplugin/Client/Models/Price.cs index ad66ea3..6c71b3e 100644 --- a/cscglobal-caplugin/Client/Models/Price.cs +++ b/cscglobal-caplugin/Client/Models/Price.cs @@ -13,5 +13,5 @@ namespace Keyfactor.Extensions.CAPlugin.CSCGlobal.Client.Models; public class Price : IPrice { [JsonProperty("currency")] public string Currency { get; set; } - [JsonProperty("total")] public decimal Total { get; set; } + [JsonProperty("total")] public decimal? Total { get; set; } } \ No newline at end of file diff --git a/cscglobal-caplugin/Interfaces/IPrice.cs b/cscglobal-caplugin/Interfaces/IPrice.cs index d4bab37..47eb1fb 100644 --- a/cscglobal-caplugin/Interfaces/IPrice.cs +++ b/cscglobal-caplugin/Interfaces/IPrice.cs @@ -10,5 +10,5 @@ namespace Keyfactor.Extensions.CAPlugin.CSCGlobal.Interfaces; public interface IPrice { string Currency { get; set; } - decimal Total { get; set; } + decimal? Total { get; set; } } \ No newline at end of file From fe0091f1f5158551e4f4ca93a76484b939312b88 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Thu, 17 Sep 2026 11:46:50 -0400 Subject: [PATCH 28/36] Fix Renew/Reissue reporting GENERATED before DCV completes CSC Global's renewal/reissue responses never include an issued certificate - the request is only submitted and still needs domain control validation (CNAME record or email confirmation) before CSC actually issues the certificate. GetRenewResponse and GetReIssueResult were unconditionally returning Status = GENERATED with no Certificate set, which crashed the gateway host (NullReferenceException in PemUtilities.PEMCertificateToDER) when it tried to parse a certificate that didn't exist. Both now return EXTERNALVALIDATION on success, matching new enrollment, and populate EnrollmentContext with the CNAME/email DCV instructions via a shared BuildDcvEnrollmentContext helper (previously only wired up for new enrollment). Also fixed GetRenewResponse never setting CARequestID on success. --- CHANGELOG.md | 1 + .../CSCGlobalCAPluginTests.cs | 6 +- .../RequestManagerTests.cs | 56 ++++++++++++++-- cscglobal-caplugin/RequestManager.cs | 64 ++++++++++++------- 4 files changed, 96 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad6c7aa..11bca87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ v1.2.0 - Added .NET 10 as a supported target framework, alongside .NET 6 and .NET 8. - Enrollment/renewal/reissue failures now include the FlowLogger step-by-step summary ahead of the underlying error message in StatusMessage, instead of just a terse error. Also fixed enrollment failures reporting Status 30 (INPROCESS) instead of the correct FAILED status, and an exception thrown mid-enrollment no longer bubbles up as an unhandled error - it's now returned as a proper failed EnrollmentResult with full flow context. - Fixed a crash when CSC Global returns a null "price.total" (e.g. reissuing a certificate that is not in an active status) - Price.Total is now nullable instead of causing a JSON deserialization exception. +- Fixed successful Renew and Reissue requests incorrectly reporting Status GENERATED (certificate issued) when CSC Global has only submitted the request and is still waiting on domain control validation. This caused the AnyGateway REST host to crash trying to parse a certificate that doesn't exist yet. Renew/Reissue now report EXTERNALVALIDATION, matching new enrollment, and surface the pending CNAME/email DCV instructions the same way. v.1.1.1 - Added Incremental Sync that goes back X Number of days diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs index 37c242b..7870044 100644 --- a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs @@ -530,7 +530,7 @@ 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.GENERATED, result!.Status); + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result!.Status); mockClient.Verify(c => c.SubmitReissueAsync(It.IsAny()), Times.Once); } @@ -559,7 +559,7 @@ public async Task Enroll_Renewal_ExpiredCertWithApplicantLastName_Succeeds() var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); - Assert.Equal((int)EndEntityStatus.GENERATED, result!.Status); + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result!.Status); } [Fact] @@ -608,7 +608,7 @@ public async Task Enroll_Reissue_ValidCertWithApplicantLastName_Succeeds() var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); - Assert.Equal((int)EndEntityStatus.GENERATED, result!.Status); + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result!.Status); } [Fact] diff --git a/cscglobal-caplugin.Tests/RequestManagerTests.cs b/cscglobal-caplugin.Tests/RequestManagerTests.cs index 7d7c16a..ed7079b 100644 --- a/cscglobal-caplugin.Tests/RequestManagerTests.cs +++ b/cscglobal-caplugin.Tests/RequestManagerTests.cs @@ -60,14 +60,41 @@ public void GetRenewResponse_NullResult_ReturnsFailed() } [Fact] - public void GetRenewResponse_Success_ReturnsGenerated() + public void GetRenewResponse_Success_ReturnsExternalValidation() { - var response = new RenewalResponse { Result = new Result { CommonName = "renewed.example.com" } }; + // CSC Global never returns an issued certificate on the renewal response itself - it + // still needs DCV, so this must not claim GENERATED (Command would then try to parse + // a certificate off a result that doesn't have one). + var response = new RenewalResponse + { + Result = new Result { CommonName = "renewed.example.com", Status = new Status { Uuid = "uuid-renew" } } + }; var result = Manager.GetRenewResponse(response); - Assert.Equal((int)EndEntityStatus.GENERATED, result.Status); + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Equal("uuid-renew", result.CARequestID); Assert.Contains("renewed.example.com", result.StatusMessage); } + [Fact] + public void GetRenewResponse_Success_WithDcvDetails_PopulatesEnrollmentContext() + { + var response = new RenewalResponse + { + Result = new Result + { + CommonName = "renewed.example.com", + Status = new Status { Uuid = "uuid-renew" }, + DcvDetails = new List + { + new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "token" } } + } + } + }; + var result = Manager.GetRenewResponse(response); + Assert.NotNull(result.EnrollmentContext); + Assert.Equal("token", result.EnrollmentContext["_dnsauth.example.com"]); + } + // --------------------------------------------------------------------- // GetEnrollmentResult // --------------------------------------------------------------------- @@ -231,17 +258,36 @@ public void GetReIssueResult_NullResult_ReturnsFailed() } [Fact] - public void GetReIssueResult_Success_ReturnsGenerated() + public void GetReIssueResult_Success_ReturnsExternalValidation() { + // Same as renewal - CSC Global still requires DCV before actually issuing the + // certificate, so a reissue submission must not claim GENERATED either. var response = new ReissueResponse { Result = new Result { CommonName = "reissued.example.com", Status = new Status { Uuid = "uuid-3" } } }; var result = Manager.GetReIssueResult(response); - Assert.Equal((int)EndEntityStatus.GENERATED, result.Status); + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); Assert.Equal("uuid-3", result.CARequestID); } + [Fact] + public void GetReIssueResult_Success_WithDcvDetails_PopulatesEnrollmentContext() + { + var response = new ReissueResponse + { + Result = new Result + { + CommonName = "reissued.example.com", + Status = new Status { Uuid = "uuid-3" }, + DcvDetails = new List { new DcvDetail { Email = "admin@example.com" } } + } + }; + var result = Manager.GetReIssueResult(response); + Assert.NotNull(result.EnrollmentContext); + Assert.Equal("admin@example.com", result.EnrollmentContext["admin@example.com"]); + } + // --------------------------------------------------------------------- // GetDomainControlValidation (email-list overload) // --------------------------------------------------------------------- diff --git a/cscglobal-caplugin/RequestManager.cs b/cscglobal-caplugin/RequestManager.cs index 4415697..20e627c 100644 --- a/cscglobal-caplugin/RequestManager.cs +++ b/cscglobal-caplugin/RequestManager.cs @@ -94,15 +94,45 @@ public EnrollmentResult GetRenewResponse(RenewalResponse renewResponse) }; } - Logger.LogInformation($"Renewal successfully completed for {renewResponse.Result.CommonName}"); + // CSC Global never returns an issued certificate on the renewal response itself - the + // renewal is only submitted here and still needs domain control validation (email + // confirmation or a CNAME record) before CSC actually issues the certificate. The real + // certificate is picked up later via Synchronize. Reporting GENERATED here tells Command + // a certificate is ready to be parsed off this result when there isn't one. + Logger.LogInformation($"Renewal successfully submitted for {renewResponse.Result.CommonName}"); return new EnrollmentResult { - Status = (int)EndEntityStatus.GENERATED, //success - - StatusMessage = $"Renewal Successfully Completed For {renewResponse.Result.CommonName}" + Status = (int)EndEntityStatus.EXTERNALVALIDATION, //success - pending DCV, not yet issued + CARequestID = renewResponse.Result.Status?.Uuid, + StatusMessage = $"Renewal Successfully Submitted For {renewResponse.Result.CommonName}", + EnrollmentContext = BuildDcvEnrollmentContext(renewResponse.Result.DcvDetails) }; } + // Builds the CNAME/email DCV instructions CSC Global returns on a submitted order into the + // key/value context Command surfaces to the requester, so they know what action is needed + // (create a CNAME record, or check their email) before the certificate will actually issue. + private static Dictionary BuildDcvEnrollmentContext(List dcvDetails) + { + var cnames = new Dictionary(); + if (dcvDetails == null) return null; + + foreach (var dcv in dcvDetails) + { + if (dcv.CName != null && !string.IsNullOrEmpty(dcv.CName.Name) && !string.IsNullOrEmpty(dcv.CName.Value)) + { + cnames.Add(dcv.CName.Name, dcv.CName.Value); + } + + if (!string.IsNullOrEmpty(dcv.Email) && !cnames.ContainsKey(dcv.Email)) + { + cnames.Add(dcv.Email, dcv.Email); + } + } + + return cnames.Count > 0 ? cnames : null; + } + public EnrollmentResult GetEnrollmentResult( @@ -138,21 +168,6 @@ public EnrollmentResult }; } - var cnames = new Dictionary(); - if (registrationResponse.Result.DcvDetails != null && registrationResponse.Result.DcvDetails.Count > 0) - foreach (var dcv in registrationResponse.Result.DcvDetails) - { - if (dcv.CName != null && !string.IsNullOrEmpty(dcv.CName.Name) && !string.IsNullOrEmpty(dcv.CName.Value)) - { - cnames.Add(dcv.CName.Name, dcv.CName.Value); - } - - if (!string.IsNullOrEmpty(dcv.Email) && !cnames.ContainsKey(dcv.Email)) - { - cnames.Add(dcv.Email, dcv.Email); - } - } - Logger.LogInformation($"Order successfully created with order number {registrationResponse.Result.CommonName}"); return new EnrollmentResult { @@ -160,7 +175,7 @@ public EnrollmentResult CARequestID = registrationResponse.Result.Status?.Uuid, StatusMessage = $"Order Successfully Created With Order Number {registrationResponse.Result.CommonName}", - EnrollmentContext = cnames.Count > 0 ? cnames : null + EnrollmentContext = BuildDcvEnrollmentContext(registrationResponse.Result.DcvDetails) }; } @@ -213,12 +228,15 @@ public EnrollmentResult GetReIssueResult(IReissueResponse reissueResponse) }; } - Logger.LogInformation($"Reissue successfully completed for {reissueResponse.Result.CommonName}"); + // Same as renewal: CSC Global submits the reissue but still requires DCV before the + // certificate is actually issued, so this must not claim GENERATED here. + Logger.LogInformation($"Reissue successfully submitted for {reissueResponse.Result.CommonName}"); return new EnrollmentResult { - Status = (int)EndEntityStatus.GENERATED, //success + Status = (int)EndEntityStatus.EXTERNALVALIDATION, //success - pending DCV, not yet issued CARequestID = reissueResponse.Result.Status?.Uuid, - StatusMessage = $"Reissue Successfully Completed For {reissueResponse.Result.CommonName}" + StatusMessage = $"Reissue Successfully Submitted For {reissueResponse.Result.CommonName}", + EnrollmentContext = BuildDcvEnrollmentContext(reissueResponse.Result.DcvDetails) }; } From b4894762ceee520457c0d4991b9fd7be13c43357 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Thu, 17 Sep 2026 12:07:30 -0400 Subject: [PATCH 29/36] Add legacy product name coverage for renew/reissue The legacy-name backward compat tests only covered new enrollment and ValidateProductInfo. GetRenewalRequest, GetReissueRequest, and the full Enroll RenewOrReissue path had no test proving a Certificate Template still configured with a pre-1.2.0 product name resolves correctly. --- .../CSCGlobalCAPluginTests.cs | 34 +++++++++++++++++++ .../RequestManagerTests.cs | 32 +++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs index 7870044..66419b8 100644 --- a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs @@ -611,6 +611,40 @@ public async Task Enroll_Reissue_ValidCertWithApplicantLastName_Succeeds() Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result!.Status); } + [Fact] + public async Task Enroll_Reissue_LegacyProductName_SendsResolvedCertificateType() + { + // Full end-to-end proof that a Certificate Template still configured with a + // pre-1.2.0 product name reissues correctly against the current extension. + var uuid = Guid.NewGuid().ToString(); + ReissueRequest capturedRequest = null!; + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitReissueAsync(It.IsAny())) + .Callback(r => capturedRequest = r) + .ReturnsAsync(new ReissueResponse + { + Result = new Result { CommonName = "reissued.example.com", Status = new Status { Uuid = uuid } } + }); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(uuid); + certDataReader.Setup(r => r.GetExpirationDateByRequestId(uuid)).Returns(DateTime.Now.AddDays(30)); + + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo("CSC TrustedSecure UC Certificate", new Dictionary + { + ["PriorCertSN"] = "ABC123", + ["Applicant Last Name"] = "Doe" + }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result!.Status); + Assert.Equal("2", capturedRequest.CertificateType); + } + [Fact] public async Task Enroll_Reissue_MissingApplicantLastName_ReturnsFailure() { diff --git a/cscglobal-caplugin.Tests/RequestManagerTests.cs b/cscglobal-caplugin.Tests/RequestManagerTests.cs index ed7079b..a27425c 100644 --- a/cscglobal-caplugin.Tests/RequestManagerTests.cs +++ b/cscglobal-caplugin.Tests/RequestManagerTests.cs @@ -593,6 +593,23 @@ public void GetRenewalRequest_MultiNameProduct_PopulatesUuidAndSans() Assert.Null(request.EvCertificateDetails); } + [Fact] + 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 + { + ["Domain Control Validation Method"] = "CNAME" + }); + + var request = Manager.GetRenewalRequest(productInfo, "uuid-legacy", SampleCsr, sans, new List()); + + Assert.Equal("6", request.CertificateType); + Assert.Single(request.SubjectAlternativeNames); + } + [Fact] public void GetRenewalRequest_EvProduct_PopulatesEvDetailsNoSans() { @@ -625,6 +642,21 @@ public void GetReissueRequest_MultiNameProduct_PopulatesUuidAndSans() Assert.Single(request.SubjectAlternativeNames); } + [Fact] + public void GetReissueRequest_LegacyProductName_ResolvesCertificateType() + { + var productInfo = ProductInfo("CSC TrustedSecure EV Certificate", new Dictionary + { + ["Organization Country"] = "US" + }); + + var request = Manager.GetReissueRequest(productInfo, "uuid-legacy-2", SampleCsr, + new Dictionary(), new List()); + + Assert.Equal("3", request.CertificateType); + Assert.NotNull(request.EvCertificateDetails); + } + [Fact] public void GetReissueRequest_EvMultiNameProduct_PopulatesBothSansAndEvDetails() { From e9172171abb9cbaa77c40b5f005300f991606a30 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Thu, 17 Sep 2026 12:11:01 -0400 Subject: [PATCH 30/36] Remove last raw status literal from MapReturnStatus The int-literal initializer was dead (every switch branch, including default, already assigns a Keyfactor.PKI EndEntityStatus value), and was the last place in the plugin where a status value wasn't backed by the library enum. Every Status assignment elsewhere already uses (int)EndEntityStatus.X. --- cscglobal-caplugin/RequestManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cscglobal-caplugin/RequestManager.cs b/cscglobal-caplugin/RequestManager.cs index 20e627c..815b497 100644 --- a/cscglobal-caplugin/RequestManager.cs +++ b/cscglobal-caplugin/RequestManager.cs @@ -488,7 +488,7 @@ private EvCertificateDetails GetEvCertificateDetails(EnrollmentProductInfo produ public int MapReturnStatus(string cscGlobalStatus) { - var returnStatus = 0; + int returnStatus; switch (cscGlobalStatus) { From ae2d4076cfaa17b1eac80ddea3826a5cd37cb7f1 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Thu, 17 Sep 2026 16:07:26 -0400 Subject: [PATCH 31/36] Add per-product-type domain scenario test cases Adds a data-driven test covering all 10 CSC certificate products with realistic domain scenarios, grounded in Sectigo's own Multi-Domain/UCC and Multi-Domain Wildcard product documentation (CSC resells Sectigo behind the scenes): SANs on the Multiple Names products are exercised with unrelated domains (not just subdomains of one base domain), and the wildcard multi-name types are exercised with wildcard SANs for unrelated domains and with the base-domain-plus-wildcard-SAN pairing Sectigo's docs call out as required for full coverage. --- .../RequestManagerTests.cs | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/cscglobal-caplugin.Tests/RequestManagerTests.cs b/cscglobal-caplugin.Tests/RequestManagerTests.cs index a27425c..5f2b530 100644 --- a/cscglobal-caplugin.Tests/RequestManagerTests.cs +++ b/cscglobal-caplugin.Tests/RequestManagerTests.cs @@ -547,6 +547,91 @@ public void GetRegistrationRequest_MultiNameProductWithNullSans_ReturnsEmptySanL Assert.Empty(request.SubjectAlternativeNames); } + // --------------------------------------------------------------------- + // Per-product-type domain scenarios (based on the underlying Sectigo Multi-Domain/UCC + // and Multi-Domain Wildcard products CSC resells - see sectigo.com/ssl-certificates-tls/ + // multi-domain-san-ucc and sectigostore.com/ssl-types/multi-domain-wildcard). SANs on the + // Multiple Names products are not restricted to the CN's own base domain; unrelated domains, + // and wildcards for unrelated domains, are valid. Note the CN itself lives inside the CSR + // blob and isn't observable at this layer, so these only exercise SAN/EV/type routing. + // --------------------------------------------------------------------- + + public static IEnumerable ProductTypeDomainScenarios() + { + // productId, sanDomains, expectedCertificateType, expectSans, expectEv + yield return new object[] { "CSC TrustedSecure OV", Array.Empty(), "0", false, false }; + yield return new object[] { "CSC TrustedSecure OV Wildcard", Array.Empty(), "1", false, false }; + yield return new object[] + { + "CSC TrustedSecure OV, Multiple Names", + new[] { "www.example.com", "shop.example.net", "portal.othercompany.org" }, + "2", true, false + }; + yield return new object[] { "CSC TrustedSecure EV", Array.Empty(), "3", false, true }; + yield return new object[] { "CSC TrustedSecure DV", Array.Empty(), "4", false, false }; + yield return new object[] { "CSC TrustedSecure DV Wildcard", Array.Empty(), "5", false, false }; + yield return new object[] + { + "CSC TrustedSecure DV, Multiple Names", + new[] { "mail.example.com", "app.example.com", "www.unrelated-domain.io" }, + "6", true, false + }; + yield return new object[] + { + "CSC TrustedSecure EV, Multiple Names", + new[] { "www.example.com", "www.example-partner.com" }, + "7", true, true + }; + yield return new object[] + { + // Wildcard multi-name: additional wildcard SANs for entirely unrelated domains, + // per Sectigo's own "*.example1.com, *.example2.com, *.example3.com" example. + "CSC TrustedSecure OV Wildcard, Multiple Names", + new[] { "*.example2.com", "*.example3.com" }, + "8", true, false + }; + yield return new object[] + { + // Base domain + wildcard for the same domain as two separate SAN entries - + // Sectigo requires both explicitly since a wildcard SAN alone does not cover + // the bare base domain. + "CSC TrustedSecure DV Wildcard, Multiple Names", + new[] { "example.com", "*.example.com" }, + "9", true, false + }; + } + + [Theory] + [MemberData(nameof(ProductTypeDomainScenarios))] + public void GetRegistrationRequest_ProductTypeDomainScenarios_BuildsExpectedRequest( + string productId, string[] sanDomains, string expectedCertificateType, bool expectSans, bool expectEv) + { + var sans = sanDomains.Length > 0 + ? new Dictionary { ["dnsname"] = sanDomains } + : new Dictionary(); + var productInfo = ProductInfo(productId, new Dictionary + { + ["Domain Control Validation Method"] = "CNAME", + ["Organization Country"] = "US" + }); + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, sans, new List()); + + Assert.Equal(expectedCertificateType, request.CertificateType); + Assert.Equal(expectEv, request.EvCertificateDetails != null); + + if (!expectSans) + { + Assert.Null(request.SubjectAlternativeNames); + return; + } + + Assert.NotNull(request.SubjectAlternativeNames); + Assert.Equal(sanDomains.Length, request.SubjectAlternativeNames.Count); + Assert.Equal(sanDomains, request.SubjectAlternativeNames.Select(s => s.DomainName)); + Assert.All(request.SubjectAlternativeNames, s => Assert.NotNull(s.DomainControlValidation)); + } + // --------------------------------------------------------------------- // GetNotifications // --------------------------------------------------------------------- From 8da3e0c61c9354ba1c76f39692f71ca3c528866f Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Fri, 18 Sep 2026 15:51:44 -0400 Subject: [PATCH 32/36] Fix typo'd lookup key breaking per-SAN DCV email overrides RequestManager.GetSubjectAlternativeNames looked up "Addtl Sans Comma Separated DVC Emails" (V/C swapped), but the field is registered and sent by Command as "Addtl Sans Comma Separated DCV Emails" per EnrollmentConfigConstants.AdditionalSansCommaSeparatedDcvEmails. The lookup never matched, so any SAN on a domain unrelated to the CN silently fell back to the CN's own DCV email - which has no authority to validate a different domain. Now reads the same constant used to register the field, so the two can't drift apart again. Also updated the one existing test that had (accidentally) encoded the same typo, matching the buggy behavior instead of the real field name. --- CHANGELOG.md | 1 + cscglobal-caplugin.Tests/RequestManagerTests.cs | 2 +- cscglobal-caplugin/RequestManager.cs | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11bca87..3a1ff74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ v1.2.0 - Enrollment/renewal/reissue failures now include the FlowLogger step-by-step summary ahead of the underlying error message in StatusMessage, instead of just a terse error. Also fixed enrollment failures reporting Status 30 (INPROCESS) instead of the correct FAILED status, and an exception thrown mid-enrollment no longer bubbles up as an unhandled error - it's now returned as a proper failed EnrollmentResult with full flow context. - Fixed a crash when CSC Global returns a null "price.total" (e.g. reissuing a certificate that is not in an active status) - Price.Total is now nullable instead of causing a JSON deserialization exception. - Fixed successful Renew and Reissue requests incorrectly reporting Status GENERATED (certificate issued) when CSC Global has only submitted the request and is still waiting on domain control validation. This caused the AnyGateway REST host to crash trying to parse a certificate that doesn't exist yet. Renew/Reissue now report EXTERNALVALIDATION, matching new enrollment, and surface the pending CNAME/email DCV instructions the same way. +- Fixed the "Addtl Sans Comma Separated DCV Emails" enrollment field never actually being read during enrollment, due to a typo in the code looking up "DVC" instead of "DCV". Per-domain DCV emails for additional SANs on unrelated domains were silently ignored, falling back to the primary CN's DCV email - which does not have authority to validate a different domain. v.1.1.1 - Added Incremental Sync that goes back X Number of days diff --git a/cscglobal-caplugin.Tests/RequestManagerTests.cs b/cscglobal-caplugin.Tests/RequestManagerTests.cs index 5f2b530..ad82f3d 100644 --- a/cscglobal-caplugin.Tests/RequestManagerTests.cs +++ b/cscglobal-caplugin.Tests/RequestManagerTests.cs @@ -469,7 +469,7 @@ public void GetRegistrationRequest_MultiNameEmailMethod_MatchesAdditionalSanEmai var productInfo = ProductInfo("CSC TrustedSecure OV, Multiple Names", new Dictionary { ["Domain Control Validation Method"] = "EMAIL", - ["Addtl Sans Comma Separated DVC Emails"] = "admin@example.com,admin@other.com" + [EnrollmentConfigConstants.AdditionalSansCommaSeparatedDcvEmails] = "admin@example.com,admin@other.com" }); var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, sans, new List()); diff --git a/cscglobal-caplugin/RequestManager.cs b/cscglobal-caplugin/RequestManager.cs index 815b497..06f0b41 100644 --- a/cscglobal-caplugin/RequestManager.cs +++ b/cscglobal-caplugin/RequestManager.cs @@ -424,7 +424,7 @@ private List GetSubjectAlternativeNames(EnrollmentProduc san.DomainName = domainName; if (methodType.ToUpper() == "EMAIL") { - productInfo.ProductParameters.TryGetValue("Addtl Sans Comma Separated DVC Emails", out var addtlSansEmails); + productInfo.ProductParameters.TryGetValue(EnrollmentConfigConstants.AdditionalSansCommaSeparatedDcvEmails, out var addtlSansEmails); var emailAddresses = string.IsNullOrWhiteSpace(addtlSansEmails) ? Array.Empty() : addtlSansEmails.Split(','); From d9597e1cfb82326bbcf0e977dc3b07aa17525de6 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Fri, 18 Sep 2026 16:09:56 -0400 Subject: [PATCH 33/36] Attach flow summary to EnrollmentContext on success Command's enrollment UI does not surface StatusMessage on a successful result - only EnrollmentContext (the DCV instructions dictionary) is shown. Renamed EnrichFailureWithFlowSummary to AttachFlowSummary and extended it to add a "Flow Summary" entry to EnrollmentContext on success, alongside whatever DCV entries came back, so the requester can see what the plugin actually did even when there's nothing to troubleshoot. --- CHANGELOG.md | 1 + .../CSCGlobalCAPluginTests.cs | 31 +++++++++++++++++++ cscglobal-caplugin/CSCGlobalCAPlugin.cs | 23 ++++++++++---- 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a1ff74..08f86a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ v1.2.0 - Fixed a crash when CSC Global returns a null "price.total" (e.g. reissuing a certificate that is not in an active status) - Price.Total is now nullable instead of causing a JSON deserialization exception. - Fixed successful Renew and Reissue requests incorrectly reporting Status GENERATED (certificate issued) when CSC Global has only submitted the request and is still waiting on domain control validation. This caused the AnyGateway REST host to crash trying to parse a certificate that doesn't exist yet. Renew/Reissue now report EXTERNALVALIDATION, matching new enrollment, and surface the pending CNAME/email DCV instructions the same way. - Fixed the "Addtl Sans Comma Separated DCV Emails" enrollment field never actually being read during enrollment, due to a typo in the code looking up "DVC" instead of "DCV". Per-domain DCV emails for additional SANs on unrelated domains were silently ignored, falling back to the primary CN's DCV email - which does not have authority to validate a different domain. +- On successful enrollment/renewal/reissue, the FlowLogger step summary is now attached to EnrollmentContext (as its own "Flow Summary" entry, alongside any DCV instructions) since Command's enrollment UI does not surface StatusMessage on success. v.1.1.1 - Added Incremental Sync that goes back X Number of days diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs index 66419b8..2c67856 100644 --- a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs @@ -609,6 +609,37 @@ public async Task Enroll_Reissue_ValidCertWithApplicantLastName_Succeeds() RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result!.Status); + Assert.NotNull(result.EnrollmentContext); + Assert.Contains("Flow: Enroll", result.EnrollmentContext["Flow Summary"]); + Assert.Contains("SubmitReissue", result.EnrollmentContext["Flow Summary"]); + } + + [Fact] + public async Task Enroll_New_Success_AttachesFlowSummaryAlongsideDcvContext() + { + // On success, StatusMessage isn't surfaced by Command's enrollment UI - only + // EnrollmentContext is - so the flow summary must ride alongside whatever DCV + // instructions came back, not replace them. + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse + { + Result = new Result + { + CommonName = "new.example.com", + Status = new Status { Uuid = "uuid-new" }, + DcvDetails = new List { new DcvDetail { Email = "admin@example.com" } } + } + }); + + var plugin = MakePlugin(mockClient); + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), + RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result!.Status); + Assert.NotNull(result.EnrollmentContext); + Assert.Equal("admin@example.com", result.EnrollmentContext["admin@example.com"]); + Assert.Contains("Flow: Enroll", result.EnrollmentContext["Flow Summary"]); } [Fact] diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index 7ee9d80..d6ecaf1 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -333,7 +333,7 @@ public async Task Enroll(string csr, string subject, Dictionar flow.EndBranch(); var newResult = _requestManager.GetEnrollmentResult(enrollmentResponse); - EnrichFailureWithFlowSummary(newResult, flow); + AttachFlowSummary(newResult, flow); LogEnrollmentOutcome(newResult, "New Enrollment"); Logger.MethodExit(LogLevel.Debug); return newResult; @@ -393,7 +393,7 @@ public async Task Enroll(string csr, string subject, Dictionar Logger.LogTrace($"Renewal Response JSON: {JsonConvert.SerializeObject(renewResponse)}"); flow.EndBranch(); var renewResult = _requestManager.GetRenewResponse(renewResponse); - EnrichFailureWithFlowSummary(renewResult, flow); + AttachFlowSummary(renewResult, flow); LogEnrollmentOutcome(renewResult, "Renewal"); Logger.MethodExit(LogLevel.Debug); return renewResult; @@ -437,7 +437,7 @@ public async Task Enroll(string csr, string subject, Dictionar Logger.LogTrace($"Reissue Response JSON: {JsonConvert.SerializeObject(reissueResponse)}"); flow.EndBranch(); var reissueResult = _requestManager.GetReIssueResult(reissueResponse); - EnrichFailureWithFlowSummary(reissueResult, flow); + AttachFlowSummary(reissueResult, flow); LogEnrollmentOutcome(reissueResult, "Reissue"); Logger.MethodExit(LogLevel.Debug); return reissueResult; @@ -486,11 +486,22 @@ private void LogEnrollmentOutcome(EnrollmentResult result, string operationName) // CSC Global business-level failures (e.g. "Open order in progress") come back from // RequestManager as a terse StatusMessage with no context on what the plugin actually did // before hitting that error. Prepend the flow's step-by-step summary so the message shown - // to the requester in Command explains what ran, not just how it ended. - private static void EnrichFailureWithFlowSummary(EnrollmentResult result, FlowLogger flow) + // to the requester in Command explains what ran, not just how it ended. On success, the + // requester-facing StatusMessage isn't surfaced by Command's enrollment UI at all - only + // EnrollmentContext is - so attach the summary there instead, as its own entry alongside + // whatever DCV instructions came back. + private static void AttachFlowSummary(EnrollmentResult result, FlowLogger flow) { - if (result?.Status == (int)EndEntityStatus.FAILED) + if (result == null) return; + + if (result.Status == (int)EndEntityStatus.FAILED) + { result.StatusMessage = $"{flow.GetSummary()}\n\n{result.StatusMessage}"; + return; + } + + result.EnrollmentContext ??= new Dictionary(); + result.EnrollmentContext["Flow Summary"] = flow.GetSummary(); } //done From 3c43b8d62cc4093ea96c55e2ddcbc4cb0241de9e Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Fri, 18 Sep 2026 16:26:44 -0400 Subject: [PATCH 34/36] Render flow summary as one bullet per step instead of one blob Command's enrollment UI renders EnrollmentContext as a bulleted list but doesn't respect embedded newlines within a single entry's value, so the multi-line flow summary was showing up as one unreadable run-on line. Added FlowLogger.GetSummaryEntries(), which returns one dictionary entry per step (plus an overview entry) instead of a single multi-line string, so each step naturally becomes its own bullet - using the UI's existing list rendering rather than trying to force line breaks into a string value. --- CHANGELOG.md | 2 +- .../CSCGlobalCAPluginTests.cs | 7 ++-- cscglobal-caplugin.Tests/FlowLoggerTests.cs | 27 +++++++++++++ cscglobal-caplugin/CSCGlobalCAPlugin.cs | 8 ++-- cscglobal-caplugin/FlowLogger.cs | 39 +++++++++++++++++++ 5 files changed, 76 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08f86a2..cab5c5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ v1.2.0 - Fixed a crash when CSC Global returns a null "price.total" (e.g. reissuing a certificate that is not in an active status) - Price.Total is now nullable instead of causing a JSON deserialization exception. - Fixed successful Renew and Reissue requests incorrectly reporting Status GENERATED (certificate issued) when CSC Global has only submitted the request and is still waiting on domain control validation. This caused the AnyGateway REST host to crash trying to parse a certificate that doesn't exist yet. Renew/Reissue now report EXTERNALVALIDATION, matching new enrollment, and surface the pending CNAME/email DCV instructions the same way. - Fixed the "Addtl Sans Comma Separated DCV Emails" enrollment field never actually being read during enrollment, due to a typo in the code looking up "DVC" instead of "DCV". Per-domain DCV emails for additional SANs on unrelated domains were silently ignored, falling back to the primary CN's DCV email - which does not have authority to validate a different domain. -- On successful enrollment/renewal/reissue, the FlowLogger step summary is now attached to EnrollmentContext (as its own "Flow Summary" entry, alongside any DCV instructions) since Command's enrollment UI does not surface StatusMessage on success. +- On successful enrollment/renewal/reissue, the FlowLogger step summary is now attached to EnrollmentContext as one entry per step (rendered as separate bullets by Command's enrollment UI, alongside any DCV instructions) instead of one multi-line block, since that UI does not render embedded newlines. v.1.1.1 - Added Incremental Sync that goes back X Number of days diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs index 2c67856..e6857b5 100644 --- a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs @@ -610,8 +610,8 @@ public async Task Enroll_Reissue_ValidCertWithApplicantLastName_Succeeds() Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result!.Status); Assert.NotNull(result.EnrollmentContext); - Assert.Contains("Flow: Enroll", result.EnrollmentContext["Flow Summary"]); - Assert.Contains("SubmitReissue", result.EnrollmentContext["Flow Summary"]); + Assert.Contains(result.EnrollmentContext.Keys, k => k.StartsWith("Flow: Enroll")); + Assert.Contains(result.EnrollmentContext.Keys, k => k.Contains("SubmitReissue")); } [Fact] @@ -639,7 +639,8 @@ public async Task Enroll_New_Success_AttachesFlowSummaryAlongsideDcvContext() Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result!.Status); Assert.NotNull(result.EnrollmentContext); Assert.Equal("admin@example.com", result.EnrollmentContext["admin@example.com"]); - Assert.Contains("Flow: Enroll", result.EnrollmentContext["Flow Summary"]); + Assert.Contains(result.EnrollmentContext.Keys, k => k.StartsWith("Flow: Enroll")); + Assert.Contains(result.EnrollmentContext.Keys, k => k.Contains("SubmitRegistration")); } [Fact] diff --git a/cscglobal-caplugin.Tests/FlowLoggerTests.cs b/cscglobal-caplugin.Tests/FlowLoggerTests.cs index 15b7f5e..6c71329 100644 --- a/cscglobal-caplugin.Tests/FlowLoggerTests.cs +++ b/cscglobal-caplugin.Tests/FlowLoggerTests.cs @@ -184,6 +184,33 @@ public void GetSummary_NoFailures_ReportsOk() Assert.DoesNotContain("FAILED", flow.GetSummary()); } + [Fact] + public void GetSummaryEntries_OneEntryPerStepPlusOverview() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Step("Ok"); + flow.Skip("Skipped", "n/a"); + flow.Fail("Failed", "bad"); + + var entries = flow.GetSummaryEntries(); + + // 1 overview entry + 3 step entries. + Assert.Equal(4, entries.Count); + Assert.Contains(entries.Keys, k => k.StartsWith("Flow: Flow")); + Assert.Contains("FAILED", entries.Single(e => e.Key.StartsWith("Flow: Flow")).Value); + Assert.Contains(entries, e => e.Key.EndsWith(": Ok") && e.Value.StartsWith("[OK]")); + Assert.Contains(entries, e => e.Key.EndsWith(": Skipped") && e.Value.Contains("n/a")); + Assert.Contains(entries, e => e.Key.EndsWith(": Failed") && e.Value.Contains("bad")); + } + + [Fact] + public void GetSummaryEntries_NoSteps_ReturnsOnlyOverview() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + var entries = flow.GetSummaryEntries(); + Assert.Single(entries); + } + [Fact] public void Dispose_DoesNotThrow() { diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index d6ecaf1..2bf7fa5 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -488,8 +488,9 @@ private void LogEnrollmentOutcome(EnrollmentResult result, string operationName) // before hitting that error. Prepend the flow's step-by-step summary so the message shown // to the requester in Command explains what ran, not just how it ended. On success, the // requester-facing StatusMessage isn't surfaced by Command's enrollment UI at all - only - // EnrollmentContext is - so attach the summary there instead, as its own entry alongside - // whatever DCV instructions came back. + // EnrollmentContext is, rendered as a bulleted list - so attach one bullet per flow step + // there instead (rather than one embedded-newline blob, which the list doesn't render as + // separate lines), alongside whatever DCV instructions came back. private static void AttachFlowSummary(EnrollmentResult result, FlowLogger flow) { if (result == null) return; @@ -501,7 +502,8 @@ private static void AttachFlowSummary(EnrollmentResult result, FlowLogger flow) } result.EnrollmentContext ??= new Dictionary(); - result.EnrollmentContext["Flow Summary"] = flow.GetSummary(); + foreach (var entry in flow.GetSummaryEntries()) + result.EnrollmentContext[entry.Key] = entry.Value; } //done diff --git a/cscglobal-caplugin/FlowLogger.cs b/cscglobal-caplugin/FlowLogger.cs index 711cac9..601115e 100644 --- a/cscglobal-caplugin/FlowLogger.cs +++ b/cscglobal-caplugin/FlowLogger.cs @@ -230,6 +230,45 @@ public string GetSummary() return sb.ToString(); } + /// + /// Same information as , but as one entry per step instead of a + /// single multi-line block. Intended for callers (e.g. EnrollmentResult.EnrollmentContext) + /// whose rendering surface displays a dictionary as a bulleted list and doesn't respect + /// embedded newlines - each step becomes its own bullet instead of one run-on line. + /// + public Dictionary GetSummaryEntries() + { + var overallStatus = HasFailures ? "FAILED" : "OK"; + var succeeded = _steps.Count(s => s.Status == StepStatus.Success); + var failed = _steps.Count(s => s.Status == StepStatus.Failed); + var skipped = _steps.Count(s => s.Status == StepStatus.Skipped); + + var entries = new Dictionary + { + [$"Flow: {_flowName}"] = + $"[{overallStatus}] {_overallStopwatch.ElapsedMilliseconds}ms total - " + + $"{_steps.Count} steps ({succeeded} ok, {failed} failed, {skipped} skipped)" + }; + + for (var i = 0; i < _steps.Count; i++) + { + var step = _steps[i]; + var icon = step.Status == StepStatus.Success ? "OK" + : step.Status == StepStatus.Failed ? "FAIL" + : step.Status == StepStatus.Skipped ? "SKIP" + : "..."; + var time = step.ElapsedMs.HasValue ? $" ({step.ElapsedMs}ms)" : ""; + var detail = !string.IsNullOrEmpty(step.ErrorMessage) ? step.ErrorMessage + : !string.IsNullOrEmpty(step.Detail) ? step.Detail + : null; + + entries[$"Flow Step {i + 1:00}: {step.Name}"] = + detail == null ? $"[{icon}]{time}" : $"[{icon}]{time} - {detail}"; + } + + return entries; + } + public void Dispose() { _overallStopwatch.Stop(); From e78789b29bf4c3ae4169ea9e765c9338b81f5798 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 22 Sep 2026 15:31:17 -0400 Subject: [PATCH 35/36] Port 200-day renewal fix: base RenewOrReissue on order expiry, not cert expiry CSC's order is a fixed 1-year paid subscription that can cover multiple shorter-lived certificates (e.g. ~200 days). Deciding Renewal vs Reissue purely from the certificate's own expiration date could trigger a paid Renewal months before the underlying order was actually due to expire, or a free Reissue after it had already expired. Add a RenewalWindowDays setting (default 30) and fetch the live order from CSC to compute orderExpiry = orderDate + 1 year; Renewal triggers when today falls within that window of orderExpiry, Reissue otherwise. Falls back to the previous cert-expiry check if the live fetch fails or the order date can't be parsed. FlowLogger now records the actual analysis (order date, computed expiry, days remaining, window, and the resulting decision) as its own step, rather than a hardcoded "cert is expired"/"cert is still valid" label, so the reasoning is visible in the flow summary alongside every other step. --- .../CSCGlobalCAPluginTests.cs | 213 +++++++++++++++++- cscglobal-caplugin/CSCGlobalCAPlugin.cs | 69 +++++- cscglobal-caplugin/Constants.cs | 1 + 3 files changed, 273 insertions(+), 10 deletions(-) diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs index e6857b5..7eba077 100644 --- a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs @@ -108,6 +108,37 @@ public void Initialize_MissingSyncFilterDays_LeavesDefault() Assert.Equal(0, plugin.SyncFilterDays); } + [Fact] + public void Initialize_ValidRenewalWindowDays_ParsesValue() + { + var data = ValidConnectionData(); + data[Constants.RenewalWindowDays] = "45"; + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(new FakeConfigProvider { CAConnectionData = data }, Mock.Of()); + Assert.Equal(45, plugin.RenewalWindowDays); + } + + [Fact] + public void Initialize_MissingRenewalWindowDays_DefaultsTo30() + { + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(new FakeConfigProvider { CAConnectionData = ValidConnectionData() }, Mock.Of()); + Assert.Equal(30, plugin.RenewalWindowDays); + } + + [Theory] + [InlineData("not-a-number")] + [InlineData("-5")] + [InlineData("0")] + public void Initialize_InvalidRenewalWindowDays_DefaultsTo30(string raw) + { + var data = ValidConnectionData(); + data[Constants.RenewalWindowDays] = raw; + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(new FakeConfigProvider { CAConnectionData = data }, Mock.Of()); + Assert.Equal(30, plugin.RenewalWindowDays); + } + // --------------------------------------------------------------------- // GetSingleRecord // --------------------------------------------------------------------- @@ -614,6 +645,185 @@ public async Task Enroll_Reissue_ValidCertWithApplicantLastName_Succeeds() Assert.Contains(result.EnrollmentContext.Keys, k => k.Contains("SubmitReissue")); } + // --------------------------------------------------------------------- + // RenewOrReissue - order-expiry-window decision ("200 day" fix) + // + // CSC's order is a fixed 1-year paid subscription; a shorter-lived certificate (e.g. + // ~200 days) issued under it can still have plenty of runway left on the order itself. + // The decision must be based on the order's expiry (orderDate + 1 year, vs + // RenewalWindowDays), not the certificate's own expiration date. + // --------------------------------------------------------------------- + + [Fact] + public async Task Enroll_RenewOrReissue_OrderNearExpiryWithinWindow_TriggersRenewalEvenThoughCertNotExpired() + { + var uuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + // Order was placed 350 days ago -> expires in 15 days, inside the default 30-day window. + mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = DateTime.UtcNow.AddDays(-350).ToString("o") + }); + mockClient.Setup(c => c.SubmitRenewalAsync(It.IsAny())).ReturnsAsync(new RenewalResponse + { + Result = new Result { CommonName = "renewed.example.com" } + }); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(uuid); + // The certificate itself still has 60 days left - under the old cert-expiry-only + // logic this would incorrectly route to Reissue. + certDataReader.Setup(r => r.GetExpirationDateByRequestId(uuid)).Returns(DateTime.Now.AddDays(60)); + + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary + { + ["PriorCertSN"] = "ABC123", + ["Applicant Last Name"] = "Doe" + }); + + 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.SubmitRenewalAsync(It.IsAny()), Times.Once); + mockClient.Verify(c => c.SubmitReissueAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Enroll_RenewOrReissue_OrderFarFromExpiry_TriggersReissueEvenThoughCertExpirationLooksExpired() + { + var uuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + // Order was placed 30 days ago -> expires in ~335 days, nowhere near the 30-day window. + mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = DateTime.UtcNow.AddDays(-30).ToString("o") + }); + mockClient.Setup(c => c.SubmitReissueAsync(It.IsAny())).ReturnsAsync(new ReissueResponse + { + Result = new Result { CommonName = "reissued.example.com" } + }); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(uuid); + // The locally-recorded cert expiration looks expired - under the old cert-expiry-only + // logic this would incorrectly route to a paid Renewal. + certDataReader.Setup(r => r.GetExpirationDateByRequestId(uuid)).Returns(DateTime.Now.AddDays(-5)); + + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary + { + ["PriorCertSN"] = "ABC123", + ["Applicant Last Name"] = "Doe" + }); + + 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); + mockClient.Verify(c => c.SubmitRenewalAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Enroll_RenewOrReissue_LiveCertFetchThrows_FallsBackToCertExpiryCheck() + { + var uuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ThrowsAsync(new InvalidOperationException("network error")); + mockClient.Setup(c => c.SubmitReissueAsync(It.IsAny())).ReturnsAsync(new ReissueResponse + { + Result = new Result { CommonName = "reissued.example.com" } + }); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(uuid); + certDataReader.Setup(r => r.GetExpirationDateByRequestId(uuid)).Returns(DateTime.Now.AddDays(30)); + + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary + { + ["PriorCertSN"] = "ABC123", + ["Applicant Last Name"] = "Doe" + }); + + 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 async Task Enroll_RenewOrReissue_LiveCertOrderDateUnparsable_FallsBackToCertExpiryCheck() + { + var uuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ReturnsAsync(new CertificateResponse { OrderDate = null }); + mockClient.Setup(c => c.SubmitRenewalAsync(It.IsAny())).ReturnsAsync(new RenewalResponse + { + Result = new Result { CommonName = "renewed.example.com" } + }); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(uuid); + certDataReader.Setup(r => r.GetExpirationDateByRequestId(uuid)).Returns(DateTime.Now.AddDays(-1)); + + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary + { + ["PriorCertSN"] = "ABC123", + ["Applicant Last Name"] = "Doe" + }); + + 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.SubmitRenewalAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Enroll_RenewOrReissue_FlowSummaryIncludesRenewalAnalysisDetail() + { + var uuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = DateTime.UtcNow.AddDays(-350).ToString("o") + }); + mockClient.Setup(c => c.SubmitRenewalAsync(It.IsAny())).ReturnsAsync(new RenewalResponse + { + Result = new Result { CommonName = "renewed.example.com" } + }); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(uuid); + certDataReader.Setup(r => r.GetExpirationDateByRequestId(uuid)).Returns(DateTime.Now.AddDays(60)); + + var plugin = MakePlugin(mockClient, certDataReader); + var productInfo = ProductInfo(parameters: new Dictionary + { + ["PriorCertSN"] = "ABC123", + ["Applicant Last Name"] = "Doe" + }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.RenewOrReissue); + + Assert.NotNull(result.EnrollmentContext); + var decisionEntry = result.EnrollmentContext.Single(e => e.Key.Contains("DetermineRenewOrReissue")); + Assert.Contains("orderDate=", decisionEntry.Value); + Assert.Contains("isRenewal=True", decisionEntry.Value); + Assert.Contains(result.EnrollmentContext.Keys, k => k.Contains("FetchLiveCertForDecision")); + } + [Fact] public async Task Enroll_New_Success_AttachesFlowSummaryAlongsideDcvContext() { @@ -814,12 +1024,13 @@ public void GetCAConnectorAnnotations_ContainsExpectedKeys() var plugin = MakePlugin(); var annotations = plugin.GetCAConnectorAnnotations(); - Assert.Equal(5, annotations.Count); + Assert.Equal(6, annotations.Count); 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.SyncFilterDays, annotations.Keys); + Assert.Contains(Constants.RenewalWindowDays, annotations.Keys); Assert.True(annotations[Constants.CscGlobalApiKey].Hidden); } diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index 2bf7fa5..5dc1e61 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -42,6 +42,12 @@ public CSCGlobalCAPlugin() public int SyncFilterDays { get; set; } + // CSC's order is a fixed 1-year paid subscription that a single cert renewal doesn't reset, + // so a shorter-lived cert (e.g. ~200 days) can come up for renewal well before its order + // actually expires. RenewOrReissue uses this window (days before order expiry) to decide + // whether to submit a paid Renewal or a free Reissue under the still-active order. + public int RenewalWindowDays { get; set; } + //done public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDataReader certificateDataReader) { @@ -64,6 +70,16 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa } } + RenewalWindowDays = 30; // default + if (configProvider.CAConnectionData.TryGetValue(Constants.RenewalWindowDays, out var renewalWindowObj)) + { + if (int.TryParse(renewalWindowObj?.ToString(), out var renewalWindowDays) && renewalWindowDays > 0) + RenewalWindowDays = renewalWindowDays; + else + Logger.LogWarning($"Could not parse {Constants.RenewalWindowDays} value '{renewalWindowObj}' as a positive integer; using default of {RenewalWindowDays} days"); + } + Logger.LogDebug("RenewalWindowDays configured to {Days} days", RenewalWindowDays); + Logger.LogInformation("CSCGlobalCAPlugin initialized successfully"); Logger.MethodExit(LogLevel.Debug); } @@ -351,8 +367,6 @@ public async Task Enroll(string csr, string subject, Dictionar }; } - //Logic to determine renew vs reissue - var renewal = false; var order_id = await _certificateDataReader.GetRequestIDBySerialNumber(priorSn); if (string.IsNullOrEmpty(order_id)) { @@ -366,17 +380,48 @@ public async Task Enroll(string csr, string subject, Dictionar }; } - var expirationDate = _certificateDataReader.GetExpirationDateByRequestId(order_id); - if (expirationDate == null) + // Determine renew vs reissue based on the CSC order's actual expiry window, + // not the certificate's own validity period - CSC's order is a fixed 1-year + // paid subscription that can cover multiple shorter-lived certs (e.g. ~200 + // days), so basing this on the cert's own expiration would trigger a paid + // Renewal months before the order itself is actually due to expire. + var renewal = false; + try + { + var liveCert = await flow.StepAsync("FetchLiveCertForDecision", + () => CscGlobalClient.SubmitGetCertificateAsync(order_id[..36])); + + if (liveCert != null && DateTime.TryParse(liveCert.OrderDate, out var orderDate)) + { + var orderExpiry = orderDate.AddYears(1); + var daysUntilOrderExpiry = (orderExpiry - DateTime.UtcNow).TotalDays; + renewal = daysUntilOrderExpiry <= RenewalWindowDays; + flow.Step("DetermineRenewOrReissue", + $"orderDate={liveCert.OrderDate}, orderExpiry={orderExpiry:dd-MMM-yyyy}, daysRemaining={(int)daysUntilOrderExpiry}, renewalWindow={RenewalWindowDays}, isRenewal={renewal}"); + } + else + { + flow.Skip("FetchLiveCertForDecision", "orderDate unavailable, falling back to cert expiry"); + var fallbackExpirationDate = _certificateDataReader.GetExpirationDateByRequestId(order_id) + ?? (await GetSingleRecord(order_id))?.RevocationDate; + renewal = fallbackExpirationDate < DateTime.Now; + flow.Step("DetermineRenewOrReissue", + $"fallback expiry check: expirationDate={fallbackExpirationDate?.ToString("o") ?? "(null)"}, isRenewal={renewal}"); + } + } + catch (Exception ex) { - var localcert = await GetSingleRecord(order_id); - expirationDate = localcert?.RevocationDate; + flow.Fail("FetchLiveCertForDecision", $"falling back to cert expiry: {ex.Message}"); + Logger.LogWarning(ex, "RenewOrReissue: failed to fetch live cert for order decision, falling back to cert expiry."); + var fallbackExpirationDate = _certificateDataReader.GetExpirationDateByRequestId(order_id) + ?? (await GetSingleRecord(order_id))?.RevocationDate; + renewal = fallbackExpirationDate < DateTime.Now; + flow.Step("DetermineRenewOrReissue", + $"fallback expiry check: expirationDate={fallbackExpirationDate?.ToString("o") ?? "(null)"}, isRenewal={renewal}"); } - if (expirationDate < DateTime.Now) renewal = true; if (renewal) { - flow.Step("DetermineRenewOrReissue", "Renewal - cert is expired"); //One click won't work for this implementation b/c we are missing enrollment params if (productParameters.ContainsKey("Applicant Last Name")) { @@ -410,7 +455,6 @@ public async Task Enroll(string csr, string subject, Dictionar }; } - flow.Step("DetermineRenewOrReissue", "Reissue - cert is still valid"); //One click won't work for this implementation b/c we are missing enrollment params if (productParameters.ContainsKey("Applicant Last Name")) { @@ -591,6 +635,13 @@ public Dictionary GetCAConnectorAnnotations() Hidden = false, DefaultValue = "5", Type = "Number" + }, + [Constants.RenewalWindowDays] = new() + { + Comments = "Number of days before the annual order expiry within which a RenewOrReissue triggers a paid Renewal rather than a free Reissue. Default is 30.", + Hidden = false, + DefaultValue = "30", + Type = "Number" } }; } diff --git a/cscglobal-caplugin/Constants.cs b/cscglobal-caplugin/Constants.cs index 69e07e4..287067d 100644 --- a/cscglobal-caplugin/Constants.cs +++ b/cscglobal-caplugin/Constants.cs @@ -14,6 +14,7 @@ public class Constants public static string BearerToken = "BearerToken"; public static string DefaultPageSize = "DefaultPageSize"; public static string SyncFilterDays = "SyncFilterDays"; + public static string RenewalWindowDays = "RenewalWindowDays"; } public class ProductIDs From 59268295305a565bf4a070e90ca28ac56edbd294 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 22 Sep 2026 16:13:05 -0400 Subject: [PATCH 36/36] Add Enabled CA connection flag Adds a config-driven Enabled toggle (default true) so a CA record can be created and saved before valid API credentials are available, or intentionally disabled later without deleting the connection: - Initialize parses Enabled from CAConnectionData. - Enroll/Revoke/Synchronize/Ping reject or skip cleanly instead of calling the CSC Global API when disabled. - ValidateCAConnectionInfo/ValidateProductInfo honor the incoming connectionInfo's Enabled value (which may differ from Initialize's snapshot while the CA is being edited), skipping validation so templates/connections can be saved on a disabled CA. - Documented via GetCAConnectorAnnotations. --- .../CSCGlobalCAPluginTests.cs | 110 +++++++++++++++++- cscglobal-caplugin/CSCGlobalCAPlugin.cs | 93 +++++++++++++++ cscglobal-caplugin/Constants.cs | 1 + 3 files changed, 203 insertions(+), 1 deletion(-) diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs index 7eba077..16fd258 100644 --- a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs @@ -80,6 +80,34 @@ public void Initialize_NullCertDataReader_Throws() plugin.Initialize(new FakeConfigProvider { CAConnectionData = ValidConnectionData() }, null!)); } + [Fact] + public void Initialize_MissingEnabled_DefaultsToTrue() + { + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(new FakeConfigProvider { CAConnectionData = ValidConnectionData() }, Mock.Of()); + Assert.True(plugin.Enabled); + } + + [Fact] + public void Initialize_ExplicitlyDisabled_ParsesFalse() + { + var data = ValidConnectionData(); + data[Constants.Enabled] = "false"; + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(new FakeConfigProvider { CAConnectionData = data }, Mock.Of()); + Assert.False(plugin.Enabled); + } + + [Fact] + public void Initialize_UnparsableEnabled_DefaultsToTrue() + { + var data = ValidConnectionData(); + data[Constants.Enabled] = "not-a-bool"; + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(new FakeConfigProvider { CAConnectionData = data }, Mock.Of()); + Assert.True(plugin.Enabled); + } + [Fact] public void Initialize_ValidSyncFilterDays_ParsesValue() { @@ -384,6 +412,22 @@ await Assert.ThrowsAsync(() => Assert.True(buffer.IsAddingCompleted); } + [Fact] + public async Task Synchronize_Disabled_CompletesBufferWithoutCallingClient() + { + var mockClient = new Mock(); + var data = ValidConnectionData(); + data[Constants.Enabled] = "false"; + var plugin = MakePlugin(mockClient, connectionData: data); + var buffer = new BlockingCollection(); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + Assert.True(buffer.IsAddingCompleted); + Assert.Empty(buffer); + mockClient.Verify(c => c.SubmitCertificateListRequestAsync(It.IsAny()), Times.Never); + } + // --------------------------------------------------------------------- // Revoke // --------------------------------------------------------------------- @@ -435,6 +479,18 @@ public async Task Revoke_FailedWithNoErrorDescription_ReturnsFailedStatus() Assert.Equal((int)EndEntityStatus.FAILED, status); } + [Fact] + public async Task Revoke_Disabled_ThrowsInvalidOperationException() + { + var mockClient = new Mock(); + var data = ValidConnectionData(); + data[Constants.Enabled] = "false"; + var plugin = MakePlugin(mockClient, connectionData: data); + + await Assert.ThrowsAsync(() => plugin.Revoke(Guid.NewGuid().ToString(), "AB12", 0)); + mockClient.Verify(c => c.SubmitRevokeCertificateAsync(It.IsAny()), Times.Never); + } + // --------------------------------------------------------------------- // Enroll // --------------------------------------------------------------------- @@ -447,6 +503,22 @@ await Assert.ThrowsAsync(() => plugin.Enroll("csr", "subject", new Dictionary(), null!, RequestFormat.PKCS10, EnrollmentType.New)); } + [Fact] + public async Task Enroll_Disabled_ReturnsFailedWithoutCallingClient() + { + var mockClient = new Mock(); + var data = ValidConnectionData(); + data[Constants.Enabled] = "false"; + var plugin = MakePlugin(mockClient, connectionData: data); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), + RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.FAILED, result!.Status); + Assert.Contains("Disabled", result.StatusMessage); + mockClient.Verify(c => c.SubmitGetCustomFields(), Times.Never); + } + [Fact] public async Task Enroll_New_Success_ReturnsExternalValidation() { @@ -971,6 +1043,15 @@ public async Task Ping_DoesNotThrow() await plugin.Ping(); } + [Fact] + public async Task Ping_Disabled_DoesNotThrow() + { + var data = ValidConnectionData(); + data[Constants.Enabled] = "false"; + var plugin = MakePlugin(connectionData: data); + await plugin.Ping(); + } + [Fact] public async Task ValidateCAConnectionInfo_NullConnectionInfo_DoesNotThrow() { @@ -985,6 +1066,13 @@ public async Task ValidateCAConnectionInfo_WithConnectionInfo_DoesNotThrow() await plugin.ValidateCAConnectionInfo(new Dictionary { ["Key"] = "Value" }); } + [Fact] + public async Task ValidateCAConnectionInfo_ExplicitlyDisabled_DoesNotThrow() + { + var plugin = MakePlugin(); + await plugin.ValidateCAConnectionInfo(new Dictionary { [Constants.Enabled] = "false" }); + } + [Fact] public async Task ValidateProductInfo_KnownProduct_DoesNotThrow() { @@ -1014,6 +1102,25 @@ public async Task ValidateProductInfo_LegacyProductName_DoesNotThrow() await plugin.ValidateProductInfo(ProductInfo("CSC TrustedSecure UC Certificate"), new Dictionary()); } + [Fact] + public async Task ValidateProductInfo_DisabledConnector_SkipsValidationEvenForUnknownProduct() + { + var plugin = MakePlugin(); + var connectionInfo = new Dictionary { [Constants.Enabled] = "false" }; + + // Should not throw even though the product is unknown - Enabled=false short-circuits + // validation entirely (pre-configuration workflow). + await plugin.ValidateProductInfo(ProductInfo("Not A Real Product"), connectionInfo); + } + + [Fact] + public async Task ValidateProductInfo_NullConnectionInfo_TreatsAsEnabled() + { + var plugin = MakePlugin(); + await Assert.ThrowsAsync(() => + plugin.ValidateProductInfo(ProductInfo("Not A Real Product"), null!)); + } + // --------------------------------------------------------------------- // GetCAConnectorAnnotations / GetTemplateParameterAnnotations / GetProductIds // --------------------------------------------------------------------- @@ -1024,7 +1131,8 @@ public void GetCAConnectorAnnotations_ContainsExpectedKeys() var plugin = MakePlugin(); var annotations = plugin.GetCAConnectorAnnotations(); - Assert.Equal(6, annotations.Count); + Assert.Equal(7, 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); diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index 5dc1e61..33d2306 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -42,6 +42,14 @@ public CSCGlobalCAPlugin() public int SyncFilterDays { get; set; } + /// + /// Whether the CA is enabled. When false, operations (Enroll, Revoke, Synchronize, Ping) + /// reject or skip cleanly instead of calling the CSC Global API, so a CA record can be + /// created and saved before valid API credentials are available (or intentionally + /// disabled later without deleting the CA connection). Defaults to true. + /// + public bool Enabled { get; set; } = true; + // CSC's order is a fixed 1-year paid subscription that a single cert renewal doesn't reset, // so a shorter-lived cert (e.g. ~200 days) can come up for renewal well before its order // actually expires. RenewOrReissue uses this window (days before order expiry) to decide @@ -56,6 +64,16 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa _certificateDataReader = certificateDataReader ?? throw new ArgumentNullException(nameof(certificateDataReader)); Config = configProvider; + Enabled = true; // default + if (configProvider.CAConnectionData.TryGetValue(Constants.Enabled, out var enabledObj)) + { + if (bool.TryParse(enabledObj?.ToString(), out var parsed)) + Enabled = parsed; + else + Logger.LogWarning($"Could not parse {Constants.Enabled} value '{enabledObj}' as a bool; defaulting to true"); + } + Logger.LogInformation("CA is {State}.", Enabled ? "Enabled" : "Disabled"); + if (configProvider.CAConnectionData.ContainsKey(Constants.SyncFilterDays)) { var syncFilterDaysStr = configProvider.CAConnectionData[Constants.SyncFilterDays]?.ToString(); @@ -135,6 +153,15 @@ public async Task Synchronize(BlockingCollection blockin Logger.LogTrace($"Full Sync? {fullSync.ToString()}"); Logger.MethodEntry(); using var flow = new FlowLogger(Logger, "Synchronize"); + + if (!Enabled) + { + Logger.LogWarning("The CA is currently in the Disabled state. It must be Enabled to perform operations. Skipping Synchronize."); + blockingBuffer.CompleteAdding(); + Logger.MethodExit(LogLevel.Debug); + return; + } + try { if (fullSync) @@ -253,6 +280,13 @@ public async Task Revoke(string caRequestID, string hexSerialNumber, uint r { Logger.MethodEntry(LogLevel.Debug); using var flow = new FlowLogger(Logger, "Revoke"); + + if (!Enabled) + { + Logger.LogWarning("The CA is currently in the Disabled state. It must be Enabled to perform operations. Rejecting Revoke."); + throw new InvalidOperationException("The CSC Global CA is in the Disabled state. Enable it to perform revocations."); + } + try { Logger.LogInformation($"Starting Revoke for CA request ID {caRequestID}, reason {revocationReason}"); @@ -304,6 +338,17 @@ public async Task Enroll(string csr, string subject, Dictionar Logger.LogInformation($"Starting Enroll for product {productInfo.ProductID}, enrollment type {enrollmentType}"); using var flow = new FlowLogger(Logger, "Enroll"); + if (!Enabled) + { + Logger.LogWarning("The CA is currently in the Disabled state. It must be Enabled to perform operations. Rejecting Enroll."); + flow.Fail("Enroll", "CA is Disabled"); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nThe CSC Global CA is in the Disabled state. Enable it to perform enrollments." + }; + } + try { RegistrationRequest enrollmentRequest; @@ -554,6 +599,14 @@ private static void AttachFlowSummary(EnrollmentResult result, FlowLogger flow) public async Task Ping() { Logger.MethodEntry(); + + if (!Enabled) + { + Logger.LogWarning("The CA is currently in the Disabled state. It must be Enabled to perform operations. Skipping Ping."); + Logger.MethodExit(); + return; + } + try { Logger.LogInformation("Ping request received"); @@ -572,6 +625,23 @@ public async Task ValidateCAConnectionInfo(Dictionary connection { Logger.MethodEntry(LogLevel.Debug); Logger.LogDebug($"Validating CA connection info with {connectionInfo?.Count ?? 0} entries"); + + // Honor the Enabled flag from the incoming connectionInfo (which may differ from + // Initialize's snapshot when the operator is currently editing the CA). If disabled, + // skip validation so the CA can be saved without valid credentials. + var incomingEnabled = true; + if (connectionInfo != null && + connectionInfo.TryGetValue(Constants.Enabled, out var enabledObj) && + bool.TryParse(enabledObj?.ToString(), out var parsed)) + incomingEnabled = parsed; + + if (!incomingEnabled) + { + Logger.LogWarning("The CA is currently in the Disabled state. It must be Enabled to perform operations. Skipping ValidateCAConnectionInfo."); + Logger.MethodExit(LogLevel.Debug); + return; + } + Logger.MethodExit(LogLevel.Debug); } @@ -580,6 +650,22 @@ public async Task ValidateProductInfo(EnrollmentProductInfo productInfo, Dictionary connectionInfo) { Logger.MethodEntry(LogLevel.Debug); + + // Honor the Enabled flag from the incoming connectionInfo. If the CA is disabled, skip + // validation so a template can be saved on a disabled CA (pre-configuration workflow). + var incomingEnabled = true; + if (connectionInfo != null && + connectionInfo.TryGetValue(Constants.Enabled, out var enabledObj) && + bool.TryParse(enabledObj?.ToString(), out var parsed)) + incomingEnabled = parsed; + + if (!incomingEnabled) + { + Logger.LogWarning("The CA is currently in the Disabled state. It must be Enabled to perform operations. Skipping ValidateProductInfo."); + Logger.MethodExit(LogLevel.Debug); + return; + } + var certType = ProductIDs.productIds.Find(x => x.Equals(productInfo.ProductID, StringComparison.InvariantCultureIgnoreCase)); @@ -601,6 +687,13 @@ public Dictionary GetCAConnectorAnnotations() { return new Dictionary { + [Constants.Enabled] = new() + { + Comments = "Whether the CA is enabled. When false, Enroll/Revoke/Synchronize/Ping reject or skip cleanly instead of calling the CSC Global API. Default is true.", + Hidden = false, + DefaultValue = "true", + Type = "String" + }, [Constants.CscGlobalUrl] = new() { Comments = "CSCGlobal API URL", diff --git a/cscglobal-caplugin/Constants.cs b/cscglobal-caplugin/Constants.cs index 287067d..f013902 100644 --- a/cscglobal-caplugin/Constants.cs +++ b/cscglobal-caplugin/Constants.cs @@ -9,6 +9,7 @@ namespace Keyfactor.Extensions.CAPlugin.CSCGlobal; public class Constants { + public static string Enabled = "Enabled"; public static string CscGlobalUrl = "CscGlobalUrl"; public static string CscGlobalApiKey = "ApiKey"; public static string BearerToken = "BearerToken";