From a1c23f6dff86951e7288b9bf1a4f04bf5d385d64 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 7 Apr 2026 16:04:44 -0400 Subject: [PATCH 01/42] 200 day renewal fixes --- .claude/settings.json | 8 ++++ cscglobal-caplugin/CSCGlobalCAPlugin.cs | 59 ++++++++++++++++++++++--- cscglobal-caplugin/Constants.cs | 1 + 3 files changed, 61 insertions(+), 7 deletions(-) create mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..c64f0fd --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(git fetch:*)", + "Bash(git checkout:*)" + ] + } +} diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index e1af2f0..7e6be8a 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -39,6 +39,8 @@ public CSCGlobalCAPlugin() public int SyncFilterDays { get; set; } + public int RenewalWindowDays { get; set; } + //done public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDataReader certificateDataReader) { @@ -57,6 +59,15 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa Logger.LogDebug($"SyncFilterDays configured to {SyncFilterDays} days"); } } + + RenewalWindowDays = 30; // default + if (configProvider.CAConnectionData.TryGetValue(Constants.RenewalWindowDays, out var renewalWindowObj)) + { + if (int.TryParse(renewalWindowObj?.ToString(), out var renewalWindowDays) && renewalWindowDays > 0) + RenewalWindowDays = renewalWindowDays; + } + Logger.LogDebug("RenewalWindowDays configured to {Days} days", RenewalWindowDays); + Logger.MethodExit(LogLevel.Debug); } @@ -252,17 +263,44 @@ public async Task Enroll(string csr, string subject, Dictionar 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) + + // Determine renew vs reissue based on order expiry window. + // Fetch the live cert record from CSC to get the orderDate, then compute + // orderExpiry = orderDate + 1 year (annual subscription assumption). + // If today falls within RenewalWindowDays of orderExpiry → Renewal (new paid order). + // Otherwise → Reissue (free reissue under the same active order). + var renewal = false; + try + { + var liveCert = await 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; + Logger.LogDebug( + "RenewOrReissue: orderDate={OrderDate}, orderExpiry={OrderExpiry}, daysRemaining={Days}, renewalWindow={Window}, isRenewal={IsRenewal}", + liveCert.OrderDate, orderExpiry.ToString("dd-MMM-yyyy"), + (int)daysUntilOrderExpiry, RenewalWindowDays, renewal); + } + else + { + // Fallback: if we can't parse orderDate, use cert expiration as before + var expirationDate = _certificateDataReader.GetExpirationDateByRequestId(order_id) + ?? (await GetSingleRecord(order_id)).RevocationDate; + renewal = expirationDate < DateTime.Now; + Logger.LogDebug("RenewOrReissue: falling back to cert expiry check, isRenewal={IsRenewal}", renewal); + } + } + catch (Exception ex) { - var localcert = await GetSingleRecord(order_id); - expirationDate = localcert.RevocationDate; + Logger.LogWarning("RenewOrReissue: failed to fetch live cert for order decision, falling back to cert expiry. Error: {Error}", ex.Message); + var expirationDate = _certificateDataReader.GetExpirationDateByRequestId(order_id) + ?? (await GetSingleRecord(order_id)).RevocationDate; + renewal = expirationDate < DateTime.Now; } - if (expirationDate < DateTime.Now) renewal = true; if (renewal) { //One click won't work for this implementation b/c we are missing enrollment params @@ -401,6 +439,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 4d6b4da..aede084 100644 --- a/cscglobal-caplugin/Constants.cs +++ b/cscglobal-caplugin/Constants.cs @@ -15,6 +15,7 @@ public class Constants public static string DefaultPageSize = "DefaultPageSize"; public static string TemplateSync = "TemplateSync"; public static string SyncFilterDays = "SyncFilterDays"; + public static string RenewalWindowDays = "RenewalWindowDays"; } public class ProductIDs From ea18abf3bdcbc3471868792976bb4a0e22989714 Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Tue, 7 Apr 2026 20:07:31 +0000 Subject: [PATCH 02/42] Update generated docs --- README.md | 13 +++++++------ integration-manifest.json | 4 ++++ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b7fb319..a83d582 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- CSCGlobal CA Gateway AnyCA Gateway REST Plugin + CSCGlobal CAPlugin AnyCA Gateway REST Plugin

@@ -38,10 +38,10 @@ This integration allows for the Synchronization, Enrollment, and Revocation of c ## Compatibility -The CSCGlobal CA Gateway AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 24.2.0 and later. +The CSCGlobal CAPlugin AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 24.2.0 and later. ## Support -The CSCGlobal CA Gateway 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 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. > 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. @@ -53,7 +53,7 @@ This integration is tested and confirmed as working for Anygateway REST 24.2 and 1. Install the AnyCA Gateway REST per the [official Keyfactor documentation](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/InstallIntroduction.htm). -2. On the server hosting the AnyCA Gateway REST, download and unzip the latest [CSCGlobal CA Gateway AnyCA Gateway REST plugin](https://github.com/Keyfactor/cscglobal-caplugin/releases/latest) from GitHub. +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: @@ -64,11 +64,11 @@ This integration is tested and confirmed as working for Anygateway REST 24.2 and Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net8.0\Extensions ``` - > The directory containing the CSCGlobal CA Gateway 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`) can be named anything, as long as it is unique within the `Extensions` directory. 4. Restart the AnyCA Gateway REST service. -5. Navigate to the AnyCA Gateway REST portal and verify that the Gateway recognizes the CSCGlobal CA Gateway plugin by hovering over the ⓘ symbol to the right of the Gateway on the top left of the portal. +5. Navigate to the AnyCA Gateway REST portal and verify that the Gateway recognizes the CSCGlobal CAPlugin plugin by hovering over the ⓘ symbol to the right of the Gateway on the top left of the portal. ## Configuration @@ -88,6 +88,7 @@ This integration is tested and confirmed as working for Anygateway REST 24.2 and * **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. + * **RenewalWindowDays** - Number of days before the annual order expiry within which a RenewOrReissue triggers a paid Renewal rather than a free Reissue. Default is 30. 2. PLEASE NOTE, AT THIS TIME THE RAPID_SSL TEMPLATE IS NOT SUPPORTED BY THE CSC API AND WILL NOT WORK WITH THIS INTEGRATION diff --git a/integration-manifest.json b/integration-manifest.json index 2b4b8c4..9237eab 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -36,6 +36,10 @@ { "name": "SyncFilterDays", "description": "Number of days from today to filter certificates by expiration date during incremental sync." + }, + { + "name": "RenewalWindowDays", + "description": "Number of days before the annual order expiry within which a RenewOrReissue triggers a paid Renewal rather than a free Reissue. Default is 30." } ], "enrollment_config": [ From a742e039bf66653eb51c38a5fa0bcede8063c794 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Wed, 8 Apr 2026 10:44:23 -0400 Subject: [PATCH 03/42] Improved logging .net 10 support --- cscglobal-caplugin/CSCGlobalCAPlugin.cs | 861 +++++++++++++++---- cscglobal-caplugin/CSCGlobalCAPlugin.csproj | 8 +- cscglobal-caplugin/Client/CscGlobalClient.cs | 269 ++++-- cscglobal-caplugin/FlowLogger.cs | 241 ++++++ cscglobal-caplugin/RequestManager.cs | 440 ++++++++-- 5 files changed, 1521 insertions(+), 298 deletions(-) create mode 100644 cscglobal-caplugin/FlowLogger.cs diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index 7e6be8a..e0c67ed 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -44,29 +44,94 @@ public CSCGlobalCAPlugin() //done public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDataReader certificateDataReader) { + using var flow = new FlowLogger(Logger, "Initialize"); Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace("Initialize called. configProvider is {Null}, certificateDataReader is {Null2}", + configProvider == null ? "NULL" : "present", + certificateDataReader == null ? "NULL" : "present"); + + flow.Step("ValidateInputs", () => + { + if (configProvider == null) + throw new ArgumentNullException(nameof(configProvider), "configProvider cannot be null in Initialize"); + if (certificateDataReader == null) + throw new ArgumentNullException(nameof(certificateDataReader), "certificateDataReader cannot be null in Initialize"); + }); + _certificateDataReader = certificateDataReader; - CscGlobalClient = new CscGlobalClient(configProvider); - var templateSync = configProvider.CAConnectionData["TemplateSync"].ToString(); - if (templateSync.ToUpper() == "ON") EnableTemplateSync = true; - if (configProvider.CAConnectionData.ContainsKey(Constants.SyncFilterDays)) + flow.Step("CreateCscGlobalClient", () => + { + Logger.LogTrace("Creating CscGlobalClient from configProvider..."); + CscGlobalClient = new CscGlobalClient(configProvider); + Logger.LogTrace("CscGlobalClient created successfully."); + }); + + flow.Step("ValidateConnectionData", () => { - var syncFilterDaysStr = configProvider.CAConnectionData[Constants.SyncFilterDays]?.ToString(); - if (int.TryParse(syncFilterDaysStr, out var syncFilterDays)) + if (configProvider.CAConnectionData == null) { - SyncFilterDays = syncFilterDays; - Logger.LogDebug($"SyncFilterDays configured to {SyncFilterDays} days"); + Logger.LogError("CAConnectionData is null. Cannot read configuration."); + throw new InvalidOperationException("CAConnectionData is null on configProvider."); } - } + Logger.LogTrace("CAConnectionData keys: {Keys}", string.Join(", ", configProvider.CAConnectionData.Keys)); + }); - RenewalWindowDays = 30; // default - if (configProvider.CAConnectionData.TryGetValue(Constants.RenewalWindowDays, out var renewalWindowObj)) + flow.Step("ReadTemplateSync", () => { - if (int.TryParse(renewalWindowObj?.ToString(), out var renewalWindowDays) && renewalWindowDays > 0) - RenewalWindowDays = renewalWindowDays; - } - Logger.LogDebug("RenewalWindowDays configured to {Days} days", RenewalWindowDays); + if (configProvider.CAConnectionData.ContainsKey("TemplateSync")) + { + var templateSync = configProvider.CAConnectionData["TemplateSync"]?.ToString(); + Logger.LogTrace("TemplateSync raw value: '{Value}'", templateSync ?? "(null)"); + if (!string.IsNullOrEmpty(templateSync) && templateSync.ToUpper() == "ON") + EnableTemplateSync = true; + } + else + { + Logger.LogTrace("TemplateSync key not found in CAConnectionData, defaulting to disabled."); + } + Logger.LogTrace("EnableTemplateSync = {Value}", EnableTemplateSync); + }, $"EnableTemplateSync={EnableTemplateSync}"); + + flow.Step("ReadSyncFilterDays", () => + { + if (configProvider.CAConnectionData.ContainsKey(Constants.SyncFilterDays)) + { + var syncFilterDaysStr = configProvider.CAConnectionData[Constants.SyncFilterDays]?.ToString(); + Logger.LogTrace("SyncFilterDays raw value: '{Value}'", syncFilterDaysStr ?? "(null)"); + if (int.TryParse(syncFilterDaysStr, out var syncFilterDays)) + { + SyncFilterDays = syncFilterDays; + Logger.LogDebug("SyncFilterDays configured to {Days} days", SyncFilterDays); + } + else + { + Logger.LogWarning("SyncFilterDays value '{Value}' could not be parsed as int, using default 0.", syncFilterDaysStr); + } + } + else + { + Logger.LogTrace("SyncFilterDays key not found in CAConnectionData, using default 0."); + } + }); + + flow.Step("ReadRenewalWindowDays", () => + { + RenewalWindowDays = 30; // default + if (configProvider.CAConnectionData.TryGetValue(Constants.RenewalWindowDays, out var renewalWindowObj)) + { + Logger.LogTrace("RenewalWindowDays raw value: '{Value}'", renewalWindowObj?.ToString() ?? "(null)"); + if (int.TryParse(renewalWindowObj?.ToString(), out var renewalWindowDays) && renewalWindowDays > 0) + RenewalWindowDays = renewalWindowDays; + else + Logger.LogWarning("RenewalWindowDays value '{Value}' could not be parsed or was <= 0, using default 30.", renewalWindowObj); + } + else + { + Logger.LogTrace("RenewalWindowDays key not found in CAConnectionData, using default 30."); + } + Logger.LogDebug("RenewalWindowDays configured to {Days} days", RenewalWindowDays); + }, $"RenewalWindowDays={RenewalWindowDays}"); Logger.MethodExit(LogLevel.Debug); } @@ -74,40 +139,97 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa //done public async Task GetSingleRecord(string caRequestID) { + using var flow = new FlowLogger(Logger, $"GetSingleRecord({caRequestID ?? "null"})"); + Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace("GetSingleRecord called with caRequestID='{CaRequestId}'", caRequestID ?? "(null)"); + + flow.Step("ValidateInput", () => + { + if (string.IsNullOrEmpty(caRequestID)) + throw new ArgumentNullException(nameof(caRequestID), "caRequestID cannot be null or empty."); + if (caRequestID.Length < 36) + throw new ArgumentException($"caRequestID '{caRequestID}' is too short to extract a UUID (need at least 36 chars).", nameof(caRequestID)); + }); + try { - Logger.MethodEntry(LogLevel.Debug); - 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)) - .Result; + var keyfactorCaId = caRequestID.Substring(0, 36); + flow.Step("ExtractUUID", $"keyfactorCaId={keyfactorCaId}"); - Logger.LogTrace($"Single Cert JSON: {JsonConvert.SerializeObject(certificateResponse)}"); + CertificateResponse certificateResponse = null; + await flow.StepAsync("FetchCertFromCSC", async () => + { + certificateResponse = await CscGlobalClient.SubmitGetCertificateAsync(keyfactorCaId); + }); - var fileContent = - Encoding.ASCII.GetString( - Convert.FromBase64String(certificateResponse?.Certificate ?? string.Empty)); + if (certificateResponse == null) + { + flow.Fail("ParseResponse", "API returned null"); + Logger.LogWarning("GetSingleRecord: SubmitGetCertificateAsync returned null for keyfactorCaId='{KeyfactorCaId}'", keyfactorCaId); + return new AnyCAPluginCertificate + { + CARequestID = keyfactorCaId, + Certificate = string.Empty, + Status = _requestManager.MapReturnStatus(null) + }; + } + + flow.Step("ParseResponse", $"Status={certificateResponse.Status ?? "(null)"}"); + Logger.LogTrace("Single Cert JSON: {Json}", JsonConvert.SerializeObject(certificateResponse)); + + var rawCert = certificateResponse.Certificate ?? string.Empty; + string fileContent = string.Empty; + flow.Step("DecodeBase64", () => + { + try + { + fileContent = Encoding.ASCII.GetString(Convert.FromBase64String(rawCert)); + } + catch (FormatException fex) + { + Logger.LogError(fex, "GetSingleRecord: Failed to decode Base64 certificate content for keyfactorCaId='{KeyfactorCaId}'", keyfactorCaId); + fileContent = string.Empty; + } + }, $"length={rawCert.Length}"); - Logger.LogTrace($"File Content {fileContent}"); - var certData = fileContent?.Replace("\r\n", string.Empty); + var certData = fileContent.Replace("\r\n", string.Empty); var certString = string.Empty; if (!string.IsNullOrEmpty(certData)) - certString = GetEndEntityCertificate(certData); - Logger.LogTrace($"Cert String Content {certString}"); + { + flow.Step("ExtractLeafCert", () => + { + certString = GetEndEntityCertificate(certData); + }, $"inputLength={certData.Length}"); + } + else + { + flow.Skip("ExtractLeafCert", "certData empty after cleanup"); + } + + var mappedStatus = _requestManager.MapReturnStatus(certificateResponse.Status); + flow.Step("MapStatus", $"{certificateResponse.Status ?? "(null)"} -> {mappedStatus}"); Logger.MethodExit(LogLevel.Debug); return new AnyCAPluginCertificate { CARequestID = keyfactorCaId, - Certificate = certString, - Status = _requestManager.MapReturnStatus(certificateResponse?.Status) + Certificate = certString ?? string.Empty, + Status = mappedStatus }; } + catch (AggregateException ae) + { + var inner = ae.Flatten().InnerException; + flow.Fail("UNHANDLED", inner?.Message ?? ae.Message); + Logger.LogError(inner, "GetSingleRecord: AggregateException for caRequestID='{CaRequestId}': {Message}", caRequestID, inner?.Message ?? ae.Message); + throw new Exception($"Error Occurred getting single cert for '{caRequestID}': {inner?.Message ?? ae.Message}", inner ?? ae); + } catch (Exception e) { - throw new Exception($"Error Occurred getting single cert {e.Message}"); + flow.Fail("UNHANDLED", e.Message); + Logger.LogError(e, "GetSingleRecord: Exception for caRequestID='{CaRequestId}': {Message}", caRequestID, e.Message); + throw new Exception($"Error Occurred getting single cert for '{caRequestID}': {e.Message}", e); } } @@ -115,31 +237,56 @@ public async Task GetSingleRecord(string caRequestID) public async Task Synchronize(BlockingCollection blockingBuffer, DateTime? lastSync, bool fullSync, CancellationToken cancelToken) { - Logger.LogTrace($"Full Sync? {fullSync.ToString()}"); + var syncType = fullSync ? "Full" : "Incremental"; + using var flow = new FlowLogger(Logger, $"Synchronize-{syncType}"); Logger.MethodEntry(); + Logger.LogTrace("Synchronize called. fullSync={FullSync}, lastSync={LastSync}, blockingBuffer is {Null}", + fullSync, lastSync?.ToString("o") ?? "(null)", + blockingBuffer == null ? "NULL" : "present"); + + if (blockingBuffer == null) + throw new ArgumentNullException(nameof(blockingBuffer), "blockingBuffer cannot be null in Synchronize"); + try { if (fullSync) { - Logger.LogDebug("Performing full sync - no date filter applied"); - await SyncCertificates(blockingBuffer, cancelToken, null); + flow.Step("DetermineFilter", "Full sync - no date filter"); + await flow.StepAsync("FetchAndProcessCerts", async () => + { + await SyncCertificates(blockingBuffer, cancelToken, null); + }); } 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); + flow.Step("DetermineFilter", $"Incremental, filterDays={filterDays}, cutoff={dateFilter}"); + await flow.StepAsync("FetchAndProcessCerts", async () => + { + await SyncCertificates(blockingBuffer, cancelToken, dateFilter); + }); } + flow.Step("CompleteAdding"); blockingBuffer.CompleteAdding(); } + catch (OperationCanceledException) + { + flow.Fail("Cancelled", "operation was cancelled"); + Logger.LogWarning("Synchronize: operation was cancelled."); + if (!blockingBuffer.IsAddingCompleted) + blockingBuffer.CompleteAdding(); + throw; + } catch (Exception e) { - Logger.LogError($"Csc Global Synchronize Task failed! {LogHandler.FlattenException(e)}"); + flow.Fail("SyncError", e.Message); + Logger.LogError(e, "Csc Global Synchronize Task failed! {FlatException}", LogHandler.FlattenException(e)); + if (!blockingBuffer.IsAddingCompleted) + blockingBuffer.CompleteAdding(); Logger.MethodExit(); - blockingBuffer.CompleteAdding(); throw; } @@ -149,70 +296,183 @@ public async Task Synchronize(BlockingCollection blockin private async Task SyncCertificates(BlockingCollection blockingBuffer, CancellationToken cancelToken, string? dateFilter) { + Logger.LogTrace("SyncCertificates: calling SubmitCertificateListRequestAsync with dateFilter='{DateFilter}'", dateFilter ?? "(null)"); var certs = await CscGlobalClient.SubmitCertificateListRequestAsync(dateFilter); + if (certs == null) + { + Logger.LogWarning("SyncCertificates: SubmitCertificateListRequestAsync returned null."); + return; + } + + if (certs.Results == null) + { + Logger.LogWarning("SyncCertificates: certificate list response Results collection is null."); + return; + } + + Logger.LogTrace("SyncCertificates: received {Count} certificate results.", certs.Results.Count); + var processedCount = 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); - //Keyfactor sync only seems to work when there is a valid cert and I can only get Active valid certs from Csc Global + if (currentResponseItem == null) + { + Logger.LogTrace("SyncCertificates: skipping null result item."); + skippedCount++; + continue; + } + + Logger.LogTrace("SyncCertificates: processing certificate UUID={Uuid}, Status='{Status}', CertificateType='{CertType}'", + currentResponseItem.Uuid ?? "(null)", + currentResponseItem.Status ?? "(null)", + currentResponseItem.CertificateType ?? "(null)"); + + var certStatus = _requestManager.MapReturnStatus(currentResponseItem.Status); + Logger.LogTrace("SyncCertificates: mapped status for UUID={Uuid}: {MappedStatus}", currentResponseItem.Uuid ?? "(null)", certStatus); + 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; + if (EnableTemplateSync) + productId = currentResponseItem.CertificateType ?? "CscGlobal"; - var fileContent = - PreparePemTextFromApi( - currentResponseItem?.Certificate ?? string.Empty); + Logger.LogTrace("SyncCertificates: UUID={Uuid} qualifies for sync. ProductId='{ProductId}'", currentResponseItem.Uuid, productId); + + string fileContent; + try + { + fileContent = PreparePemTextFromApi(currentResponseItem.Certificate ?? string.Empty); + } + catch (Exception ex) + { + Logger.LogError(ex, "SyncCertificates: PreparePemTextFromApi failed for UUID={Uuid}", currentResponseItem.Uuid); + skippedCount++; + continue; + } if (fileContent.Length > 0) { - Logger.LogTrace($"File Content {fileContent}"); + Logger.LogTrace("SyncCertificates: fileContent length={Length} for UUID={Uuid}", fileContent.Length, currentResponseItem.Uuid); var certData = fileContent.Replace("\r\n", string.Empty); - var certString = GetEndEntityCertificate(certData); - if (certString.Length > 0) + string certString; + try + { + certString = GetEndEntityCertificate(certData); + } + catch (Exception ex) + { + Logger.LogError(ex, "SyncCertificates: GetEndEntityCertificate failed for UUID={Uuid}", currentResponseItem.Uuid); + skippedCount++; + continue; + } + + if (!string.IsNullOrEmpty(certString)) + { blockingBuffer.Add(new AnyCAPluginCertificate { - CARequestID = $"{currentResponseItem?.Uuid}", + CARequestID = $"{currentResponseItem.Uuid}", Certificate = certString, Status = certStatus, ProductID = productId }, cancelToken); + processedCount++; + Logger.LogTrace("SyncCertificates: added UUID={Uuid} to buffer.", currentResponseItem.Uuid); + } + else + { + Logger.LogTrace("SyncCertificates: certString was empty for UUID={Uuid}, skipping.", currentResponseItem.Uuid); + skippedCount++; + } } + else + { + Logger.LogTrace("SyncCertificates: fileContent was empty for UUID={Uuid}, skipping.", currentResponseItem.Uuid); + skippedCount++; + } + } + else + { + Logger.LogTrace("SyncCertificates: UUID={Uuid} status {Status} not eligible for sync, skipping.", currentResponseItem.Uuid, certStatus); + skippedCount++; } } + + Logger.LogDebug("SyncCertificates: completed. Processed={Processed}, Skipped={Skipped}, Total={Total}", + processedCount, skippedCount, certs.Results.Count); } //done public async Task Revoke(string caRequestID, string hexSerialNumber, uint revocationReason) { + using var flow = new FlowLogger(Logger, $"Revoke({caRequestID ?? "null"})"); + Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace("Revoke called with caRequestID='{CaRequestId}', hexSerialNumber='{SerialNumber}', revocationReason={Reason}", + caRequestID ?? "(null)", hexSerialNumber ?? "(null)", revocationReason); + + flow.Step("ValidateInput", () => + { + if (string.IsNullOrEmpty(caRequestID)) + throw new ArgumentNullException(nameof(caRequestID), "caRequestID cannot be null or empty for Revoke."); + if (caRequestID.Length < 36) + throw new ArgumentException($"caRequestID '{caRequestID}' is too short to extract a UUID.", nameof(caRequestID)); + }); + 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 + var uuid = caRequestID.Substring(0, 36); + flow.Step("ExtractUUID", $"uuid={uuid}"); - Logger.LogTrace($"Revoke Response JSON: {JsonConvert.SerializeObject(revokeResponse)}"); - Logger.MethodExit(LogLevel.Debug); + RevokeResponse revokeResponse = null; + await flow.StepAsync("SubmitRevokeToCSC", async () => + { + revokeResponse = await CscGlobalClient.SubmitRevokeCertificateAsync(uuid); + }); + + if (revokeResponse == null) + { + flow.Fail("ParseResponse", "API returned null"); + throw new InvalidOperationException($"Revoke received null response for UUID '{uuid}'."); + } + + Logger.LogTrace("Revoke Response JSON: {Json}", JsonConvert.SerializeObject(revokeResponse)); var revokeResult = _requestManager.GetRevokeResult(revokeResponse); + flow.Step("MapResult", $"result={revokeResult}"); if (revokeResult == (int)EndEntityStatus.FAILED) - if (!string.IsNullOrEmpty(revokeResponse?.RegistrationError?.Description)) - throw new HttpRequestException( - $"Revoke Failed with message {revokeResponse?.RegistrationError?.Description}"); + { + var errorDesc = revokeResponse.RegistrationError?.Description; + flow.Fail("RevokeResult", errorDesc ?? "(no description)"); + Logger.LogError("Revoke: failed for UUID='{Uuid}'. Error description: '{ErrorDesc}'", + uuid, errorDesc ?? "(no description)"); + if (!string.IsNullOrEmpty(errorDesc)) + throw new HttpRequestException($"Revoke Failed with message {errorDesc}"); + } + Logger.MethodExit(LogLevel.Debug); return revokeResult; } + catch (AggregateException ae) + { + var inner = ae.Flatten().InnerException; + flow.Fail("UNHANDLED", inner?.Message ?? ae.Message); + Logger.LogError(inner, "Revoke: AggregateException for caRequestID='{CaRequestId}': {Message}", caRequestID, inner?.Message ?? ae.Message); + throw new Exception($"Revoke Failed for '{caRequestID}' with message {inner?.Message ?? ae.Message}", inner ?? ae); + } + catch (HttpRequestException) + { + throw; // already logged in flow above + } catch (Exception e) { - throw new Exception($"Revoke Failed with message {e?.Message}"); + flow.Fail("UNHANDLED", e.Message); + Logger.LogError(e, "Revoke: Exception for caRequestID='{CaRequestId}': {Message}", caRequestID, e.Message); + throw new Exception($"Revoke Failed for '{caRequestID}' with message {e.Message}", e); } } @@ -220,155 +480,382 @@ await CscGlobalClient.SubmitRevokeCertificateAsync(caRequestID.Substring(0, 36)) public async Task Enroll(string csr, string subject, Dictionary san, EnrollmentProductInfo productInfo, RequestFormat requestFormat, EnrollmentType enrollmentType) { + using var flow = new FlowLogger(Logger, $"Enroll-{enrollmentType}"); Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace("Enroll called. enrollmentType={EnrollmentType}, subject='{Subject}', productId='{ProductId}', requestFormat={RequestFormat}", + enrollmentType, subject ?? "(null)", + productInfo?.ProductID ?? "(null)", requestFormat); + Logger.LogTrace("Enroll: csr is {CsrStatus}, san has {SanCount} entries, productInfo is {PiStatus}", + string.IsNullOrEmpty(csr) ? "empty/null" : $"present ({csr.Length} chars)", + san?.Count ?? 0, + productInfo == null ? "NULL" : "present"); + + flow.Step("ValidateInputs", () => + { + if (productInfo == null) + throw new ArgumentNullException(nameof(productInfo), "productInfo cannot be null for Enroll."); + if (productInfo.ProductParameters == null) + throw new ArgumentNullException(nameof(productInfo), "productInfo.ProductParameters cannot be null for Enroll."); + if (string.IsNullOrEmpty(csr)) + throw new ArgumentNullException(nameof(csr), "CSR cannot be null or empty for Enroll."); + }); + + Logger.LogTrace("Enroll: ProductParameters keys: [{Keys}]", + string.Join(", ", productInfo.ProductParameters.Keys)); 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(); - switch (enrollmentType) + flow.Step("CheckPriorCertSN", () => { - 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")) + if (productInfo.ProductParameters.ContainsKey("priorcertsn")) + { + 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)}"); + priorSn = productInfo.ProductParameters["PriorCertSN"]; + Logger.LogDebug("Enroll: Prior cert SN: '{PriorSn}'", priorSn ?? "(null)"); } else { - return new EnrollmentResult - { - Status = 30, //failure - StatusMessage = "You cannot renew an expired cert please perform an new enrollment." - }; + Logger.LogWarning("Enroll: 'priorcertsn' key exists but 'PriorCertSN' (case-sensitive) not found."); } + } + }, string.IsNullOrEmpty(priorSn) ? "none" : $"SN={priorSn}"); - Logger.MethodExit(LogLevel.Debug); - return _requestManager.GetEnrollmentResult(enrollmentResponse); - case EnrollmentType.RenewOrReissue: - Logger.LogTrace("Entering Renew Enrollment"); - var order_id = await _certificateDataReader.GetRequestIDBySerialNumber(priorSn); - - // Determine renew vs reissue based on order expiry window. - // Fetch the live cert record from CSC to get the orderDate, then compute - // orderExpiry = orderDate + 1 year (annual subscription assumption). - // If today falls within RenewalWindowDays of orderExpiry → Renewal (new paid order). - // Otherwise → Reissue (free reissue under the same active order). - var renewal = false; - try - { - var liveCert = await CscGlobalClient.SubmitGetCertificateAsync(order_id[..36]); - if (liveCert != null && DateTime.TryParse(liveCert.OrderDate, out var orderDate)) + string uUId; + List customFields = null; + await flow.StepAsync("FetchCustomFields", async () => + { + customFields = await CscGlobalClient.SubmitGetCustomFields(); + }, $"count={customFields?.Count ?? 0}"); + + if (customFields == null) + { + Logger.LogWarning("Enroll: SubmitGetCustomFields returned null, using empty list."); + customFields = new List(); + } + + try + { + switch (enrollmentType) + { + case EnrollmentType.New: + flow.Step("SelectPath", "New Enrollment"); + IRegistrationResponse enrollmentResponse; + if (!productInfo.ProductParameters.ContainsKey("PriorCertSN")) { - var orderExpiry = orderDate.AddYears(1); - var daysUntilOrderExpiry = (orderExpiry - DateTime.UtcNow).TotalDays; - renewal = daysUntilOrderExpiry <= RenewalWindowDays; - Logger.LogDebug( - "RenewOrReissue: orderDate={OrderDate}, orderExpiry={OrderExpiry}, daysRemaining={Days}, renewalWindow={Window}, isRenewal={IsRenewal}", - liveCert.OrderDate, orderExpiry.ToString("dd-MMM-yyyy"), - (int)daysUntilOrderExpiry, RenewalWindowDays, renewal); + enrollmentRequest = null; + flow.Step("BuildRegistrationRequest", () => + { + enrollmentRequest = _requestManager.GetRegistrationRequest(productInfo, csr, san, customFields); + }); + Logger.LogTrace("Enrollment Request JSON: {Json}", JsonConvert.SerializeObject(enrollmentRequest)); + + RegistrationResponse regResponse = null; + await flow.StepAsync("SubmitRegistrationToCSC", async () => + { + regResponse = await CscGlobalClient.SubmitRegistrationAsync(enrollmentRequest); + }); + enrollmentResponse = regResponse; + + if (enrollmentResponse == null) + { + flow.Fail("ParseResponse", "API returned null"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = "Enrollment failed: CSC API returned a null response." + }; + } + flow.Step("ParseResponse", $"error={enrollmentResponse.RegistrationError != null}"); + Logger.LogTrace("Enrollment Response JSON: {Json}", JsonConvert.SerializeObject(enrollmentResponse)); } else { - // Fallback: if we can't parse orderDate, use cert expiration as before - var expirationDate = _certificateDataReader.GetExpirationDateByRequestId(order_id) - ?? (await GetSingleRecord(order_id)).RevocationDate; - renewal = expirationDate < DateTime.Now; - Logger.LogDebug("RenewOrReissue: falling back to cert expiry check, isRenewal={IsRenewal}", renewal); + flow.Fail("RejectExpiredRenew", "PriorCertSN present on New enrollment"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = "You cannot renew an expired cert please perform an new enrollment." + }; } - } - catch (Exception ex) - { - Logger.LogWarning("RenewOrReissue: failed to fetch live cert for order decision, falling back to cert expiry. Error: {Error}", ex.Message); - var expirationDate = _certificateDataReader.GetExpirationDateByRequestId(order_id) - ?? (await GetSingleRecord(order_id)).RevocationDate; - renewal = expirationDate < DateTime.Now; - } - if (renewal) - { - //One click won't work for this implementation b/c we are missing enrollment params + var enrollResult = _requestManager.GetEnrollmentResult(enrollmentResponse); + flow.Step("MapResult", $"Status={enrollResult?.Status}, ID={enrollResult?.CARequestID ?? "(null)"}"); + Logger.MethodExit(LogLevel.Debug); + return enrollResult; + + case EnrollmentType.RenewOrReissue: + flow.Step("SelectPath", "RenewOrReissue"); + + if (string.IsNullOrEmpty(priorSn)) + { + flow.Fail("ValidatePriorSN", "PriorCertSN is empty"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = "RenewOrReissue failed: PriorCertSN is required but was not provided." + }; + } + + string order_id = null; + await flow.StepAsync("LookupOrderId", async () => + { + order_id = await _certificateDataReader.GetRequestIDBySerialNumber(priorSn); + }, $"orderId={order_id ?? "(null)"}"); + + if (string.IsNullOrEmpty(order_id)) + { + flow.Fail("ValidateOrderId", $"no order found for SN={priorSn}"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = $"RenewOrReissue failed: could not find order ID for serial number '{priorSn}'." + }; + } + + if (order_id.Length < 36) + { + flow.Fail("ValidateOrderId", $"order_id too short ({order_id.Length} chars)"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = $"RenewOrReissue failed: order ID '{order_id}' is too short to extract a UUID." + }; + } + flow.Step("ValidateOrderId", $"orderId={order_id}"); + + // Determine renew vs reissue based on order expiry window. + var renewal = false; + try + { + CertificateResponse liveCert = null; + await flow.StepAsync("FetchLiveCertForDecision", async () => + { + liveCert = await 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("ComputeRenewalDecision", + $"orderDate={liveCert.OrderDate}, expiry={orderExpiry:dd-MMM-yyyy}, daysLeft={(int)daysUntilOrderExpiry}, window={RenewalWindowDays}, isRenewal={renewal}"); + } + else + { + flow.Skip("ComputeRenewalDecision", "orderDate unavailable, falling back to cert expiry"); + var expirationDate = _certificateDataReader.GetExpirationDateByRequestId(order_id) + ?? (await GetSingleRecord(order_id)).RevocationDate; + renewal = expirationDate < DateTime.Now; + flow.Step("FallbackExpiryCheck", $"expirationDate={expirationDate?.ToString("o") ?? "(null)"}, isRenewal={renewal}"); + } + } + catch (Exception ex) + { + flow.Fail("FetchLiveCertForDecision", $"falling back: {ex.Message}"); + Logger.LogWarning(ex, "RenewOrReissue: failed to fetch live cert, falling back to cert expiry."); + try + { + var expirationDate = _certificateDataReader.GetExpirationDateByRequestId(order_id) + ?? (await GetSingleRecord(order_id)).RevocationDate; + renewal = expirationDate < DateTime.Now; + flow.Step("FallbackExpiryCheck", $"isRenewal={renewal}"); + } + catch (Exception fallbackEx) + { + flow.Fail("FallbackExpiryCheck", fallbackEx.Message); + return new EnrollmentResult + { + Status = 30, + StatusMessage = $"RenewOrReissue failed: unable to determine renewal status for order '{order_id}'. {fallbackEx.Message}" + }; + } + } + + flow.Step("RenewalDecision", renewal ? "RENEWAL (paid order)" : "REISSUE (free under active order)"); + + if (renewal) + { + if (productInfo.ProductParameters.ContainsKey("Applicant Last Name")) + { + uUId = null; + await flow.StepAsync("LookupRenewalUUID", async () => + { + uUId = await _certificateDataReader.GetRequestIDBySerialNumber( + productInfo.ProductParameters["PriorCertSN"]); + }); + + if (string.IsNullOrEmpty(uUId)) + { + flow.Fail("ValidateRenewalUUID", "could not resolve PriorCertSN"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = "Renewal failed: could not resolve prior certificate serial number to a request ID." + }; + } + flow.Step("ValidateRenewalUUID", $"uuid={uUId}"); + + RenewalRequest builtRenewRequest = null; + flow.Step("BuildRenewalRequest", () => + { + builtRenewRequest = _requestManager.GetRenewalRequest(productInfo, uUId, csr, san, customFields); + }); + renewRequest = builtRenewRequest; + Logger.LogTrace("Renewal Request JSON: {Json}", JsonConvert.SerializeObject(renewRequest)); + + RenewalResponse renewResponse = null; + await flow.StepAsync("SubmitRenewalToCSC", async () => + { + renewResponse = await CscGlobalClient.SubmitRenewalAsync(renewRequest); + }); + + if (renewResponse == null) + { + flow.Fail("ParseRenewalResponse", "API returned null"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = "Renewal failed: CSC API returned a null response." + }; + } + + Logger.LogTrace("Renewal Response JSON: {Json}", JsonConvert.SerializeObject(renewResponse)); + var renewResult = _requestManager.GetRenewResponse(renewResponse); + flow.Step("MapRenewalResult", $"Status={renewResult?.Status}, Message={renewResult?.StatusMessage ?? "(null)"}"); + Logger.MethodExit(LogLevel.Debug); + return renewResult; + } + + flow.Fail("MissingEnrollmentParams", "Applicant Last Name not present — one-click renew unavailable"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = + "One click Renew Is Not Available for this Certificate Type. Use the configure button instead." + }; + } + + // Reissue path 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 = Task.Run(async () => await CscGlobalClient.SubmitRenewalAsync(renewRequest)) - .Result; - Logger.LogTrace($"Renewal Response JSON: {JsonConvert.SerializeObject(renewResponse)}"); + string requestid = null; + await flow.StepAsync("LookupReissueRequestId", async () => + { + requestid = await _certificateDataReader.GetRequestIDBySerialNumber( + productInfo.ProductParameters["PriorCertSN"]); + }); + + if (string.IsNullOrEmpty(requestid)) + { + flow.Fail("ValidateReissueRequestId", "could not resolve PriorCertSN"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = "Reissue failed: could not resolve prior certificate serial number to a request ID." + }; + } + + if (requestid.Length < 36) + { + flow.Fail("ValidateReissueRequestId", $"requestid too short ({requestid.Length} chars)"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = $"Reissue failed: request ID '{requestid}' is too short to extract a UUID." + }; + } + + uUId = requestid.Substring(0, 36); + flow.Step("ExtractReissueUUID", $"uuid={uUId}"); + + ReissueRequest builtReissueRequest = null; + flow.Step("BuildReissueRequest", () => + { + builtReissueRequest = _requestManager.GetReissueRequest(productInfo, uUId, csr, san, customFields); + }); + reissueRequest = builtReissueRequest; + Logger.LogTrace("Reissue JSON: {Json}", JsonConvert.SerializeObject(reissueRequest)); + + ReissueResponse reissueResponse = null; + await flow.StepAsync("SubmitReissueToCSC", async () => + { + reissueResponse = await CscGlobalClient.SubmitReissueAsync(reissueRequest); + }); + + if (reissueResponse == null) + { + flow.Fail("ParseReissueResponse", "API returned null"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = "Reissue failed: CSC API returned a null response." + }; + } + + Logger.LogTrace("Reissue Response JSON: {Json}", JsonConvert.SerializeObject(reissueResponse)); + var reissueResult = _requestManager.GetReIssueResult(reissueResponse); + flow.Step("MapReissueResult", $"Status={reissueResult?.Status}, Message={reissueResult?.StatusMessage ?? "(null)"}"); Logger.MethodExit(LogLevel.Debug); - return _requestManager.GetRenewResponse(renewResponse); + return reissueResult; } + flow.Fail("MissingEnrollmentParams", "Applicant Last Name not present — one-click reissue unavailable"); return new EnrollmentResult { - Status = 30, //failure + Status = 30, 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." - }; + default: + flow.Fail("UnhandledType", $"enrollmentType={enrollmentType}"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = $"Enroll failed: unhandled enrollment type '{enrollmentType}'." + }; + } + } + catch (AggregateException ae) + { + var inner = ae.Flatten().InnerException; + flow.Fail("UNHANDLED", inner?.Message ?? ae.Message); + Logger.LogError(inner, "Enroll: AggregateException during {EnrollmentType}: {Message}", enrollmentType, inner?.Message ?? ae.Message); + return new EnrollmentResult + { + Status = 30, + StatusMessage = $"Enrollment failed with error: {inner?.Message ?? ae.Message}" + }; + } + catch (Exception ex) + { + flow.Fail("UNHANDLED", ex.Message); + Logger.LogError(ex, "Enroll: unhandled exception during {EnrollmentType}: {Message}", enrollmentType, ex.Message); + return new EnrollmentResult + { + Status = 30, + StatusMessage = $"Enrollment failed with error: {ex.Message}" + }; } - - Logger.MethodExit(LogLevel.Debug); - return null; } //done public async Task Ping() { Logger.MethodEntry(); + Logger.LogTrace("Ping: CscGlobalClient is {Null}", CscGlobalClient == null ? "NULL" : "present"); try { Logger.LogInformation("Ping request received"); } catch (Exception e) { - Logger.LogError($"There was an error contacting CSCGlobal: {e.Message}."); + Logger.LogError(e, "There was an error contacting CSCGlobal: {Message}", e.Message); throw new Exception($"Error attempting to ping CSCGlobal: {e.Message}.", e); } @@ -378,19 +865,53 @@ public async Task Ping() //do public async Task ValidateCAConnectionInfo(Dictionary connectionInfo) { + Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace("ValidateCAConnectionInfo called. connectionInfo is {Null}, keys=[{Keys}]", + connectionInfo == null ? "NULL" : "present", + connectionInfo != null ? string.Join(", ", connectionInfo.Keys) : ""); + + if (connectionInfo == null) + { + Logger.LogError("ValidateCAConnectionInfo: connectionInfo is null."); + throw new ArgumentNullException(nameof(connectionInfo), "connectionInfo cannot be null."); + } + + Logger.MethodExit(LogLevel.Debug); } //do public async Task ValidateProductInfo(EnrollmentProductInfo productInfo, Dictionary connectionInfo) { + Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace("ValidateProductInfo called. productInfo is {Null}, productId='{ProductId}'", + productInfo == null ? "NULL" : "present", + productInfo?.ProductID ?? "(null)"); + + if (productInfo == null) + { + Logger.LogError("ValidateProductInfo: productInfo is null."); + throw new ArgumentNullException(nameof(productInfo), "productInfo cannot be null."); + } + + if (string.IsNullOrEmpty(productInfo.ProductID)) + { + Logger.LogError("ValidateProductInfo: productInfo.ProductID is null or empty."); + throw new ArgumentException("ProductID cannot be null or empty.", nameof(productInfo)); + } + var certType = ProductIDs.productIds.Find(x => x.Equals(productInfo.ProductID, StringComparison.InvariantCultureIgnoreCase)); - if (certType == null) throw new ArgumentException($"Cannot find {productInfo.ProductID}", "ProductId"); - - Logger.LogInformation($"Validated {certType} ({certType})configured for AnyGateway"); + if (certType == null) + { + Logger.LogError("ValidateProductInfo: cannot find product ID '{ProductId}'. Known IDs: [{KnownIds}]", + productInfo.ProductID, string.Join(", ", ProductIDs.productIds)); + throw new ArgumentException($"Cannot find {productInfo.ProductID}", "ProductId"); + } + Logger.LogInformation("Validated {CertType} configured for AnyGateway", certType); + Logger.MethodExit(LogLevel.Debug); } //done diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.csproj b/cscglobal-caplugin/CSCGlobalCAPlugin.csproj index 5118677..4d71ec5 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 @@ -23,6 +23,12 @@ + + + + + + diff --git a/cscglobal-caplugin/Client/CscGlobalClient.cs b/cscglobal-caplugin/Client/CscGlobalClient.cs index 0a5c7c5..032f535 100644 --- a/cscglobal-caplugin/Client/CscGlobalClient.cs +++ b/cscglobal-caplugin/Client/CscGlobalClient.cs @@ -23,13 +23,58 @@ public sealed class CscGlobalClient : ICscGlobalClient public CscGlobalClient(IAnyCAPluginConfigProvider config) { - Logger = LogHandler.GetClassLogger(); + Logger = LogHandler.GetClassLogger(); + + if (config == null) + throw new ArgumentNullException(nameof(config), "config cannot be null in CscGlobalClient constructor."); + + if (config.CAConnectionData == null) + throw new InvalidOperationException("CAConnectionData is null on config provider."); + + Logger.LogTrace("CscGlobalClient: CAConnectionData keys=[{Keys}]", string.Join(", ", config.CAConnectionData.Keys)); + 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(); + var rawUrl = config.CAConnectionData.ContainsKey(Constants.CscGlobalUrl) + ? config.CAConnectionData[Constants.CscGlobalUrl]?.ToString() + : null; + if (string.IsNullOrEmpty(rawUrl)) + { + Logger.LogError("CscGlobalClient: CscGlobalUrl is missing or empty in CAConnectionData."); + throw new InvalidOperationException("CscGlobalUrl is required but was not configured."); + } + + Logger.LogTrace("CscGlobalClient: BaseUrl='{BaseUrl}'", rawUrl); + BaseUrl = new Uri(rawUrl); + + ApiKey = config.CAConnectionData[Constants.CscGlobalApiKey]?.ToString(); + if (string.IsNullOrEmpty(ApiKey)) + { + Logger.LogError("CscGlobalClient: ApiKey is empty or null."); + throw new InvalidOperationException("ApiKey is required but was not configured."); + } + Logger.LogTrace("CscGlobalClient: ApiKey is present (length={Length}).", ApiKey.Length); + + if (!config.CAConnectionData.ContainsKey(Constants.BearerToken)) + { + Logger.LogError("CscGlobalClient: BearerToken key not found in CAConnectionData."); + throw new InvalidOperationException("BearerToken is required but was not configured."); + } + Authorization = config.CAConnectionData[Constants.BearerToken]?.ToString(); + if (string.IsNullOrEmpty(Authorization)) + { + Logger.LogError("CscGlobalClient: BearerToken is empty or null."); + throw new InvalidOperationException("BearerToken is required but was empty."); + } + Logger.LogTrace("CscGlobalClient: BearerToken is present (length={Length}).", Authorization.Length); + RestClient = ConfigureRestClient(); + Logger.LogTrace("CscGlobalClient: RestClient configured successfully."); + } + else + { + Logger.LogError("CscGlobalClient: ApiKey key '{Key}' not found in CAConnectionData. Client will not be functional.", Constants.CscGlobalApiKey); + throw new InvalidOperationException($"Required key '{Constants.CscGlobalApiKey}' not found in CAConnectionData."); } } @@ -41,25 +86,42 @@ public CscGlobalClient(IAnyCAPluginConfigProvider config) public async Task SubmitRegistrationAsync( RegistrationRequest registerRequest) { + Logger.LogTrace("SubmitRegistrationAsync: sending registration request..."); + if (registerRequest == null) + throw new ArgumentNullException(nameof(registerRequest)); + + var requestJson = JsonConvert.SerializeObject(registerRequest); + Logger.LogTrace("SubmitRegistrationAsync: request JSON: {Json}", requestJson); + using (var resp = await RestClient.PostAsync("/dbs/api/v2/tls/registration", new StringContent( - JsonConvert.SerializeObject(registerRequest), Encoding.ASCII, "application/json"))) + requestJson, Encoding.ASCII, "application/json"))) { - Logger.LogTrace(JsonConvert.SerializeObject(registerRequest)); + var rawBody = await resp.Content.ReadAsStringAsync(); + Logger.LogTrace("SubmitRegistrationAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); + Logger.LogTrace("SubmitRegistrationAsync: response body: {Body}", rawBody ?? "(null)"); + var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; - if (resp.StatusCode == HttpStatusCode.BadRequest) //Csc Sends Errors back in 400 Json Response + if (resp.StatusCode == HttpStatusCode.BadRequest) { - var errorResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync(), - settings); + Logger.LogWarning("SubmitRegistrationAsync: received 400 BadRequest."); + var errorResponse = JsonConvert.DeserializeObject(rawBody ?? "{}", settings); + Logger.LogTrace("SubmitRegistrationAsync: error description='{Desc}'", errorResponse?.Description ?? "(null)"); var response = new RegistrationResponse(); response.RegistrationError = errorResponse; response.Result = null; return response; } - var registrationResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync(), - settings); + if (!resp.IsSuccessStatusCode) + { + Logger.LogError("SubmitRegistrationAsync: unexpected HTTP {StatusCode}: {Body}", (int)resp.StatusCode, rawBody); + throw new HttpRequestException($"SubmitRegistrationAsync failed with HTTP {(int)resp.StatusCode}: {rawBody}"); + } + + var registrationResponse = JsonConvert.DeserializeObject(rawBody ?? "{}", settings); + Logger.LogTrace("SubmitRegistrationAsync: deserialized response. Result is {Null}, RegistrationError is {Null2}", + registrationResponse?.Result == null ? "null" : "present", + registrationResponse?.RegistrationError == null ? "null" : "present"); return registrationResponse; } } @@ -67,31 +129,42 @@ public async Task SubmitRegistrationAsync( public async Task SubmitRenewalAsync( RenewalRequest renewalRequest) { + Logger.LogTrace("SubmitRenewalAsync: sending renewal request..."); + if (renewalRequest == null) + throw new ArgumentNullException(nameof(renewalRequest)); + + var requestJson = JsonConvert.SerializeObject(renewalRequest); + Logger.LogTrace("SubmitRenewalAsync: request JSON: {Json}", requestJson); + using (var resp = await RestClient.PostAsync("/dbs/api/v2/tls/renewal", new StringContent( - JsonConvert.SerializeObject(renewalRequest), Encoding.ASCII, "application/json"))) + requestJson, Encoding.ASCII, "application/json"))) { - Logger.LogTrace(JsonConvert.SerializeObject(renewalRequest)); + var rawBody = await resp.Content.ReadAsStringAsync(); + Logger.LogTrace("SubmitRenewalAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); + Logger.LogTrace("SubmitRenewalAsync: response body: {Body}", rawBody ?? "(null)"); 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(); - Logger.LogTrace("Logging Error Response Raw"); - Logger.LogTrace(rawErrorResponse); - var errorResponse = - JsonConvert.DeserializeObject(rawErrorResponse, - settings); + if (resp.StatusCode == HttpStatusCode.BadRequest) + { + Logger.LogWarning("SubmitRenewalAsync: received 400 BadRequest."); + var errorResponse = JsonConvert.DeserializeObject(rawBody ?? "{}", settings); + Logger.LogTrace("SubmitRenewalAsync: error description='{Desc}'", errorResponse?.Description ?? "(null)"); var response = new RenewalResponse(); response.RegistrationError = errorResponse; response.Result = null; return response; } - var rawRenewResponse = await resp.Content.ReadAsStringAsync(); - Logger.LogTrace("Logging Success Response Raw"); - Logger.LogTrace(rawRenewResponse); - var renewalResponse = - JsonConvert.DeserializeObject(rawRenewResponse); + if (!resp.IsSuccessStatusCode) + { + Logger.LogError("SubmitRenewalAsync: unexpected HTTP {StatusCode}: {Body}", (int)resp.StatusCode, rawBody); + throw new HttpRequestException($"SubmitRenewalAsync failed with HTTP {(int)resp.StatusCode}: {rawBody}"); + } + + var renewalResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); + Logger.LogTrace("SubmitRenewalAsync: deserialized response. Result is {Null}, RegistrationError is {Null2}", + renewalResponse?.Result == null ? "null" : "present", + renewalResponse?.RegistrationError == null ? "null" : "present"); return renewalResponse; } } @@ -99,69 +172,145 @@ public async Task SubmitRenewalAsync( public async Task SubmitReissueAsync( ReissueRequest reissueRequest) { + Logger.LogTrace("SubmitReissueAsync: sending reissue request..."); + if (reissueRequest == null) + throw new ArgumentNullException(nameof(reissueRequest)); + + var requestJson = JsonConvert.SerializeObject(reissueRequest); + Logger.LogTrace("SubmitReissueAsync: request JSON: {Json}", requestJson); + using (var resp = await RestClient.PostAsync("/dbs/api/v2/tls/reissue", new StringContent( - JsonConvert.SerializeObject(reissueRequest), Encoding.ASCII, "application/json"))) + requestJson, Encoding.ASCII, "application/json"))) { - Logger.LogTrace(JsonConvert.SerializeObject(reissueRequest)); + var rawBody = await resp.Content.ReadAsStringAsync(); + Logger.LogTrace("SubmitReissueAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); + Logger.LogTrace("SubmitReissueAsync: response body: {Body}", rawBody ?? "(null)"); var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; - if (resp.StatusCode == HttpStatusCode.BadRequest) //Csc Sends Errors back in 400 Json Response + if (resp.StatusCode == HttpStatusCode.BadRequest) { - var errorResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync(), - settings); + Logger.LogWarning("SubmitReissueAsync: received 400 BadRequest."); + var errorResponse = JsonConvert.DeserializeObject(rawBody ?? "{}", settings); + Logger.LogTrace("SubmitReissueAsync: error description='{Desc}'", errorResponse?.Description ?? "(null)"); var response = new ReissueResponse(); response.RegistrationError = errorResponse; response.Result = null; return response; } - var reissueResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + if (!resp.IsSuccessStatusCode) + { + Logger.LogError("SubmitReissueAsync: unexpected HTTP {StatusCode}: {Body}", (int)resp.StatusCode, rawBody); + throw new HttpRequestException($"SubmitReissueAsync failed with HTTP {(int)resp.StatusCode}: {rawBody}"); + } + + var reissueResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); + Logger.LogTrace("SubmitReissueAsync: deserialized response. Result is {Null}, RegistrationError is {Null2}", + reissueResponse?.Result == null ? "null" : "present", + reissueResponse?.RegistrationError == null ? "null" : "present"); return reissueResponse; } } public async Task SubmitGetCertificateAsync(string certificateId) { + Logger.LogTrace("SubmitGetCertificateAsync: fetching certificate for id='{CertificateId}'", certificateId ?? "(null)"); + + if (string.IsNullOrEmpty(certificateId)) + throw new ArgumentNullException(nameof(certificateId), "certificateId cannot be null or empty."); + using (var resp = await RestClient.GetAsync($"/dbs/api/v2/tls/certificate/{certificateId}")) { - resp.EnsureSuccessStatusCode(); - var getCertificateResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + var rawBody = await resp.Content.ReadAsStringAsync(); + Logger.LogTrace("SubmitGetCertificateAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); + + if (!resp.IsSuccessStatusCode) + { + Logger.LogError("SubmitGetCertificateAsync: HTTP {StatusCode} for certificateId='{CertificateId}': {Body}", + (int)resp.StatusCode, certificateId, rawBody); + resp.EnsureSuccessStatusCode(); // will throw + } + + Logger.LogTrace("SubmitGetCertificateAsync: response body: {Body}", rawBody ?? "(null)"); + var getCertificateResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); + Logger.LogTrace("SubmitGetCertificateAsync: deserialized. Status='{Status}', OrderDate='{OrderDate}', Certificate is {Null}", + getCertificateResponse?.Status ?? "(null)", + getCertificateResponse?.OrderDate ?? "(null)", + string.IsNullOrEmpty(getCertificateResponse?.Certificate) ? "empty/null" : "present"); return getCertificateResponse; } } public async Task> SubmitGetCustomFields() { + Logger.LogTrace("SubmitGetCustomFields: fetching custom fields..."); + using (var resp = await RestClient.GetAsync("/dbs/api/v2/admin/customfields")) { - resp.EnsureSuccessStatusCode(); - var getCustomFieldsResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + var rawBody = await resp.Content.ReadAsStringAsync(); + Logger.LogTrace("SubmitGetCustomFields: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); + + if (!resp.IsSuccessStatusCode) + { + Logger.LogError("SubmitGetCustomFields: HTTP {StatusCode}: {Body}", (int)resp.StatusCode, rawBody); + resp.EnsureSuccessStatusCode(); // will throw + } + + Logger.LogTrace("SubmitGetCustomFields: response body: {Body}", rawBody ?? "(null)"); + var getCustomFieldsResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); + + if (getCustomFieldsResponse == null) + { + Logger.LogWarning("SubmitGetCustomFields: deserialized response is null, returning empty list."); + return new List(); + } + + if (getCustomFieldsResponse.CustomFields == null) + { + Logger.LogWarning("SubmitGetCustomFields: CustomFields property is null, returning empty list."); + return new List(); + } + + Logger.LogTrace("SubmitGetCustomFields: received {Count} custom fields.", getCustomFieldsResponse.CustomFields.Count); return getCustomFieldsResponse.CustomFields; } } public async Task SubmitRevokeCertificateAsync(string uuId) { + Logger.LogTrace("SubmitRevokeCertificateAsync: revoking certificate UUID='{Uuid}'", uuId ?? "(null)"); + + if (string.IsNullOrEmpty(uuId)) + throw new ArgumentNullException(nameof(uuId), "uuId cannot be null or empty."); + using (var resp = await RestClient.PutAsync($"/dbs/api/v2/tls/revoke/{uuId}", new StringContent(""))) { + var rawBody = await resp.Content.ReadAsStringAsync(); + Logger.LogTrace("SubmitRevokeCertificateAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); + Logger.LogTrace("SubmitRevokeCertificateAsync: response body: {Body}", rawBody ?? "(null)"); + var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; - if (resp.StatusCode == HttpStatusCode.BadRequest) //Csc Sends Errors back in 400 Json Response + if (resp.StatusCode == HttpStatusCode.BadRequest) { - var errorResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync(), - settings); + Logger.LogWarning("SubmitRevokeCertificateAsync: received 400 BadRequest for UUID='{Uuid}'.", uuId); + var errorResponse = JsonConvert.DeserializeObject(rawBody ?? "{}", settings); + Logger.LogTrace("SubmitRevokeCertificateAsync: error description='{Desc}'", errorResponse?.Description ?? "(null)"); var response = new RevokeResponse(); response.RegistrationError = errorResponse; response.RevokeSuccess = null; return response; } - var getRevokeResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + if (!resp.IsSuccessStatusCode) + { + Logger.LogError("SubmitRevokeCertificateAsync: unexpected HTTP {StatusCode} for UUID='{Uuid}': {Body}", (int)resp.StatusCode, uuId, rawBody); + throw new HttpRequestException($"SubmitRevokeCertificateAsync failed with HTTP {(int)resp.StatusCode}: {rawBody}"); + } + + var getRevokeResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); + Logger.LogTrace("SubmitRevokeCertificateAsync: deserialized. RevokeSuccess is {Null}, RegistrationError is {Null2}", + getRevokeResponse?.RevokeSuccess == null ? "null" : "present", + getRevokeResponse?.RegistrationError == null ? "null" : "present"); return getRevokeResponse; } } @@ -169,23 +318,37 @@ public async Task SubmitRevokeCertificateAsync(string uuId) public async Task SubmitCertificateListRequestAsync(string? dateFilter = null) { Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace("SubmitCertificateListRequestAsync: dateFilter='{DateFilter}'", dateFilter ?? "(null)"); + var filterQuery = "filter=status=in=(ACTIVE,REVOKED)"; if (!string.IsNullOrEmpty(dateFilter)) { filterQuery += $";effectiveDate=ge={dateFilter}"; } - Logger.LogTrace($"Certificate list filter query: {filterQuery}"); + Logger.LogTrace("SubmitCertificateListRequestAsync: filter query: {FilterQuery}", filterQuery); + var resp = RestClient.GetAsync($"/dbs/api/v2/tls/certificate?{filterQuery}").Result; + var rawBody = await resp.Content.ReadAsStringAsync(); + Logger.LogTrace("SubmitCertificateListRequestAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); if (!resp.IsSuccessStatusCode) { - var responseMessage = resp.Content.ReadAsStringAsync().Result; Logger.LogError( - $"Failed Request to Keyfactor. Retrying request. Status Code {resp.StatusCode} | Message: {responseMessage}"); + "SubmitCertificateListRequestAsync: failed request. StatusCode={StatusCode}, Body={Body}", + (int)resp.StatusCode, rawBody); + } + + var certificateListResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); + + if (certificateListResponse == null) + { + Logger.LogWarning("SubmitCertificateListRequestAsync: deserialized response is null."); + return new CertificateListResponse(); } - var certificateListResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + Logger.LogTrace("SubmitCertificateListRequestAsync: Results count={Count}", + certificateListResponse.Results?.Count ?? 0); + Logger.MethodExit(LogLevel.Debug); return certificateListResponse; } diff --git a/cscglobal-caplugin/FlowLogger.cs b/cscglobal-caplugin/FlowLogger.cs new file mode 100644 index 0000000..5696fcd --- /dev/null +++ b/cscglobal-caplugin/FlowLogger.cs @@ -0,0 +1,241 @@ +// 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; + +public enum FlowStepStatus +{ + Success, + Failed, + Skipped, + InProgress +} + +public class FlowStep +{ + public string Name { get; set; } + public FlowStepStatus Status { get; set; } + public string Detail { get; set; } + public long ElapsedMs { get; set; } + public List Children { get; } = new(); +} + +///

+/// Tracks high-level operation flow and renders a visual step diagram to Trace logs. +/// Usage: +/// using var flow = new FlowLogger(logger, "Enroll-New"); +/// flow.Step("ParseCSR"); +/// flow.Step("ValidateCSR", () => { ... }); +/// flow.Fail("CreateOrder", "API returned 400"); +/// // flow renders automatically on Dispose +/// +public sealed class FlowLogger : IDisposable +{ + private readonly ILogger _logger; + private readonly string _flowName; + private readonly Stopwatch _totalTimer; + private readonly List _steps = new(); + private FlowStep _currentParent; + private bool _disposed; + + public FlowLogger(ILogger logger, string flowName) + { + _logger = logger; + _flowName = flowName; + _totalTimer = Stopwatch.StartNew(); + _logger.LogTrace("===== FLOW START: {FlowName} =====", _flowName); + } + + /// Record a completed step. + public FlowLogger Step(string name, string detail = null) + { + var step = new FlowStep { Name = name, Status = FlowStepStatus.Success, Detail = detail }; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... OK{Detail}", + _flowName, name, detail != null ? $" ({detail})" : ""); + return this; + } + + /// Record a step that executes an action and times it. + public FlowLogger Step(string name, Action action, string detail = null) + { + var sw = Stopwatch.StartNew(); + var step = new FlowStep { Name = name, Detail = detail }; + try + { + _logger.LogTrace(" [{FlowName}] {StepName} ...", _flowName, name); + action(); + sw.Stop(); + step.Status = FlowStepStatus.Success; + step.ElapsedMs = sw.ElapsedMilliseconds; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... OK ({Elapsed}ms){Detail}", + _flowName, name, sw.ElapsedMilliseconds, detail != null ? $" {detail}" : ""); + } + catch (Exception ex) + { + sw.Stop(); + step.Status = FlowStepStatus.Failed; + step.ElapsedMs = sw.ElapsedMilliseconds; + step.Detail = ex.Message; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... FAILED ({Elapsed}ms): {Error}", + _flowName, name, sw.ElapsedMilliseconds, ex.Message); + throw; + } + return this; + } + + /// Record an async step that executes and times it. + public async Task StepAsync(string name, Func action, string detail = null) + { + var sw = Stopwatch.StartNew(); + var step = new FlowStep { Name = name, Detail = detail }; + try + { + _logger.LogTrace(" [{FlowName}] {StepName} ...", _flowName, name); + await action(); + sw.Stop(); + step.Status = FlowStepStatus.Success; + step.ElapsedMs = sw.ElapsedMilliseconds; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... OK ({Elapsed}ms){Detail}", + _flowName, name, sw.ElapsedMilliseconds, detail != null ? $" {detail}" : ""); + } + catch (Exception ex) + { + sw.Stop(); + step.Status = FlowStepStatus.Failed; + step.ElapsedMs = sw.ElapsedMilliseconds; + step.Detail = ex.Message; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... FAILED ({Elapsed}ms): {Error}", + _flowName, name, sw.ElapsedMilliseconds, ex.Message); + throw; + } + return this; + } + + /// Record a failed step without throwing. + public FlowLogger Fail(string name, string reason = null) + { + var step = new FlowStep { Name = name, Status = FlowStepStatus.Failed, Detail = reason }; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... FAILED{Reason}", + _flowName, name, reason != null ? $": {reason}" : ""); + return this; + } + + /// Record a skipped step. + public FlowLogger Skip(string name, string reason = null) + { + var step = new FlowStep { Name = name, Status = FlowStepStatus.Skipped, Detail = reason }; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... SKIPPED{Reason}", + _flowName, name, reason != null ? $": {reason}" : ""); + return this; + } + + /// Start a branch (group of child steps). + public FlowLogger Branch(string name) + { + var step = new FlowStep { Name = name, Status = FlowStepStatus.InProgress }; + AddStep(step); + _currentParent = step; + _logger.LogTrace(" [{FlowName}] >> Branch: {BranchName}", _flowName, name); + return this; + } + + /// End the current branch. + public FlowLogger EndBranch() + { + _currentParent = null; + return this; + } + + private void AddStep(FlowStep step) + { + if (_currentParent != null) + _currentParent.Children.Add(step); + else + _steps.Add(step); + } + + /// Render the visual flow diagram to Trace log. + private string RenderFlow() + { + var sb = new StringBuilder(); + sb.AppendLine(); + sb.AppendLine($" ===== FLOW: {_flowName} ({_totalTimer.ElapsedMilliseconds}ms total) ====="); + sb.AppendLine(); + + for (var i = 0; i < _steps.Count; i++) + { + var step = _steps[i]; + var icon = GetStatusIcon(step.Status); + var elapsed = step.ElapsedMs > 0 ? $" ({step.ElapsedMs}ms)" : ""; + var detail = !string.IsNullOrEmpty(step.Detail) ? $" [{step.Detail}]" : ""; + + sb.AppendLine($" {icon} {step.Name}{elapsed}{detail}"); + + // Render children (branch) + if (step.Children.Count > 0) + { + for (var j = 0; j < step.Children.Count; j++) + { + var child = step.Children[j]; + var childIcon = GetStatusIcon(child.Status); + var childElapsed = child.ElapsedMs > 0 ? $" ({child.ElapsedMs}ms)" : ""; + var childDetail = !string.IsNullOrEmpty(child.Detail) ? $" [{child.Detail}]" : ""; + var connector = j < step.Children.Count - 1 ? "| " : " "; + sb.AppendLine($" |"); + sb.AppendLine($" +-- {childIcon} {child.Name}{childElapsed}{childDetail}"); + } + } + + // Connector between top-level steps + if (i < _steps.Count - 1) + { + sb.AppendLine(" |"); + sb.AppendLine(" v"); + } + } + + sb.AppendLine(); + + // Final status line + var finalStatus = _steps.Count > 0 && _steps.Last().Status == FlowStepStatus.Failed + ? "FAILED" : _steps.Any(s => s.Status == FlowStepStatus.Failed) ? "PARTIAL FAILURE" : "SUCCESS"; + sb.AppendLine($" ===== FLOW RESULT: {finalStatus} ====="); + + return sb.ToString(); + } + + private static string GetStatusIcon(FlowStepStatus status) + { + return status switch + { + FlowStepStatus.Success => "[OK]", + FlowStepStatus.Failed => "[FAIL]", + FlowStepStatus.Skipped => "[SKIP]", + FlowStepStatus.InProgress => "[...]", + _ => "[?]" + }; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _totalTimer.Stop(); + _logger.LogTrace(RenderFlow()); + } +} diff --git a/cscglobal-caplugin/RequestManager.cs b/cscglobal-caplugin/RequestManager.cs index aa19b1e..b930083 100644 --- a/cscglobal-caplugin/RequestManager.cs +++ b/cscglobal-caplugin/RequestManager.cs @@ -10,19 +10,48 @@ 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)); private List GetCustomFields(EnrollmentProductInfo productInfo, List customFields) { + Logger.LogTrace("GetCustomFields: productInfo is {Null}, customFields count={Count}", + productInfo == null ? "NULL" : "present", + customFields?.Count ?? 0); + var customFieldList = new List(); + if (customFields == null || productInfo?.ProductParameters == null) + { + Logger.LogTrace("GetCustomFields: returning empty list (null customFields or ProductParameters)."); + return customFieldList; + } + foreach (var field in customFields) + { + if (field == null) + { + Logger.LogTrace("GetCustomFields: skipping null field entry."); + continue; + } + + Logger.LogTrace("GetCustomFields: checking field Label='{Label}', Mandatory={Mandatory}", + field.Label ?? "(null)", field.Mandatory); + + if (string.IsNullOrEmpty(field.Label)) + { + Logger.LogTrace("GetCustomFields: skipping field with null/empty label."); + continue; + } + if (productInfo.ProductParameters.ContainsKey(field.Label)) { var newField = new CustomField @@ -30,32 +59,58 @@ private List GetCustomFields(EnrollmentProductInfo productInfo, Lis Name = field.Label, Value = productInfo.ProductParameters[field.Label] }; + Logger.LogTrace("GetCustomFields: matched field '{Label}' = '{Value}'", field.Label, newField.Value ?? "(null)"); customFieldList.Add(newField); } else if (field.Mandatory) { + Logger.LogError("GetCustomFields: mandatory field '{Label}' was not supplied. Available keys: [{Keys}]", + field.Label, string.Join(", ", productInfo.ProductParameters.Keys)); throw new Exception( $"Custom field {field.Label} is marked as mandatory, but was not supplied in the request."); } + else + { + Logger.LogTrace("GetCustomFields: optional field '{Label}' not found in ProductParameters, skipping.", field.Label); + } + } + Logger.LogTrace("GetCustomFields: returning {Count} custom fields.", customFieldList.Count); return customFieldList; } public EnrollmentResult GetRenewResponse(RenewalResponse renewResponse) { + Logger.LogTrace("GetRenewResponse: renewResponse is {Null}", renewResponse == null ? "NULL" : "present"); + + if (renewResponse == null) + { + Logger.LogError("GetRenewResponse: renewResponse is null."); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "Renewal failed: received null response from CSC." + }; + } + if (renewResponse.RegistrationError != null) + { + Logger.LogWarning("GetRenewResponse: RegistrationError present. Description='{Desc}'", + renewResponse.RegistrationError.Description ?? "(null)"); return new EnrollmentResult { - Status = (int)EndEntityStatus.FAILED, //failure - CARequestID = renewResponse?.Result?.Status?.Uuid, - StatusMessage = renewResponse.RegistrationError.Description + Status = (int)EndEntityStatus.FAILED, + CARequestID = renewResponse.Result?.Status?.Uuid, + StatusMessage = renewResponse.RegistrationError.Description ?? "Renewal failed with unknown error." }; + } + var commonName = renewResponse.Result?.CommonName ?? "(unknown)"; + Logger.LogTrace("GetRenewResponse: renewal succeeded for CommonName='{CommonName}'", commonName); return new EnrollmentResult { - Status = (int)EndEntityStatus.GENERATED, //success - - StatusMessage = $"Renewal Successfully Completed For {renewResponse.Result.CommonName}" + Status = (int)EndEntityStatus.GENERATED, + StatusMessage = $"Renewal Successfully Completed For {commonName}" }; } @@ -64,77 +119,210 @@ public EnrollmentResult GetEnrollmentResult( IRegistrationResponse registrationResponse) { + Logger.LogTrace("GetEnrollmentResult: registrationResponse is {Null}", registrationResponse == null ? "NULL" : "present"); + + if (registrationResponse == null) + { + Logger.LogError("GetEnrollmentResult: registrationResponse is null."); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "Enrollment failed: received null response from CSC." + }; + } + if (registrationResponse.RegistrationError != null) + { + Logger.LogWarning("GetEnrollmentResult: RegistrationError present. Description='{Desc}'", + registrationResponse.RegistrationError.Description ?? "(null)"); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = registrationResponse.RegistrationError.Description ?? "Enrollment failed with unknown error." + }; + } + + if (registrationResponse.Result == null) + { + Logger.LogError("GetEnrollmentResult: Result is null but no RegistrationError present."); return new EnrollmentResult { - Status = (int)EndEntityStatus.FAILED, //failure - StatusMessage = registrationResponse.RegistrationError.Description + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "Enrollment failed: response Result is null." }; + } var cnames = new Dictionary(); if (registrationResponse.Result.DcvDetails != null && registrationResponse.Result.DcvDetails.Count > 0) + { + Logger.LogTrace("GetEnrollmentResult: processing {Count} DcvDetails.", registrationResponse.Result.DcvDetails.Count); foreach (var dcv in registrationResponse.Result.DcvDetails) { + if (dcv == null) + { + Logger.LogTrace("GetEnrollmentResult: skipping null DcvDetail."); + continue; + } + if (dcv.CName != null && !string.IsNullOrEmpty(dcv.CName.Name) && !string.IsNullOrEmpty(dcv.CName.Value)) { - cnames.Add(dcv.CName.Name, dcv.CName.Value); + if (!cnames.ContainsKey(dcv.CName.Name)) + { + Logger.LogTrace("GetEnrollmentResult: adding CName '{Name}'='{Value}'", dcv.CName.Name, dcv.CName.Value); + cnames.Add(dcv.CName.Name, dcv.CName.Value); + } + else + { + Logger.LogTrace("GetEnrollmentResult: duplicate CName key '{Name}', skipping.", dcv.CName.Name); + } } - if (string.IsNullOrEmpty(dcv.Email)) + if (!string.IsNullOrEmpty(dcv.Email)) { - cnames.Add(dcv.Email, dcv.Email); + if (!cnames.ContainsKey(dcv.Email)) + { + Logger.LogTrace("GetEnrollmentResult: adding DCV email '{Email}'", dcv.Email); + cnames.Add(dcv.Email, dcv.Email); + } + else + { + Logger.LogTrace("GetEnrollmentResult: duplicate email key '{Email}', skipping.", dcv.Email); + } } } - + } + else + { + Logger.LogTrace("GetEnrollmentResult: no DcvDetails to process."); + } + + var uuid = registrationResponse.Result.Status?.Uuid; + var commonName = registrationResponse.Result.CommonName ?? "(unknown)"; + Logger.LogTrace("GetEnrollmentResult: success. UUID='{Uuid}', CommonName='{CommonName}', cnames count={Count}", + uuid ?? "(null)", commonName, cnames.Count); + return new EnrollmentResult { - Status = (int)EndEntityStatus.EXTERNALVALIDATION, //success - CARequestID = registrationResponse.Result.Status.Uuid, + Status = (int)EndEntityStatus.EXTERNALVALIDATION, + CARequestID = uuid, StatusMessage = - $"Order Successfully Created With Order Number {registrationResponse.Result.CommonName}", + $"Order Successfully Created With Order Number {commonName}", EnrollmentContext = cnames.Count > 0 ? cnames : null }; } public int GetRevokeResult(IRevokeResponse revokeResponse) { + Logger.LogTrace("GetRevokeResult: revokeResponse is {Null}", revokeResponse == null ? "NULL" : "present"); + + if (revokeResponse == null) + { + Logger.LogError("GetRevokeResult: revokeResponse is null, returning FAILED."); + return (int)EndEntityStatus.FAILED; + } + if (revokeResponse.RegistrationError != null) + { + Logger.LogWarning("GetRevokeResult: RegistrationError present. Description='{Desc}'", + revokeResponse.RegistrationError.Description ?? "(null)"); return (int)EndEntityStatus.FAILED; + } + Logger.LogTrace("GetRevokeResult: returning REVOKED."); return (int)EndEntityStatus.REVOKED; } public EnrollmentResult GetReIssueResult(IReissueResponse reissueResponse) { + Logger.LogTrace("GetReIssueResult: reissueResponse is {Null}", reissueResponse == null ? "NULL" : "present"); + + if (reissueResponse == null) + { + Logger.LogError("GetReIssueResult: reissueResponse is null."); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "Reissue failed: received null response from CSC." + }; + } + if (reissueResponse.RegistrationError != null) + { + Logger.LogWarning("GetReIssueResult: RegistrationError present. Description='{Desc}'", + reissueResponse.RegistrationError.Description ?? "(null)"); return new EnrollmentResult { - Status = (int)EndEntityStatus.FAILED, //failure - StatusMessage = reissueResponse.RegistrationError.Description + Status = (int)EndEntityStatus.FAILED, + StatusMessage = reissueResponse.RegistrationError.Description ?? "Reissue failed with unknown error." }; + } + + if (reissueResponse.Result == null) + { + Logger.LogError("GetReIssueResult: Result is null but no RegistrationError present."); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "Reissue failed: response Result is null." + }; + } + + var uuid = reissueResponse.Result.Status?.Uuid; + var commonName = reissueResponse.Result.CommonName ?? "(unknown)"; + Logger.LogTrace("GetReIssueResult: success. UUID='{Uuid}', CommonName='{CommonName}'", uuid ?? "(null)", commonName); return new EnrollmentResult { - Status = (int)EndEntityStatus.GENERATED, //success - CARequestID = reissueResponse.Result.Status.Uuid, - StatusMessage = $"Reissue Successfully Completed For {reissueResponse.Result.CommonName}" + Status = (int)EndEntityStatus.GENERATED, + CARequestID = uuid, + StatusMessage = $"Reissue Successfully Completed For {commonName}" }; } public DomainControlValidation GetDomainControlValidation(string methodType, string[] emailAddress, string domainName) { + Logger.LogTrace("GetDomainControlValidation(array): methodType='{MethodType}', domainName='{DomainName}', emailAddress count={Count}", + methodType ?? "(null)", domainName ?? "(null)", emailAddress?.Length ?? 0); + + if (emailAddress == null || emailAddress.Length == 0) + { + Logger.LogTrace("GetDomainControlValidation(array): no email addresses provided, returning null."); + return null; + } + foreach (var address in emailAddress) { - var email = new MailAddress(address); - if (domainName.Contains(email.Host.Split('.')[0])) - return new DomainControlValidation + if (string.IsNullOrEmpty(address)) + { + Logger.LogTrace("GetDomainControlValidation(array): skipping null/empty email address."); + continue; + } + + try + { + var email = new MailAddress(address); + var hostPart = email.Host?.Split('.')[0] ?? ""; + Logger.LogTrace("GetDomainControlValidation(array): checking email='{Email}', hostPart='{HostPart}' against domain='{Domain}'", + address, hostPart, domainName); + + if (!string.IsNullOrEmpty(domainName) && domainName.Contains(hostPart)) { - MethodType = methodType, - EmailAddress = email.ToString() - }; + Logger.LogTrace("GetDomainControlValidation(array): matched! Returning email='{Email}'", email.ToString()); + return new DomainControlValidation + { + MethodType = methodType, + EmailAddress = email.ToString() + }; + } + } + catch (FormatException fex) + { + Logger.LogWarning("GetDomainControlValidation(array): invalid email address '{Address}': {Message}", address, fex.Message); + } } + Logger.LogTrace("GetDomainControlValidation(array): no matching email found, returning null."); return null; } @@ -150,32 +338,42 @@ public DomainControlValidation GetDomainControlValidation(string methodType, str public RegistrationRequest GetRegistrationRequest(EnrollmentProductInfo productInfo, string csr, Dictionary sans, List customFields) { - //var cert = "-----BEGIN CERTIFICATE REQUEST-----\r\n"; - var cert = Pemify(csr); - //cert = cert + "\r\n-----END CERTIFICATE REQUEST-----"; + Logger.LogTrace("GetRegistrationRequest: building registration request. ProductID='{ProductId}'", productInfo?.ProductID ?? "(null)"); + if (productInfo?.ProductParameters == null) + throw new ArgumentNullException(nameof(productInfo), "productInfo or ProductParameters cannot be null."); + if (string.IsNullOrEmpty(csr)) + throw new ArgumentNullException(nameof(csr), "CSR cannot be null or empty."); + var cert = Pemify(csr); 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"]; + Logger.LogTrace("GetRegistrationRequest: CSR encoded, length={Length}", encodedString.Length); + + var commonNameValidationEmail = productInfo.ProductParameters.ContainsKey("CN DCV Email") + ? productInfo.ProductParameters["CN DCV Email"] : null; + var methodType = productInfo.ProductParameters.ContainsKey("Domain Control Validation Method") + ? productInfo.ProductParameters["Domain Control Validation Method"] : null; var certificateType = GetCertificateType(productInfo.ProductID); + Logger.LogTrace("GetRegistrationRequest: cnDcvEmail='{Email}', methodType='{Method}', certType='{CertType}'", + commonNameValidationEmail ?? "(null)", methodType ?? "(null)", certificateType); + return new RegistrationRequest { Csr = encodedString, - ServerSoftware = "-1", //Just default to other, user does not need to fill this in + 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 = productInfo.ProductParameters.ContainsKey("Term") ? productInfo.ProductParameters["Term"] : null, + ApplicantFirstName = productInfo.ProductParameters.ContainsKey("Applicant First Name") ? productInfo.ProductParameters["Applicant First Name"] : null, + ApplicantLastName = productInfo.ProductParameters.ContainsKey("Applicant Last Name") ? productInfo.ProductParameters["Applicant Last Name"] : null, + ApplicantEmailAddress = productInfo.ProductParameters.ContainsKey("Applicant Email Address") ? productInfo.ProductParameters["Applicant Email Address"] : null, + ApplicantPhoneNumber = productInfo.ProductParameters.ContainsKey("Applicant Phone") ? productInfo.ProductParameters["Applicant Phone"] : null, DomainControlValidation = GetDomainControlValidation(methodType, commonNameValidationEmail), Notifications = GetNotifications(productInfo), - OrganizationContact = productInfo.ProductParameters["Organization Contact"], - BusinessUnit = productInfo.ProductParameters["Business Unit"], - ShowPrice = true, //User should not have to fill this out + OrganizationContact = productInfo.ProductParameters.ContainsKey("Organization Contact") ? productInfo.ProductParameters["Organization Contact"] : null, + BusinessUnit = productInfo.ProductParameters.ContainsKey("Business Unit") ? productInfo.ProductParameters["Business Unit"] : null, + ShowPrice = true, CustomFields = GetCustomFields(productInfo, customFields), SubjectAlternativeNames = certificateType == "2" ? GetSubjectAlternativeNames(productInfo, sans) : null, EvCertificateDetails = certificateType == "3" ? GetEvCertificateDetails(productInfo) : null @@ -213,42 +411,68 @@ private string GetCertificateType(string productId) public Notifications GetNotifications(EnrollmentProductInfo productInfo) { + Logger.LogTrace("GetNotifications: building notifications."); + var emailsRaw = productInfo?.ProductParameters != null + && productInfo.ProductParameters.ContainsKey("Notification Email(s) Comma Separated") + ? productInfo.ProductParameters["Notification Email(s) Comma Separated"] + : null; + + Logger.LogTrace("GetNotifications: raw notification emails='{Emails}'", emailsRaw ?? "(null)"); + + var emailList = !string.IsNullOrEmpty(emailsRaw) + ? emailsRaw.Split(',').Where(e => !string.IsNullOrWhiteSpace(e)).ToList() + : new List(); + + Logger.LogTrace("GetNotifications: parsed {Count} notification emails.", emailList.Count); + return new Notifications { Enabled = true, - AdditionalNotificationEmails = productInfo.ProductParameters["Notification Email(s) Comma Separated"] - .Split(',').ToList() + AdditionalNotificationEmails = emailList }; } public RenewalRequest GetRenewalRequest(EnrollmentProductInfo productInfo, string uUId, string csr, Dictionary sans, List customFields) { - //var cert = "-----BEGIN CERTIFICATE REQUEST-----\r\n"; - var cert = Pemify(csr); - //cert = cert + "\r\n-----END CERTIFICATE REQUEST-----"; + Logger.LogTrace("GetRenewalRequest: building renewal request. UUID='{Uuid}', ProductID='{ProductId}'", + uUId ?? "(null)", productInfo?.ProductID ?? "(null)"); + + if (productInfo?.ProductParameters == null) + throw new ArgumentNullException(nameof(productInfo), "productInfo or ProductParameters cannot be null."); + if (string.IsNullOrEmpty(csr)) + throw new ArgumentNullException(nameof(csr), "CSR cannot be null or empty."); + if (string.IsNullOrEmpty(uUId)) + throw new ArgumentNullException(nameof(uUId), "uUId cannot be null or empty."); + var cert = Pemify(csr); 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 = productInfo.ProductParameters.ContainsKey("CN DCV Email") + ? productInfo.ProductParameters["CN DCV Email"] : null; + var methodType = productInfo.ProductParameters.ContainsKey("Domain Control Validation Method") + ? productInfo.ProductParameters["Domain Control Validation Method"] : null; var certificateType = GetCertificateType(productInfo.ProductID); + Logger.LogTrace("GetRenewalRequest: cnDcvEmail='{Email}', methodType='{Method}', certType='{CertType}'", + commonNameValidationEmail ?? "(null)", methodType ?? "(null)", certificateType); + return new RenewalRequest { Uuid = uUId, 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 = productInfo.ProductParameters.ContainsKey("Term") ? productInfo.ProductParameters["Term"] : null, + ApplicantFirstName = productInfo.ProductParameters.ContainsKey("Applicant First Name") ? productInfo.ProductParameters["Applicant First Name"] : null, + ApplicantLastName = productInfo.ProductParameters.ContainsKey("Applicant Last Name") ? productInfo.ProductParameters["Applicant Last Name"] : null, + ApplicantEmailAddress = productInfo.ProductParameters.ContainsKey("Applicant Email Address") ? productInfo.ProductParameters["Applicant Email Address"] : null, + ApplicantPhoneNumber = productInfo.ProductParameters.ContainsKey("Applicant Phone") ? productInfo.ProductParameters["Applicant Phone"] : null, DomainControlValidation = GetDomainControlValidation(methodType, commonNameValidationEmail), Notifications = GetNotifications(productInfo), - OrganizationContact = productInfo.ProductParameters["Organization Contact"], - BusinessUnit = productInfo.ProductParameters["Business Unit"], + OrganizationContact = productInfo.ProductParameters.ContainsKey("Organization Contact") ? productInfo.ProductParameters["Organization Contact"] : null, + BusinessUnit = productInfo.ProductParameters.ContainsKey("Business Unit") ? productInfo.ProductParameters["Business Unit"] : null, ShowPrice = true, SubjectAlternativeNames = certificateType == "2" ? GetSubjectAlternativeNames(productInfo, sans) : null, CustomFields = GetCustomFields(productInfo, customFields), @@ -259,54 +483,107 @@ public RenewalRequest GetRenewalRequest(EnrollmentProductInfo productInfo, strin private List GetSubjectAlternativeNames(EnrollmentProductInfo productInfo, Dictionary sans) { + Logger.LogTrace("GetSubjectAlternativeNames: building SANs."); var subjectNameList = new List(); - var methodType = productInfo.ProductParameters["Domain Control Validation Method"]; - foreach (var v in sans["dnsname"]) + if (sans == null || !sans.ContainsKey("dnsname")) + { + Logger.LogTrace("GetSubjectAlternativeNames: no 'dnsname' key in SANs dictionary, returning empty list."); + return subjectNameList; + } + + var dnsNames = sans["dnsname"]; + if (dnsNames == null || dnsNames.Length == 0) { + Logger.LogTrace("GetSubjectAlternativeNames: 'dnsname' array is null or empty, returning empty list."); + return subjectNameList; + } + + var methodType = productInfo?.ProductParameters != null + && productInfo.ProductParameters.ContainsKey("Domain Control Validation Method") + ? productInfo.ProductParameters["Domain Control Validation Method"] + : null; + + Logger.LogTrace("GetSubjectAlternativeNames: processing {Count} DNS names, methodType='{MethodType}'", + dnsNames.Length, methodType ?? "(null)"); + + foreach (var v in dnsNames) + { + if (string.IsNullOrEmpty(v)) + { + Logger.LogTrace("GetSubjectAlternativeNames: skipping null/empty DNS name."); + continue; + } + 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") + Logger.LogTrace("GetSubjectAlternativeNames: processing domain='{Domain}'", domainName); + + if (!string.IsNullOrEmpty(methodType) && methodType.ToUpper() == "EMAIL") + { + var emailsRaw = productInfo.ProductParameters.ContainsKey("Addtl Sans Comma Separated DVC Emails") + ? productInfo.ProductParameters["Addtl Sans Comma Separated DVC Emails"] + : null; + var emailAddresses = !string.IsNullOrEmpty(emailsRaw) ? emailsRaw.Split(',') : Array.Empty(); + Logger.LogTrace("GetSubjectAlternativeNames: EMAIL validation, {Count} email addresses for domain='{Domain}'", + emailAddresses.Length, domainName); san.DomainControlValidation = GetDomainControlValidation(methodType, emailAddresses, domainName); - else //it is a CNAME validation so no email is needed + } + else + { + Logger.LogTrace("GetSubjectAlternativeNames: CNAME/other validation for domain='{Domain}'", domainName); san.DomainControlValidation = GetDomainControlValidation(methodType, ""); + } subjectNameList.Add(san); } + Logger.LogTrace("GetSubjectAlternativeNames: returning {Count} SANs.", subjectNameList.Count); return subjectNameList; } public ReissueRequest GetReissueRequest(EnrollmentProductInfo productInfo, string uUId, string csr, Dictionary sans, List customFields) { - //var cert = "-----BEGIN CERTIFICATE REQUEST-----\r\n"; - var cert = Pemify(csr); - //cert = cert + "\r\n-----END CERTIFICATE REQUEST-----"; + Logger.LogTrace("GetReissueRequest: building reissue request. UUID='{Uuid}', ProductID='{ProductId}'", + uUId ?? "(null)", productInfo?.ProductID ?? "(null)"); + if (productInfo?.ProductParameters == null) + throw new ArgumentNullException(nameof(productInfo), "productInfo or ProductParameters cannot be null."); + if (string.IsNullOrEmpty(csr)) + throw new ArgumentNullException(nameof(csr), "CSR cannot be null or empty."); + if (string.IsNullOrEmpty(uUId)) + throw new ArgumentNullException(nameof(uUId), "uUId cannot be null or empty."); + + var cert = Pemify(csr); 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 = productInfo.ProductParameters.ContainsKey("CN DCV Email") + ? productInfo.ProductParameters["CN DCV Email"] : null; + var methodType = productInfo.ProductParameters.ContainsKey("Domain Control Validation Method") + ? productInfo.ProductParameters["Domain Control Validation Method"] : null; var certificateType = GetCertificateType(productInfo.ProductID); + Logger.LogTrace("GetReissueRequest: cnDcvEmail='{Email}', methodType='{Method}', certType='{CertType}'", + commonNameValidationEmail ?? "(null)", methodType ?? "(null)", certificateType); + return new ReissueRequest { 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 = productInfo.ProductParameters.ContainsKey("Term") ? productInfo.ProductParameters["Term"] : null, + ApplicantFirstName = productInfo.ProductParameters.ContainsKey("Applicant First Name") ? productInfo.ProductParameters["Applicant First Name"] : null, + ApplicantLastName = productInfo.ProductParameters.ContainsKey("Applicant Last Name") ? productInfo.ProductParameters["Applicant Last Name"] : null, + ApplicantEmailAddress = productInfo.ProductParameters.ContainsKey("Applicant Email Address") ? productInfo.ProductParameters["Applicant Email Address"] : null, + ApplicantPhoneNumber = productInfo.ProductParameters.ContainsKey("Applicant Phone") ? productInfo.ProductParameters["Applicant Phone"] : null, DomainControlValidation = GetDomainControlValidation(methodType, commonNameValidationEmail), Notifications = GetNotifications(productInfo), - OrganizationContact = productInfo.ProductParameters["Organization Contact"], - BusinessUnit = productInfo.ProductParameters["Business Unit"], + OrganizationContact = productInfo.ProductParameters.ContainsKey("Organization Contact") ? productInfo.ProductParameters["Organization Contact"] : null, + BusinessUnit = productInfo.ProductParameters.ContainsKey("Business Unit") ? productInfo.ProductParameters["Business Unit"] : null, ShowPrice = true, SubjectAlternativeNames = certificateType == "2" ? GetSubjectAlternativeNames(productInfo, sans) : null, CustomFields = GetCustomFields(productInfo, customFields), @@ -316,15 +593,28 @@ public ReissueRequest GetReissueRequest(EnrollmentProductInfo productInfo, strin private EvCertificateDetails GetEvCertificateDetails(EnrollmentProductInfo productInfo) { + Logger.LogTrace("GetEvCertificateDetails: building EV details."); + var country = productInfo?.ProductParameters != null + && productInfo.ProductParameters.ContainsKey("Organization Country") + ? productInfo.ProductParameters["Organization Country"] + : null; + Logger.LogTrace("GetEvCertificateDetails: country='{Country}'", country ?? "(null)"); var evDetails = new EvCertificateDetails(); - evDetails.Country = productInfo.ProductParameters["Organization Country"]; + evDetails.Country = country; return evDetails; } public int MapReturnStatus(string cscGlobalStatus) { - var returnStatus = 0; + Logger.LogTrace("MapReturnStatus: input status='{Status}'", cscGlobalStatus ?? "(null)"); + + if (string.IsNullOrEmpty(cscGlobalStatus)) + { + Logger.LogWarning("MapReturnStatus: status is null or empty, returning FAILED."); + return (int)EndEntityStatus.FAILED; + } + int returnStatus; switch (cscGlobalStatus) { case "ACTIVE": @@ -340,10 +630,12 @@ public int MapReturnStatus(string cscGlobalStatus) returnStatus = (int)EndEntityStatus.REVOKED; break; default: + Logger.LogWarning("MapReturnStatus: unrecognized status '{Status}', returning FAILED.", cscGlobalStatus); returnStatus = (int)EndEntityStatus.FAILED; break; } + Logger.LogTrace("MapReturnStatus: mapped '{Status}' to {Result}", cscGlobalStatus, returnStatus); return returnStatus; } } \ No newline at end of file From fba58ac0fa3980e556f48a6ab852c806ce5e04b8 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Wed, 8 Apr 2026 12:23:02 -0400 Subject: [PATCH 04/42] Removed Template Sync Logic --- cscglobal-caplugin/CSCGlobalCAPlugin.cs | 29 +------------------------ cscglobal-caplugin/Constants.cs | 1 - 2 files changed, 1 insertion(+), 29 deletions(-) diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index e0c67ed..a4bcb47 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; } public int RenewalWindowDays { get; set; } @@ -77,22 +75,6 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa Logger.LogTrace("CAConnectionData keys: {Keys}", string.Join(", ", configProvider.CAConnectionData.Keys)); }); - flow.Step("ReadTemplateSync", () => - { - if (configProvider.CAConnectionData.ContainsKey("TemplateSync")) - { - var templateSync = configProvider.CAConnectionData["TemplateSync"]?.ToString(); - Logger.LogTrace("TemplateSync raw value: '{Value}'", templateSync ?? "(null)"); - if (!string.IsNullOrEmpty(templateSync) && templateSync.ToUpper() == "ON") - EnableTemplateSync = true; - } - else - { - Logger.LogTrace("TemplateSync key not found in CAConnectionData, defaulting to disabled."); - } - Logger.LogTrace("EnableTemplateSync = {Value}", EnableTemplateSync); - }, $"EnableTemplateSync={EnableTemplateSync}"); - flow.Step("ReadSyncFilterDays", () => { if (configProvider.CAConnectionData.ContainsKey(Constants.SyncFilterDays)) @@ -337,9 +319,7 @@ private async Task SyncCertificates(BlockingCollection b if (certStatus == Convert.ToInt32(EndEntityStatus.GENERATED) || certStatus == Convert.ToInt32(EndEntityStatus.REVOKED)) { - var productId = "CscGlobal"; - if (EnableTemplateSync) - productId = currentResponseItem.CertificateType ?? "CscGlobal"; + var productId = currentResponseItem.CertificateType ?? "CscGlobal"; Logger.LogTrace("SyncCertificates: UUID={Uuid} qualifies for sync. ProductId='{ProductId}'", currentResponseItem.Uuid, productId); @@ -947,13 +927,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 aede084..d53fa61 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"; public static string RenewalWindowDays = "RenewalWindowDays"; } From 58177dffc314efaa6fef26148b10dadf85c4b75d Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Wed, 8 Apr 2026 16:24:46 +0000 Subject: [PATCH 05/42] Update generated docs --- README.md | 1 - integration-manifest.json | 4 ---- 2 files changed, 5 deletions(-) diff --git a/README.md b/README.md index a83d582..10b6189 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,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. * **RenewalWindowDays** - Number of days before the annual order expiry within which a RenewOrReissue triggers a paid Renewal rather than a free Reissue. Default is 30. diff --git a/integration-manifest.json b/integration-manifest.json index 9237eab..bab6aae 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 b8d74db5017c2ea2d9a43f5b22cdb95f60c0040d Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Wed, 8 Apr 2026 14:25:32 -0400 Subject: [PATCH 06/42] fixed template mapping issue --- cscglobal-caplugin/CSCGlobalCAPlugin.cs | 5 +- cscglobal-caplugin/RequestManager.cs | 89 +++++++++++++++++++------ 2 files changed, 70 insertions(+), 24 deletions(-) diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index a4bcb47..5449f64 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -319,9 +319,10 @@ private async Task SyncCertificates(BlockingCollection b if (certStatus == Convert.ToInt32(EndEntityStatus.GENERATED) || certStatus == Convert.ToInt32(EndEntityStatus.REVOKED)) { - var productId = currentResponseItem.CertificateType ?? "CscGlobal"; + var productId = _requestManager.MapCertificateTypeToProductId(currentResponseItem.CertificateType); - Logger.LogTrace("SyncCertificates: UUID={Uuid} qualifies for sync. ProductId='{ProductId}'", currentResponseItem.Uuid, productId); + Logger.LogTrace("SyncCertificates: UUID={Uuid} qualifies for sync. CertificateType='{CertType}' -> ProductId='{ProductId}'", + currentResponseItem.Uuid, currentResponseItem.CertificateType ?? "(null)", productId); string fileContent; try diff --git a/cscglobal-caplugin/RequestManager.cs b/cscglobal-caplugin/RequestManager.cs index b930083..85e9b38 100644 --- a/cscglobal-caplugin/RequestManager.cs +++ b/cscglobal-caplugin/RequestManager.cs @@ -380,35 +380,80 @@ public RegistrationRequest GetRegistrationRequest(EnrollmentProductInfo productI }; } + // Maps Keyfactor product ID -> CSC API certificate type code (used for enrollment requests) + private static readonly Dictionary ProductIdToCodeMap = new(StringComparer.OrdinalIgnoreCase) + { + ["CSC TrustedSecure Premium Certificate"] = "0", + ["CSC TrustedSecure Premium Wildcard Certificate"] = "1", + ["CSC TrustedSecure UC Certificate"] = "2", + ["CSC TrustedSecure EV Certificate"] = "3", + ["CSC TrustedSecure Domain Validated SSL"] = "4", + ["CSC Trusted Secure Domain Validated SSL"] = "4", + ["CSC TrustedSecure Domain Validated Wildcard SSL"] = "5", + ["CSC Trusted Secure Domain Validated Wildcard SSL"] = "5", + ["CSC TrustedSecure Domain Validated UC Certificate"] = "6", + ["CSC Trusted Secure Domain Validated UC Certificate"] = "6", + }; + + // Reverse map: CSC API certificate type string -> Keyfactor product ID (used during sync) + // CSC may return numeric codes ("0","1") or descriptive strings ("Premium","EV","UC", etc.) + private static readonly Dictionary CodeToProductIdMap = new(StringComparer.OrdinalIgnoreCase) + { + ["0"] = "CSC TrustedSecure Premium Certificate", + ["Premium"] = "CSC TrustedSecure Premium Certificate", + ["CSC TrustedSecure Premium Certificate"] = "CSC TrustedSecure Premium Certificate", + ["1"] = "CSC TrustedSecure Premium Wildcard Certificate", + ["Wildcard"] = "CSC TrustedSecure Premium Wildcard Certificate", + ["Premium Wildcard"] = "CSC TrustedSecure Premium Wildcard Certificate", + ["CSC TrustedSecure Premium Wildcard Certificate"] = "CSC TrustedSecure Premium Wildcard Certificate", + ["2"] = "CSC TrustedSecure UC Certificate", + ["UC"] = "CSC TrustedSecure UC Certificate", + ["CSC TrustedSecure UC Certificate"] = "CSC TrustedSecure UC Certificate", + ["3"] = "CSC TrustedSecure EV Certificate", + ["EV"] = "CSC TrustedSecure EV Certificate", + ["CSC TrustedSecure EV Certificate"] = "CSC TrustedSecure EV Certificate", + ["4"] = "CSC TrustedSecure Domain Validated SSL", + ["DV"] = "CSC TrustedSecure Domain Validated SSL", + ["Domain Validated SSL"] = "CSC TrustedSecure Domain Validated SSL", + ["CSC TrustedSecure Domain Validated SSL"] = "CSC TrustedSecure Domain Validated SSL", + ["5"] = "CSC TrustedSecure Domain Validated Wildcard SSL", + ["DV Wildcard"] = "CSC TrustedSecure Domain Validated Wildcard SSL", + ["Domain Validated Wildcard SSL"] = "CSC TrustedSecure Domain Validated Wildcard SSL", + ["CSC TrustedSecure Domain Validated Wildcard SSL"] = "CSC TrustedSecure Domain Validated Wildcard SSL", + ["6"] = "CSC TrustedSecure Domain Validated UC Certificate", + ["DV UC"] = "CSC TrustedSecure Domain Validated UC Certificate", + ["Domain Validated UC Certificate"] = "CSC TrustedSecure Domain Validated UC Certificate", + ["CSC TrustedSecure Domain Validated UC Certificate"] = "CSC TrustedSecure Domain Validated UC Certificate", + }; + private string GetCertificateType(string productId) { - switch (productId) + Logger.LogTrace("GetCertificateType: productId='{ProductId}'", productId ?? "(null)"); + if (!string.IsNullOrEmpty(productId) && ProductIdToCodeMap.TryGetValue(productId, out var code)) { - case "CSC TrustedSecure Premium Certificate": - return "0"; - case "CSC TrustedSecure EV Certificate": - return "3"; - case "CSC TrustedSecure UC Certificate": - return "2"; - case "CSC TrustedSecure Premium Wildcard Certificate": - 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": - return "4"; - case "CSC TrustedSecure Domain Validated Wildcard SSL": - return "5"; - case "CSC TrustedSecure Domain Validated UC Certificate": - return "6"; + Logger.LogTrace("GetCertificateType: mapped '{ProductId}' -> '{Code}'", productId, code); + return code; } - + Logger.LogWarning("GetCertificateType: no mapping found for '{ProductId}', returning -1.", productId); return "-1"; } + /// + /// Maps a CSC API certificateType value back to a Keyfactor product ID. + /// Handles numeric codes, descriptive strings, and passthrough of already-correct values. + /// + public string MapCertificateTypeToProductId(string cscCertificateType) + { + Logger.LogTrace("MapCertificateTypeToProductId: input='{CscCertType}'", cscCertificateType ?? "(null)"); + if (!string.IsNullOrEmpty(cscCertificateType) && CodeToProductIdMap.TryGetValue(cscCertificateType, out var productId)) + { + Logger.LogTrace("MapCertificateTypeToProductId: mapped '{CscCertType}' -> '{ProductId}'", cscCertificateType, productId); + return productId; + } + Logger.LogWarning("MapCertificateTypeToProductId: no mapping for '{CscCertType}', passing through as-is.", cscCertificateType); + return cscCertificateType ?? "CscGlobal"; + } + public Notifications GetNotifications(EnrollmentProductInfo productInfo) { Logger.LogTrace("GetNotifications: building notifications."); From 0b6f8a5ab9b30d6153dc217abc67bbc689b01486 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Wed, 8 Apr 2026 16:28:25 -0400 Subject: [PATCH 07/42] product fixes --- cscglobal-caplugin/Constants.cs | 4 +-- cscglobal-caplugin/RequestManager.cs | 42 +++++++++++++++------------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/cscglobal-caplugin/Constants.cs b/cscglobal-caplugin/Constants.cs index d53fa61..d588706 100644 --- a/cscglobal-caplugin/Constants.cs +++ b/cscglobal-caplugin/Constants.cs @@ -26,8 +26,8 @@ public class ProductIDs "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 Trusted Secure Domain Validated Wildcard SSL", + "CSC Trusted Secure Domain Validated UC Certificate" }; } diff --git a/cscglobal-caplugin/RequestManager.cs b/cscglobal-caplugin/RequestManager.cs index 85e9b38..4e3a7ac 100644 --- a/cscglobal-caplugin/RequestManager.cs +++ b/cscglobal-caplugin/RequestManager.cs @@ -389,41 +389,45 @@ public RegistrationRequest GetRegistrationRequest(EnrollmentProductInfo productI ["CSC TrustedSecure EV Certificate"] = "3", ["CSC TrustedSecure Domain Validated SSL"] = "4", ["CSC Trusted Secure Domain Validated SSL"] = "4", - ["CSC TrustedSecure Domain Validated Wildcard SSL"] = "5", ["CSC Trusted Secure Domain Validated Wildcard SSL"] = "5", - ["CSC TrustedSecure Domain Validated UC Certificate"] = "6", ["CSC Trusted Secure Domain Validated UC Certificate"] = "6", }; - // Reverse map: CSC API certificate type string -> Keyfactor product ID (used during sync) - // CSC may return numeric codes ("0","1") or descriptive strings ("Premium","EV","UC", etc.) + // Reverse map: CSC API certificateType string -> Keyfactor product ID (used during sync) + // Note: CSC naming is inconsistent — first 4 types use "TrustedSecure" (no space), + // DV Wildcard and DV UC use "Trusted Secure" (with space), + // but CSC API returns DV SSL as "CSC Trusted Secure Domain Validated SSL" (with space) + // while the product ID is "CSC TrustedSecure Domain Validated SSL" (no space). private static readonly Dictionary CodeToProductIdMap = new(StringComparer.OrdinalIgnoreCase) { + // Premium ["0"] = "CSC TrustedSecure Premium Certificate", - ["Premium"] = "CSC TrustedSecure Premium Certificate", ["CSC TrustedSecure Premium Certificate"] = "CSC TrustedSecure Premium Certificate", + ["CSC Trusted Secure Premium Certificate"] = "CSC TrustedSecure Premium Certificate", + // Premium Wildcard ["1"] = "CSC TrustedSecure Premium Wildcard Certificate", - ["Wildcard"] = "CSC TrustedSecure Premium Wildcard Certificate", - ["Premium Wildcard"] = "CSC TrustedSecure Premium Wildcard Certificate", ["CSC TrustedSecure Premium Wildcard Certificate"] = "CSC TrustedSecure Premium Wildcard Certificate", + ["CSC Trusted Secure Premium Wildcard Certificate"] = "CSC TrustedSecure Premium Wildcard Certificate", + // UC ["2"] = "CSC TrustedSecure UC Certificate", - ["UC"] = "CSC TrustedSecure UC Certificate", ["CSC TrustedSecure UC Certificate"] = "CSC TrustedSecure UC Certificate", + ["CSC Trusted Secure UC Certificate"] = "CSC TrustedSecure UC Certificate", + // EV ["3"] = "CSC TrustedSecure EV Certificate", - ["EV"] = "CSC TrustedSecure EV Certificate", ["CSC TrustedSecure EV Certificate"] = "CSC TrustedSecure EV Certificate", + ["CSC Trusted Secure EV Certificate"] = "CSC TrustedSecure EV Certificate", + // DV SSL — product ID has no space, but CSC API returns with space ["4"] = "CSC TrustedSecure Domain Validated SSL", - ["DV"] = "CSC TrustedSecure Domain Validated SSL", - ["Domain Validated SSL"] = "CSC TrustedSecure Domain Validated SSL", ["CSC TrustedSecure Domain Validated SSL"] = "CSC TrustedSecure Domain Validated SSL", - ["5"] = "CSC TrustedSecure Domain Validated Wildcard SSL", - ["DV Wildcard"] = "CSC TrustedSecure Domain Validated Wildcard SSL", - ["Domain Validated Wildcard SSL"] = "CSC TrustedSecure Domain Validated Wildcard SSL", - ["CSC TrustedSecure Domain Validated Wildcard SSL"] = "CSC TrustedSecure Domain Validated Wildcard SSL", - ["6"] = "CSC TrustedSecure Domain Validated UC Certificate", - ["DV UC"] = "CSC TrustedSecure Domain Validated UC Certificate", - ["Domain Validated UC Certificate"] = "CSC TrustedSecure Domain Validated UC Certificate", - ["CSC TrustedSecure Domain Validated UC Certificate"] = "CSC TrustedSecure Domain Validated UC Certificate", + ["CSC Trusted Secure Domain Validated SSL"] = "CSC TrustedSecure Domain Validated SSL", + // DV Wildcard — product ID has space (matches CSC API) + ["5"] = "CSC Trusted Secure Domain Validated Wildcard SSL", + ["CSC Trusted Secure Domain Validated Wildcard SSL"] = "CSC Trusted Secure Domain Validated Wildcard SSL", + ["CSC TrustedSecure Domain Validated Wildcard SSL"] = "CSC Trusted Secure Domain Validated Wildcard SSL", + // DV UC — product ID has space (matches CSC API) + ["6"] = "CSC Trusted Secure Domain Validated UC Certificate", + ["CSC Trusted Secure Domain Validated UC Certificate"] = "CSC Trusted Secure Domain Validated UC Certificate", + ["CSC TrustedSecure Domain Validated UC Certificate"] = "CSC Trusted Secure Domain Validated UC Certificate", }; private string GetCertificateType(string productId) From 445eb57e40b20de2723f6190e7e0ce11d2bea170 Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Wed, 8 Apr 2026 20:30:35 +0000 Subject: [PATCH 08/42] Update generated docs --- integration-manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integration-manifest.json b/integration-manifest.json index bab6aae..71d13f4 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -94,8 +94,8 @@ "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 Trusted Secure Domain Validated Wildcard SSL", + "CSC Trusted Secure Domain Validated UC Certificate" ] } } From 8f098a9fe9792492acbe3113c81c2e1d55e7c63f Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Wed, 8 Apr 2026 16:45:25 -0400 Subject: [PATCH 09/42] fixed renewal issue --- cscglobal-caplugin/RequestManager.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/cscglobal-caplugin/RequestManager.cs b/cscglobal-caplugin/RequestManager.cs index 4e3a7ac..00e4de1 100644 --- a/cscglobal-caplugin/RequestManager.cs +++ b/cscglobal-caplugin/RequestManager.cs @@ -106,11 +106,13 @@ public EnrollmentResult GetRenewResponse(RenewalResponse renewResponse) } var commonName = renewResponse.Result?.CommonName ?? "(unknown)"; - Logger.LogTrace("GetRenewResponse: renewal succeeded for CommonName='{CommonName}'", commonName); + var uuid = renewResponse.Result?.Status?.Uuid; + Logger.LogTrace("GetRenewResponse: renewal succeeded for CommonName='{CommonName}', UUID='{Uuid}'", commonName, uuid ?? "(null)"); return new EnrollmentResult { - Status = (int)EndEntityStatus.GENERATED, - StatusMessage = $"Renewal Successfully Completed For {commonName}" + Status = (int)EndEntityStatus.EXTERNALVALIDATION, + CARequestID = uuid, + StatusMessage = $"Renewal Successfully Submitted For {commonName}. Certificate will be available after next sync." }; } @@ -273,9 +275,9 @@ public EnrollmentResult GetReIssueResult(IReissueResponse reissueResponse) return new EnrollmentResult { - Status = (int)EndEntityStatus.GENERATED, + Status = (int)EndEntityStatus.EXTERNALVALIDATION, CARequestID = uuid, - StatusMessage = $"Reissue Successfully Completed For {commonName}" + StatusMessage = $"Reissue Successfully Submitted For {commonName}. Certificate will be available after next sync." }; } From 77b42cd5c55135c5d5a88a18c2d4f0678ae61ea2 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Wed, 8 Apr 2026 17:12:20 -0400 Subject: [PATCH 10/42] documentation fixes --- docsource/configuration.md | 49 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/docsource/configuration.md b/docsource/configuration.md index d8c196e..54c3210 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -10,6 +10,55 @@ This integration is tested and confirmed as working for Anygateway REST 24.2 and The Root certificates for installation on the Anygateway server machine should be obtained from CSC. +## CA Connection Configuration + +When defining the Certificate Authority in the AnyCA Gateway REST portal, configure the following fields on the **CA Connection** tab: + +CONFIG ELEMENT | DESCRIPTION | DEFAULT +---------------|-------------|-------- +CscGlobalUrl | The base URL for the CSCGlobal API (e.g. `https://apis.cscglobal.com`) | (required) +ApiKey | Your CSCGlobal API key | (required) +BearerToken | Your CSCGlobal Bearer token for authentication | (required) +DefaultPageSize | Page size for API list requests | 100 +SyncFilterDays | Number of days from today used to filter certificates by expiration date during **incremental** sync. Only certificates expiring within this window are returned. Does not apply to full sync. | 5 +RenewalWindowDays | Number of days before the annual order expiry date within which a **RenewOrReissue** request triggers a paid **Renewal** rather than a free **Reissue**. See [Renewal vs. Reissue Logic](#renewal-vs-reissue-logic) below. | 30 + +## Renewal vs. Reissue Logic + +CSC Global subscriptions are annual orders. When Keyfactor Command sends a **RenewOrReissue** request, the plugin must decide whether to submit a **Renewal** (a new paid order) or a **Reissue** (a free re-key under the existing active order). + +The decision is based on the **RenewalWindowDays** setting and works as follows: + +1. The plugin fetches the original certificate from CSC and reads its `orderDate`. +2. It computes the **order expiry** as `orderDate + 1 year`. +3. It calculates **days remaining** until the order expires. +4. If `days remaining <= RenewalWindowDays`, the request is treated as a **Renewal** (new paid order). +5. If `days remaining > RenewalWindowDays`, the request is treated as a **Reissue** (free under the active order). + +**Example with default RenewalWindowDays = 30:** + +``` +Order Date: 2025-04-08 +Order Expiry: 2026-04-08 +Today: 2026-03-15 +Days Left: 24 + +24 <= 30 --> RENEWAL (new paid order) +``` + +``` +Order Date: 2025-04-08 +Order Expiry: 2026-04-08 +Today: 2025-09-01 +Days Left: 219 + +219 > 30 --> REISSUE (free under active order) +``` + +**Fallback behavior:** If the plugin cannot retrieve the `orderDate` from CSC (e.g., API error or missing field), it falls back to checking the certificate's expiration date. If the certificate is already expired, it treats the request as a Renewal. + +**Note:** Both Renewal and Reissue submissions are asynchronous at CSC. The plugin returns a "pending" status and the issued certificate will appear in Keyfactor after the next sync cycle. + ## Certificate Template Creation Step PLEASE NOTE, AT THIS TIME THE RAPID_SSL TEMPLATE IS NOT SUPPORTED BY THE CSC API AND WILL NOT WORK WITH THIS INTEGRATION From 204367dd23acd4ad12deefb642ddeb649b109c65 Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Wed, 8 Apr 2026 21:14:19 +0000 Subject: [PATCH 11/42] Update generated docs --- README.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/README.md b/README.md index 10b6189..f5e93ae 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,55 @@ This integration is tested and confirmed as working for Anygateway REST 24.2 and * **Addtl Sans Comma Separated DCV Emails** - OPTIONAL: Additional SANs DCV Emails, comma separated +## CA Connection Configuration + +When defining the Certificate Authority in the AnyCA Gateway REST portal, configure the following fields on the **CA Connection** tab: + +CONFIG ELEMENT | DESCRIPTION | DEFAULT +---------------|-------------|-------- +CscGlobalUrl | The base URL for the CSCGlobal API (e.g. `https://apis.cscglobal.com`) | (required) +ApiKey | Your CSCGlobal API key | (required) +BearerToken | Your CSCGlobal Bearer token for authentication | (required) +DefaultPageSize | Page size for API list requests | 100 +SyncFilterDays | Number of days from today used to filter certificates by expiration date during **incremental** sync. Only certificates expiring within this window are returned. Does not apply to full sync. | 5 +RenewalWindowDays | Number of days before the annual order expiry date within which a **RenewOrReissue** request triggers a paid **Renewal** rather than a free **Reissue**. See [Renewal vs. Reissue Logic](#renewal-vs-reissue-logic) below. | 30 + +## Renewal vs. Reissue Logic + +CSC Global subscriptions are annual orders. When Keyfactor Command sends a **RenewOrReissue** request, the plugin must decide whether to submit a **Renewal** (a new paid order) or a **Reissue** (a free re-key under the existing active order). + +The decision is based on the **RenewalWindowDays** setting and works as follows: + +1. The plugin fetches the original certificate from CSC and reads its `orderDate`. +2. It computes the **order expiry** as `orderDate + 1 year`. +3. It calculates **days remaining** until the order expires. +4. If `days remaining <= RenewalWindowDays`, the request is treated as a **Renewal** (new paid order). +5. If `days remaining > RenewalWindowDays`, the request is treated as a **Reissue** (free under the active order). + +**Example with default RenewalWindowDays = 30:** + +``` +Order Date: 2025-04-08 +Order Expiry: 2026-04-08 +Today: 2026-03-15 +Days Left: 24 + +24 <= 30 --> RENEWAL (new paid order) +``` + +``` +Order Date: 2025-04-08 +Order Expiry: 2026-04-08 +Today: 2025-09-01 +Days Left: 219 + +219 > 30 --> REISSUE (free under active order) +``` + +**Fallback behavior:** If the plugin cannot retrieve the `orderDate` from CSC (e.g., API error or missing field), it falls back to checking the certificate's expiration date. If the certificate is already expired, it treats the request as a Renewal. + +**Note:** Both Renewal and Reissue submissions are asynchronous at CSC. The plugin returns a "pending" status and the issued certificate will appear in Keyfactor after the next sync cycle. + ## License From ff2f4c52cc873008cf8a7602d97c4d708c4d4cab Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Fri, 22 May 2026 11:51:12 -0400 Subject: [PATCH 12/42] DNS Changes --- cscglobal-caplugin/CSCGlobalCAPlugin.cs | 125 ++++++++++++++++++ cscglobal-caplugin/Dns/DnsProviderFactory.cs | 105 +++++++++++++++ cscglobal-caplugin/Interfaces/IDnsProvider.cs | 49 +++++++ docsource/configuration.md | 42 ++++++ 4 files changed, 321 insertions(+) create mode 100644 cscglobal-caplugin/Dns/DnsProviderFactory.cs create mode 100644 cscglobal-caplugin/Interfaces/IDnsProvider.cs diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index 5449f64..5d18b54 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -13,6 +13,7 @@ using Keyfactor.AnyGateway.Extensions; using Keyfactor.Extensions.CAPlugin.CSCGlobal.Client; using Keyfactor.Extensions.CAPlugin.CSCGlobal.Client.Models; +using Keyfactor.Extensions.CAPlugin.CSCGlobal.Dns; using Keyfactor.Extensions.CAPlugin.CSCGlobal.Interfaces; using Keyfactor.Logging; using Keyfactor.PKI.Enums.EJBCA; @@ -39,6 +40,12 @@ public CSCGlobalCAPlugin() public int RenewalWindowDays { get; set; } + /// + /// Registry of available DNS providers. Resolution happens per-record at enrollment time + /// so a single CA can publish across multiple DNS providers based on the domain. + /// + private DnsProviderFactory? _dnsProviderFactory; + //done public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDataReader certificateDataReader) { @@ -115,6 +122,25 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa Logger.LogDebug("RenewalWindowDays configured to {Days} days", RenewalWindowDays); }, $"RenewalWindowDays={RenewalWindowDays}"); + flow.Step("InitDnsProviderRegistry", () => + { + try + { + _dnsProviderFactory = new DnsProviderFactory(configProvider); + } + catch (Exception ex) + { + Logger.LogError(ex, "InitDnsProviderRegistry: factory threw, DNS auto-publishing disabled. {Error}", ex.Message); + _dnsProviderFactory = null; + } + + if (_dnsProviderFactory == null || _dnsProviderFactory.Providers.Count == 0) + Logger.LogInformation("No DNS providers registered. CNAME DCV records will require manual publishing."); + else + Logger.LogInformation("{Count} DNS provider(s) registered for per-domain CNAME DCV auto-publishing.", + _dnsProviderFactory.Providers.Count); + }); + Logger.MethodExit(LogLevel.Debug); } @@ -565,6 +591,12 @@ await flow.StepAsync("SubmitRegistrationToCSC", async () => var enrollResult = _requestManager.GetEnrollmentResult(enrollmentResponse); flow.Step("MapResult", $"Status={enrollResult?.Status}, ID={enrollResult?.CARequestID ?? "(null)"}"); + + await flow.StepAsync("PublishCnameDcv", async () => + { + await TryPublishCnameDcvAsync(productInfo, enrollResult); + }); + Logger.MethodExit(LogLevel.Debug); return enrollResult; @@ -1057,6 +1089,99 @@ public List GetProductIds() #region PRIVATE + /// + /// Attempts to publish CNAME DCV records by resolving an for + /// each record from the registry. Resolution is + /// per-record so different domains can be handled by different DNS providers. + /// No-op if no providers are registered, the cert isn't using CNAME validation, or the + /// response contains no CNAME details. Failures are logged but never thrown — manual + /// publishing remains a fallback so the enrollment result is still returned to Keyfactor. + /// + private async Task TryPublishCnameDcvAsync(EnrollmentProductInfo productInfo, EnrollmentResult? enrollResult) + { + if (_dnsProviderFactory == null || _dnsProviderFactory.Providers.Count == 0) + { + Logger.LogTrace("TryPublishCnameDcvAsync: no DNS providers registered, skipping auto-publish."); + return; + } + + if (enrollResult?.EnrollmentContext == null || enrollResult.EnrollmentContext.Count == 0) + { + Logger.LogTrace("TryPublishCnameDcvAsync: no CNAME entries in EnrollmentContext, skipping."); + return; + } + + var dcvMethod = productInfo?.ProductParameters != null + && productInfo.ProductParameters.TryGetValue(EnrollmentConfigConstants.DomainControlValidationMethod, out var m) + ? m + : null; + + if (string.IsNullOrEmpty(dcvMethod) || + !string.Equals(dcvMethod, "CNAME", StringComparison.OrdinalIgnoreCase)) + { + Logger.LogTrace("TryPublishCnameDcvAsync: DCV method '{Method}' is not CNAME, skipping auto-publish.", dcvMethod ?? "(null)"); + return; + } + + Logger.LogInformation("TryPublishCnameDcvAsync: attempting to publish {Count} CNAME record(s) via registered DNS providers.", + enrollResult.EnrollmentContext.Count); + + var successCount = 0; + var failCount = 0; + var unresolvedCount = 0; + + foreach (var entry in enrollResult.EnrollmentContext) + { + var recordName = entry.Key; + var cnameTarget = entry.Value; + + // CSC may also surface DCV email entries in this dictionary (key == value). Skip those. + if (string.Equals(recordName, cnameTarget, StringComparison.OrdinalIgnoreCase)) + { + Logger.LogTrace("TryPublishCnameDcvAsync: skipping entry '{Key}' (looks like an email DCV passthrough, not a CNAME).", recordName); + continue; + } + + var provider = _dnsProviderFactory.ResolveForDomain(recordName); + if (provider == null) + { + unresolvedCount++; + Logger.LogWarning( + "TryPublishCnameDcvAsync: no registered DNS provider claims '{Record}'. Manual publish required for this record.", + recordName); + continue; + } + + try + { + Logger.LogTrace("TryPublishCnameDcvAsync: creating CNAME '{Name}' -> '{Target}' via '{Provider}'.", + recordName, cnameTarget, provider.Name); + var ok = await provider.CreateCnameRecordAsync(recordName, cnameTarget); + if (ok) + { + successCount++; + Logger.LogInformation("Published CNAME '{Name}' -> '{Target}' via '{Provider}'.", recordName, cnameTarget, provider.Name); + } + else + { + failCount++; + Logger.LogWarning("DNS provider '{Provider}' reported failure publishing CNAME '{Name}'. Manual publish may be required.", + provider.Name, recordName); + } + } + catch (Exception ex) + { + failCount++; + Logger.LogError(ex, "DNS provider '{Provider}' threw publishing CNAME '{Name}'. Manual publish may be required. {Error}", + provider.Name, recordName, ex.Message); + } + } + + Logger.LogInformation( + "TryPublishCnameDcvAsync: complete. Published={Published}, Failed={Failed}, Unresolved={Unresolved}", + successCount, failCount, unresolvedCount); + } + //Trying to fix leaf extraction private static readonly Regex PemBlock = new( "-----BEGIN CERTIFICATE-----\\s*(?[A-Za-z0-9+/=\\r\\n]+?)\\s*-----END CERTIFICATE-----", diff --git a/cscglobal-caplugin/Dns/DnsProviderFactory.cs b/cscglobal-caplugin/Dns/DnsProviderFactory.cs new file mode 100644 index 0000000..4cfb3ab --- /dev/null +++ b/cscglobal-caplugin/Dns/DnsProviderFactory.cs @@ -0,0 +1,105 @@ +// 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 Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CSCGlobal.Interfaces; +using Keyfactor.Logging; +using Microsoft.Extensions.Logging; + +namespace Keyfactor.Extensions.CAPlugin.CSCGlobal.Dns; + +/// +/// Registry of available implementations. Resolution is +/// **per domain** — at enrollment time we ask each registered provider whether it +/// owns the DNS zone for a given record, and use the first match. This mirrors the +/// pattern used by the Keyfactor ACME CA plugin and avoids per-CA configuration of +/// a single global provider, so one CA can publish across multiple DNS providers. +/// +/// Providers register themselves by being added to the +/// switch below as their concrete implementations land in the codebase. +/// +public class DnsProviderFactory +{ + private readonly ILogger _logger; + private readonly List _providers; + + public DnsProviderFactory(IAnyCAPluginConfigProvider configProvider) + { + _logger = LogHandler.GetClassLogger(); + _providers = LoadProviders(configProvider); + _logger.LogInformation("DnsProviderFactory initialized with {Count} provider(s): [{Names}]", + _providers.Count, + string.Join(", ", _providers.Select(p => p.Name))); + } + + /// The set of providers known to this factory, in registration order. + public IReadOnlyList Providers => _providers; + + /// + /// Find the first registered provider that can handle the given record name. + /// Returns null if no provider claims ownership of the zone (in which case the + /// CNAME must be published manually). + /// + public IDnsProvider? ResolveForDomain(string recordName) + { + _logger.LogTrace("ResolveForDomain: looking up provider for '{Record}' across {Count} provider(s).", + recordName ?? "(null)", _providers.Count); + + if (string.IsNullOrWhiteSpace(recordName)) + { + _logger.LogWarning("ResolveForDomain: record name is null/empty, cannot resolve."); + return null; + } + + foreach (var provider in _providers) + { + try + { + if (provider.CanHandleDomain(recordName)) + { + _logger.LogTrace("ResolveForDomain: provider '{Provider}' claims '{Record}'.", provider.Name, recordName); + return provider; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "ResolveForDomain: provider '{Provider}' threw in CanHandleDomain('{Record}'); skipping. {Error}", + provider.Name, recordName, ex.Message); + } + } + + _logger.LogDebug("ResolveForDomain: no provider claims '{Record}'.", recordName); + return null; + } + + /// + /// Instantiate the set of providers available to this gateway. Each provider + /// receives the full CA connection data so it can read its own configuration + /// keys (credentials, endpoints, etc.). Add new providers here as their + /// implementations land. + /// + private List LoadProviders(IAnyCAPluginConfigProvider configProvider) + { + var providers = new List(); + + if (configProvider?.CAConnectionData == null) + { + _logger.LogWarning("LoadProviders: configProvider or CAConnectionData is null, no providers will be loaded."); + return providers; + } + + // Register concrete providers below as they are implemented. Each provider should + // be defensive about its own configuration — only instantiate if required keys + // are present so a missing/optional provider doesn't break the gateway. + // + // Example: + // if (configProvider.CAConnectionData.ContainsKey("Cloudflare_ApiToken")) + // providers.Add(new CloudflareDnsProvider(configProvider.CAConnectionData)); + + return providers; + } +} diff --git a/cscglobal-caplugin/Interfaces/IDnsProvider.cs b/cscglobal-caplugin/Interfaces/IDnsProvider.cs new file mode 100644 index 0000000..b7d63cb --- /dev/null +++ b/cscglobal-caplugin/Interfaces/IDnsProvider.cs @@ -0,0 +1,49 @@ +// 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. + +namespace Keyfactor.Extensions.CAPlugin.CSCGlobal.Interfaces; + +/// +/// Contract implemented by external DNS provider plugins so the CSC plugin can +/// auto-publish CNAME records required by CSC's Domain Control Validation (DCV). +/// +/// Resolution happens **per domain** at enrollment time. The framework asks each +/// registered provider and uses the first match — +/// so a single CA can publish across multiple DNS providers (e.g. some domains +/// on Cloudflare, others on Route 53) without per-CA configuration. +/// +public interface IDnsProvider +{ + /// The unique provider name (e.g. "Cloudflare", "Route53", "Azure"). + string Name { get; } + + /// + /// Returns true if this provider owns the DNS zone for the given record name and can + /// therefore publish a CNAME on its behalf. Typically implemented by listing managed + /// zones from the provider's API and matching by suffix. + /// + /// The FQDN of the record being considered (e.g. "_dcv.example.com"). + bool CanHandleDomain(string recordName); + + /// + /// Publish a CNAME DCV record for the given record name pointing at the supplied target. + /// + /// FQDN of the record to create (e.g. "_dcv.example.com"). + /// Target value the CNAME should resolve to (supplied by CSC). + /// Cancellation token. + /// True if the record was created (or already existed and matches); false on failure. + Task CreateCnameRecordAsync(string recordName, string cnameTarget, CancellationToken cancellationToken = default); + + /// + /// Remove a previously created CNAME DCV record. Called after CSC validation completes + /// (or during cleanup). Implementations should be tolerant of missing records. + /// + /// FQDN of the record to remove. + /// Cancellation token. + /// True if the record was removed (or not present); false on failure. + Task DeleteCnameRecordAsync(string recordName, CancellationToken cancellationToken = default); +} diff --git a/docsource/configuration.md b/docsource/configuration.md index 54c3210..c28d128 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -23,6 +23,8 @@ DefaultPageSize | Page size for API list requests | 100 SyncFilterDays | Number of days from today used to filter certificates by expiration date during **incremental** sync. Only certificates expiring within this window are returned. Does not apply to full sync. | 5 RenewalWindowDays | Number of days before the annual order expiry date within which a **RenewOrReissue** request triggers a paid **Renewal** rather than a free **Reissue**. See [Renewal vs. Reissue Logic](#renewal-vs-reissue-logic) below. | 30 +> **Note:** DNS auto-publishing is configured by deploying provider DLLs, not via a CA setting. See [Pluggable DNS Providers](#pluggable-dns-providers). + ## Renewal vs. Reissue Logic CSC Global subscriptions are annual orders. When Keyfactor Command sends a **RenewOrReissue** request, the plugin must decide whether to submit a **Renewal** (a new paid order) or a **Reissue** (a free re-key under the existing active order). @@ -59,6 +61,46 @@ Days Left: 219 **Note:** Both Renewal and Reissue submissions are asynchronous at CSC. The plugin returns a "pending" status and the issued certificate will appear in Keyfactor after the next sync cycle. +## Pluggable DNS Providers + +CSC supports two Domain Control Validation (DCV) methods: **EMAIL** and **CNAME**. With CNAME validation, CSC returns a CNAME record (name → target) that must exist in DNS before they will validate the order. + +By default this plugin returns the CNAME details to Keyfactor Command for **manual publishing**. To fully automate enrollment, you can deploy one or more DNS provider DLLs alongside the plugin — the framework will publish each CNAME via the provider that owns the matching DNS zone. + +### Behavior + +* **Resolution is per record, not per CA.** When a CSC order returns CNAME details, the plugin asks each registered DNS provider `CanHandleDomain(recordName)`. The first provider that claims the zone publishes the record. One CA can drive multiple providers (e.g. Cloudflare for some domains, Route 53 for others) with no per-CA configuration. +* **Only invoked for CNAME DCV.** Templates configured with EMAIL validation are unaffected. +* **Best-effort.** If no registered provider owns the zone, or the publish call fails, the enrollment still succeeds and the CNAME details are still surfaced to Keyfactor Command so a human can publish manually as a fallback. +* **Trace-logged.** Every resolution and publish attempt (success, failure, unresolved) is logged so issues are visible without surprising end users. + +### Authoring a DNS Provider + +A DNS provider is a separate DLL that implements `Keyfactor.Extensions.CAPlugin.CSCGlobal.Interfaces.IDnsProvider`: + +```csharp +public interface IDnsProvider +{ + string Name { get; } + bool CanHandleDomain(string recordName); + Task CreateCnameRecordAsync(string recordName, string cnameTarget, CancellationToken cancellationToken = default); + Task DeleteCnameRecordAsync(string recordName, CancellationToken cancellationToken = default); +} +``` + +`CanHandleDomain` is the resolution hook — implementations typically list managed zones from the provider's API (cached at construction time) and return true when `recordName` falls within one of them. + +To wire a provider into the gateway: + +1. Build the provider as a separate DLL referencing the CSC plugin's `IDnsProvider` interface. +2. Drop the DLL into the gateway `Extensions` folder alongside this plugin. +3. Add provider-specific configuration keys to the CA Connection tab (for example `Cloudflare_ApiToken`, `Route53_AccessKey`, `Route53_SecretKey`). +4. Add a registration line to `DnsProviderFactory.LoadProviders()` that instantiates the provider when its required keys are present. + +### Currently Built-In Providers + +None at this time. The framework is in place; concrete provider implementations are tracked separately. + ## Certificate Template Creation Step PLEASE NOTE, AT THIS TIME THE RAPID_SSL TEMPLATE IS NOT SUPPORTED BY THE CSC API AND WILL NOT WORK WITH THIS INTEGRATION From b13867078d9dec0e4dde16b18f1b9bf58a5a7fff Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Fri, 22 May 2026 15:52:44 +0000 Subject: [PATCH 13/42] Update generated docs --- README.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/README.md b/README.md index f5e93ae..42f6e4a 100644 --- a/README.md +++ b/README.md @@ -324,6 +324,8 @@ DefaultPageSize | Page size for API list requests | 100 SyncFilterDays | Number of days from today used to filter certificates by expiration date during **incremental** sync. Only certificates expiring within this window are returned. Does not apply to full sync. | 5 RenewalWindowDays | Number of days before the annual order expiry date within which a **RenewOrReissue** request triggers a paid **Renewal** rather than a free **Reissue**. See [Renewal vs. Reissue Logic](#renewal-vs-reissue-logic) below. | 30 +> **Note:** DNS auto-publishing is configured by deploying provider DLLs, not via a CA setting. See [Pluggable DNS Providers](#pluggable-dns-providers). + ## Renewal vs. Reissue Logic CSC Global subscriptions are annual orders. When Keyfactor Command sends a **RenewOrReissue** request, the plugin must decide whether to submit a **Renewal** (a new paid order) or a **Reissue** (a free re-key under the existing active order). @@ -360,6 +362,46 @@ Days Left: 219 **Note:** Both Renewal and Reissue submissions are asynchronous at CSC. The plugin returns a "pending" status and the issued certificate will appear in Keyfactor after the next sync cycle. +## Pluggable DNS Providers + +CSC supports two Domain Control Validation (DCV) methods: **EMAIL** and **CNAME**. With CNAME validation, CSC returns a CNAME record (name → target) that must exist in DNS before they will validate the order. + +By default this plugin returns the CNAME details to Keyfactor Command for **manual publishing**. To fully automate enrollment, you can deploy one or more DNS provider DLLs alongside the plugin — the framework will publish each CNAME via the provider that owns the matching DNS zone. + +### Behavior + +* **Resolution is per record, not per CA.** When a CSC order returns CNAME details, the plugin asks each registered DNS provider `CanHandleDomain(recordName)`. The first provider that claims the zone publishes the record. One CA can drive multiple providers (e.g. Cloudflare for some domains, Route 53 for others) with no per-CA configuration. +* **Only invoked for CNAME DCV.** Templates configured with EMAIL validation are unaffected. +* **Best-effort.** If no registered provider owns the zone, or the publish call fails, the enrollment still succeeds and the CNAME details are still surfaced to Keyfactor Command so a human can publish manually as a fallback. +* **Trace-logged.** Every resolution and publish attempt (success, failure, unresolved) is logged so issues are visible without surprising end users. + +### Authoring a DNS Provider + +A DNS provider is a separate DLL that implements `Keyfactor.Extensions.CAPlugin.CSCGlobal.Interfaces.IDnsProvider`: + +```csharp +public interface IDnsProvider +{ + string Name { get; } + bool CanHandleDomain(string recordName); + Task CreateCnameRecordAsync(string recordName, string cnameTarget, CancellationToken cancellationToken = default); + Task DeleteCnameRecordAsync(string recordName, CancellationToken cancellationToken = default); +} +``` + +`CanHandleDomain` is the resolution hook — implementations typically list managed zones from the provider's API (cached at construction time) and return true when `recordName` falls within one of them. + +To wire a provider into the gateway: + +1. Build the provider as a separate DLL referencing the CSC plugin's `IDnsProvider` interface. +2. Drop the DLL into the gateway `Extensions` folder alongside this plugin. +3. Add provider-specific configuration keys to the CA Connection tab (for example `Cloudflare_ApiToken`, `Route53_AccessKey`, `Route53_SecretKey`). +4. Add a registration line to `DnsProviderFactory.LoadProviders()` that instantiates the provider when its required keys are present. + +### Currently Built-In Providers + +None at this time. The framework is in place; concrete provider implementations are tracked separately. + ## License From 84d48a84aad926dbc87f6dbfc1ce3639152adf9c Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 26 May 2026 11:36:45 -0400 Subject: [PATCH 14/42] dns code updates --- cscglobal-caplugin/CSCGlobalCAPlugin.cs | 115 +++++++++++------- cscglobal-caplugin/CSCGlobalCAPlugin.csproj | 6 +- cscglobal-caplugin/Dns/DnsProviderFactory.cs | 105 ---------------- cscglobal-caplugin/Interfaces/IDnsProvider.cs | 49 -------- docsource/configuration.md | 54 ++++---- 5 files changed, 100 insertions(+), 229 deletions(-) delete mode 100644 cscglobal-caplugin/Dns/DnsProviderFactory.cs delete mode 100644 cscglobal-caplugin/Interfaces/IDnsProvider.cs diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index 5d18b54..ec2569a 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -13,7 +13,6 @@ using Keyfactor.AnyGateway.Extensions; using Keyfactor.Extensions.CAPlugin.CSCGlobal.Client; using Keyfactor.Extensions.CAPlugin.CSCGlobal.Client.Models; -using Keyfactor.Extensions.CAPlugin.CSCGlobal.Dns; using Keyfactor.Extensions.CAPlugin.CSCGlobal.Interfaces; using Keyfactor.Logging; using Keyfactor.PKI.Enums.EJBCA; @@ -24,14 +23,39 @@ namespace Keyfactor.Extensions.CAPlugin.CSCGlobal; public class CSCGlobalCAPlugin : IAnyCAPlugin { + /// + /// Validation type string passed to . + /// CSC's Domain Control Validation publishes a CNAME record, so we ask the framework for a + /// validator that handles the "cname" challenge type. + /// + private const string DNS_VALIDATION_TYPE = "cname"; + private readonly RequestManager _requestManager; private readonly ILogger Logger; + private readonly IDomainValidatorFactory? _validatorFactory; private ICertificateDataReader _certificateDataReader; + /// + /// Parameterless constructor retained for compatibility with older gateway hosts that don't + /// perform DI. When constructed this way the plugin runs without DNS auto-publishing. + /// public CSCGlobalCAPlugin() { Logger = LogHandler.GetClassLogger(); _requestManager = new RequestManager(); + _validatorFactory = null; + } + + /// + /// DI constructor used by AnyCA Gateway 3.3+ which injects the framework's domain validator + /// factory. When non-null, CNAME DCV records returned by CSC are auto-published via the + /// framework's registered DNS providers (resolved per-domain). + /// + public CSCGlobalCAPlugin(IDomainValidatorFactory validatorFactory) + { + Logger = LogHandler.GetClassLogger(); + _requestManager = new RequestManager(); + _validatorFactory = validatorFactory; } private ICscGlobalClient CscGlobalClient { get; set; } @@ -40,12 +64,6 @@ public CSCGlobalCAPlugin() public int RenewalWindowDays { get; set; } - /// - /// Registry of available DNS providers. Resolution happens per-record at enrollment time - /// so a single CA can publish across multiple DNS providers based on the domain. - /// - private DnsProviderFactory? _dnsProviderFactory; - //done public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDataReader certificateDataReader) { @@ -122,23 +140,15 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa Logger.LogDebug("RenewalWindowDays configured to {Days} days", RenewalWindowDays); }, $"RenewalWindowDays={RenewalWindowDays}"); - flow.Step("InitDnsProviderRegistry", () => + flow.Step("CheckDnsValidatorFactory", () => { - try - { - _dnsProviderFactory = new DnsProviderFactory(configProvider); - } - catch (Exception ex) - { - Logger.LogError(ex, "InitDnsProviderRegistry: factory threw, DNS auto-publishing disabled. {Error}", ex.Message); - _dnsProviderFactory = null; - } - - if (_dnsProviderFactory == null || _dnsProviderFactory.Providers.Count == 0) - Logger.LogInformation("No DNS providers registered. CNAME DCV records will require manual publishing."); + if (_validatorFactory == null) + Logger.LogInformation( + "No IDomainValidatorFactory was injected by the gateway host. CNAME DCV records will require manual publishing."); else - Logger.LogInformation("{Count} DNS provider(s) registered for per-domain CNAME DCV auto-publishing.", - _dnsProviderFactory.Providers.Count); + Logger.LogInformation( + "IDomainValidatorFactory available from gateway host. CNAME DCV records will be auto-published per-domain via the framework's registered DNS providers (validation type '{Type}').", + DNS_VALIDATION_TYPE); }); Logger.MethodExit(LogLevel.Debug); @@ -1090,18 +1100,17 @@ public List GetProductIds() #region PRIVATE /// - /// Attempts to publish CNAME DCV records by resolving an for - /// each record from the registry. Resolution is - /// per-record so different domains can be handled by different DNS providers. - /// No-op if no providers are registered, the cert isn't using CNAME validation, or the - /// response contains no CNAME details. Failures are logged but never thrown — manual - /// publishing remains a fallback so the enrollment result is still returned to Keyfactor. + /// Publishes CNAME DCV records via the gateway framework's . + /// Per-record resolution: each record is routed to whichever DNS provider plugin the framework + /// resolves for its domain. No-op if the factory wasn't injected, the cert isn't using CNAME + /// validation, or the response contains no CNAME details. Failures are logged but never thrown — + /// manual publishing remains a fallback so the enrollment result is still returned to Keyfactor. /// private async Task TryPublishCnameDcvAsync(EnrollmentProductInfo productInfo, EnrollmentResult? enrollResult) { - if (_dnsProviderFactory == null || _dnsProviderFactory.Providers.Count == 0) + if (_validatorFactory == null) { - Logger.LogTrace("TryPublishCnameDcvAsync: no DNS providers registered, skipping auto-publish."); + Logger.LogTrace("TryPublishCnameDcvAsync: no IDomainValidatorFactory was injected, skipping auto-publish."); return; } @@ -1123,8 +1132,9 @@ private async Task TryPublishCnameDcvAsync(EnrollmentProductInfo productInfo, En return; } - Logger.LogInformation("TryPublishCnameDcvAsync: attempting to publish {Count} CNAME record(s) via registered DNS providers.", - enrollResult.EnrollmentContext.Count); + Logger.LogInformation( + "TryPublishCnameDcvAsync: attempting to publish {Count} CNAME record(s) via framework DNS providers (validation type '{Type}').", + enrollResult.EnrollmentContext.Count, DNS_VALIDATION_TYPE); var successCount = 0; var failCount = 0; @@ -1142,38 +1152,53 @@ private async Task TryPublishCnameDcvAsync(EnrollmentProductInfo productInfo, En continue; } - var provider = _dnsProviderFactory.ResolveForDomain(recordName); - if (provider == null) + IDomainValidator? validator; + try + { + validator = _validatorFactory.ResolveDomainValidator(recordName, DNS_VALIDATION_TYPE); + } + catch (Exception ex) + { + unresolvedCount++; + Logger.LogWarning(ex, "ResolveDomainValidator threw for '{Record}' (type '{Type}'): {Error}", + recordName, DNS_VALIDATION_TYPE, ex.Message); + continue; + } + + if (validator == null) { unresolvedCount++; Logger.LogWarning( - "TryPublishCnameDcvAsync: no registered DNS provider claims '{Record}'. Manual publish required for this record.", - recordName); + "No DNS provider matched domain '{Record}' for validation type '{Type}'. Manual publish required for this record.", + recordName, DNS_VALIDATION_TYPE); continue; } try { - Logger.LogTrace("TryPublishCnameDcvAsync: creating CNAME '{Name}' -> '{Target}' via '{Provider}'.", - recordName, cnameTarget, provider.Name); - var ok = await provider.CreateCnameRecordAsync(recordName, cnameTarget); - if (ok) + Logger.LogTrace("StageValidation: '{Name}' -> '{Target}' via validator type '{ValType}'.", + recordName, cnameTarget, validator.GetValidationType()); + var result = await validator.StageValidation(recordName, cnameTarget, CancellationToken.None); + + if (result?.Success == true) { successCount++; - Logger.LogInformation("Published CNAME '{Name}' -> '{Target}' via '{Provider}'.", recordName, cnameTarget, provider.Name); + Logger.LogInformation("Published CNAME '{Name}' -> '{Target}' (status='{Status}').", + recordName, cnameTarget, result.Status ?? "(none)"); } else { failCount++; - Logger.LogWarning("DNS provider '{Provider}' reported failure publishing CNAME '{Name}'. Manual publish may be required.", - provider.Name, recordName); + Logger.LogWarning( + "StageValidation reported failure for CNAME '{Name}'. Status='{Status}', Error='{Error}'. Manual publish may be required.", + recordName, result?.Status ?? "(none)", result?.ErrorMessage ?? "(none)"); } } catch (Exception ex) { failCount++; - Logger.LogError(ex, "DNS provider '{Provider}' threw publishing CNAME '{Name}'. Manual publish may be required. {Error}", - provider.Name, recordName, ex.Message); + Logger.LogError(ex, "StageValidation threw publishing CNAME '{Name}'. Manual publish may be required. {Error}", + recordName, ex.Message); } } diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.csproj b/cscglobal-caplugin/CSCGlobalCAPlugin.csproj index 4d71ec5..01ee6c9 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.csproj +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.csproj @@ -18,19 +18,19 @@ - + - + - + diff --git a/cscglobal-caplugin/Dns/DnsProviderFactory.cs b/cscglobal-caplugin/Dns/DnsProviderFactory.cs deleted file mode 100644 index 4cfb3ab..0000000 --- a/cscglobal-caplugin/Dns/DnsProviderFactory.cs +++ /dev/null @@ -1,105 +0,0 @@ -// 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 Keyfactor.AnyGateway.Extensions; -using Keyfactor.Extensions.CAPlugin.CSCGlobal.Interfaces; -using Keyfactor.Logging; -using Microsoft.Extensions.Logging; - -namespace Keyfactor.Extensions.CAPlugin.CSCGlobal.Dns; - -/// -/// Registry of available implementations. Resolution is -/// **per domain** — at enrollment time we ask each registered provider whether it -/// owns the DNS zone for a given record, and use the first match. This mirrors the -/// pattern used by the Keyfactor ACME CA plugin and avoids per-CA configuration of -/// a single global provider, so one CA can publish across multiple DNS providers. -/// -/// Providers register themselves by being added to the -/// switch below as their concrete implementations land in the codebase. -/// -public class DnsProviderFactory -{ - private readonly ILogger _logger; - private readonly List _providers; - - public DnsProviderFactory(IAnyCAPluginConfigProvider configProvider) - { - _logger = LogHandler.GetClassLogger(); - _providers = LoadProviders(configProvider); - _logger.LogInformation("DnsProviderFactory initialized with {Count} provider(s): [{Names}]", - _providers.Count, - string.Join(", ", _providers.Select(p => p.Name))); - } - - /// The set of providers known to this factory, in registration order. - public IReadOnlyList Providers => _providers; - - /// - /// Find the first registered provider that can handle the given record name. - /// Returns null if no provider claims ownership of the zone (in which case the - /// CNAME must be published manually). - /// - public IDnsProvider? ResolveForDomain(string recordName) - { - _logger.LogTrace("ResolveForDomain: looking up provider for '{Record}' across {Count} provider(s).", - recordName ?? "(null)", _providers.Count); - - if (string.IsNullOrWhiteSpace(recordName)) - { - _logger.LogWarning("ResolveForDomain: record name is null/empty, cannot resolve."); - return null; - } - - foreach (var provider in _providers) - { - try - { - if (provider.CanHandleDomain(recordName)) - { - _logger.LogTrace("ResolveForDomain: provider '{Provider}' claims '{Record}'.", provider.Name, recordName); - return provider; - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "ResolveForDomain: provider '{Provider}' threw in CanHandleDomain('{Record}'); skipping. {Error}", - provider.Name, recordName, ex.Message); - } - } - - _logger.LogDebug("ResolveForDomain: no provider claims '{Record}'.", recordName); - return null; - } - - /// - /// Instantiate the set of providers available to this gateway. Each provider - /// receives the full CA connection data so it can read its own configuration - /// keys (credentials, endpoints, etc.). Add new providers here as their - /// implementations land. - /// - private List LoadProviders(IAnyCAPluginConfigProvider configProvider) - { - var providers = new List(); - - if (configProvider?.CAConnectionData == null) - { - _logger.LogWarning("LoadProviders: configProvider or CAConnectionData is null, no providers will be loaded."); - return providers; - } - - // Register concrete providers below as they are implemented. Each provider should - // be defensive about its own configuration — only instantiate if required keys - // are present so a missing/optional provider doesn't break the gateway. - // - // Example: - // if (configProvider.CAConnectionData.ContainsKey("Cloudflare_ApiToken")) - // providers.Add(new CloudflareDnsProvider(configProvider.CAConnectionData)); - - return providers; - } -} diff --git a/cscglobal-caplugin/Interfaces/IDnsProvider.cs b/cscglobal-caplugin/Interfaces/IDnsProvider.cs deleted file mode 100644 index b7d63cb..0000000 --- a/cscglobal-caplugin/Interfaces/IDnsProvider.cs +++ /dev/null @@ -1,49 +0,0 @@ -// 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. - -namespace Keyfactor.Extensions.CAPlugin.CSCGlobal.Interfaces; - -/// -/// Contract implemented by external DNS provider plugins so the CSC plugin can -/// auto-publish CNAME records required by CSC's Domain Control Validation (DCV). -/// -/// Resolution happens **per domain** at enrollment time. The framework asks each -/// registered provider and uses the first match — -/// so a single CA can publish across multiple DNS providers (e.g. some domains -/// on Cloudflare, others on Route 53) without per-CA configuration. -/// -public interface IDnsProvider -{ - /// The unique provider name (e.g. "Cloudflare", "Route53", "Azure"). - string Name { get; } - - /// - /// Returns true if this provider owns the DNS zone for the given record name and can - /// therefore publish a CNAME on its behalf. Typically implemented by listing managed - /// zones from the provider's API and matching by suffix. - /// - /// The FQDN of the record being considered (e.g. "_dcv.example.com"). - bool CanHandleDomain(string recordName); - - /// - /// Publish a CNAME DCV record for the given record name pointing at the supplied target. - /// - /// FQDN of the record to create (e.g. "_dcv.example.com"). - /// Target value the CNAME should resolve to (supplied by CSC). - /// Cancellation token. - /// True if the record was created (or already existed and matches); false on failure. - Task CreateCnameRecordAsync(string recordName, string cnameTarget, CancellationToken cancellationToken = default); - - /// - /// Remove a previously created CNAME DCV record. Called after CSC validation completes - /// (or during cleanup). Implementations should be tolerant of missing records. - /// - /// FQDN of the record to remove. - /// Cancellation token. - /// True if the record was removed (or not present); false on failure. - Task DeleteCnameRecordAsync(string recordName, CancellationToken cancellationToken = default); -} diff --git a/docsource/configuration.md b/docsource/configuration.md index c28d128..a0f240c 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -23,7 +23,7 @@ DefaultPageSize | Page size for API list requests | 100 SyncFilterDays | Number of days from today used to filter certificates by expiration date during **incremental** sync. Only certificates expiring within this window are returned. Does not apply to full sync. | 5 RenewalWindowDays | Number of days before the annual order expiry date within which a **RenewOrReissue** request triggers a paid **Renewal** rather than a free **Reissue**. See [Renewal vs. Reissue Logic](#renewal-vs-reissue-logic) below. | 30 -> **Note:** DNS auto-publishing is configured by deploying provider DLLs, not via a CA setting. See [Pluggable DNS Providers](#pluggable-dns-providers). +> **Note:** DNS auto-publishing for CNAME DCV is handled by the AnyCA Gateway REST framework's Domain Validation system (gateway 3.3+). It's configured in the gateway UI under **Domain Validation Configurations**, not on the CA Connection tab. See [DNS Auto-Publishing (CNAME DCV)](#dns-auto-publishing-cname-dcv). ## Renewal vs. Reissue Logic @@ -61,45 +61,45 @@ Days Left: 219 **Note:** Both Renewal and Reissue submissions are asynchronous at CSC. The plugin returns a "pending" status and the issued certificate will appear in Keyfactor after the next sync cycle. -## Pluggable DNS Providers +## DNS Auto-Publishing (CNAME DCV) CSC supports two Domain Control Validation (DCV) methods: **EMAIL** and **CNAME**. With CNAME validation, CSC returns a CNAME record (name → target) that must exist in DNS before they will validate the order. -By default this plugin returns the CNAME details to Keyfactor Command for **manual publishing**. To fully automate enrollment, you can deploy one or more DNS provider DLLs alongside the plugin — the framework will publish each CNAME via the provider that owns the matching DNS zone. +By default this plugin returns the CNAME details to Keyfactor Command for **manual publishing**. To fully automate enrollment, the plugin uses the **AnyCA Gateway REST framework's built-in DNS provider system** (available in framework 3.3 and later). The framework discovers DNS provider plugins deployed alongside the CA plugin and routes each CNAME to whichever provider claims the matching DNS zone. -### Behavior +### Requirements -* **Resolution is per record, not per CA.** When a CSC order returns CNAME details, the plugin asks each registered DNS provider `CanHandleDomain(recordName)`. The first provider that claims the zone publishes the record. One CA can drive multiple providers (e.g. Cloudflare for some domains, Route 53 for others) with no per-CA configuration. -* **Only invoked for CNAME DCV.** Templates configured with EMAIL validation are unaffected. -* **Best-effort.** If no registered provider owns the zone, or the publish call fails, the enrollment still succeeds and the CNAME details are still surfaced to Keyfactor Command so a human can publish manually as a fallback. -* **Trace-logged.** Every resolution and publish attempt (success, failure, unresolved) is logged so issues are visible without surprising end users. +* AnyCA Gateway REST framework **3.3 or later** (the `IDomainValidatorFactory` interface ships in `Keyfactor.AnyGateway.IAnyCAPlugin` 3.3+). +* At least one DNS provider DLL (e.g. GoDaddy, Cloudflare, Route 53, Azure) deployed in the gateway `Extensions` folder. +* A Domain Validation Configuration registered in the gateway UI that maps your domain(s) to the deployed provider (for example, `*.example.com` → GoDaddy). -### Authoring a DNS Provider +### How It Works -A DNS provider is a separate DLL that implements `Keyfactor.Extensions.CAPlugin.CSCGlobal.Interfaces.IDnsProvider`: +1. CSC returns the CNAME `name → target` details in the enrollment response. +2. For each CNAME entry, the plugin calls `IDomainValidatorFactory.ResolveDomainValidator(recordName, "cname")`. +3. The framework returns the `IDomainValidator` whose Domain Validation Configuration matches the record's zone (or `null` if no match). +4. The plugin calls `validator.StageValidation(recordName, cnameTarget, ct)` to publish the record. +5. CSC asynchronously validates the CNAME; the issued certificate appears on the next sync. -```csharp -public interface IDnsProvider -{ - string Name { get; } - bool CanHandleDomain(string recordName); - Task CreateCnameRecordAsync(string recordName, string cnameTarget, CancellationToken cancellationToken = default); - Task DeleteCnameRecordAsync(string recordName, CancellationToken cancellationToken = default); -} -``` +### Behavior -`CanHandleDomain` is the resolution hook — implementations typically list managed zones from the provider's API (cached at construction time) and return true when `recordName` falls within one of them. +* **Resolution is per record, not per CA.** One CA can drive multiple DNS providers (GoDaddy for some domains, Route 53 for others) with no per-CA configuration. +* **Only invoked for CNAME DCV.** Templates configured with EMAIL validation are unaffected — no DNS publishing occurs. +* **Best-effort.** If no provider claims the zone, the publish call fails, or the factory wasn't injected (gateway pre-3.3), the enrollment still succeeds and the CNAME details remain in the Keyfactor request so a human can publish manually as a fallback. +* **Trace-logged.** Every resolution (matched/unresolved) and publish attempt (success/failure) is logged at Info/Trace level. +* **Validation type string.** The plugin passes `"cname"` to `ResolveDomainValidator`. The DNS provider you deploy must advertise support for that validation type (via its `GetValidationType()` method) or it won't be matched. -To wire a provider into the gateway: +### Configuration in the Gateway UI -1. Build the provider as a separate DLL referencing the CSC plugin's `IDnsProvider` interface. -2. Drop the DLL into the gateway `Extensions` folder alongside this plugin. -3. Add provider-specific configuration keys to the CA Connection tab (for example `Cloudflare_ApiToken`, `Route53_AccessKey`, `Route53_SecretKey`). -4. Add a registration line to `DnsProviderFactory.LoadProviders()` that instantiates the provider when its required keys are present. +In the AnyCA Gateway REST portal, under **Domain Validation Configurations**: -### Currently Built-In Providers +1. **Add** a new configuration. +2. Pick the **Domain Validator** (e.g. `GoDaddyDnsPlugin`) — these come from the DNS provider DLLs you've dropped in `Extensions/`. +3. Add one or more **domain patterns** (e.g. `*.example.com`). +4. Fill out the provider-specific **Configuration Settings** (API keys, zone IDs, etc.). +5. Save. -None at this time. The framework is in place; concrete provider implementations are tracked separately. +Once configured, any CSC enrollment for a domain matching one of those patterns will have its CNAME auto-published. ## Certificate Template Creation Step From 448f1679864b40c456e02a6dd3886e0a50e5585c Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Tue, 26 May 2026 15:38:43 +0000 Subject: [PATCH 15/42] Update generated docs --- README.md | 54 +++++++++++++++++++++++++++--------------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 42f6e4a..2793370 100644 --- a/README.md +++ b/README.md @@ -324,7 +324,7 @@ DefaultPageSize | Page size for API list requests | 100 SyncFilterDays | Number of days from today used to filter certificates by expiration date during **incremental** sync. Only certificates expiring within this window are returned. Does not apply to full sync. | 5 RenewalWindowDays | Number of days before the annual order expiry date within which a **RenewOrReissue** request triggers a paid **Renewal** rather than a free **Reissue**. See [Renewal vs. Reissue Logic](#renewal-vs-reissue-logic) below. | 30 -> **Note:** DNS auto-publishing is configured by deploying provider DLLs, not via a CA setting. See [Pluggable DNS Providers](#pluggable-dns-providers). +> **Note:** DNS auto-publishing for CNAME DCV is handled by the AnyCA Gateway REST framework's Domain Validation system (gateway 3.3+). It's configured in the gateway UI under **Domain Validation Configurations**, not on the CA Connection tab. See [DNS Auto-Publishing (CNAME DCV)](#dns-auto-publishing-cname-dcv). ## Renewal vs. Reissue Logic @@ -362,45 +362,45 @@ Days Left: 219 **Note:** Both Renewal and Reissue submissions are asynchronous at CSC. The plugin returns a "pending" status and the issued certificate will appear in Keyfactor after the next sync cycle. -## Pluggable DNS Providers +## DNS Auto-Publishing (CNAME DCV) CSC supports two Domain Control Validation (DCV) methods: **EMAIL** and **CNAME**. With CNAME validation, CSC returns a CNAME record (name → target) that must exist in DNS before they will validate the order. -By default this plugin returns the CNAME details to Keyfactor Command for **manual publishing**. To fully automate enrollment, you can deploy one or more DNS provider DLLs alongside the plugin — the framework will publish each CNAME via the provider that owns the matching DNS zone. +By default this plugin returns the CNAME details to Keyfactor Command for **manual publishing**. To fully automate enrollment, the plugin uses the **AnyCA Gateway REST framework's built-in DNS provider system** (available in framework 3.3 and later). The framework discovers DNS provider plugins deployed alongside the CA plugin and routes each CNAME to whichever provider claims the matching DNS zone. -### Behavior +### Requirements -* **Resolution is per record, not per CA.** When a CSC order returns CNAME details, the plugin asks each registered DNS provider `CanHandleDomain(recordName)`. The first provider that claims the zone publishes the record. One CA can drive multiple providers (e.g. Cloudflare for some domains, Route 53 for others) with no per-CA configuration. -* **Only invoked for CNAME DCV.** Templates configured with EMAIL validation are unaffected. -* **Best-effort.** If no registered provider owns the zone, or the publish call fails, the enrollment still succeeds and the CNAME details are still surfaced to Keyfactor Command so a human can publish manually as a fallback. -* **Trace-logged.** Every resolution and publish attempt (success, failure, unresolved) is logged so issues are visible without surprising end users. +* AnyCA Gateway REST framework **3.3 or later** (the `IDomainValidatorFactory` interface ships in `Keyfactor.AnyGateway.IAnyCAPlugin` 3.3+). +* At least one DNS provider DLL (e.g. GoDaddy, Cloudflare, Route 53, Azure) deployed in the gateway `Extensions` folder. +* A Domain Validation Configuration registered in the gateway UI that maps your domain(s) to the deployed provider (for example, `*.example.com` → GoDaddy). -### Authoring a DNS Provider +### How It Works -A DNS provider is a separate DLL that implements `Keyfactor.Extensions.CAPlugin.CSCGlobal.Interfaces.IDnsProvider`: +1. CSC returns the CNAME `name → target` details in the enrollment response. +2. For each CNAME entry, the plugin calls `IDomainValidatorFactory.ResolveDomainValidator(recordName, "cname")`. +3. The framework returns the `IDomainValidator` whose Domain Validation Configuration matches the record's zone (or `null` if no match). +4. The plugin calls `validator.StageValidation(recordName, cnameTarget, ct)` to publish the record. +5. CSC asynchronously validates the CNAME; the issued certificate appears on the next sync. -```csharp -public interface IDnsProvider -{ - string Name { get; } - bool CanHandleDomain(string recordName); - Task CreateCnameRecordAsync(string recordName, string cnameTarget, CancellationToken cancellationToken = default); - Task DeleteCnameRecordAsync(string recordName, CancellationToken cancellationToken = default); -} -``` +### Behavior -`CanHandleDomain` is the resolution hook — implementations typically list managed zones from the provider's API (cached at construction time) and return true when `recordName` falls within one of them. +* **Resolution is per record, not per CA.** One CA can drive multiple DNS providers (GoDaddy for some domains, Route 53 for others) with no per-CA configuration. +* **Only invoked for CNAME DCV.** Templates configured with EMAIL validation are unaffected — no DNS publishing occurs. +* **Best-effort.** If no provider claims the zone, the publish call fails, or the factory wasn't injected (gateway pre-3.3), the enrollment still succeeds and the CNAME details remain in the Keyfactor request so a human can publish manually as a fallback. +* **Trace-logged.** Every resolution (matched/unresolved) and publish attempt (success/failure) is logged at Info/Trace level. +* **Validation type string.** The plugin passes `"cname"` to `ResolveDomainValidator`. The DNS provider you deploy must advertise support for that validation type (via its `GetValidationType()` method) or it won't be matched. -To wire a provider into the gateway: +### Configuration in the Gateway UI -1. Build the provider as a separate DLL referencing the CSC plugin's `IDnsProvider` interface. -2. Drop the DLL into the gateway `Extensions` folder alongside this plugin. -3. Add provider-specific configuration keys to the CA Connection tab (for example `Cloudflare_ApiToken`, `Route53_AccessKey`, `Route53_SecretKey`). -4. Add a registration line to `DnsProviderFactory.LoadProviders()` that instantiates the provider when its required keys are present. +In the AnyCA Gateway REST portal, under **Domain Validation Configurations**: -### Currently Built-In Providers +1. **Add** a new configuration. +2. Pick the **Domain Validator** (e.g. `GoDaddyDnsPlugin`) — these come from the DNS provider DLLs you've dropped in `Extensions/`. +3. Add one or more **domain patterns** (e.g. `*.example.com`). +4. Fill out the provider-specific **Configuration Settings** (API keys, zone IDs, etc.). +5. Save. -None at this time. The framework is in place; concrete provider implementations are tracked separately. +Once configured, any CSC enrollment for a domain matching one of those patterns will have its CNAME auto-published. ## License From eb39fcfaceb4b035fbd16826310b829d838a490f Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 26 May 2026 13:01:13 -0400 Subject: [PATCH 16/42] change type --- cscglobal-caplugin/CSCGlobalCAPlugin.cs | 10 +++++++--- docsource/configuration.md | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index ec2569a..04d25bd 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -25,10 +25,14 @@ public class CSCGlobalCAPlugin : IAnyCAPlugin { /// /// Validation type string passed to . - /// CSC's Domain Control Validation publishes a CNAME record, so we ask the framework for a - /// validator that handles the "cname" challenge type. + /// DNS provider plugins in the Keyfactor ecosystem (GoDaddy, Cloudflare, Route 53, Azure, etc.) + /// standardize on "dns-01" as their advertised validation type — originally from ACME's + /// DNS-01 challenge, but in practice used as a generic "publishes DNS records" capability tag. + /// CSC's Domain Control Validation publishes a CNAME (not a TXT), but the underlying + /// call is generic key/value — the provider + /// publishes whatever record type its implementation chooses. /// - private const string DNS_VALIDATION_TYPE = "cname"; + private const string DNS_VALIDATION_TYPE = "dns-01"; private readonly RequestManager _requestManager; private readonly ILogger Logger; diff --git a/docsource/configuration.md b/docsource/configuration.md index a0f240c..cd028a9 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -87,7 +87,7 @@ By default this plugin returns the CNAME details to Keyfactor Command for **manu * **Only invoked for CNAME DCV.** Templates configured with EMAIL validation are unaffected — no DNS publishing occurs. * **Best-effort.** If no provider claims the zone, the publish call fails, or the factory wasn't injected (gateway pre-3.3), the enrollment still succeeds and the CNAME details remain in the Keyfactor request so a human can publish manually as a fallback. * **Trace-logged.** Every resolution (matched/unresolved) and publish attempt (success/failure) is logged at Info/Trace level. -* **Validation type string.** The plugin passes `"cname"` to `ResolveDomainValidator`. The DNS provider you deploy must advertise support for that validation type (via its `GetValidationType()` method) or it won't be matched. +* **Validation type string.** The plugin passes `"dns-01"` to `ResolveDomainValidator`. This is the de-facto standard string used by the Keyfactor DNS provider plugins (GoDaddy, Cloudflare, Route 53, Azure) — originally from ACME's DNS-01 challenge, but used generically as a "publishes DNS records" capability tag. The DNS provider you deploy must advertise support for `"dns-01"` (via its `GetValidationType()` method) or it won't be matched. The underlying `StageValidation(key, value, ct)` call is generic, and the provider implementation decides whether to publish a CNAME or TXT based on the value supplied. ### Configuration in the Gateway UI From 3ba367be3c638f47664a329d324e60ccc8d682d2 Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Tue, 26 May 2026 17:03:03 +0000 Subject: [PATCH 17/42] Update generated docs --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2793370..e5bf317 100644 --- a/README.md +++ b/README.md @@ -388,7 +388,7 @@ By default this plugin returns the CNAME details to Keyfactor Command for **manu * **Only invoked for CNAME DCV.** Templates configured with EMAIL validation are unaffected — no DNS publishing occurs. * **Best-effort.** If no provider claims the zone, the publish call fails, or the factory wasn't injected (gateway pre-3.3), the enrollment still succeeds and the CNAME details remain in the Keyfactor request so a human can publish manually as a fallback. * **Trace-logged.** Every resolution (matched/unresolved) and publish attempt (success/failure) is logged at Info/Trace level. -* **Validation type string.** The plugin passes `"cname"` to `ResolveDomainValidator`. The DNS provider you deploy must advertise support for that validation type (via its `GetValidationType()` method) or it won't be matched. +* **Validation type string.** The plugin passes `"dns-01"` to `ResolveDomainValidator`. This is the de-facto standard string used by the Keyfactor DNS provider plugins (GoDaddy, Cloudflare, Route 53, Azure) — originally from ACME's DNS-01 challenge, but used generically as a "publishes DNS records" capability tag. The DNS provider you deploy must advertise support for `"dns-01"` (via its `GetValidationType()` method) or it won't be matched. The underlying `StageValidation(key, value, ct)` call is generic, and the provider implementation decides whether to publish a CNAME or TXT based on the value supplied. ### Configuration in the Gateway UI From 65454d0d61931ab62634b112d4f41c403fd64742 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 26 May 2026 14:39:12 -0400 Subject: [PATCH 18/42] fixed mismatch --- cscglobal-caplugin/CSCGlobalCAPlugin.cs | 29 +++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index 04d25bd..431ab23 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -1103,6 +1103,17 @@ public List GetProductIds() #region PRIVATE + /// + /// Strip a single trailing dot from a DNS name. CSC returns FQDN-canonical names with + /// a trailing dot but the framework's Domain Validation Configurations are stored without + /// one, so the strings have to be normalized before lookup or the equality check fails. + /// + private static string StripTrailingDot(string? s) + { + if (string.IsNullOrEmpty(s)) return s ?? string.Empty; + return s.EndsWith('.') ? s[..^1] : s; + } + /// /// Publishes CNAME DCV records via the gateway framework's . /// Per-record resolution: each record is routed to whichever DNS provider plugin the framework @@ -1146,16 +1157,26 @@ private async Task TryPublishCnameDcvAsync(EnrollmentProductInfo productInfo, En foreach (var entry in enrollResult.EnrollmentContext) { - var recordName = entry.Key; - var cnameTarget = entry.Value; + var rawRecordName = entry.Key; + var rawCnameTarget = entry.Value; // CSC may also surface DCV email entries in this dictionary (key == value). Skip those. - if (string.Equals(recordName, cnameTarget, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(rawRecordName, rawCnameTarget, StringComparison.OrdinalIgnoreCase)) { - Logger.LogTrace("TryPublishCnameDcvAsync: skipping entry '{Key}' (looks like an email DCV passthrough, not a CNAME).", recordName); + Logger.LogTrace("TryPublishCnameDcvAsync: skipping entry '{Key}' (looks like an email DCV passthrough, not a CNAME).", rawRecordName); continue; } + // CSC returns FQDN-canonical names with trailing dots (e.g. "foo.example.com."). + // The framework's Domain Validation Configuration stores domain patterns without + // the trailing dot, so strip it before resolution and publishing or no provider + // will match (the framework will look up "*.example.com." which won't equal "*.example.com"). + var recordName = StripTrailingDot(rawRecordName); + var cnameTarget = StripTrailingDot(rawCnameTarget); + + if (recordName != rawRecordName) + Logger.LogTrace("TryPublishCnameDcvAsync: normalized record name '{Raw}' -> '{Normalized}'.", rawRecordName, recordName); + IDomainValidator? validator; try { From 01273319473b4dd346879c517513c38470ce0ed0 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 26 May 2026 15:34:30 -0400 Subject: [PATCH 19/42] Use 'cname' validation type for CSC CNAME DCV CSC DCV requires a CNAME record, so resolve a DNS provider advertising the 'cname' validation type (e.g. GoDaddyCnameDomainValidator) rather than the ACME 'dns-01'/TXT variant. Docs updated to call out the CNAME validator and warn against selecting the TXT validator for CSC domains. --- cscglobal-caplugin/CSCGlobalCAPlugin.cs | 12 +++++------- docsource/configuration.md | 9 ++++++--- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index 431ab23..0c3721d 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -25,14 +25,12 @@ public class CSCGlobalCAPlugin : IAnyCAPlugin { /// /// Validation type string passed to . - /// DNS provider plugins in the Keyfactor ecosystem (GoDaddy, Cloudflare, Route 53, Azure, etc.) - /// standardize on "dns-01" as their advertised validation type — originally from ACME's - /// DNS-01 challenge, but in practice used as a generic "publishes DNS records" capability tag. - /// CSC's Domain Control Validation publishes a CNAME (not a TXT), but the underlying - /// call is generic key/value — the provider - /// publishes whatever record type its implementation chooses. + /// CSC's Domain Control Validation publishes a CNAME record, so we resolve a DNS provider + /// that advertises the "cname" validation type (e.g. GoDaddy's GoDaddyCnameDomainValidator). + /// This is distinct from ACME's "dns-01" challenge, which publishes TXT records — a single + /// DNS provider DLL can ship separate validator classes for each type. /// - private const string DNS_VALIDATION_TYPE = "dns-01"; + private const string DNS_VALIDATION_TYPE = "cname"; private readonly RequestManager _requestManager; private readonly ILogger Logger; diff --git a/docsource/configuration.md b/docsource/configuration.md index cd028a9..9b5961c 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -87,20 +87,23 @@ By default this plugin returns the CNAME details to Keyfactor Command for **manu * **Only invoked for CNAME DCV.** Templates configured with EMAIL validation are unaffected — no DNS publishing occurs. * **Best-effort.** If no provider claims the zone, the publish call fails, or the factory wasn't injected (gateway pre-3.3), the enrollment still succeeds and the CNAME details remain in the Keyfactor request so a human can publish manually as a fallback. * **Trace-logged.** Every resolution (matched/unresolved) and publish attempt (success/failure) is logged at Info/Trace level. -* **Validation type string.** The plugin passes `"dns-01"` to `ResolveDomainValidator`. This is the de-facto standard string used by the Keyfactor DNS provider plugins (GoDaddy, Cloudflare, Route 53, Azure) — originally from ACME's DNS-01 challenge, but used generically as a "publishes DNS records" capability tag. The DNS provider you deploy must advertise support for `"dns-01"` (via its `GetValidationType()` method) or it won't be matched. The underlying `StageValidation(key, value, ct)` call is generic, and the provider implementation decides whether to publish a CNAME or TXT based on the value supplied. +* **Validation type string.** The plugin passes `"cname"` to `ResolveDomainValidator`. CSC's DCV requires a **CNAME** record, which is different from ACME's `"dns-01"` challenge (a TXT record). A single DNS provider DLL can ship multiple validator classes — one advertising `"dns-01"` (publishes TXT, for ACME) and one advertising `"cname"` (publishes CNAME, for CSC). You must deploy and configure a validator that advertises `"cname"` or no provider will match. +* **Trailing dots normalized.** CSC returns FQDN-canonical names with a trailing dot (e.g. `_token.example.com.`). The plugin strips the trailing dot before resolution and publishing, because Domain Validation Configurations and DNS provider APIs expect names without it. ### Configuration in the Gateway UI In the AnyCA Gateway REST portal, under **Domain Validation Configurations**: 1. **Add** a new configuration. -2. Pick the **Domain Validator** (e.g. `GoDaddyDnsPlugin`) — these come from the DNS provider DLLs you've dropped in `Extensions/`. +2. Pick a **Domain Validator Type** that publishes **CNAME** records and advertises validation type `cname`. For GoDaddy this is `GoDaddyCnameDomainValidator` (the `GoDaddyDomainValidator` variant publishes TXT for ACME and will **not** work for CSC). 3. Add one or more **domain patterns** (e.g. `*.example.com`). -4. Fill out the provider-specific **Configuration Settings** (API keys, zone IDs, etc.). +4. Fill out the provider-specific **Configuration Settings** (API keys, base URL, etc.). 5. Save. Once configured, any CSC enrollment for a domain matching one of those patterns will have its CNAME auto-published. +> **Common pitfall:** If you configure the TXT/`dns-01` validator (e.g. `GoDaddyDomainValidator`) for a CSC domain, the record will publish as a **TXT** and CSC's CNAME validation will never succeed. Make sure you select the **CNAME** validator variant. + ## Certificate Template Creation Step PLEASE NOTE, AT THIS TIME THE RAPID_SSL TEMPLATE IS NOT SUPPORTED BY THE CSC API AND WILL NOT WORK WITH THIS INTEGRATION From 5ea38b462661f5e69f2b3eb98b2fb8bd3f087788 Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Tue, 26 May 2026 19:36:03 +0000 Subject: [PATCH 20/42] Update generated docs --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e5bf317..36d4a34 100644 --- a/README.md +++ b/README.md @@ -388,20 +388,23 @@ By default this plugin returns the CNAME details to Keyfactor Command for **manu * **Only invoked for CNAME DCV.** Templates configured with EMAIL validation are unaffected — no DNS publishing occurs. * **Best-effort.** If no provider claims the zone, the publish call fails, or the factory wasn't injected (gateway pre-3.3), the enrollment still succeeds and the CNAME details remain in the Keyfactor request so a human can publish manually as a fallback. * **Trace-logged.** Every resolution (matched/unresolved) and publish attempt (success/failure) is logged at Info/Trace level. -* **Validation type string.** The plugin passes `"dns-01"` to `ResolveDomainValidator`. This is the de-facto standard string used by the Keyfactor DNS provider plugins (GoDaddy, Cloudflare, Route 53, Azure) — originally from ACME's DNS-01 challenge, but used generically as a "publishes DNS records" capability tag. The DNS provider you deploy must advertise support for `"dns-01"` (via its `GetValidationType()` method) or it won't be matched. The underlying `StageValidation(key, value, ct)` call is generic, and the provider implementation decides whether to publish a CNAME or TXT based on the value supplied. +* **Validation type string.** The plugin passes `"cname"` to `ResolveDomainValidator`. CSC's DCV requires a **CNAME** record, which is different from ACME's `"dns-01"` challenge (a TXT record). A single DNS provider DLL can ship multiple validator classes — one advertising `"dns-01"` (publishes TXT, for ACME) and one advertising `"cname"` (publishes CNAME, for CSC). You must deploy and configure a validator that advertises `"cname"` or no provider will match. +* **Trailing dots normalized.** CSC returns FQDN-canonical names with a trailing dot (e.g. `_token.example.com.`). The plugin strips the trailing dot before resolution and publishing, because Domain Validation Configurations and DNS provider APIs expect names without it. ### Configuration in the Gateway UI In the AnyCA Gateway REST portal, under **Domain Validation Configurations**: 1. **Add** a new configuration. -2. Pick the **Domain Validator** (e.g. `GoDaddyDnsPlugin`) — these come from the DNS provider DLLs you've dropped in `Extensions/`. +2. Pick a **Domain Validator Type** that publishes **CNAME** records and advertises validation type `cname`. For GoDaddy this is `GoDaddyCnameDomainValidator` (the `GoDaddyDomainValidator` variant publishes TXT for ACME and will **not** work for CSC). 3. Add one or more **domain patterns** (e.g. `*.example.com`). -4. Fill out the provider-specific **Configuration Settings** (API keys, zone IDs, etc.). +4. Fill out the provider-specific **Configuration Settings** (API keys, base URL, etc.). 5. Save. Once configured, any CSC enrollment for a domain matching one of those patterns will have its CNAME auto-published. +> **Common pitfall:** If you configure the TXT/`dns-01` validator (e.g. `GoDaddyDomainValidator`) for a CSC domain, the record will publish as a **TXT** and CSC's CNAME validation will never succeed. Make sure you select the **CNAME** validator variant. + ## License From 73f7dddd85cd3d523e3be5b6f38ded9a7cc5b46d Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Wed, 27 May 2026 11:10:59 -0400 Subject: [PATCH 21/42] added polling to grab cert --- cscglobal-caplugin/CSCGlobalCAPlugin.cs | 136 +++++++++++++++++++++++- cscglobal-caplugin/Constants.cs | 1 + docsource/configuration.md | 14 +++ 3 files changed, 149 insertions(+), 2 deletions(-) diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index 0c3721d..4ff69fd 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -32,6 +32,9 @@ public class CSCGlobalCAPlugin : IAnyCAPlugin /// private const string DNS_VALIDATION_TYPE = "cname"; + /// Delay between CSC status polls while waiting for DCV to complete. + private static readonly TimeSpan DcvPollInterval = TimeSpan.FromSeconds(10); + private readonly RequestManager _requestManager; private readonly ILogger Logger; private readonly IDomainValidatorFactory? _validatorFactory; @@ -66,6 +69,14 @@ public CSCGlobalCAPlugin(IDomainValidatorFactory validatorFactory) public int RenewalWindowDays { get; set; } + /// + /// Maximum seconds to synchronously poll CSC for certificate issuance after submitting an + /// order (and publishing CNAME DCV). 0 disables polling — the enrollment returns "pending" + /// immediately and the cert is picked up on the next sync. When > 0, fast-validating + /// orders can return the issued cert directly in the enrollment response. + /// + public int DcvPollTimeoutSeconds { get; set; } + //done public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDataReader certificateDataReader) { @@ -142,6 +153,25 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa Logger.LogDebug("RenewalWindowDays configured to {Days} days", RenewalWindowDays); }, $"RenewalWindowDays={RenewalWindowDays}"); + flow.Step("ReadDcvPollTimeoutSeconds", () => + { + DcvPollTimeoutSeconds = 0; // default: disabled + if (configProvider.CAConnectionData.TryGetValue(Constants.DcvPollTimeoutSeconds, out var pollObj)) + { + Logger.LogTrace("DcvPollTimeoutSeconds raw value: '{Value}'", pollObj?.ToString() ?? "(null)"); + if (int.TryParse(pollObj?.ToString(), out var pollSeconds) && pollSeconds >= 0) + DcvPollTimeoutSeconds = pollSeconds; + else + Logger.LogWarning("DcvPollTimeoutSeconds value '{Value}' could not be parsed or was < 0, using default 0 (disabled).", pollObj); + } + else + { + Logger.LogTrace("DcvPollTimeoutSeconds key not found in CAConnectionData, using default 0 (disabled)."); + } + Logger.LogDebug("DcvPollTimeoutSeconds configured to {Seconds}s ({State})", + DcvPollTimeoutSeconds, DcvPollTimeoutSeconds > 0 ? "enabled" : "disabled"); + }); + flow.Step("CheckDnsValidatorFactory", () => { if (_validatorFactory == null) @@ -609,6 +639,18 @@ await flow.StepAsync("PublishCnameDcv", async () => await TryPublishCnameDcvAsync(productInfo, enrollResult); }); + EnrollmentResult? newPolled = null; + await flow.StepAsync("PollForIssuance", async () => + { + newPolled = await TryPollForIssuedCertAsync(enrollResult?.CARequestID); + }); + if (newPolled != null) + { + flow.Step("PollResult", "issued during poll window"); + Logger.MethodExit(LogLevel.Debug); + return newPolled; + } + Logger.MethodExit(LogLevel.Debug); return enrollResult; @@ -752,8 +794,14 @@ await flow.StepAsync("SubmitRenewalToCSC", async () => Logger.LogTrace("Renewal Response JSON: {Json}", JsonConvert.SerializeObject(renewResponse)); var renewResult = _requestManager.GetRenewResponse(renewResponse); flow.Step("MapRenewalResult", $"Status={renewResult?.Status}, Message={renewResult?.StatusMessage ?? "(null)"}"); + + EnrollmentResult? renewPolled = null; + await flow.StepAsync("PollForIssuance", async () => + { + renewPolled = await TryPollForIssuedCertAsync(renewResult?.CARequestID); + }); Logger.MethodExit(LogLevel.Debug); - return renewResult; + return renewPolled ?? renewResult; } flow.Fail("MissingEnrollmentParams", "Applicant Last Name not present — one-click renew unavailable"); @@ -825,8 +873,14 @@ await flow.StepAsync("SubmitReissueToCSC", async () => Logger.LogTrace("Reissue Response JSON: {Json}", JsonConvert.SerializeObject(reissueResponse)); var reissueResult = _requestManager.GetReIssueResult(reissueResponse); flow.Step("MapReissueResult", $"Status={reissueResult?.Status}, Message={reissueResult?.StatusMessage ?? "(null)"}"); + + EnrollmentResult? reissuePolled = null; + await flow.StepAsync("PollForIssuance", async () => + { + reissuePolled = await TryPollForIssuedCertAsync(reissueResult?.CARequestID); + }); Logger.MethodExit(LogLevel.Debug); - return reissueResult; + return reissuePolled ?? reissueResult; } flow.Fail("MissingEnrollmentParams", "Applicant Last Name not present — one-click reissue unavailable"); @@ -985,6 +1039,13 @@ public Dictionary GetCAConnectorAnnotations() Hidden = false, DefaultValue = "30", Type = "Number" + }, + [Constants.DcvPollTimeoutSeconds] = new() + { + Comments = "Max seconds to synchronously poll CSC for issuance after submitting an order (and publishing CNAME DCV). 0 disables polling (enrollment returns pending immediately; cert arrives on next sync). When >0, fast-validating orders can return the cert directly. Keep small to avoid long-blocking enrollment requests.", + Hidden = false, + DefaultValue = "0", + Type = "Number" } }; } @@ -1112,6 +1173,77 @@ private static string StripTrailingDot(string? s) return s.EndsWith('.') ? s[..^1] : s; } + /// + /// Synchronously poll CSC for issuance of the order identified by , + /// up to . Returns a GENERATED + /// carrying the issued leaf certificate if CSC issues within the window, or null if the + /// window expires (in which case the caller falls back to its pending/EXTERNALVALIDATION result). + /// No-op (returns null) when polling is disabled or the uuid is missing. + /// + private async Task TryPollForIssuedCertAsync(string? uuid) + { + if (DcvPollTimeoutSeconds <= 0) + { + Logger.LogTrace("TryPollForIssuedCertAsync: polling disabled (DcvPollTimeoutSeconds=0), skipping."); + return null; + } + + if (string.IsNullOrEmpty(uuid)) + { + Logger.LogWarning("TryPollForIssuedCertAsync: no UUID/CARequestID to poll, skipping."); + return null; + } + + var deadline = DateTime.UtcNow.AddSeconds(DcvPollTimeoutSeconds); + Logger.LogInformation("TryPollForIssuedCertAsync: polling CSC for issuance of '{Uuid}' for up to {Seconds}s (interval {Interval}s).", + uuid, DcvPollTimeoutSeconds, (int)DcvPollInterval.TotalSeconds); + + var attempt = 0; + while (DateTime.UtcNow < deadline) + { + attempt++; + AnyCAPluginCertificate record; + try + { + record = await GetSingleRecord(uuid); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "TryPollForIssuedCertAsync: poll attempt {Attempt} for '{Uuid}' threw, will retry. {Error}", + attempt, uuid, ex.Message); + record = null; + } + + if (record != null) + { + Logger.LogTrace("TryPollForIssuedCertAsync: attempt {Attempt} for '{Uuid}' — status={Status}, cert={CertState}.", + attempt, uuid, record.Status, string.IsNullOrEmpty(record.Certificate) ? "empty" : "present"); + + if (record.Status == (int)EndEntityStatus.GENERATED && !string.IsNullOrEmpty(record.Certificate)) + { + Logger.LogInformation("TryPollForIssuedCertAsync: '{Uuid}' issued after {Attempt} poll(s); returning cert directly.", uuid, attempt); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.GENERATED, + CARequestID = uuid, + Certificate = record.Certificate, + StatusMessage = $"Certificate issued and retrieved for order {uuid}." + }; + } + } + + // Don't sleep past the deadline. + if (DateTime.UtcNow.Add(DcvPollInterval) >= deadline) + break; + + await Task.Delay(DcvPollInterval); + } + + Logger.LogInformation("TryPollForIssuedCertAsync: '{Uuid}' not issued within {Seconds}s after {Attempts} attempt(s); falling back to pending.", + uuid, DcvPollTimeoutSeconds, attempt); + return null; + } + /// /// Publishes CNAME DCV records via the gateway framework's . /// Per-record resolution: each record is routed to whichever DNS provider plugin the framework diff --git a/cscglobal-caplugin/Constants.cs b/cscglobal-caplugin/Constants.cs index d588706..6331af9 100644 --- a/cscglobal-caplugin/Constants.cs +++ b/cscglobal-caplugin/Constants.cs @@ -15,6 +15,7 @@ public class Constants public static string DefaultPageSize = "DefaultPageSize"; public static string SyncFilterDays = "SyncFilterDays"; public static string RenewalWindowDays = "RenewalWindowDays"; + public static string DcvPollTimeoutSeconds = "DcvPollTimeoutSeconds"; } public class ProductIDs diff --git a/docsource/configuration.md b/docsource/configuration.md index 9b5961c..a8c3f38 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -22,6 +22,7 @@ BearerToken | Your CSCGlobal Bearer token for authentication | (required) DefaultPageSize | Page size for API list requests | 100 SyncFilterDays | Number of days from today used to filter certificates by expiration date during **incremental** sync. Only certificates expiring within this window are returned. Does not apply to full sync. | 5 RenewalWindowDays | Number of days before the annual order expiry date within which a **RenewOrReissue** request triggers a paid **Renewal** rather than a free **Reissue**. See [Renewal vs. Reissue Logic](#renewal-vs-reissue-logic) below. | 30 +DcvPollTimeoutSeconds | Max seconds to synchronously poll CSC for certificate issuance after submitting an order. `0` disables polling (enrollment returns pending immediately; cert arrives on the next sync). When `>0`, fast-validating orders can return the issued cert directly in the enrollment response. See [Synchronous Issuance Polling](#synchronous-issuance-polling) below. | 0 > **Note:** DNS auto-publishing for CNAME DCV is handled by the AnyCA Gateway REST framework's Domain Validation system (gateway 3.3+). It's configured in the gateway UI under **Domain Validation Configurations**, not on the CA Connection tab. See [DNS Auto-Publishing (CNAME DCV)](#dns-auto-publishing-cname-dcv). @@ -104,6 +105,19 @@ Once configured, any CSC enrollment for a domain matching one of those patterns > **Common pitfall:** If you configure the TXT/`dns-01` validator (e.g. `GoDaddyDomainValidator`) for a CSC domain, the record will publish as a **TXT** and CSC's CNAME validation will never succeed. Make sure you select the **CNAME** validator variant. +## Synchronous Issuance Polling + +CSC validates domain control asynchronously — after an order is submitted (and the CNAME DCV record published), CSC/Sectigo polls public DNS on its own schedule and issues the certificate once validation passes. By default this plugin returns a **pending** (`EXTERNALVALIDATION`) result immediately and the issued certificate is picked up on the next gateway **sync** cycle. + +For environments where DNS is published automatically (see [DNS Auto-Publishing](#dns-auto-publishing-cname-dcv)) and validation tends to complete quickly, you can have the plugin **poll CSC synchronously** at the end of enrollment and return the issued certificate directly — avoiding the wait for the next sync. + +* Set **`DcvPollTimeoutSeconds`** to the maximum number of seconds to poll (e.g. `60`). `0` (default) disables polling entirely. +* The plugin polls CSC every 10 seconds until the order is issued or the timeout is reached. +* If the certificate issues within the window, the enrollment returns it immediately with a success status. +* If the window expires, the plugin falls back to the **pending** result and the certificate arrives on the next sync — exactly as it would with polling disabled. + +**Tradeoff:** Polling blocks the enrollment request for up to `DcvPollTimeoutSeconds`. CSC validation frequently takes minutes to hours, so most orders will still fall through to pending — keep the timeout small (30–90s) to catch only the fast cases without hanging callers. This applies to New enrollments, Renewals, and Reissues. + ## Certificate Template Creation Step PLEASE NOTE, AT THIS TIME THE RAPID_SSL TEMPLATE IS NOT SUPPORTED BY THE CSC API AND WILL NOT WORK WITH THIS INTEGRATION From 95e9337d83f216f49db0705e0c28d2d62e6e6757 Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Wed, 27 May 2026 15:12:54 +0000 Subject: [PATCH 22/42] Update generated docs --- README.md | 15 +++++++++++++++ integration-manifest.json | 4 ++++ 2 files changed, 19 insertions(+) diff --git a/README.md b/README.md index 36d4a34..2157d48 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ This integration is tested and confirmed as working for Anygateway REST 24.2 and * **DefaultPageSize** - Default page size for use with the API. Default is 100 * **SyncFilterDays** - Number of days from today to filter certificates by expiration date during incremental sync. * **RenewalWindowDays** - Number of days before the annual order expiry within which a RenewOrReissue triggers a paid Renewal rather than a free Reissue. Default is 30. + * **DcvPollTimeoutSeconds** - Max seconds to synchronously poll CSC for issuance after submitting an order (and publishing CNAME DCV). 0 disables polling (enrollment returns pending immediately; cert arrives on next sync). When >0, fast-validating orders can return the cert directly. Keep small to avoid long-blocking enrollment requests. 2. PLEASE NOTE, AT THIS TIME THE RAPID_SSL TEMPLATE IS NOT SUPPORTED BY THE CSC API AND WILL NOT WORK WITH THIS INTEGRATION @@ -323,6 +324,7 @@ BearerToken | Your CSCGlobal Bearer token for authentication | (required) DefaultPageSize | Page size for API list requests | 100 SyncFilterDays | Number of days from today used to filter certificates by expiration date during **incremental** sync. Only certificates expiring within this window are returned. Does not apply to full sync. | 5 RenewalWindowDays | Number of days before the annual order expiry date within which a **RenewOrReissue** request triggers a paid **Renewal** rather than a free **Reissue**. See [Renewal vs. Reissue Logic](#renewal-vs-reissue-logic) below. | 30 +DcvPollTimeoutSeconds | Max seconds to synchronously poll CSC for certificate issuance after submitting an order. `0` disables polling (enrollment returns pending immediately; cert arrives on the next sync). When `>0`, fast-validating orders can return the issued cert directly in the enrollment response. See [Synchronous Issuance Polling](#synchronous-issuance-polling) below. | 0 > **Note:** DNS auto-publishing for CNAME DCV is handled by the AnyCA Gateway REST framework's Domain Validation system (gateway 3.3+). It's configured in the gateway UI under **Domain Validation Configurations**, not on the CA Connection tab. See [DNS Auto-Publishing (CNAME DCV)](#dns-auto-publishing-cname-dcv). @@ -405,6 +407,19 @@ Once configured, any CSC enrollment for a domain matching one of those patterns > **Common pitfall:** If you configure the TXT/`dns-01` validator (e.g. `GoDaddyDomainValidator`) for a CSC domain, the record will publish as a **TXT** and CSC's CNAME validation will never succeed. Make sure you select the **CNAME** validator variant. +## Synchronous Issuance Polling + +CSC validates domain control asynchronously — after an order is submitted (and the CNAME DCV record published), CSC/Sectigo polls public DNS on its own schedule and issues the certificate once validation passes. By default this plugin returns a **pending** (`EXTERNALVALIDATION`) result immediately and the issued certificate is picked up on the next gateway **sync** cycle. + +For environments where DNS is published automatically (see [DNS Auto-Publishing](#dns-auto-publishing-cname-dcv)) and validation tends to complete quickly, you can have the plugin **poll CSC synchronously** at the end of enrollment and return the issued certificate directly — avoiding the wait for the next sync. + +* Set **`DcvPollTimeoutSeconds`** to the maximum number of seconds to poll (e.g. `60`). `0` (default) disables polling entirely. +* The plugin polls CSC every 10 seconds until the order is issued or the timeout is reached. +* If the certificate issues within the window, the enrollment returns it immediately with a success status. +* If the window expires, the plugin falls back to the **pending** result and the certificate arrives on the next sync — exactly as it would with polling disabled. + +**Tradeoff:** Polling blocks the enrollment request for up to `DcvPollTimeoutSeconds`. CSC validation frequently takes minutes to hours, so most orders will still fall through to pending — keep the timeout small (30–90s) to catch only the fast cases without hanging callers. This applies to New enrollments, Renewals, and Reissues. + ## License diff --git a/integration-manifest.json b/integration-manifest.json index 71d13f4..3e167ff 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -36,6 +36,10 @@ { "name": "RenewalWindowDays", "description": "Number of days before the annual order expiry within which a RenewOrReissue triggers a paid Renewal rather than a free Reissue. Default is 30." + }, + { + "name": "DcvPollTimeoutSeconds", + "description": "Max seconds to synchronously poll CSC for issuance after submitting an order (and publishing CNAME DCV). 0 disables polling (enrollment returns pending immediately; cert arrives on next sync). When >0, fast-validating orders can return the cert directly. Keep small to avoid long-blocking enrollment requests." } ], "enrollment_config": [ From b016c6c0b61cf1666822e8327e6fe374ffee0b28 Mon Sep 17 00:00:00 2001 From: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:14:39 -0400 Subject: [PATCH 23/42] Update integration-manifest.json --- integration-manifest.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/integration-manifest.json b/integration-manifest.json index 3e167ff..2fea8e9 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -2,12 +2,12 @@ "$schema": "https://keyfactor.github.io/integration-manifest-schema.json", "integration_type": "anyca-plugin", "name": "CSCGlobal CAPlugin REST Gateway Plugin", - "status": "pilot", + "status": "production", "support_level": "kf-supported", "link_github": true, "update_catalog": true, "description": "CSCGlobal CAPlugin for the AnyCA REST Gateway framework", - "gateway_framework": "24.2.0", + "gateway_framework": "26.2.0", "release_project": "cscglobal-caplugin/CSCGlobalCAPlugin.csproj", "release_dir": "cscglobal-caplugin/bin/Release", "about": { @@ -103,4 +103,4 @@ ] } } -} \ No newline at end of file +} From cc9750498bf30a5866aa518784b69e565d5a6eb7 Mon Sep 17 00:00:00 2001 From: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:15:57 -0400 Subject: [PATCH 24/42] Update CSCGlobalCAPlugin.csproj --- cscglobal-caplugin/CSCGlobalCAPlugin.csproj | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.csproj b/cscglobal-caplugin/CSCGlobalCAPlugin.csproj index 01ee6c9..6436739 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.csproj +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.csproj @@ -16,30 +16,16 @@ - - - - - - - - + - - - - - - - Always - \ No newline at end of file + From 4d9ff865875b73bb44913098b05d1a07b600629b Mon Sep 17 00:00:00 2001 From: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:16:30 -0400 Subject: [PATCH 25/42] Update CSCGlobalCAPlugin.csproj --- cscglobal-caplugin/CSCGlobalCAPlugin.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.csproj b/cscglobal-caplugin/CSCGlobalCAPlugin.csproj index 6436739..e5f5ff7 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.csproj +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.csproj @@ -3,7 +3,7 @@ true - net6.0;net8.0;net10.0 + net10.0 Keyfactor.Extensions.CAPlugin.CSCGlobal true enable From 78f95a304523efba2f07cbbd174fa5d00d585a21 Mon Sep 17 00:00:00 2001 From: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:21:05 -0400 Subject: [PATCH 26/42] 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 979379bffa432e7667d48982fc3e8feca52e9047 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 14 Jul 2026 14:21:39 +0000 Subject: [PATCH 27/42] docs: auto-generate README and documentation [skip ci] --- README.md | 460 +++++++++++++++++++++++++++--------------------------- 1 file changed, 228 insertions(+), 232 deletions(-) diff --git a/README.md b/README.md index 2157d48..b06589c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

-Integration Status: pilot +Integration Status: production Release Issues GitHub Downloads (all assets, all releases) @@ -14,7 +14,7 @@ Support - + · Requirements @@ -33,15 +33,14 @@

- This integration allows for the Synchronization, Enrollment, and Revocation of certificates from the CSCGlobal. This is the AnyGateway REST version. ## Compatibility -The CSCGlobal CAPlugin AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 24.2.0 and later. +The CSCGlobal CAPlugin AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 26.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. @@ -55,16 +54,15 @@ 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 `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 (`net10.0`) can be named anything, as long as it is unique within the `Extensions` directory. 4. Restart the AnyCA Gateway REST service. @@ -82,235 +80,234 @@ 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 - * **SyncFilterDays** - Number of days from today to filter certificates by expiration date during incremental sync. - * **RenewalWindowDays** - Number of days before the annual order expiry within which a RenewOrReissue triggers a paid Renewal rather than a free Reissue. Default is 30. - * **DcvPollTimeoutSeconds** - Max seconds to synchronously poll CSC for issuance after submitting an order (and publishing CNAME DCV). 0 disables polling (enrollment returns pending immediately; cert arrives on next sync). When >0, fast-validating orders can return the cert directly. Keep small to avoid long-blocking enrollment requests. + * **CscGlobalUrl** - CSCGlobal API URL + * **ApiKey** - CSCGlobal API Key + * **BearerToken** - CSCGlobal Bearer Token + * **DefaultPageSize** - Default page size for use with the API. Default is 100 + * **SyncFilterDays** - Number of days from today to filter certificates by expiration date during incremental sync. + * **RenewalWindowDays** - Number of days before the annual order expiry within which a RenewOrReissue triggers a paid Renewal rather than a free Reissue. Default is 30. + * **DcvPollTimeoutSeconds** - Max seconds to synchronously poll CSC for issuance after submitting an order (and publishing CNAME DCV). 0 disables polling (enrollment returns pending immediately; cert arrives on next sync). When >0, fast-validating orders can return the cert directly. Keep small to avoid long-blocking enrollment requests. 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 +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 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 ## CA Connection Configuration @@ -420,11 +417,10 @@ For environments where DNS is published automatically (see [DNS Auto-Publishing] **Tradeoff:** Polling blocks the enrollment request for up to `DcvPollTimeoutSeconds`. CSC validation frequently takes minutes to hours, so most orders will still fall through to pending — keep the timeout small (30–90s) to catch only the fast cases without hanging callers. This applies to New enrollments, Renewals, and Reissues. - ## License 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 a1275f6fd62c55346c5cc9638c3c2bb4292a05e3 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 14 Jul 2026 11:03:42 -0400 Subject: [PATCH 28/42] Add Enabled CA connection flag Adds the standard Enabled boolean field to the CA Connection settings, matching the pattern used by every other Keyfactor CA plugin (SSL Store, Digicert, HydrantId, GCP CAS, Idnomic, etc.). Purpose: allow ops to create the CA record before all API credentials are available. When Enabled=false, the plugin short-circuits: - Initialize: skips CscGlobalClient construction (no valid creds needed) - Ping: no-op, logs a warning - ValidateCAConnectionInfo / ValidateProductInfo: skip validation - Synchronize: completes the buffer immediately - Enroll: returns FAILED with a clear message - Revoke: throws InvalidOperationException with a clear message Default is true so existing deployments that don't set the key continue to function without change. Reads the value from incoming connectionInfo in the Validate* methods so an operator editing the CA sees consistent behavior with the current form state. --- cscglobal-caplugin/CSCGlobalCAPlugin.cs | 123 ++++++++++++++++++++++-- cscglobal-caplugin/Constants.cs | 1 + docsource/configuration.md | 1 + 3 files changed, 117 insertions(+), 8 deletions(-) diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index 4ff69fd..c22aa32 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -65,6 +65,15 @@ public CSCGlobalCAPlugin(IDomainValidatorFactory validatorFactory) private ICscGlobalClient CscGlobalClient { get; set; } + /// + /// Whether the CA is enabled. When false, the plugin returns early from Ping, + /// ValidateCAConnectionInfo, ValidateProductInfo, Synchronize, Enroll, and Revoke without + /// calling CSC. Primarily used to allow creation of the CA record prior to configuration + /// information being available (standard field across Keyfactor CA plugins). Defaults to true + /// so existing deployments that don't set this key continue to function. + /// + public bool Enabled { get; set; } = true; + public int SyncFilterDays { get; set; } public int RenewalWindowDays { get; set; } @@ -96,13 +105,6 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa _certificateDataReader = certificateDataReader; - flow.Step("CreateCscGlobalClient", () => - { - Logger.LogTrace("Creating CscGlobalClient from configProvider..."); - CscGlobalClient = new CscGlobalClient(configProvider); - Logger.LogTrace("CscGlobalClient created successfully."); - }); - flow.Step("ValidateConnectionData", () => { if (configProvider.CAConnectionData == null) @@ -113,6 +115,41 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa Logger.LogTrace("CAConnectionData keys: {Keys}", string.Join(", ", configProvider.CAConnectionData.Keys)); }); + flow.Step("ReadEnabled", () => + { + Enabled = true; // default + if (configProvider.CAConnectionData.TryGetValue(Constants.Enabled, out var enabledObj)) + { + Logger.LogTrace("Enabled raw value: '{Value}'", enabledObj?.ToString() ?? "(null)"); + if (bool.TryParse(enabledObj?.ToString(), out var parsed)) + Enabled = parsed; + else + Logger.LogWarning("Enabled value '{Value}' could not be parsed as bool, defaulting to true.", enabledObj); + } + else + { + Logger.LogTrace("Enabled key not found in CAConnectionData, defaulting to true."); + } + Logger.LogInformation("CA is {State}.", Enabled ? "Enabled" : "Disabled"); + }, $"Enabled={Enabled}"); + + // Construct the CSC client only when enabled. When disabled we allow Initialize to complete + // without valid API credentials — this is the whole point of the Enabled toggle (so ops can + // create the CA record before credentials are available). + if (Enabled) + { + flow.Step("CreateCscGlobalClient", () => + { + Logger.LogTrace("Creating CscGlobalClient from configProvider..."); + CscGlobalClient = new CscGlobalClient(configProvider); + Logger.LogTrace("CscGlobalClient created successfully."); + }); + } + else + { + flow.Skip("CreateCscGlobalClient", "CA is Disabled"); + } + flow.Step("ReadSyncFilterDays", () => { if (configProvider.CAConnectionData.ContainsKey(Constants.SyncFilterDays)) @@ -297,6 +334,14 @@ public async Task Synchronize(BlockingCollection blockin if (blockingBuffer == null) throw new ArgumentNullException(nameof(blockingBuffer), "blockingBuffer cannot be null in 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) @@ -463,6 +508,12 @@ public async Task Revoke(string caRequestID, string hexSerialNumber, uint r Logger.LogTrace("Revoke called with caRequestID='{CaRequestId}', hexSerialNumber='{SerialNumber}', revocationReason={Reason}", caRequestID ?? "(null)", hexSerialNumber ?? "(null)", revocationReason); + 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."); + } + flow.Step("ValidateInput", () => { if (string.IsNullOrEmpty(caRequestID)) @@ -539,6 +590,17 @@ public async Task Enroll(string csr, string subject, Dictionar san?.Count ?? 0, productInfo == null ? "NULL" : "present"); + if (!Enabled) + { + flow.Fail("Disabled", "CA is Disabled"); + Logger.LogWarning("The CA is currently in the Disabled state. It must be Enabled to perform operations. Rejecting Enroll."); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "The CSC Global CA is in the Disabled state. Enable it to perform enrollments." + }; + } + flow.Step("ValidateInputs", () => { if (productInfo == null) @@ -927,7 +989,15 @@ await flow.StepAsync("PollForIssuance", async () => public async Task Ping() { Logger.MethodEntry(); - Logger.LogTrace("Ping: CscGlobalClient is {Null}", CscGlobalClient == null ? "NULL" : "present"); + Logger.LogTrace("Ping: Enabled={Enabled}, CscGlobalClient is {Null}", Enabled, CscGlobalClient == null ? "NULL" : "present"); + + 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"); @@ -955,6 +1025,21 @@ public async Task ValidateCAConnectionInfo(Dictionary connection throw new ArgumentNullException(nameof(connectionInfo), "connectionInfo cannot be null."); } + // 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.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); } @@ -973,6 +1058,21 @@ public async Task ValidateProductInfo(EnrollmentProductInfo productInfo, throw new ArgumentNullException(nameof(productInfo), "productInfo cannot be null."); } + // 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; + } + if (string.IsNullOrEmpty(productInfo.ProductID)) { Logger.LogError("ValidateProductInfo: productInfo.ProductID is null or empty."); @@ -998,6 +1098,13 @@ public Dictionary GetCAConnectorAnnotations() { return new Dictionary { + [Constants.Enabled] = new() + { + Comments = "Flag to Enable or Disable gateway functionality. Disabling is primarily used to allow creation of the CA prior to configuration information being available.", + Hidden = false, + DefaultValue = true, + Type = "Boolean" + }, [Constants.CscGlobalUrl] = new() { Comments = "CSCGlobal API URL", diff --git a/cscglobal-caplugin/Constants.cs b/cscglobal-caplugin/Constants.cs index 6331af9..dc10866 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"; diff --git a/docsource/configuration.md b/docsource/configuration.md index a8c3f38..5dee9d3 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -16,6 +16,7 @@ When defining the Certificate Authority in the AnyCA Gateway REST portal, config CONFIG ELEMENT | DESCRIPTION | DEFAULT ---------------|-------------|-------- +Enabled | Flag to Enable or Disable gateway functionality. Set to `false` to allow creating the CA record before configuration information is available; the plugin then short-circuits Ping, Sync, Enroll, and Revoke with a warning until it is re-enabled. | `true` CscGlobalUrl | The base URL for the CSCGlobal API (e.g. `https://apis.cscglobal.com`) | (required) ApiKey | Your CSCGlobal API key | (required) BearerToken | Your CSCGlobal Bearer token for authentication | (required) From c03418010c64eccc8c0fa31f87e8d401f89070a1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 14 Jul 2026 15:04:26 +0000 Subject: [PATCH 29/42] docs: auto-generate README and documentation [skip ci] --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index b06589c..5effcf2 100644 --- a/README.md +++ b/README.md @@ -315,6 +315,7 @@ When defining the Certificate Authority in the AnyCA Gateway REST portal, config CONFIG ELEMENT | DESCRIPTION | DEFAULT ---------------|-------------|-------- +Enabled | Flag to Enable or Disable gateway functionality. Set to `false` to allow creating the CA record before configuration information is available; the plugin then short-circuits Ping, Sync, Enroll, and Revoke with a warning until it is re-enabled. | `true` CscGlobalUrl | The base URL for the CSCGlobal API (e.g. `https://apis.cscglobal.com`) | (required) ApiKey | Your CSCGlobal API key | (required) BearerToken | Your CSCGlobal Bearer token for authentication | (required) From 5018877332143b6b94bf320619b1a89a4b7bedad Mon Sep 17 00:00:00 2001 From: Morgan Gangwere <470584+indrora@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:14:24 -0700 Subject: [PATCH 30/42] Merge 2.0.0 to main (#15) * Add custom field support * changelog * support cname return from enrollment * Update generated docs * feat: release 1.1.1 * Update generated docs * Fix for issues with * Test * Added template parameter configuration via REST gateway. Fixed bug with email used for verification. Changed docs and enrollment field/template parameter names. See changelog. * Update generated docs * Fixed broken logging. * Incremental sync support added using csc date filter so sync timing can run faster that default full sync periods * Update generated docs * Fixes for Incremental Sync * Update CHANGELOG.md --------- Co-authored-by: Mikey Henderson <4452096+fiddlermikey@users.noreply.github.com> Co-authored-by: Sean <1661003+spbsoluble@users.noreply.github.com> Co-authored-by: Keyfactor Co-authored-by: Brian Hill Co-authored-by: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> * Fix NullReferenceException in GetEnrollmentResult for null DCV email (#9) * Fix NullReferenceException in GetEnrollmentResult for null DCV email The condition for adding DCV email entries to the cnames dictionary was inverted (string.IsNullOrEmpty instead of !string.IsNullOrEmpty), causing cnames.Add(null, null) and an ArgumentNullException on every enrollment where CSC returned a DcvDetail with email=null (typical for EMAIL DCV orders that have actionNeeded=N, and for CNAME-only DCV). Inverts the condition and adds a ContainsKey guard to mirror the existing CName branch. * Update generated docs --------- Co-authored-by: Keyfactor * Feature/dns plugins (#12) * 200 day renewal fixes * Update generated docs * Improved logging .net 10 support * Removed Template Sync Logic * Update generated docs * fixed template mapping issue * product fixes * Update generated docs * fixed renewal issue * documentation fixes * Update generated docs * DNS Changes * Update generated docs * dns code updates * Update generated docs * change type * Update generated docs * fixed mismatch * Use 'cname' validation type for CSC CNAME DCV CSC DCV requires a CNAME record, so resolve a DNS provider advertising the 'cname' validation type (e.g. GoDaddyCnameDomainValidator) rather than the ACME 'dns-01'/TXT variant. Docs updated to call out the CNAME validator and warn against selecting the TXT validator for CSC domains. * Update generated docs * added polling to grab cert * Update generated docs * Update integration-manifest.json * Update CSCGlobalCAPlugin.csproj * Update CSCGlobalCAPlugin.csproj * Update keyfactor-bootstrap-workflow-v3.yml * docs: auto-generate README and documentation [skip ci] * Add Enabled CA connection flag Adds the standard Enabled boolean field to the CA Connection settings, matching the pattern used by every other Keyfactor CA plugin (SSL Store, Digicert, HydrantId, GCP CAS, Idnomic, etc.). Purpose: allow ops to create the CA record before all API credentials are available. When Enabled=false, the plugin short-circuits: - Initialize: skips CscGlobalClient construction (no valid creds needed) - Ping: no-op, logs a warning - ValidateCAConnectionInfo / ValidateProductInfo: skip validation - Synchronize: completes the buffer immediately - Enroll: returns FAILED with a clear message - Revoke: throws InvalidOperationException with a clear message Default is true so existing deployments that don't set the key continue to function without change. Reads the value from incoming connectionInfo in the Validate* methods so an operator editing the CA sees consistent behavior with the current form state. * docs: auto-generate README and documentation [skip ci] --------- Co-authored-by: Keyfactor Co-authored-by: github-actions[bot] * Update integration-manifest.json (#14) --------- Co-authored-by: David Galey Co-authored-by: Keyfactor Co-authored-by: Mark Kachkaev <37276742+mkachk@users.noreply.github.com> Co-authored-by: Mikey Henderson <4452096+fiddlermikey@users.noreply.github.com> Co-authored-by: Sean <1661003+spbsoluble@users.noreply.github.com> Co-authored-by: Brian Hill Co-authored-by: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Co-authored-by: github-actions[bot] --- .claude/settings.json | 8 + .../keyfactor-bootstrap-workflow-v3.yml | 11 +- README.md | 564 +++++--- cscglobal-caplugin/CSCGlobalCAPlugin.cs | 1252 +++++++++++++++-- cscglobal-caplugin/CSCGlobalCAPlugin.csproj | 16 +- cscglobal-caplugin/Client/CscGlobalClient.cs | 269 +++- cscglobal-caplugin/Constants.cs | 8 +- cscglobal-caplugin/FlowLogger.cs | 241 ++++ cscglobal-caplugin/RequestManager.cs | 533 +++++-- docsource/configuration.md | 109 ++ integration-manifest.json | 20 +- 11 files changed, 2479 insertions(+), 552 deletions(-) create mode 100644 .claude/settings.json create mode 100644 cscglobal-caplugin/FlowLogger.cs diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..c64f0fd --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(git fetch:*)", + "Bash(git checkout:*)" + ] + } +} 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 }} diff --git a/README.md b/README.md index c68aac4..5effcf2 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

-Integration Status: pilot +Integration Status: production Release Issues GitHub Downloads (all assets, all releases) @@ -14,7 +14,7 @@ Support - + · Requirements @@ -33,15 +33,14 @@

- This integration allows for the Synchronization, Enrollment, and Revocation of certificates from the CSCGlobal. This is the AnyGateway REST version. ## Compatibility -The CSCGlobal CAPlugin AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 24.2.0 and later. +The CSCGlobal CAPlugin AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 26.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. @@ -55,16 +54,15 @@ 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 `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 (`net10.0`) can be named anything, as long as it is unique within the `Extensions` directory. 4. Restart the AnyCA Gateway REST service. @@ -82,235 +80,343 @@ 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 + * **SyncFilterDays** - Number of days from today to filter certificates by expiration date during incremental sync. + * **RenewalWindowDays** - Number of days before the annual order expiry within which a RenewOrReissue triggers a paid Renewal rather than a free Reissue. Default is 30. + * **DcvPollTimeoutSeconds** - Max seconds to synchronously poll CSC for issuance after submitting an order (and publishing CNAME DCV). 0 disables polling (enrollment returns pending immediately; cert arrives on next sync). When >0, fast-validating orders can return the cert directly. Keep small to avoid long-blocking enrollment requests. 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 +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 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 + +## CA Connection Configuration + +When defining the Certificate Authority in the AnyCA Gateway REST portal, configure the following fields on the **CA Connection** tab: + +CONFIG ELEMENT | DESCRIPTION | DEFAULT +---------------|-------------|-------- +Enabled | Flag to Enable or Disable gateway functionality. Set to `false` to allow creating the CA record before configuration information is available; the plugin then short-circuits Ping, Sync, Enroll, and Revoke with a warning until it is re-enabled. | `true` +CscGlobalUrl | The base URL for the CSCGlobal API (e.g. `https://apis.cscglobal.com`) | (required) +ApiKey | Your CSCGlobal API key | (required) +BearerToken | Your CSCGlobal Bearer token for authentication | (required) +DefaultPageSize | Page size for API list requests | 100 +SyncFilterDays | Number of days from today used to filter certificates by expiration date during **incremental** sync. Only certificates expiring within this window are returned. Does not apply to full sync. | 5 +RenewalWindowDays | Number of days before the annual order expiry date within which a **RenewOrReissue** request triggers a paid **Renewal** rather than a free **Reissue**. See [Renewal vs. Reissue Logic](#renewal-vs-reissue-logic) below. | 30 +DcvPollTimeoutSeconds | Max seconds to synchronously poll CSC for certificate issuance after submitting an order. `0` disables polling (enrollment returns pending immediately; cert arrives on the next sync). When `>0`, fast-validating orders can return the issued cert directly in the enrollment response. See [Synchronous Issuance Polling](#synchronous-issuance-polling) below. | 0 + +> **Note:** DNS auto-publishing for CNAME DCV is handled by the AnyCA Gateway REST framework's Domain Validation system (gateway 3.3+). It's configured in the gateway UI under **Domain Validation Configurations**, not on the CA Connection tab. See [DNS Auto-Publishing (CNAME DCV)](#dns-auto-publishing-cname-dcv). + +## Renewal vs. Reissue Logic + +CSC Global subscriptions are annual orders. When Keyfactor Command sends a **RenewOrReissue** request, the plugin must decide whether to submit a **Renewal** (a new paid order) or a **Reissue** (a free re-key under the existing active order). + +The decision is based on the **RenewalWindowDays** setting and works as follows: + +1. The plugin fetches the original certificate from CSC and reads its `orderDate`. +2. It computes the **order expiry** as `orderDate + 1 year`. +3. It calculates **days remaining** until the order expires. +4. If `days remaining <= RenewalWindowDays`, the request is treated as a **Renewal** (new paid order). +5. If `days remaining > RenewalWindowDays`, the request is treated as a **Reissue** (free under the active order). + +**Example with default RenewalWindowDays = 30:** + +``` +Order Date: 2025-04-08 +Order Expiry: 2026-04-08 +Today: 2026-03-15 +Days Left: 24 + +24 <= 30 --> RENEWAL (new paid order) +``` + +``` +Order Date: 2025-04-08 +Order Expiry: 2026-04-08 +Today: 2025-09-01 +Days Left: 219 + +219 > 30 --> REISSUE (free under active order) +``` + +**Fallback behavior:** If the plugin cannot retrieve the `orderDate` from CSC (e.g., API error or missing field), it falls back to checking the certificate's expiration date. If the certificate is already expired, it treats the request as a Renewal. + +**Note:** Both Renewal and Reissue submissions are asynchronous at CSC. The plugin returns a "pending" status and the issued certificate will appear in Keyfactor after the next sync cycle. + +## DNS Auto-Publishing (CNAME DCV) + +CSC supports two Domain Control Validation (DCV) methods: **EMAIL** and **CNAME**. With CNAME validation, CSC returns a CNAME record (name → target) that must exist in DNS before they will validate the order. + +By default this plugin returns the CNAME details to Keyfactor Command for **manual publishing**. To fully automate enrollment, the plugin uses the **AnyCA Gateway REST framework's built-in DNS provider system** (available in framework 3.3 and later). The framework discovers DNS provider plugins deployed alongside the CA plugin and routes each CNAME to whichever provider claims the matching DNS zone. + +### Requirements + +* AnyCA Gateway REST framework **3.3 or later** (the `IDomainValidatorFactory` interface ships in `Keyfactor.AnyGateway.IAnyCAPlugin` 3.3+). +* At least one DNS provider DLL (e.g. GoDaddy, Cloudflare, Route 53, Azure) deployed in the gateway `Extensions` folder. +* A Domain Validation Configuration registered in the gateway UI that maps your domain(s) to the deployed provider (for example, `*.example.com` → GoDaddy). + +### How It Works + +1. CSC returns the CNAME `name → target` details in the enrollment response. +2. For each CNAME entry, the plugin calls `IDomainValidatorFactory.ResolveDomainValidator(recordName, "cname")`. +3. The framework returns the `IDomainValidator` whose Domain Validation Configuration matches the record's zone (or `null` if no match). +4. The plugin calls `validator.StageValidation(recordName, cnameTarget, ct)` to publish the record. +5. CSC asynchronously validates the CNAME; the issued certificate appears on the next sync. + +### Behavior + +* **Resolution is per record, not per CA.** One CA can drive multiple DNS providers (GoDaddy for some domains, Route 53 for others) with no per-CA configuration. +* **Only invoked for CNAME DCV.** Templates configured with EMAIL validation are unaffected — no DNS publishing occurs. +* **Best-effort.** If no provider claims the zone, the publish call fails, or the factory wasn't injected (gateway pre-3.3), the enrollment still succeeds and the CNAME details remain in the Keyfactor request so a human can publish manually as a fallback. +* **Trace-logged.** Every resolution (matched/unresolved) and publish attempt (success/failure) is logged at Info/Trace level. +* **Validation type string.** The plugin passes `"cname"` to `ResolveDomainValidator`. CSC's DCV requires a **CNAME** record, which is different from ACME's `"dns-01"` challenge (a TXT record). A single DNS provider DLL can ship multiple validator classes — one advertising `"dns-01"` (publishes TXT, for ACME) and one advertising `"cname"` (publishes CNAME, for CSC). You must deploy and configure a validator that advertises `"cname"` or no provider will match. +* **Trailing dots normalized.** CSC returns FQDN-canonical names with a trailing dot (e.g. `_token.example.com.`). The plugin strips the trailing dot before resolution and publishing, because Domain Validation Configurations and DNS provider APIs expect names without it. + +### Configuration in the Gateway UI + +In the AnyCA Gateway REST portal, under **Domain Validation Configurations**: + +1. **Add** a new configuration. +2. Pick a **Domain Validator Type** that publishes **CNAME** records and advertises validation type `cname`. For GoDaddy this is `GoDaddyCnameDomainValidator` (the `GoDaddyDomainValidator` variant publishes TXT for ACME and will **not** work for CSC). +3. Add one or more **domain patterns** (e.g. `*.example.com`). +4. Fill out the provider-specific **Configuration Settings** (API keys, base URL, etc.). +5. Save. + +Once configured, any CSC enrollment for a domain matching one of those patterns will have its CNAME auto-published. + +> **Common pitfall:** If you configure the TXT/`dns-01` validator (e.g. `GoDaddyDomainValidator`) for a CSC domain, the record will publish as a **TXT** and CSC's CNAME validation will never succeed. Make sure you select the **CNAME** validator variant. + +## Synchronous Issuance Polling + +CSC validates domain control asynchronously — after an order is submitted (and the CNAME DCV record published), CSC/Sectigo polls public DNS on its own schedule and issues the certificate once validation passes. By default this plugin returns a **pending** (`EXTERNALVALIDATION`) result immediately and the issued certificate is picked up on the next gateway **sync** cycle. + +For environments where DNS is published automatically (see [DNS Auto-Publishing](#dns-auto-publishing-cname-dcv)) and validation tends to complete quickly, you can have the plugin **poll CSC synchronously** at the end of enrollment and return the issued certificate directly — avoiding the wait for the next sync. +* Set **`DcvPollTimeoutSeconds`** to the maximum number of seconds to poll (e.g. `60`). `0` (default) disables polling entirely. +* The plugin polls CSC every 10 seconds until the order is issued or the timeout is reached. +* If the certificate issues within the window, the enrollment returns it immediately with a success status. +* If the window expires, the plugin falls back to the **pending** result and the certificate arrives on the next sync — exactly as it would with polling disabled. +**Tradeoff:** Polling blocks the enrollment request for up to `DcvPollTimeoutSeconds`. CSC validation frequently takes minutes to hours, so most orders will still fall through to pending — keep the timeout small (30–90s) to catch only the fast cases without hanging callers. This applies to New enrollments, Renewals, and Reissues. ## License @@ -318,4 +424,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). diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index e1af2f0..c22aa32 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -23,80 +23,300 @@ namespace Keyfactor.Extensions.CAPlugin.CSCGlobal; public class CSCGlobalCAPlugin : IAnyCAPlugin { + /// + /// Validation type string passed to . + /// CSC's Domain Control Validation publishes a CNAME record, so we resolve a DNS provider + /// that advertises the "cname" validation type (e.g. GoDaddy's GoDaddyCnameDomainValidator). + /// This is distinct from ACME's "dns-01" challenge, which publishes TXT records — a single + /// DNS provider DLL can ship separate validator classes for each type. + /// + private const string DNS_VALIDATION_TYPE = "cname"; + + /// Delay between CSC status polls while waiting for DCV to complete. + private static readonly TimeSpan DcvPollInterval = TimeSpan.FromSeconds(10); + private readonly RequestManager _requestManager; private readonly ILogger Logger; + private readonly IDomainValidatorFactory? _validatorFactory; private ICertificateDataReader _certificateDataReader; + /// + /// Parameterless constructor retained for compatibility with older gateway hosts that don't + /// perform DI. When constructed this way the plugin runs without DNS auto-publishing. + /// public CSCGlobalCAPlugin() { Logger = LogHandler.GetClassLogger(); _requestManager = new RequestManager(); + _validatorFactory = null; + } + + /// + /// DI constructor used by AnyCA Gateway 3.3+ which injects the framework's domain validator + /// factory. When non-null, CNAME DCV records returned by CSC are auto-published via the + /// framework's registered DNS providers (resolved per-domain). + /// + public CSCGlobalCAPlugin(IDomainValidatorFactory validatorFactory) + { + Logger = LogHandler.GetClassLogger(); + _requestManager = new RequestManager(); + _validatorFactory = validatorFactory; } private ICscGlobalClient CscGlobalClient { get; set; } - public bool EnableTemplateSync { get; set; } + /// + /// Whether the CA is enabled. When false, the plugin returns early from Ping, + /// ValidateCAConnectionInfo, ValidateProductInfo, Synchronize, Enroll, and Revoke without + /// calling CSC. Primarily used to allow creation of the CA record prior to configuration + /// information being available (standard field across Keyfactor CA plugins). Defaults to true + /// so existing deployments that don't set this key continue to function. + /// + public bool Enabled { get; set; } = true; public int SyncFilterDays { get; set; } + public int RenewalWindowDays { get; set; } + + /// + /// Maximum seconds to synchronously poll CSC for certificate issuance after submitting an + /// order (and publishing CNAME DCV). 0 disables polling — the enrollment returns "pending" + /// immediately and the cert is picked up on the next sync. When > 0, fast-validating + /// orders can return the issued cert directly in the enrollment response. + /// + public int DcvPollTimeoutSeconds { get; set; } + //done public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDataReader certificateDataReader) { + using var flow = new FlowLogger(Logger, "Initialize"); Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace("Initialize called. configProvider is {Null}, certificateDataReader is {Null2}", + configProvider == null ? "NULL" : "present", + certificateDataReader == null ? "NULL" : "present"); + + flow.Step("ValidateInputs", () => + { + if (configProvider == null) + throw new ArgumentNullException(nameof(configProvider), "configProvider cannot be null in Initialize"); + if (certificateDataReader == null) + throw new ArgumentNullException(nameof(certificateDataReader), "certificateDataReader cannot be null in Initialize"); + }); + _certificateDataReader = certificateDataReader; - CscGlobalClient = new CscGlobalClient(configProvider); - var templateSync = configProvider.CAConnectionData["TemplateSync"].ToString(); - if (templateSync.ToUpper() == "ON") EnableTemplateSync = true; - if (configProvider.CAConnectionData.ContainsKey(Constants.SyncFilterDays)) + flow.Step("ValidateConnectionData", () => { - var syncFilterDaysStr = configProvider.CAConnectionData[Constants.SyncFilterDays]?.ToString(); - if (int.TryParse(syncFilterDaysStr, out var syncFilterDays)) + if (configProvider.CAConnectionData == null) { - SyncFilterDays = syncFilterDays; - Logger.LogDebug($"SyncFilterDays configured to {SyncFilterDays} days"); + Logger.LogError("CAConnectionData is null. Cannot read configuration."); + throw new InvalidOperationException("CAConnectionData is null on configProvider."); } + Logger.LogTrace("CAConnectionData keys: {Keys}", string.Join(", ", configProvider.CAConnectionData.Keys)); + }); + + flow.Step("ReadEnabled", () => + { + Enabled = true; // default + if (configProvider.CAConnectionData.TryGetValue(Constants.Enabled, out var enabledObj)) + { + Logger.LogTrace("Enabled raw value: '{Value}'", enabledObj?.ToString() ?? "(null)"); + if (bool.TryParse(enabledObj?.ToString(), out var parsed)) + Enabled = parsed; + else + Logger.LogWarning("Enabled value '{Value}' could not be parsed as bool, defaulting to true.", enabledObj); + } + else + { + Logger.LogTrace("Enabled key not found in CAConnectionData, defaulting to true."); + } + Logger.LogInformation("CA is {State}.", Enabled ? "Enabled" : "Disabled"); + }, $"Enabled={Enabled}"); + + // Construct the CSC client only when enabled. When disabled we allow Initialize to complete + // without valid API credentials — this is the whole point of the Enabled toggle (so ops can + // create the CA record before credentials are available). + if (Enabled) + { + flow.Step("CreateCscGlobalClient", () => + { + Logger.LogTrace("Creating CscGlobalClient from configProvider..."); + CscGlobalClient = new CscGlobalClient(configProvider); + Logger.LogTrace("CscGlobalClient created successfully."); + }); } + else + { + flow.Skip("CreateCscGlobalClient", "CA is Disabled"); + } + + flow.Step("ReadSyncFilterDays", () => + { + if (configProvider.CAConnectionData.ContainsKey(Constants.SyncFilterDays)) + { + var syncFilterDaysStr = configProvider.CAConnectionData[Constants.SyncFilterDays]?.ToString(); + Logger.LogTrace("SyncFilterDays raw value: '{Value}'", syncFilterDaysStr ?? "(null)"); + if (int.TryParse(syncFilterDaysStr, out var syncFilterDays)) + { + SyncFilterDays = syncFilterDays; + Logger.LogDebug("SyncFilterDays configured to {Days} days", SyncFilterDays); + } + else + { + Logger.LogWarning("SyncFilterDays value '{Value}' could not be parsed as int, using default 0.", syncFilterDaysStr); + } + } + else + { + Logger.LogTrace("SyncFilterDays key not found in CAConnectionData, using default 0."); + } + }); + + flow.Step("ReadRenewalWindowDays", () => + { + RenewalWindowDays = 30; // default + if (configProvider.CAConnectionData.TryGetValue(Constants.RenewalWindowDays, out var renewalWindowObj)) + { + Logger.LogTrace("RenewalWindowDays raw value: '{Value}'", renewalWindowObj?.ToString() ?? "(null)"); + if (int.TryParse(renewalWindowObj?.ToString(), out var renewalWindowDays) && renewalWindowDays > 0) + RenewalWindowDays = renewalWindowDays; + else + Logger.LogWarning("RenewalWindowDays value '{Value}' could not be parsed or was <= 0, using default 30.", renewalWindowObj); + } + else + { + Logger.LogTrace("RenewalWindowDays key not found in CAConnectionData, using default 30."); + } + Logger.LogDebug("RenewalWindowDays configured to {Days} days", RenewalWindowDays); + }, $"RenewalWindowDays={RenewalWindowDays}"); + + flow.Step("ReadDcvPollTimeoutSeconds", () => + { + DcvPollTimeoutSeconds = 0; // default: disabled + if (configProvider.CAConnectionData.TryGetValue(Constants.DcvPollTimeoutSeconds, out var pollObj)) + { + Logger.LogTrace("DcvPollTimeoutSeconds raw value: '{Value}'", pollObj?.ToString() ?? "(null)"); + if (int.TryParse(pollObj?.ToString(), out var pollSeconds) && pollSeconds >= 0) + DcvPollTimeoutSeconds = pollSeconds; + else + Logger.LogWarning("DcvPollTimeoutSeconds value '{Value}' could not be parsed or was < 0, using default 0 (disabled).", pollObj); + } + else + { + Logger.LogTrace("DcvPollTimeoutSeconds key not found in CAConnectionData, using default 0 (disabled)."); + } + Logger.LogDebug("DcvPollTimeoutSeconds configured to {Seconds}s ({State})", + DcvPollTimeoutSeconds, DcvPollTimeoutSeconds > 0 ? "enabled" : "disabled"); + }); + + flow.Step("CheckDnsValidatorFactory", () => + { + if (_validatorFactory == null) + Logger.LogInformation( + "No IDomainValidatorFactory was injected by the gateway host. CNAME DCV records will require manual publishing."); + else + Logger.LogInformation( + "IDomainValidatorFactory available from gateway host. CNAME DCV records will be auto-published per-domain via the framework's registered DNS providers (validation type '{Type}').", + DNS_VALIDATION_TYPE); + }); + Logger.MethodExit(LogLevel.Debug); } //done public async Task GetSingleRecord(string caRequestID) { + using var flow = new FlowLogger(Logger, $"GetSingleRecord({caRequestID ?? "null"})"); + Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace("GetSingleRecord called with caRequestID='{CaRequestId}'", caRequestID ?? "(null)"); + + flow.Step("ValidateInput", () => + { + if (string.IsNullOrEmpty(caRequestID)) + throw new ArgumentNullException(nameof(caRequestID), "caRequestID cannot be null or empty."); + if (caRequestID.Length < 36) + throw new ArgumentException($"caRequestID '{caRequestID}' is too short to extract a UUID (need at least 36 chars).", nameof(caRequestID)); + }); + try { - Logger.MethodEntry(LogLevel.Debug); - 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)) - .Result; + var keyfactorCaId = caRequestID.Substring(0, 36); + flow.Step("ExtractUUID", $"keyfactorCaId={keyfactorCaId}"); - Logger.LogTrace($"Single Cert JSON: {JsonConvert.SerializeObject(certificateResponse)}"); + CertificateResponse certificateResponse = null; + await flow.StepAsync("FetchCertFromCSC", async () => + { + certificateResponse = await CscGlobalClient.SubmitGetCertificateAsync(keyfactorCaId); + }); + + if (certificateResponse == null) + { + flow.Fail("ParseResponse", "API returned null"); + Logger.LogWarning("GetSingleRecord: SubmitGetCertificateAsync returned null for keyfactorCaId='{KeyfactorCaId}'", keyfactorCaId); + return new AnyCAPluginCertificate + { + CARequestID = keyfactorCaId, + Certificate = string.Empty, + Status = _requestManager.MapReturnStatus(null) + }; + } - var fileContent = - Encoding.ASCII.GetString( - Convert.FromBase64String(certificateResponse?.Certificate ?? string.Empty)); + flow.Step("ParseResponse", $"Status={certificateResponse.Status ?? "(null)"}"); + Logger.LogTrace("Single Cert JSON: {Json}", JsonConvert.SerializeObject(certificateResponse)); - Logger.LogTrace($"File Content {fileContent}"); - var certData = fileContent?.Replace("\r\n", string.Empty); + var rawCert = certificateResponse.Certificate ?? string.Empty; + string fileContent = string.Empty; + flow.Step("DecodeBase64", () => + { + try + { + fileContent = Encoding.ASCII.GetString(Convert.FromBase64String(rawCert)); + } + catch (FormatException fex) + { + Logger.LogError(fex, "GetSingleRecord: Failed to decode Base64 certificate content for keyfactorCaId='{KeyfactorCaId}'", keyfactorCaId); + fileContent = string.Empty; + } + }, $"length={rawCert.Length}"); + + var certData = fileContent.Replace("\r\n", string.Empty); var certString = string.Empty; if (!string.IsNullOrEmpty(certData)) - certString = GetEndEntityCertificate(certData); - Logger.LogTrace($"Cert String Content {certString}"); + { + flow.Step("ExtractLeafCert", () => + { + certString = GetEndEntityCertificate(certData); + }, $"inputLength={certData.Length}"); + } + else + { + flow.Skip("ExtractLeafCert", "certData empty after cleanup"); + } + + var mappedStatus = _requestManager.MapReturnStatus(certificateResponse.Status); + flow.Step("MapStatus", $"{certificateResponse.Status ?? "(null)"} -> {mappedStatus}"); Logger.MethodExit(LogLevel.Debug); return new AnyCAPluginCertificate { CARequestID = keyfactorCaId, - Certificate = certString, - Status = _requestManager.MapReturnStatus(certificateResponse?.Status) + Certificate = certString ?? string.Empty, + Status = mappedStatus }; } + catch (AggregateException ae) + { + var inner = ae.Flatten().InnerException; + flow.Fail("UNHANDLED", inner?.Message ?? ae.Message); + Logger.LogError(inner, "GetSingleRecord: AggregateException for caRequestID='{CaRequestId}': {Message}", caRequestID, inner?.Message ?? ae.Message); + throw new Exception($"Error Occurred getting single cert for '{caRequestID}': {inner?.Message ?? ae.Message}", inner ?? ae); + } catch (Exception e) { - throw new Exception($"Error Occurred getting single cert {e.Message}"); + flow.Fail("UNHANDLED", e.Message); + Logger.LogError(e, "GetSingleRecord: Exception for caRequestID='{CaRequestId}': {Message}", caRequestID, e.Message); + throw new Exception($"Error Occurred getting single cert for '{caRequestID}': {e.Message}", e); } } @@ -104,31 +324,64 @@ public async Task GetSingleRecord(string caRequestID) public async Task Synchronize(BlockingCollection blockingBuffer, DateTime? lastSync, bool fullSync, CancellationToken cancelToken) { - Logger.LogTrace($"Full Sync? {fullSync.ToString()}"); + var syncType = fullSync ? "Full" : "Incremental"; + using var flow = new FlowLogger(Logger, $"Synchronize-{syncType}"); Logger.MethodEntry(); + Logger.LogTrace("Synchronize called. fullSync={FullSync}, lastSync={LastSync}, blockingBuffer is {Null}", + fullSync, lastSync?.ToString("o") ?? "(null)", + blockingBuffer == null ? "NULL" : "present"); + + if (blockingBuffer == null) + throw new ArgumentNullException(nameof(blockingBuffer), "blockingBuffer cannot be null in 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) { - Logger.LogDebug("Performing full sync - no date filter applied"); - await SyncCertificates(blockingBuffer, cancelToken, null); + flow.Step("DetermineFilter", "Full sync - no date filter"); + await flow.StepAsync("FetchAndProcessCerts", async () => + { + await SyncCertificates(blockingBuffer, cancelToken, null); + }); } 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); + flow.Step("DetermineFilter", $"Incremental, filterDays={filterDays}, cutoff={dateFilter}"); + await flow.StepAsync("FetchAndProcessCerts", async () => + { + await SyncCertificates(blockingBuffer, cancelToken, dateFilter); + }); } + flow.Step("CompleteAdding"); blockingBuffer.CompleteAdding(); } + catch (OperationCanceledException) + { + flow.Fail("Cancelled", "operation was cancelled"); + Logger.LogWarning("Synchronize: operation was cancelled."); + if (!blockingBuffer.IsAddingCompleted) + blockingBuffer.CompleteAdding(); + throw; + } catch (Exception e) { - Logger.LogError($"Csc Global Synchronize Task failed! {LogHandler.FlattenException(e)}"); + flow.Fail("SyncError", e.Message); + Logger.LogError(e, "Csc Global Synchronize Task failed! {FlatException}", LogHandler.FlattenException(e)); + if (!blockingBuffer.IsAddingCompleted) + blockingBuffer.CompleteAdding(); Logger.MethodExit(); - blockingBuffer.CompleteAdding(); throw; } @@ -138,70 +391,188 @@ public async Task Synchronize(BlockingCollection blockin private async Task SyncCertificates(BlockingCollection blockingBuffer, CancellationToken cancelToken, string? dateFilter) { + Logger.LogTrace("SyncCertificates: calling SubmitCertificateListRequestAsync with dateFilter='{DateFilter}'", dateFilter ?? "(null)"); var certs = await CscGlobalClient.SubmitCertificateListRequestAsync(dateFilter); + if (certs == null) + { + Logger.LogWarning("SyncCertificates: SubmitCertificateListRequestAsync returned null."); + return; + } + + if (certs.Results == null) + { + Logger.LogWarning("SyncCertificates: certificate list response Results collection is null."); + return; + } + + Logger.LogTrace("SyncCertificates: received {Count} certificate results.", certs.Results.Count); + var processedCount = 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); - //Keyfactor sync only seems to work when there is a valid cert and I can only get Active valid certs from Csc Global + if (currentResponseItem == null) + { + Logger.LogTrace("SyncCertificates: skipping null result item."); + skippedCount++; + continue; + } + + Logger.LogTrace("SyncCertificates: processing certificate UUID={Uuid}, Status='{Status}', CertificateType='{CertType}'", + currentResponseItem.Uuid ?? "(null)", + currentResponseItem.Status ?? "(null)", + currentResponseItem.CertificateType ?? "(null)"); + + var certStatus = _requestManager.MapReturnStatus(currentResponseItem.Status); + Logger.LogTrace("SyncCertificates: mapped status for UUID={Uuid}: {MappedStatus}", currentResponseItem.Uuid ?? "(null)", certStatus); + 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 productId = _requestManager.MapCertificateTypeToProductId(currentResponseItem.CertificateType); - var fileContent = - PreparePemTextFromApi( - currentResponseItem?.Certificate ?? string.Empty); + Logger.LogTrace("SyncCertificates: UUID={Uuid} qualifies for sync. CertificateType='{CertType}' -> ProductId='{ProductId}'", + currentResponseItem.Uuid, currentResponseItem.CertificateType ?? "(null)", productId); + + string fileContent; + try + { + fileContent = PreparePemTextFromApi(currentResponseItem.Certificate ?? string.Empty); + } + catch (Exception ex) + { + Logger.LogError(ex, "SyncCertificates: PreparePemTextFromApi failed for UUID={Uuid}", currentResponseItem.Uuid); + skippedCount++; + continue; + } if (fileContent.Length > 0) { - Logger.LogTrace($"File Content {fileContent}"); + Logger.LogTrace("SyncCertificates: fileContent length={Length} for UUID={Uuid}", fileContent.Length, currentResponseItem.Uuid); var certData = fileContent.Replace("\r\n", string.Empty); - var certString = GetEndEntityCertificate(certData); - if (certString.Length > 0) + string certString; + try + { + certString = GetEndEntityCertificate(certData); + } + catch (Exception ex) + { + Logger.LogError(ex, "SyncCertificates: GetEndEntityCertificate failed for UUID={Uuid}", currentResponseItem.Uuid); + skippedCount++; + continue; + } + + if (!string.IsNullOrEmpty(certString)) + { blockingBuffer.Add(new AnyCAPluginCertificate { - CARequestID = $"{currentResponseItem?.Uuid}", + CARequestID = $"{currentResponseItem.Uuid}", Certificate = certString, Status = certStatus, ProductID = productId }, cancelToken); + processedCount++; + Logger.LogTrace("SyncCertificates: added UUID={Uuid} to buffer.", currentResponseItem.Uuid); + } + else + { + Logger.LogTrace("SyncCertificates: certString was empty for UUID={Uuid}, skipping.", currentResponseItem.Uuid); + skippedCount++; + } + } + else + { + Logger.LogTrace("SyncCertificates: fileContent was empty for UUID={Uuid}, skipping.", currentResponseItem.Uuid); + skippedCount++; } } + else + { + Logger.LogTrace("SyncCertificates: UUID={Uuid} status {Status} not eligible for sync, skipping.", currentResponseItem.Uuid, certStatus); + skippedCount++; + } } + + Logger.LogDebug("SyncCertificates: completed. Processed={Processed}, Skipped={Skipped}, Total={Total}", + processedCount, skippedCount, certs.Results.Count); } //done public async Task Revoke(string caRequestID, string hexSerialNumber, uint revocationReason) { + using var flow = new FlowLogger(Logger, $"Revoke({caRequestID ?? "null"})"); + Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace("Revoke called with caRequestID='{CaRequestId}', hexSerialNumber='{SerialNumber}', revocationReason={Reason}", + caRequestID ?? "(null)", hexSerialNumber ?? "(null)", revocationReason); + + 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."); + } + + flow.Step("ValidateInput", () => + { + if (string.IsNullOrEmpty(caRequestID)) + throw new ArgumentNullException(nameof(caRequestID), "caRequestID cannot be null or empty for Revoke."); + if (caRequestID.Length < 36) + throw new ArgumentException($"caRequestID '{caRequestID}' is too short to extract a UUID.", nameof(caRequestID)); + }); + 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 + var uuid = caRequestID.Substring(0, 36); + flow.Step("ExtractUUID", $"uuid={uuid}"); - Logger.LogTrace($"Revoke Response JSON: {JsonConvert.SerializeObject(revokeResponse)}"); - Logger.MethodExit(LogLevel.Debug); + RevokeResponse revokeResponse = null; + await flow.StepAsync("SubmitRevokeToCSC", async () => + { + revokeResponse = await CscGlobalClient.SubmitRevokeCertificateAsync(uuid); + }); + + if (revokeResponse == null) + { + flow.Fail("ParseResponse", "API returned null"); + throw new InvalidOperationException($"Revoke received null response for UUID '{uuid}'."); + } + + Logger.LogTrace("Revoke Response JSON: {Json}", JsonConvert.SerializeObject(revokeResponse)); var revokeResult = _requestManager.GetRevokeResult(revokeResponse); + flow.Step("MapResult", $"result={revokeResult}"); if (revokeResult == (int)EndEntityStatus.FAILED) - if (!string.IsNullOrEmpty(revokeResponse?.RegistrationError?.Description)) - throw new HttpRequestException( - $"Revoke Failed with message {revokeResponse?.RegistrationError?.Description}"); + { + var errorDesc = revokeResponse.RegistrationError?.Description; + flow.Fail("RevokeResult", errorDesc ?? "(no description)"); + Logger.LogError("Revoke: failed for UUID='{Uuid}'. Error description: '{ErrorDesc}'", + uuid, errorDesc ?? "(no description)"); + if (!string.IsNullOrEmpty(errorDesc)) + throw new HttpRequestException($"Revoke Failed with message {errorDesc}"); + } + Logger.MethodExit(LogLevel.Debug); return revokeResult; } + catch (AggregateException ae) + { + var inner = ae.Flatten().InnerException; + flow.Fail("UNHANDLED", inner?.Message ?? ae.Message); + Logger.LogError(inner, "Revoke: AggregateException for caRequestID='{CaRequestId}': {Message}", caRequestID, inner?.Message ?? ae.Message); + throw new Exception($"Revoke Failed for '{caRequestID}' with message {inner?.Message ?? ae.Message}", inner ?? ae); + } + catch (HttpRequestException) + { + throw; // already logged in flow above + } catch (Exception e) { - throw new Exception($"Revoke Failed with message {e?.Message}"); + flow.Fail("UNHANDLED", e.Message); + Logger.LogError(e, "Revoke: Exception for caRequestID='{CaRequestId}': {Message}", caRequestID, e.Message); + throw new Exception($"Revoke Failed for '{caRequestID}' with message {e.Message}", e); } } @@ -209,128 +580,431 @@ await CscGlobalClient.SubmitRevokeCertificateAsync(caRequestID.Substring(0, 36)) public async Task Enroll(string csr, string subject, Dictionary san, EnrollmentProductInfo productInfo, RequestFormat requestFormat, EnrollmentType enrollmentType) { + using var flow = new FlowLogger(Logger, $"Enroll-{enrollmentType}"); Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace("Enroll called. enrollmentType={EnrollmentType}, subject='{Subject}', productId='{ProductId}', requestFormat={RequestFormat}", + enrollmentType, subject ?? "(null)", + productInfo?.ProductID ?? "(null)", requestFormat); + Logger.LogTrace("Enroll: csr is {CsrStatus}, san has {SanCount} entries, productInfo is {PiStatus}", + string.IsNullOrEmpty(csr) ? "empty/null" : $"present ({csr.Length} chars)", + san?.Count ?? 0, + productInfo == null ? "NULL" : "present"); + + if (!Enabled) + { + flow.Fail("Disabled", "CA is Disabled"); + Logger.LogWarning("The CA is currently in the Disabled state. It must be Enabled to perform operations. Rejecting Enroll."); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "The CSC Global CA is in the Disabled state. Enable it to perform enrollments." + }; + } + + flow.Step("ValidateInputs", () => + { + if (productInfo == null) + throw new ArgumentNullException(nameof(productInfo), "productInfo cannot be null for Enroll."); + if (productInfo.ProductParameters == null) + throw new ArgumentNullException(nameof(productInfo), "productInfo.ProductParameters cannot be null for Enroll."); + if (string.IsNullOrEmpty(csr)) + throw new ArgumentNullException(nameof(csr), "CSR cannot be null or empty for Enroll."); + }); + + Logger.LogTrace("Enroll: ProductParameters keys: [{Keys}]", + string.Join(", ", productInfo.ProductParameters.Keys)); 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(); - switch (enrollmentType) + flow.Step("CheckPriorCertSN", () => { - 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")) + if (productInfo.ProductParameters.ContainsKey("priorcertsn")) + { + 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)}"); + priorSn = productInfo.ProductParameters["PriorCertSN"]; + Logger.LogDebug("Enroll: Prior cert SN: '{PriorSn}'", priorSn ?? "(null)"); } else { - return new EnrollmentResult - { - Status = 30, //failure - StatusMessage = "You cannot renew an expired cert please perform an new enrollment." - }; + Logger.LogWarning("Enroll: 'priorcertsn' key exists but 'PriorCertSN' (case-sensitive) not found."); } + } + }, string.IsNullOrEmpty(priorSn) ? "none" : $"SN={priorSn}"); - 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; - } + string uUId; + List customFields = null; + await flow.StepAsync("FetchCustomFields", async () => + { + customFields = await CscGlobalClient.SubmitGetCustomFields(); + }, $"count={customFields?.Count ?? 0}"); - if (expirationDate < DateTime.Now) renewal = true; - if (renewal) - { - //One click won't work for this implementation b/c we are missing enrollment params + if (customFields == null) + { + Logger.LogWarning("Enroll: SubmitGetCustomFields returned null, using empty list."); + customFields = new List(); + } + + try + { + switch (enrollmentType) + { + case EnrollmentType.New: + flow.Step("SelectPath", "New Enrollment"); + IRegistrationResponse enrollmentResponse; + if (!productInfo.ProductParameters.ContainsKey("PriorCertSN")) + { + enrollmentRequest = null; + flow.Step("BuildRegistrationRequest", () => + { + enrollmentRequest = _requestManager.GetRegistrationRequest(productInfo, csr, san, customFields); + }); + Logger.LogTrace("Enrollment Request JSON: {Json}", JsonConvert.SerializeObject(enrollmentRequest)); + + RegistrationResponse regResponse = null; + await flow.StepAsync("SubmitRegistrationToCSC", async () => + { + regResponse = await CscGlobalClient.SubmitRegistrationAsync(enrollmentRequest); + }); + enrollmentResponse = regResponse; + + if (enrollmentResponse == null) + { + flow.Fail("ParseResponse", "API returned null"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = "Enrollment failed: CSC API returned a null response." + }; + } + flow.Step("ParseResponse", $"error={enrollmentResponse.RegistrationError != null}"); + Logger.LogTrace("Enrollment Response JSON: {Json}", JsonConvert.SerializeObject(enrollmentResponse)); + } + else + { + flow.Fail("RejectExpiredRenew", "PriorCertSN present on New enrollment"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = "You cannot renew an expired cert please perform an new enrollment." + }; + } + + var enrollResult = _requestManager.GetEnrollmentResult(enrollmentResponse); + flow.Step("MapResult", $"Status={enrollResult?.Status}, ID={enrollResult?.CARequestID ?? "(null)"}"); + + await flow.StepAsync("PublishCnameDcv", async () => + { + await TryPublishCnameDcvAsync(productInfo, enrollResult); + }); + + EnrollmentResult? newPolled = null; + await flow.StepAsync("PollForIssuance", async () => + { + newPolled = await TryPollForIssuedCertAsync(enrollResult?.CARequestID); + }); + if (newPolled != null) + { + flow.Step("PollResult", "issued during poll window"); + Logger.MethodExit(LogLevel.Debug); + return newPolled; + } + + Logger.MethodExit(LogLevel.Debug); + return enrollResult; + + case EnrollmentType.RenewOrReissue: + flow.Step("SelectPath", "RenewOrReissue"); + + if (string.IsNullOrEmpty(priorSn)) + { + flow.Fail("ValidatePriorSN", "PriorCertSN is empty"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = "RenewOrReissue failed: PriorCertSN is required but was not provided." + }; + } + + string order_id = null; + await flow.StepAsync("LookupOrderId", async () => + { + order_id = await _certificateDataReader.GetRequestIDBySerialNumber(priorSn); + }, $"orderId={order_id ?? "(null)"}"); + + if (string.IsNullOrEmpty(order_id)) + { + flow.Fail("ValidateOrderId", $"no order found for SN={priorSn}"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = $"RenewOrReissue failed: could not find order ID for serial number '{priorSn}'." + }; + } + + if (order_id.Length < 36) + { + flow.Fail("ValidateOrderId", $"order_id too short ({order_id.Length} chars)"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = $"RenewOrReissue failed: order ID '{order_id}' is too short to extract a UUID." + }; + } + flow.Step("ValidateOrderId", $"orderId={order_id}"); + + // Determine renew vs reissue based on order expiry window. + var renewal = false; + try + { + CertificateResponse liveCert = null; + await flow.StepAsync("FetchLiveCertForDecision", async () => + { + liveCert = await 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("ComputeRenewalDecision", + $"orderDate={liveCert.OrderDate}, expiry={orderExpiry:dd-MMM-yyyy}, daysLeft={(int)daysUntilOrderExpiry}, window={RenewalWindowDays}, isRenewal={renewal}"); + } + else + { + flow.Skip("ComputeRenewalDecision", "orderDate unavailable, falling back to cert expiry"); + var expirationDate = _certificateDataReader.GetExpirationDateByRequestId(order_id) + ?? (await GetSingleRecord(order_id)).RevocationDate; + renewal = expirationDate < DateTime.Now; + flow.Step("FallbackExpiryCheck", $"expirationDate={expirationDate?.ToString("o") ?? "(null)"}, isRenewal={renewal}"); + } + } + catch (Exception ex) + { + flow.Fail("FetchLiveCertForDecision", $"falling back: {ex.Message}"); + Logger.LogWarning(ex, "RenewOrReissue: failed to fetch live cert, falling back to cert expiry."); + try + { + var expirationDate = _certificateDataReader.GetExpirationDateByRequestId(order_id) + ?? (await GetSingleRecord(order_id)).RevocationDate; + renewal = expirationDate < DateTime.Now; + flow.Step("FallbackExpiryCheck", $"isRenewal={renewal}"); + } + catch (Exception fallbackEx) + { + flow.Fail("FallbackExpiryCheck", fallbackEx.Message); + return new EnrollmentResult + { + Status = 30, + StatusMessage = $"RenewOrReissue failed: unable to determine renewal status for order '{order_id}'. {fallbackEx.Message}" + }; + } + } + + flow.Step("RenewalDecision", renewal ? "RENEWAL (paid order)" : "REISSUE (free under active order)"); + + if (renewal) + { + if (productInfo.ProductParameters.ContainsKey("Applicant Last Name")) + { + uUId = null; + await flow.StepAsync("LookupRenewalUUID", async () => + { + uUId = await _certificateDataReader.GetRequestIDBySerialNumber( + productInfo.ProductParameters["PriorCertSN"]); + }); + + if (string.IsNullOrEmpty(uUId)) + { + flow.Fail("ValidateRenewalUUID", "could not resolve PriorCertSN"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = "Renewal failed: could not resolve prior certificate serial number to a request ID." + }; + } + flow.Step("ValidateRenewalUUID", $"uuid={uUId}"); + + RenewalRequest builtRenewRequest = null; + flow.Step("BuildRenewalRequest", () => + { + builtRenewRequest = _requestManager.GetRenewalRequest(productInfo, uUId, csr, san, customFields); + }); + renewRequest = builtRenewRequest; + Logger.LogTrace("Renewal Request JSON: {Json}", JsonConvert.SerializeObject(renewRequest)); + + RenewalResponse renewResponse = null; + await flow.StepAsync("SubmitRenewalToCSC", async () => + { + renewResponse = await CscGlobalClient.SubmitRenewalAsync(renewRequest); + }); + + if (renewResponse == null) + { + flow.Fail("ParseRenewalResponse", "API returned null"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = "Renewal failed: CSC API returned a null response." + }; + } + + Logger.LogTrace("Renewal Response JSON: {Json}", JsonConvert.SerializeObject(renewResponse)); + var renewResult = _requestManager.GetRenewResponse(renewResponse); + flow.Step("MapRenewalResult", $"Status={renewResult?.Status}, Message={renewResult?.StatusMessage ?? "(null)"}"); + + EnrollmentResult? renewPolled = null; + await flow.StepAsync("PollForIssuance", async () => + { + renewPolled = await TryPollForIssuedCertAsync(renewResult?.CARequestID); + }); + Logger.MethodExit(LogLevel.Debug); + return renewPolled ?? renewResult; + } + + flow.Fail("MissingEnrollmentParams", "Applicant Last Name not present — one-click renew unavailable"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = + "One click Renew Is Not Available for this Certificate Type. Use the configure button instead." + }; + } + + // Reissue path 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 = Task.Run(async () => await CscGlobalClient.SubmitRenewalAsync(renewRequest)) - .Result; - Logger.LogTrace($"Renewal Response JSON: {JsonConvert.SerializeObject(renewResponse)}"); + string requestid = null; + await flow.StepAsync("LookupReissueRequestId", async () => + { + requestid = await _certificateDataReader.GetRequestIDBySerialNumber( + productInfo.ProductParameters["PriorCertSN"]); + }); + + if (string.IsNullOrEmpty(requestid)) + { + flow.Fail("ValidateReissueRequestId", "could not resolve PriorCertSN"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = "Reissue failed: could not resolve prior certificate serial number to a request ID." + }; + } + + if (requestid.Length < 36) + { + flow.Fail("ValidateReissueRequestId", $"requestid too short ({requestid.Length} chars)"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = $"Reissue failed: request ID '{requestid}' is too short to extract a UUID." + }; + } + + uUId = requestid.Substring(0, 36); + flow.Step("ExtractReissueUUID", $"uuid={uUId}"); + + ReissueRequest builtReissueRequest = null; + flow.Step("BuildReissueRequest", () => + { + builtReissueRequest = _requestManager.GetReissueRequest(productInfo, uUId, csr, san, customFields); + }); + reissueRequest = builtReissueRequest; + Logger.LogTrace("Reissue JSON: {Json}", JsonConvert.SerializeObject(reissueRequest)); + + ReissueResponse reissueResponse = null; + await flow.StepAsync("SubmitReissueToCSC", async () => + { + reissueResponse = await CscGlobalClient.SubmitReissueAsync(reissueRequest); + }); + + if (reissueResponse == null) + { + flow.Fail("ParseReissueResponse", "API returned null"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = "Reissue failed: CSC API returned a null response." + }; + } + + Logger.LogTrace("Reissue Response JSON: {Json}", JsonConvert.SerializeObject(reissueResponse)); + var reissueResult = _requestManager.GetReIssueResult(reissueResponse); + flow.Step("MapReissueResult", $"Status={reissueResult?.Status}, Message={reissueResult?.StatusMessage ?? "(null)"}"); + + EnrollmentResult? reissuePolled = null; + await flow.StepAsync("PollForIssuance", async () => + { + reissuePolled = await TryPollForIssuedCertAsync(reissueResult?.CARequestID); + }); Logger.MethodExit(LogLevel.Debug); - return _requestManager.GetRenewResponse(renewResponse); + return reissuePolled ?? reissueResult; } + flow.Fail("MissingEnrollmentParams", "Applicant Last Name not present — one-click reissue unavailable"); return new EnrollmentResult { - Status = 30, //failure + Status = 30, 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." - }; + default: + flow.Fail("UnhandledType", $"enrollmentType={enrollmentType}"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = $"Enroll failed: unhandled enrollment type '{enrollmentType}'." + }; + } + } + catch (AggregateException ae) + { + var inner = ae.Flatten().InnerException; + flow.Fail("UNHANDLED", inner?.Message ?? ae.Message); + Logger.LogError(inner, "Enroll: AggregateException during {EnrollmentType}: {Message}", enrollmentType, inner?.Message ?? ae.Message); + return new EnrollmentResult + { + Status = 30, + StatusMessage = $"Enrollment failed with error: {inner?.Message ?? ae.Message}" + }; + } + catch (Exception ex) + { + flow.Fail("UNHANDLED", ex.Message); + Logger.LogError(ex, "Enroll: unhandled exception during {EnrollmentType}: {Message}", enrollmentType, ex.Message); + return new EnrollmentResult + { + Status = 30, + StatusMessage = $"Enrollment failed with error: {ex.Message}" + }; } - - Logger.MethodExit(LogLevel.Debug); - return null; } //done public async Task Ping() { Logger.MethodEntry(); + Logger.LogTrace("Ping: Enabled={Enabled}, CscGlobalClient is {Null}", Enabled, CscGlobalClient == null ? "NULL" : "present"); + + 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"); } catch (Exception e) { - Logger.LogError($"There was an error contacting CSCGlobal: {e.Message}."); + Logger.LogError(e, "There was an error contacting CSCGlobal: {Message}", e.Message); throw new Exception($"Error attempting to ping CSCGlobal: {e.Message}.", e); } @@ -340,19 +1014,83 @@ public async Task Ping() //do public async Task ValidateCAConnectionInfo(Dictionary connectionInfo) { + Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace("ValidateCAConnectionInfo called. connectionInfo is {Null}, keys=[{Keys}]", + connectionInfo == null ? "NULL" : "present", + connectionInfo != null ? string.Join(", ", connectionInfo.Keys) : ""); + + if (connectionInfo == null) + { + Logger.LogError("ValidateCAConnectionInfo: connectionInfo is null."); + throw new ArgumentNullException(nameof(connectionInfo), "connectionInfo cannot be null."); + } + + // 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.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); } //do public async Task ValidateProductInfo(EnrollmentProductInfo productInfo, Dictionary connectionInfo) { + Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace("ValidateProductInfo called. productInfo is {Null}, productId='{ProductId}'", + productInfo == null ? "NULL" : "present", + productInfo?.ProductID ?? "(null)"); + + if (productInfo == null) + { + Logger.LogError("ValidateProductInfo: productInfo is null."); + throw new ArgumentNullException(nameof(productInfo), "productInfo cannot be null."); + } + + // 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; + } + + if (string.IsNullOrEmpty(productInfo.ProductID)) + { + Logger.LogError("ValidateProductInfo: productInfo.ProductID is null or empty."); + throw new ArgumentException("ProductID cannot be null or empty.", nameof(productInfo)); + } + var certType = ProductIDs.productIds.Find(x => x.Equals(productInfo.ProductID, StringComparison.InvariantCultureIgnoreCase)); - if (certType == null) throw new ArgumentException($"Cannot find {productInfo.ProductID}", "ProductId"); - - Logger.LogInformation($"Validated {certType} ({certType})configured for AnyGateway"); + if (certType == null) + { + Logger.LogError("ValidateProductInfo: cannot find product ID '{ProductId}'. Known IDs: [{KnownIds}]", + productInfo.ProductID, string.Join(", ", ProductIDs.productIds)); + throw new ArgumentException($"Cannot find {productInfo.ProductID}", "ProductId"); + } + Logger.LogInformation("Validated {CertType} configured for AnyGateway", certType); + Logger.MethodExit(LogLevel.Debug); } //done @@ -360,6 +1098,13 @@ public Dictionary GetCAConnectorAnnotations() { return new Dictionary { + [Constants.Enabled] = new() + { + Comments = "Flag to Enable or Disable gateway functionality. Disabling is primarily used to allow creation of the CA prior to configuration information being available.", + Hidden = false, + DefaultValue = true, + Type = "Boolean" + }, [Constants.CscGlobalUrl] = new() { Comments = "CSCGlobal API URL", @@ -388,19 +1133,26 @@ 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.", 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" + }, + [Constants.DcvPollTimeoutSeconds] = new() + { + Comments = "Max seconds to synchronously poll CSC for issuance after submitting an order (and publishing CNAME DCV). 0 disables polling (enrollment returns pending immediately; cert arrives on next sync). When >0, fast-validating orders can return the cert directly. Keep small to avoid long-blocking enrollment requests.", + Hidden = false, + DefaultValue = "0", + Type = "Number" } }; } @@ -517,6 +1269,206 @@ public List GetProductIds() #region PRIVATE + /// + /// Strip a single trailing dot from a DNS name. CSC returns FQDN-canonical names with + /// a trailing dot but the framework's Domain Validation Configurations are stored without + /// one, so the strings have to be normalized before lookup or the equality check fails. + /// + private static string StripTrailingDot(string? s) + { + if (string.IsNullOrEmpty(s)) return s ?? string.Empty; + return s.EndsWith('.') ? s[..^1] : s; + } + + /// + /// Synchronously poll CSC for issuance of the order identified by , + /// up to . Returns a GENERATED + /// carrying the issued leaf certificate if CSC issues within the window, or null if the + /// window expires (in which case the caller falls back to its pending/EXTERNALVALIDATION result). + /// No-op (returns null) when polling is disabled or the uuid is missing. + /// + private async Task TryPollForIssuedCertAsync(string? uuid) + { + if (DcvPollTimeoutSeconds <= 0) + { + Logger.LogTrace("TryPollForIssuedCertAsync: polling disabled (DcvPollTimeoutSeconds=0), skipping."); + return null; + } + + if (string.IsNullOrEmpty(uuid)) + { + Logger.LogWarning("TryPollForIssuedCertAsync: no UUID/CARequestID to poll, skipping."); + return null; + } + + var deadline = DateTime.UtcNow.AddSeconds(DcvPollTimeoutSeconds); + Logger.LogInformation("TryPollForIssuedCertAsync: polling CSC for issuance of '{Uuid}' for up to {Seconds}s (interval {Interval}s).", + uuid, DcvPollTimeoutSeconds, (int)DcvPollInterval.TotalSeconds); + + var attempt = 0; + while (DateTime.UtcNow < deadline) + { + attempt++; + AnyCAPluginCertificate record; + try + { + record = await GetSingleRecord(uuid); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "TryPollForIssuedCertAsync: poll attempt {Attempt} for '{Uuid}' threw, will retry. {Error}", + attempt, uuid, ex.Message); + record = null; + } + + if (record != null) + { + Logger.LogTrace("TryPollForIssuedCertAsync: attempt {Attempt} for '{Uuid}' — status={Status}, cert={CertState}.", + attempt, uuid, record.Status, string.IsNullOrEmpty(record.Certificate) ? "empty" : "present"); + + if (record.Status == (int)EndEntityStatus.GENERATED && !string.IsNullOrEmpty(record.Certificate)) + { + Logger.LogInformation("TryPollForIssuedCertAsync: '{Uuid}' issued after {Attempt} poll(s); returning cert directly.", uuid, attempt); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.GENERATED, + CARequestID = uuid, + Certificate = record.Certificate, + StatusMessage = $"Certificate issued and retrieved for order {uuid}." + }; + } + } + + // Don't sleep past the deadline. + if (DateTime.UtcNow.Add(DcvPollInterval) >= deadline) + break; + + await Task.Delay(DcvPollInterval); + } + + Logger.LogInformation("TryPollForIssuedCertAsync: '{Uuid}' not issued within {Seconds}s after {Attempts} attempt(s); falling back to pending.", + uuid, DcvPollTimeoutSeconds, attempt); + return null; + } + + /// + /// Publishes CNAME DCV records via the gateway framework's . + /// Per-record resolution: each record is routed to whichever DNS provider plugin the framework + /// resolves for its domain. No-op if the factory wasn't injected, the cert isn't using CNAME + /// validation, or the response contains no CNAME details. Failures are logged but never thrown — + /// manual publishing remains a fallback so the enrollment result is still returned to Keyfactor. + /// + private async Task TryPublishCnameDcvAsync(EnrollmentProductInfo productInfo, EnrollmentResult? enrollResult) + { + if (_validatorFactory == null) + { + Logger.LogTrace("TryPublishCnameDcvAsync: no IDomainValidatorFactory was injected, skipping auto-publish."); + return; + } + + if (enrollResult?.EnrollmentContext == null || enrollResult.EnrollmentContext.Count == 0) + { + Logger.LogTrace("TryPublishCnameDcvAsync: no CNAME entries in EnrollmentContext, skipping."); + return; + } + + var dcvMethod = productInfo?.ProductParameters != null + && productInfo.ProductParameters.TryGetValue(EnrollmentConfigConstants.DomainControlValidationMethod, out var m) + ? m + : null; + + if (string.IsNullOrEmpty(dcvMethod) || + !string.Equals(dcvMethod, "CNAME", StringComparison.OrdinalIgnoreCase)) + { + Logger.LogTrace("TryPublishCnameDcvAsync: DCV method '{Method}' is not CNAME, skipping auto-publish.", dcvMethod ?? "(null)"); + return; + } + + Logger.LogInformation( + "TryPublishCnameDcvAsync: attempting to publish {Count} CNAME record(s) via framework DNS providers (validation type '{Type}').", + enrollResult.EnrollmentContext.Count, DNS_VALIDATION_TYPE); + + var successCount = 0; + var failCount = 0; + var unresolvedCount = 0; + + foreach (var entry in enrollResult.EnrollmentContext) + { + var rawRecordName = entry.Key; + var rawCnameTarget = entry.Value; + + // CSC may also surface DCV email entries in this dictionary (key == value). Skip those. + if (string.Equals(rawRecordName, rawCnameTarget, StringComparison.OrdinalIgnoreCase)) + { + Logger.LogTrace("TryPublishCnameDcvAsync: skipping entry '{Key}' (looks like an email DCV passthrough, not a CNAME).", rawRecordName); + continue; + } + + // CSC returns FQDN-canonical names with trailing dots (e.g. "foo.example.com."). + // The framework's Domain Validation Configuration stores domain patterns without + // the trailing dot, so strip it before resolution and publishing or no provider + // will match (the framework will look up "*.example.com." which won't equal "*.example.com"). + var recordName = StripTrailingDot(rawRecordName); + var cnameTarget = StripTrailingDot(rawCnameTarget); + + if (recordName != rawRecordName) + Logger.LogTrace("TryPublishCnameDcvAsync: normalized record name '{Raw}' -> '{Normalized}'.", rawRecordName, recordName); + + IDomainValidator? validator; + try + { + validator = _validatorFactory.ResolveDomainValidator(recordName, DNS_VALIDATION_TYPE); + } + catch (Exception ex) + { + unresolvedCount++; + Logger.LogWarning(ex, "ResolveDomainValidator threw for '{Record}' (type '{Type}'): {Error}", + recordName, DNS_VALIDATION_TYPE, ex.Message); + continue; + } + + if (validator == null) + { + unresolvedCount++; + Logger.LogWarning( + "No DNS provider matched domain '{Record}' for validation type '{Type}'. Manual publish required for this record.", + recordName, DNS_VALIDATION_TYPE); + continue; + } + + try + { + Logger.LogTrace("StageValidation: '{Name}' -> '{Target}' via validator type '{ValType}'.", + recordName, cnameTarget, validator.GetValidationType()); + var result = await validator.StageValidation(recordName, cnameTarget, CancellationToken.None); + + if (result?.Success == true) + { + successCount++; + Logger.LogInformation("Published CNAME '{Name}' -> '{Target}' (status='{Status}').", + recordName, cnameTarget, result.Status ?? "(none)"); + } + else + { + failCount++; + Logger.LogWarning( + "StageValidation reported failure for CNAME '{Name}'. Status='{Status}', Error='{Error}'. Manual publish may be required.", + recordName, result?.Status ?? "(none)", result?.ErrorMessage ?? "(none)"); + } + } + catch (Exception ex) + { + failCount++; + Logger.LogError(ex, "StageValidation threw publishing CNAME '{Name}'. Manual publish may be required. {Error}", + recordName, ex.Message); + } + } + + Logger.LogInformation( + "TryPublishCnameDcvAsync: complete. Published={Published}, Failed={Failed}, Unresolved={Unresolved}", + successCount, failCount, unresolvedCount); + } + //Trying to fix leaf extraction private static readonly Regex PemBlock = new( "-----BEGIN CERTIFICATE-----\\s*(?[A-Za-z0-9+/=\\r\\n]+?)\\s*-----END CERTIFICATE-----", diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.csproj b/cscglobal-caplugin/CSCGlobalCAPlugin.csproj index 5118677..e5f5ff7 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.csproj +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.csproj @@ -3,7 +3,7 @@ true - net6.0;net8.0 + net10.0 Keyfactor.Extensions.CAPlugin.CSCGlobal true enable @@ -16,24 +16,16 @@ - - - + + - - - - - - - Always - \ No newline at end of file + diff --git a/cscglobal-caplugin/Client/CscGlobalClient.cs b/cscglobal-caplugin/Client/CscGlobalClient.cs index 0a5c7c5..032f535 100644 --- a/cscglobal-caplugin/Client/CscGlobalClient.cs +++ b/cscglobal-caplugin/Client/CscGlobalClient.cs @@ -23,13 +23,58 @@ public sealed class CscGlobalClient : ICscGlobalClient public CscGlobalClient(IAnyCAPluginConfigProvider config) { - Logger = LogHandler.GetClassLogger(); + Logger = LogHandler.GetClassLogger(); + + if (config == null) + throw new ArgumentNullException(nameof(config), "config cannot be null in CscGlobalClient constructor."); + + if (config.CAConnectionData == null) + throw new InvalidOperationException("CAConnectionData is null on config provider."); + + Logger.LogTrace("CscGlobalClient: CAConnectionData keys=[{Keys}]", string.Join(", ", config.CAConnectionData.Keys)); + 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(); + var rawUrl = config.CAConnectionData.ContainsKey(Constants.CscGlobalUrl) + ? config.CAConnectionData[Constants.CscGlobalUrl]?.ToString() + : null; + if (string.IsNullOrEmpty(rawUrl)) + { + Logger.LogError("CscGlobalClient: CscGlobalUrl is missing or empty in CAConnectionData."); + throw new InvalidOperationException("CscGlobalUrl is required but was not configured."); + } + + Logger.LogTrace("CscGlobalClient: BaseUrl='{BaseUrl}'", rawUrl); + BaseUrl = new Uri(rawUrl); + + ApiKey = config.CAConnectionData[Constants.CscGlobalApiKey]?.ToString(); + if (string.IsNullOrEmpty(ApiKey)) + { + Logger.LogError("CscGlobalClient: ApiKey is empty or null."); + throw new InvalidOperationException("ApiKey is required but was not configured."); + } + Logger.LogTrace("CscGlobalClient: ApiKey is present (length={Length}).", ApiKey.Length); + + if (!config.CAConnectionData.ContainsKey(Constants.BearerToken)) + { + Logger.LogError("CscGlobalClient: BearerToken key not found in CAConnectionData."); + throw new InvalidOperationException("BearerToken is required but was not configured."); + } + Authorization = config.CAConnectionData[Constants.BearerToken]?.ToString(); + if (string.IsNullOrEmpty(Authorization)) + { + Logger.LogError("CscGlobalClient: BearerToken is empty or null."); + throw new InvalidOperationException("BearerToken is required but was empty."); + } + Logger.LogTrace("CscGlobalClient: BearerToken is present (length={Length}).", Authorization.Length); + RestClient = ConfigureRestClient(); + Logger.LogTrace("CscGlobalClient: RestClient configured successfully."); + } + else + { + Logger.LogError("CscGlobalClient: ApiKey key '{Key}' not found in CAConnectionData. Client will not be functional.", Constants.CscGlobalApiKey); + throw new InvalidOperationException($"Required key '{Constants.CscGlobalApiKey}' not found in CAConnectionData."); } } @@ -41,25 +86,42 @@ public CscGlobalClient(IAnyCAPluginConfigProvider config) public async Task SubmitRegistrationAsync( RegistrationRequest registerRequest) { + Logger.LogTrace("SubmitRegistrationAsync: sending registration request..."); + if (registerRequest == null) + throw new ArgumentNullException(nameof(registerRequest)); + + var requestJson = JsonConvert.SerializeObject(registerRequest); + Logger.LogTrace("SubmitRegistrationAsync: request JSON: {Json}", requestJson); + using (var resp = await RestClient.PostAsync("/dbs/api/v2/tls/registration", new StringContent( - JsonConvert.SerializeObject(registerRequest), Encoding.ASCII, "application/json"))) + requestJson, Encoding.ASCII, "application/json"))) { - Logger.LogTrace(JsonConvert.SerializeObject(registerRequest)); + var rawBody = await resp.Content.ReadAsStringAsync(); + Logger.LogTrace("SubmitRegistrationAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); + Logger.LogTrace("SubmitRegistrationAsync: response body: {Body}", rawBody ?? "(null)"); + var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; - if (resp.StatusCode == HttpStatusCode.BadRequest) //Csc Sends Errors back in 400 Json Response + if (resp.StatusCode == HttpStatusCode.BadRequest) { - var errorResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync(), - settings); + Logger.LogWarning("SubmitRegistrationAsync: received 400 BadRequest."); + var errorResponse = JsonConvert.DeserializeObject(rawBody ?? "{}", settings); + Logger.LogTrace("SubmitRegistrationAsync: error description='{Desc}'", errorResponse?.Description ?? "(null)"); var response = new RegistrationResponse(); response.RegistrationError = errorResponse; response.Result = null; return response; } - var registrationResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync(), - settings); + if (!resp.IsSuccessStatusCode) + { + Logger.LogError("SubmitRegistrationAsync: unexpected HTTP {StatusCode}: {Body}", (int)resp.StatusCode, rawBody); + throw new HttpRequestException($"SubmitRegistrationAsync failed with HTTP {(int)resp.StatusCode}: {rawBody}"); + } + + var registrationResponse = JsonConvert.DeserializeObject(rawBody ?? "{}", settings); + Logger.LogTrace("SubmitRegistrationAsync: deserialized response. Result is {Null}, RegistrationError is {Null2}", + registrationResponse?.Result == null ? "null" : "present", + registrationResponse?.RegistrationError == null ? "null" : "present"); return registrationResponse; } } @@ -67,31 +129,42 @@ public async Task SubmitRegistrationAsync( public async Task SubmitRenewalAsync( RenewalRequest renewalRequest) { + Logger.LogTrace("SubmitRenewalAsync: sending renewal request..."); + if (renewalRequest == null) + throw new ArgumentNullException(nameof(renewalRequest)); + + var requestJson = JsonConvert.SerializeObject(renewalRequest); + Logger.LogTrace("SubmitRenewalAsync: request JSON: {Json}", requestJson); + using (var resp = await RestClient.PostAsync("/dbs/api/v2/tls/renewal", new StringContent( - JsonConvert.SerializeObject(renewalRequest), Encoding.ASCII, "application/json"))) + requestJson, Encoding.ASCII, "application/json"))) { - Logger.LogTrace(JsonConvert.SerializeObject(renewalRequest)); + var rawBody = await resp.Content.ReadAsStringAsync(); + Logger.LogTrace("SubmitRenewalAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); + Logger.LogTrace("SubmitRenewalAsync: response body: {Body}", rawBody ?? "(null)"); 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(); - Logger.LogTrace("Logging Error Response Raw"); - Logger.LogTrace(rawErrorResponse); - var errorResponse = - JsonConvert.DeserializeObject(rawErrorResponse, - settings); + if (resp.StatusCode == HttpStatusCode.BadRequest) + { + Logger.LogWarning("SubmitRenewalAsync: received 400 BadRequest."); + var errorResponse = JsonConvert.DeserializeObject(rawBody ?? "{}", settings); + Logger.LogTrace("SubmitRenewalAsync: error description='{Desc}'", errorResponse?.Description ?? "(null)"); var response = new RenewalResponse(); response.RegistrationError = errorResponse; response.Result = null; return response; } - var rawRenewResponse = await resp.Content.ReadAsStringAsync(); - Logger.LogTrace("Logging Success Response Raw"); - Logger.LogTrace(rawRenewResponse); - var renewalResponse = - JsonConvert.DeserializeObject(rawRenewResponse); + if (!resp.IsSuccessStatusCode) + { + Logger.LogError("SubmitRenewalAsync: unexpected HTTP {StatusCode}: {Body}", (int)resp.StatusCode, rawBody); + throw new HttpRequestException($"SubmitRenewalAsync failed with HTTP {(int)resp.StatusCode}: {rawBody}"); + } + + var renewalResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); + Logger.LogTrace("SubmitRenewalAsync: deserialized response. Result is {Null}, RegistrationError is {Null2}", + renewalResponse?.Result == null ? "null" : "present", + renewalResponse?.RegistrationError == null ? "null" : "present"); return renewalResponse; } } @@ -99,69 +172,145 @@ public async Task SubmitRenewalAsync( public async Task SubmitReissueAsync( ReissueRequest reissueRequest) { + Logger.LogTrace("SubmitReissueAsync: sending reissue request..."); + if (reissueRequest == null) + throw new ArgumentNullException(nameof(reissueRequest)); + + var requestJson = JsonConvert.SerializeObject(reissueRequest); + Logger.LogTrace("SubmitReissueAsync: request JSON: {Json}", requestJson); + using (var resp = await RestClient.PostAsync("/dbs/api/v2/tls/reissue", new StringContent( - JsonConvert.SerializeObject(reissueRequest), Encoding.ASCII, "application/json"))) + requestJson, Encoding.ASCII, "application/json"))) { - Logger.LogTrace(JsonConvert.SerializeObject(reissueRequest)); + var rawBody = await resp.Content.ReadAsStringAsync(); + Logger.LogTrace("SubmitReissueAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); + Logger.LogTrace("SubmitReissueAsync: response body: {Body}", rawBody ?? "(null)"); var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; - if (resp.StatusCode == HttpStatusCode.BadRequest) //Csc Sends Errors back in 400 Json Response + if (resp.StatusCode == HttpStatusCode.BadRequest) { - var errorResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync(), - settings); + Logger.LogWarning("SubmitReissueAsync: received 400 BadRequest."); + var errorResponse = JsonConvert.DeserializeObject(rawBody ?? "{}", settings); + Logger.LogTrace("SubmitReissueAsync: error description='{Desc}'", errorResponse?.Description ?? "(null)"); var response = new ReissueResponse(); response.RegistrationError = errorResponse; response.Result = null; return response; } - var reissueResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + if (!resp.IsSuccessStatusCode) + { + Logger.LogError("SubmitReissueAsync: unexpected HTTP {StatusCode}: {Body}", (int)resp.StatusCode, rawBody); + throw new HttpRequestException($"SubmitReissueAsync failed with HTTP {(int)resp.StatusCode}: {rawBody}"); + } + + var reissueResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); + Logger.LogTrace("SubmitReissueAsync: deserialized response. Result is {Null}, RegistrationError is {Null2}", + reissueResponse?.Result == null ? "null" : "present", + reissueResponse?.RegistrationError == null ? "null" : "present"); return reissueResponse; } } public async Task SubmitGetCertificateAsync(string certificateId) { + Logger.LogTrace("SubmitGetCertificateAsync: fetching certificate for id='{CertificateId}'", certificateId ?? "(null)"); + + if (string.IsNullOrEmpty(certificateId)) + throw new ArgumentNullException(nameof(certificateId), "certificateId cannot be null or empty."); + using (var resp = await RestClient.GetAsync($"/dbs/api/v2/tls/certificate/{certificateId}")) { - resp.EnsureSuccessStatusCode(); - var getCertificateResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + var rawBody = await resp.Content.ReadAsStringAsync(); + Logger.LogTrace("SubmitGetCertificateAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); + + if (!resp.IsSuccessStatusCode) + { + Logger.LogError("SubmitGetCertificateAsync: HTTP {StatusCode} for certificateId='{CertificateId}': {Body}", + (int)resp.StatusCode, certificateId, rawBody); + resp.EnsureSuccessStatusCode(); // will throw + } + + Logger.LogTrace("SubmitGetCertificateAsync: response body: {Body}", rawBody ?? "(null)"); + var getCertificateResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); + Logger.LogTrace("SubmitGetCertificateAsync: deserialized. Status='{Status}', OrderDate='{OrderDate}', Certificate is {Null}", + getCertificateResponse?.Status ?? "(null)", + getCertificateResponse?.OrderDate ?? "(null)", + string.IsNullOrEmpty(getCertificateResponse?.Certificate) ? "empty/null" : "present"); return getCertificateResponse; } } public async Task> SubmitGetCustomFields() { + Logger.LogTrace("SubmitGetCustomFields: fetching custom fields..."); + using (var resp = await RestClient.GetAsync("/dbs/api/v2/admin/customfields")) { - resp.EnsureSuccessStatusCode(); - var getCustomFieldsResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + var rawBody = await resp.Content.ReadAsStringAsync(); + Logger.LogTrace("SubmitGetCustomFields: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); + + if (!resp.IsSuccessStatusCode) + { + Logger.LogError("SubmitGetCustomFields: HTTP {StatusCode}: {Body}", (int)resp.StatusCode, rawBody); + resp.EnsureSuccessStatusCode(); // will throw + } + + Logger.LogTrace("SubmitGetCustomFields: response body: {Body}", rawBody ?? "(null)"); + var getCustomFieldsResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); + + if (getCustomFieldsResponse == null) + { + Logger.LogWarning("SubmitGetCustomFields: deserialized response is null, returning empty list."); + return new List(); + } + + if (getCustomFieldsResponse.CustomFields == null) + { + Logger.LogWarning("SubmitGetCustomFields: CustomFields property is null, returning empty list."); + return new List(); + } + + Logger.LogTrace("SubmitGetCustomFields: received {Count} custom fields.", getCustomFieldsResponse.CustomFields.Count); return getCustomFieldsResponse.CustomFields; } } public async Task SubmitRevokeCertificateAsync(string uuId) { + Logger.LogTrace("SubmitRevokeCertificateAsync: revoking certificate UUID='{Uuid}'", uuId ?? "(null)"); + + if (string.IsNullOrEmpty(uuId)) + throw new ArgumentNullException(nameof(uuId), "uuId cannot be null or empty."); + using (var resp = await RestClient.PutAsync($"/dbs/api/v2/tls/revoke/{uuId}", new StringContent(""))) { + var rawBody = await resp.Content.ReadAsStringAsync(); + Logger.LogTrace("SubmitRevokeCertificateAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); + Logger.LogTrace("SubmitRevokeCertificateAsync: response body: {Body}", rawBody ?? "(null)"); + var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; - if (resp.StatusCode == HttpStatusCode.BadRequest) //Csc Sends Errors back in 400 Json Response + if (resp.StatusCode == HttpStatusCode.BadRequest) { - var errorResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync(), - settings); + Logger.LogWarning("SubmitRevokeCertificateAsync: received 400 BadRequest for UUID='{Uuid}'.", uuId); + var errorResponse = JsonConvert.DeserializeObject(rawBody ?? "{}", settings); + Logger.LogTrace("SubmitRevokeCertificateAsync: error description='{Desc}'", errorResponse?.Description ?? "(null)"); var response = new RevokeResponse(); response.RegistrationError = errorResponse; response.RevokeSuccess = null; return response; } - var getRevokeResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + if (!resp.IsSuccessStatusCode) + { + Logger.LogError("SubmitRevokeCertificateAsync: unexpected HTTP {StatusCode} for UUID='{Uuid}': {Body}", (int)resp.StatusCode, uuId, rawBody); + throw new HttpRequestException($"SubmitRevokeCertificateAsync failed with HTTP {(int)resp.StatusCode}: {rawBody}"); + } + + var getRevokeResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); + Logger.LogTrace("SubmitRevokeCertificateAsync: deserialized. RevokeSuccess is {Null}, RegistrationError is {Null2}", + getRevokeResponse?.RevokeSuccess == null ? "null" : "present", + getRevokeResponse?.RegistrationError == null ? "null" : "present"); return getRevokeResponse; } } @@ -169,23 +318,37 @@ public async Task SubmitRevokeCertificateAsync(string uuId) public async Task SubmitCertificateListRequestAsync(string? dateFilter = null) { Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace("SubmitCertificateListRequestAsync: dateFilter='{DateFilter}'", dateFilter ?? "(null)"); + var filterQuery = "filter=status=in=(ACTIVE,REVOKED)"; if (!string.IsNullOrEmpty(dateFilter)) { filterQuery += $";effectiveDate=ge={dateFilter}"; } - Logger.LogTrace($"Certificate list filter query: {filterQuery}"); + Logger.LogTrace("SubmitCertificateListRequestAsync: filter query: {FilterQuery}", filterQuery); + var resp = RestClient.GetAsync($"/dbs/api/v2/tls/certificate?{filterQuery}").Result; + var rawBody = await resp.Content.ReadAsStringAsync(); + Logger.LogTrace("SubmitCertificateListRequestAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); if (!resp.IsSuccessStatusCode) { - var responseMessage = resp.Content.ReadAsStringAsync().Result; Logger.LogError( - $"Failed Request to Keyfactor. Retrying request. Status Code {resp.StatusCode} | Message: {responseMessage}"); + "SubmitCertificateListRequestAsync: failed request. StatusCode={StatusCode}, Body={Body}", + (int)resp.StatusCode, rawBody); + } + + var certificateListResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); + + if (certificateListResponse == null) + { + Logger.LogWarning("SubmitCertificateListRequestAsync: deserialized response is null."); + return new CertificateListResponse(); } - var certificateListResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + Logger.LogTrace("SubmitCertificateListRequestAsync: Results count={Count}", + certificateListResponse.Results?.Count ?? 0); + Logger.MethodExit(LogLevel.Debug); return certificateListResponse; } diff --git a/cscglobal-caplugin/Constants.cs b/cscglobal-caplugin/Constants.cs index 4d6b4da..dc10866 100644 --- a/cscglobal-caplugin/Constants.cs +++ b/cscglobal-caplugin/Constants.cs @@ -9,12 +9,14 @@ 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"; public static string DefaultPageSize = "DefaultPageSize"; - public static string TemplateSync = "TemplateSync"; public static string SyncFilterDays = "SyncFilterDays"; + public static string RenewalWindowDays = "RenewalWindowDays"; + public static string DcvPollTimeoutSeconds = "DcvPollTimeoutSeconds"; } public class ProductIDs @@ -26,8 +28,8 @@ public class ProductIDs "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 Trusted Secure Domain Validated Wildcard SSL", + "CSC Trusted Secure Domain Validated UC Certificate" }; } diff --git a/cscglobal-caplugin/FlowLogger.cs b/cscglobal-caplugin/FlowLogger.cs new file mode 100644 index 0000000..5696fcd --- /dev/null +++ b/cscglobal-caplugin/FlowLogger.cs @@ -0,0 +1,241 @@ +// 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; + +public enum FlowStepStatus +{ + Success, + Failed, + Skipped, + InProgress +} + +public class FlowStep +{ + public string Name { get; set; } + public FlowStepStatus Status { get; set; } + public string Detail { get; set; } + public long ElapsedMs { get; set; } + public List Children { get; } = new(); +} + +/// +/// Tracks high-level operation flow and renders a visual step diagram to Trace logs. +/// Usage: +/// using var flow = new FlowLogger(logger, "Enroll-New"); +/// flow.Step("ParseCSR"); +/// flow.Step("ValidateCSR", () => { ... }); +/// flow.Fail("CreateOrder", "API returned 400"); +/// // flow renders automatically on Dispose +/// +public sealed class FlowLogger : IDisposable +{ + private readonly ILogger _logger; + private readonly string _flowName; + private readonly Stopwatch _totalTimer; + private readonly List _steps = new(); + private FlowStep _currentParent; + private bool _disposed; + + public FlowLogger(ILogger logger, string flowName) + { + _logger = logger; + _flowName = flowName; + _totalTimer = Stopwatch.StartNew(); + _logger.LogTrace("===== FLOW START: {FlowName} =====", _flowName); + } + + /// Record a completed step. + public FlowLogger Step(string name, string detail = null) + { + var step = new FlowStep { Name = name, Status = FlowStepStatus.Success, Detail = detail }; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... OK{Detail}", + _flowName, name, detail != null ? $" ({detail})" : ""); + return this; + } + + /// Record a step that executes an action and times it. + public FlowLogger Step(string name, Action action, string detail = null) + { + var sw = Stopwatch.StartNew(); + var step = new FlowStep { Name = name, Detail = detail }; + try + { + _logger.LogTrace(" [{FlowName}] {StepName} ...", _flowName, name); + action(); + sw.Stop(); + step.Status = FlowStepStatus.Success; + step.ElapsedMs = sw.ElapsedMilliseconds; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... OK ({Elapsed}ms){Detail}", + _flowName, name, sw.ElapsedMilliseconds, detail != null ? $" {detail}" : ""); + } + catch (Exception ex) + { + sw.Stop(); + step.Status = FlowStepStatus.Failed; + step.ElapsedMs = sw.ElapsedMilliseconds; + step.Detail = ex.Message; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... FAILED ({Elapsed}ms): {Error}", + _flowName, name, sw.ElapsedMilliseconds, ex.Message); + throw; + } + return this; + } + + /// Record an async step that executes and times it. + public async Task StepAsync(string name, Func action, string detail = null) + { + var sw = Stopwatch.StartNew(); + var step = new FlowStep { Name = name, Detail = detail }; + try + { + _logger.LogTrace(" [{FlowName}] {StepName} ...", _flowName, name); + await action(); + sw.Stop(); + step.Status = FlowStepStatus.Success; + step.ElapsedMs = sw.ElapsedMilliseconds; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... OK ({Elapsed}ms){Detail}", + _flowName, name, sw.ElapsedMilliseconds, detail != null ? $" {detail}" : ""); + } + catch (Exception ex) + { + sw.Stop(); + step.Status = FlowStepStatus.Failed; + step.ElapsedMs = sw.ElapsedMilliseconds; + step.Detail = ex.Message; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... FAILED ({Elapsed}ms): {Error}", + _flowName, name, sw.ElapsedMilliseconds, ex.Message); + throw; + } + return this; + } + + /// Record a failed step without throwing. + public FlowLogger Fail(string name, string reason = null) + { + var step = new FlowStep { Name = name, Status = FlowStepStatus.Failed, Detail = reason }; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... FAILED{Reason}", + _flowName, name, reason != null ? $": {reason}" : ""); + return this; + } + + /// Record a skipped step. + public FlowLogger Skip(string name, string reason = null) + { + var step = new FlowStep { Name = name, Status = FlowStepStatus.Skipped, Detail = reason }; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... SKIPPED{Reason}", + _flowName, name, reason != null ? $": {reason}" : ""); + return this; + } + + /// Start a branch (group of child steps). + public FlowLogger Branch(string name) + { + var step = new FlowStep { Name = name, Status = FlowStepStatus.InProgress }; + AddStep(step); + _currentParent = step; + _logger.LogTrace(" [{FlowName}] >> Branch: {BranchName}", _flowName, name); + return this; + } + + /// End the current branch. + public FlowLogger EndBranch() + { + _currentParent = null; + return this; + } + + private void AddStep(FlowStep step) + { + if (_currentParent != null) + _currentParent.Children.Add(step); + else + _steps.Add(step); + } + + /// Render the visual flow diagram to Trace log. + private string RenderFlow() + { + var sb = new StringBuilder(); + sb.AppendLine(); + sb.AppendLine($" ===== FLOW: {_flowName} ({_totalTimer.ElapsedMilliseconds}ms total) ====="); + sb.AppendLine(); + + for (var i = 0; i < _steps.Count; i++) + { + var step = _steps[i]; + var icon = GetStatusIcon(step.Status); + var elapsed = step.ElapsedMs > 0 ? $" ({step.ElapsedMs}ms)" : ""; + var detail = !string.IsNullOrEmpty(step.Detail) ? $" [{step.Detail}]" : ""; + + sb.AppendLine($" {icon} {step.Name}{elapsed}{detail}"); + + // Render children (branch) + if (step.Children.Count > 0) + { + for (var j = 0; j < step.Children.Count; j++) + { + var child = step.Children[j]; + var childIcon = GetStatusIcon(child.Status); + var childElapsed = child.ElapsedMs > 0 ? $" ({child.ElapsedMs}ms)" : ""; + var childDetail = !string.IsNullOrEmpty(child.Detail) ? $" [{child.Detail}]" : ""; + var connector = j < step.Children.Count - 1 ? "| " : " "; + sb.AppendLine($" |"); + sb.AppendLine($" +-- {childIcon} {child.Name}{childElapsed}{childDetail}"); + } + } + + // Connector between top-level steps + if (i < _steps.Count - 1) + { + sb.AppendLine(" |"); + sb.AppendLine(" v"); + } + } + + sb.AppendLine(); + + // Final status line + var finalStatus = _steps.Count > 0 && _steps.Last().Status == FlowStepStatus.Failed + ? "FAILED" : _steps.Any(s => s.Status == FlowStepStatus.Failed) ? "PARTIAL FAILURE" : "SUCCESS"; + sb.AppendLine($" ===== FLOW RESULT: {finalStatus} ====="); + + return sb.ToString(); + } + + private static string GetStatusIcon(FlowStepStatus status) + { + return status switch + { + FlowStepStatus.Success => "[OK]", + FlowStepStatus.Failed => "[FAIL]", + FlowStepStatus.Skipped => "[SKIP]", + FlowStepStatus.InProgress => "[...]", + _ => "[?]" + }; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _totalTimer.Stop(); + _logger.LogTrace(RenderFlow()); + } +} diff --git a/cscglobal-caplugin/RequestManager.cs b/cscglobal-caplugin/RequestManager.cs index 776902c..faf02ac 100644 --- a/cscglobal-caplugin/RequestManager.cs +++ b/cscglobal-caplugin/RequestManager.cs @@ -10,19 +10,48 @@ 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)); private List GetCustomFields(EnrollmentProductInfo productInfo, List customFields) { + Logger.LogTrace("GetCustomFields: productInfo is {Null}, customFields count={Count}", + productInfo == null ? "NULL" : "present", + customFields?.Count ?? 0); + var customFieldList = new List(); + if (customFields == null || productInfo?.ProductParameters == null) + { + Logger.LogTrace("GetCustomFields: returning empty list (null customFields or ProductParameters)."); + return customFieldList; + } + foreach (var field in customFields) + { + if (field == null) + { + Logger.LogTrace("GetCustomFields: skipping null field entry."); + continue; + } + + Logger.LogTrace("GetCustomFields: checking field Label='{Label}', Mandatory={Mandatory}", + field.Label ?? "(null)", field.Mandatory); + + if (string.IsNullOrEmpty(field.Label)) + { + Logger.LogTrace("GetCustomFields: skipping field with null/empty label."); + continue; + } + if (productInfo.ProductParameters.ContainsKey(field.Label)) { var newField = new CustomField @@ -30,32 +59,60 @@ private List GetCustomFields(EnrollmentProductInfo productInfo, Lis Name = field.Label, Value = productInfo.ProductParameters[field.Label] }; + Logger.LogTrace("GetCustomFields: matched field '{Label}' = '{Value}'", field.Label, newField.Value ?? "(null)"); customFieldList.Add(newField); } else if (field.Mandatory) { + Logger.LogError("GetCustomFields: mandatory field '{Label}' was not supplied. Available keys: [{Keys}]", + field.Label, string.Join(", ", productInfo.ProductParameters.Keys)); throw new Exception( $"Custom field {field.Label} is marked as mandatory, but was not supplied in the request."); } + else + { + Logger.LogTrace("GetCustomFields: optional field '{Label}' not found in ProductParameters, skipping.", field.Label); + } + } + Logger.LogTrace("GetCustomFields: returning {Count} custom fields.", customFieldList.Count); return customFieldList; } public EnrollmentResult GetRenewResponse(RenewalResponse renewResponse) { + Logger.LogTrace("GetRenewResponse: renewResponse is {Null}", renewResponse == null ? "NULL" : "present"); + + if (renewResponse == null) + { + Logger.LogError("GetRenewResponse: renewResponse is null."); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "Renewal failed: received null response from CSC." + }; + } + if (renewResponse.RegistrationError != null) + { + Logger.LogWarning("GetRenewResponse: RegistrationError present. Description='{Desc}'", + renewResponse.RegistrationError.Description ?? "(null)"); return new EnrollmentResult { - Status = (int)EndEntityStatus.FAILED, //failure - CARequestID = renewResponse?.Result?.Status?.Uuid, - StatusMessage = renewResponse.RegistrationError.Description + Status = (int)EndEntityStatus.FAILED, + CARequestID = renewResponse.Result?.Status?.Uuid, + StatusMessage = renewResponse.RegistrationError.Description ?? "Renewal failed with unknown error." }; + } + var commonName = renewResponse.Result?.CommonName ?? "(unknown)"; + var uuid = renewResponse.Result?.Status?.Uuid; + Logger.LogTrace("GetRenewResponse: renewal succeeded for CommonName='{CommonName}', UUID='{Uuid}'", commonName, uuid ?? "(null)"); return new EnrollmentResult { - Status = (int)EndEntityStatus.GENERATED, //success - - StatusMessage = $"Renewal Successfully Completed For {renewResponse.Result.CommonName}" + Status = (int)EndEntityStatus.EXTERNALVALIDATION, + CARequestID = uuid, + StatusMessage = $"Renewal Successfully Submitted For {commonName}. Certificate will be available after next sync." }; } @@ -64,77 +121,210 @@ public EnrollmentResult GetEnrollmentResult( IRegistrationResponse registrationResponse) { + Logger.LogTrace("GetEnrollmentResult: registrationResponse is {Null}", registrationResponse == null ? "NULL" : "present"); + + if (registrationResponse == null) + { + Logger.LogError("GetEnrollmentResult: registrationResponse is null."); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "Enrollment failed: received null response from CSC." + }; + } + if (registrationResponse.RegistrationError != null) + { + Logger.LogWarning("GetEnrollmentResult: RegistrationError present. Description='{Desc}'", + registrationResponse.RegistrationError.Description ?? "(null)"); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = registrationResponse.RegistrationError.Description ?? "Enrollment failed with unknown error." + }; + } + + if (registrationResponse.Result == null) + { + Logger.LogError("GetEnrollmentResult: Result is null but no RegistrationError present."); return new EnrollmentResult { - Status = (int)EndEntityStatus.FAILED, //failure - StatusMessage = registrationResponse.RegistrationError.Description + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "Enrollment failed: response Result is null." }; + } var cnames = new Dictionary(); if (registrationResponse.Result.DcvDetails != null && registrationResponse.Result.DcvDetails.Count > 0) + { + Logger.LogTrace("GetEnrollmentResult: processing {Count} DcvDetails.", registrationResponse.Result.DcvDetails.Count); foreach (var dcv in registrationResponse.Result.DcvDetails) { + if (dcv == null) + { + Logger.LogTrace("GetEnrollmentResult: skipping null DcvDetail."); + continue; + } + if (dcv.CName != null && !string.IsNullOrEmpty(dcv.CName.Name) && !string.IsNullOrEmpty(dcv.CName.Value)) { - cnames.Add(dcv.CName.Name, dcv.CName.Value); + if (!cnames.ContainsKey(dcv.CName.Name)) + { + Logger.LogTrace("GetEnrollmentResult: adding CName '{Name}'='{Value}'", dcv.CName.Name, dcv.CName.Value); + cnames.Add(dcv.CName.Name, dcv.CName.Value); + } + else + { + Logger.LogTrace("GetEnrollmentResult: duplicate CName key '{Name}', skipping.", dcv.CName.Name); + } } if (!string.IsNullOrEmpty(dcv.Email) && !cnames.ContainsKey(dcv.Email)) { - cnames.Add(dcv.Email, dcv.Email); + if (!cnames.ContainsKey(dcv.Email)) + { + Logger.LogTrace("GetEnrollmentResult: adding DCV email '{Email}'", dcv.Email); + cnames.Add(dcv.Email, dcv.Email); + } + else + { + Logger.LogTrace("GetEnrollmentResult: duplicate email key '{Email}', skipping.", dcv.Email); + } } } - + } + else + { + Logger.LogTrace("GetEnrollmentResult: no DcvDetails to process."); + } + + var uuid = registrationResponse.Result.Status?.Uuid; + var commonName = registrationResponse.Result.CommonName ?? "(unknown)"; + Logger.LogTrace("GetEnrollmentResult: success. UUID='{Uuid}', CommonName='{CommonName}', cnames count={Count}", + uuid ?? "(null)", commonName, cnames.Count); + return new EnrollmentResult { - Status = (int)EndEntityStatus.EXTERNALVALIDATION, //success - CARequestID = registrationResponse.Result.Status.Uuid, + Status = (int)EndEntityStatus.EXTERNALVALIDATION, + CARequestID = uuid, StatusMessage = - $"Order Successfully Created With Order Number {registrationResponse.Result.CommonName}", + $"Order Successfully Created With Order Number {commonName}", EnrollmentContext = cnames.Count > 0 ? cnames : null }; } public int GetRevokeResult(IRevokeResponse revokeResponse) { + Logger.LogTrace("GetRevokeResult: revokeResponse is {Null}", revokeResponse == null ? "NULL" : "present"); + + if (revokeResponse == null) + { + Logger.LogError("GetRevokeResult: revokeResponse is null, returning FAILED."); + return (int)EndEntityStatus.FAILED; + } + if (revokeResponse.RegistrationError != null) + { + Logger.LogWarning("GetRevokeResult: RegistrationError present. Description='{Desc}'", + revokeResponse.RegistrationError.Description ?? "(null)"); return (int)EndEntityStatus.FAILED; + } + Logger.LogTrace("GetRevokeResult: returning REVOKED."); return (int)EndEntityStatus.REVOKED; } public EnrollmentResult GetReIssueResult(IReissueResponse reissueResponse) { + Logger.LogTrace("GetReIssueResult: reissueResponse is {Null}", reissueResponse == null ? "NULL" : "present"); + + if (reissueResponse == null) + { + Logger.LogError("GetReIssueResult: reissueResponse is null."); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "Reissue failed: received null response from CSC." + }; + } + if (reissueResponse.RegistrationError != null) + { + Logger.LogWarning("GetReIssueResult: RegistrationError present. Description='{Desc}'", + reissueResponse.RegistrationError.Description ?? "(null)"); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = reissueResponse.RegistrationError.Description ?? "Reissue failed with unknown error." + }; + } + + if (reissueResponse.Result == null) + { + Logger.LogError("GetReIssueResult: Result is null but no RegistrationError present."); return new EnrollmentResult { - Status = (int)EndEntityStatus.FAILED, //failure - StatusMessage = reissueResponse.RegistrationError.Description + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "Reissue failed: response Result is null." }; + } + + var uuid = reissueResponse.Result.Status?.Uuid; + var commonName = reissueResponse.Result.CommonName ?? "(unknown)"; + Logger.LogTrace("GetReIssueResult: success. UUID='{Uuid}', CommonName='{CommonName}'", uuid ?? "(null)", commonName); return new EnrollmentResult { - Status = (int)EndEntityStatus.GENERATED, //success - CARequestID = reissueResponse.Result.Status.Uuid, - StatusMessage = $"Reissue Successfully Completed For {reissueResponse.Result.CommonName}" + Status = (int)EndEntityStatus.EXTERNALVALIDATION, + CARequestID = uuid, + StatusMessage = $"Reissue Successfully Submitted For {commonName}. Certificate will be available after next sync." }; } public DomainControlValidation GetDomainControlValidation(string methodType, string[] emailAddress, string domainName) { + Logger.LogTrace("GetDomainControlValidation(array): methodType='{MethodType}', domainName='{DomainName}', emailAddress count={Count}", + methodType ?? "(null)", domainName ?? "(null)", emailAddress?.Length ?? 0); + + if (emailAddress == null || emailAddress.Length == 0) + { + Logger.LogTrace("GetDomainControlValidation(array): no email addresses provided, returning null."); + return null; + } + foreach (var address in emailAddress) { - var email = new MailAddress(address); - if (domainName.Contains(email.Host.Split('.')[0])) - return new DomainControlValidation + if (string.IsNullOrEmpty(address)) + { + Logger.LogTrace("GetDomainControlValidation(array): skipping null/empty email address."); + continue; + } + + try + { + var email = new MailAddress(address); + var hostPart = email.Host?.Split('.')[0] ?? ""; + Logger.LogTrace("GetDomainControlValidation(array): checking email='{Email}', hostPart='{HostPart}' against domain='{Domain}'", + address, hostPart, domainName); + + if (!string.IsNullOrEmpty(domainName) && domainName.Contains(hostPart)) { - MethodType = methodType, - EmailAddress = email.ToString() - }; + Logger.LogTrace("GetDomainControlValidation(array): matched! Returning email='{Email}'", email.ToString()); + return new DomainControlValidation + { + MethodType = methodType, + EmailAddress = email.ToString() + }; + } + } + catch (FormatException fex) + { + Logger.LogWarning("GetDomainControlValidation(array): invalid email address '{Address}': {Message}", address, fex.Message); + } } + Logger.LogTrace("GetDomainControlValidation(array): no matching email found, returning null."); return null; } @@ -150,105 +340,190 @@ public DomainControlValidation GetDomainControlValidation(string methodType, str public RegistrationRequest GetRegistrationRequest(EnrollmentProductInfo productInfo, string csr, Dictionary sans, List customFields) { - //var cert = "-----BEGIN CERTIFICATE REQUEST-----\r\n"; - var cert = Pemify(csr); - //cert = cert + "\r\n-----END CERTIFICATE REQUEST-----"; + Logger.LogTrace("GetRegistrationRequest: building registration request. ProductID='{ProductId}'", productInfo?.ProductID ?? "(null)"); + if (productInfo?.ProductParameters == null) + throw new ArgumentNullException(nameof(productInfo), "productInfo or ProductParameters cannot be null."); + if (string.IsNullOrEmpty(csr)) + throw new ArgumentNullException(nameof(csr), "CSR cannot be null or empty."); + var cert = Pemify(csr); 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"]; + Logger.LogTrace("GetRegistrationRequest: CSR encoded, length={Length}", encodedString.Length); + + var commonNameValidationEmail = productInfo.ProductParameters.ContainsKey("CN DCV Email") + ? productInfo.ProductParameters["CN DCV Email"] : null; + var methodType = productInfo.ProductParameters.ContainsKey("Domain Control Validation Method") + ? productInfo.ProductParameters["Domain Control Validation Method"] : null; var certificateType = GetCertificateType(productInfo.ProductID); + Logger.LogTrace("GetRegistrationRequest: cnDcvEmail='{Email}', methodType='{Method}', certType='{CertType}'", + commonNameValidationEmail ?? "(null)", methodType ?? "(null)", certificateType); + return new RegistrationRequest { Csr = encodedString, - ServerSoftware = "-1", //Just default to other, user does not need to fill this in + 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 = productInfo.ProductParameters.ContainsKey("Term") ? productInfo.ProductParameters["Term"] : null, + ApplicantFirstName = productInfo.ProductParameters.ContainsKey("Applicant First Name") ? productInfo.ProductParameters["Applicant First Name"] : null, + ApplicantLastName = productInfo.ProductParameters.ContainsKey("Applicant Last Name") ? productInfo.ProductParameters["Applicant Last Name"] : null, + ApplicantEmailAddress = productInfo.ProductParameters.ContainsKey("Applicant Email Address") ? productInfo.ProductParameters["Applicant Email Address"] : null, + ApplicantPhoneNumber = productInfo.ProductParameters.ContainsKey("Applicant Phone") ? productInfo.ProductParameters["Applicant Phone"] : null, DomainControlValidation = GetDomainControlValidation(methodType, commonNameValidationEmail), Notifications = GetNotifications(productInfo), - OrganizationContact = productInfo.ProductParameters["Organization Contact"], - BusinessUnit = productInfo.ProductParameters["Business Unit"], - ShowPrice = true, //User should not have to fill this out + OrganizationContact = productInfo.ProductParameters.ContainsKey("Organization Contact") ? productInfo.ProductParameters["Organization Contact"] : null, + BusinessUnit = productInfo.ProductParameters.ContainsKey("Business Unit") ? productInfo.ProductParameters["Business Unit"] : null, + ShowPrice = true, CustomFields = GetCustomFields(productInfo, customFields), SubjectAlternativeNames = certificateType == "2" ? GetSubjectAlternativeNames(productInfo, sans) : null, EvCertificateDetails = certificateType == "3" ? GetEvCertificateDetails(productInfo) : null }; } + // Maps Keyfactor product ID -> CSC API certificate type code (used for enrollment requests) + private static readonly Dictionary ProductIdToCodeMap = new(StringComparer.OrdinalIgnoreCase) + { + ["CSC TrustedSecure Premium Certificate"] = "0", + ["CSC TrustedSecure Premium Wildcard Certificate"] = "1", + ["CSC TrustedSecure UC Certificate"] = "2", + ["CSC TrustedSecure EV Certificate"] = "3", + ["CSC TrustedSecure Domain Validated SSL"] = "4", + ["CSC Trusted Secure Domain Validated SSL"] = "4", + ["CSC Trusted Secure Domain Validated Wildcard SSL"] = "5", + ["CSC Trusted Secure Domain Validated UC Certificate"] = "6", + }; + + // Reverse map: CSC API certificateType string -> Keyfactor product ID (used during sync) + // Note: CSC naming is inconsistent — first 4 types use "TrustedSecure" (no space), + // DV Wildcard and DV UC use "Trusted Secure" (with space), + // but CSC API returns DV SSL as "CSC Trusted Secure Domain Validated SSL" (with space) + // while the product ID is "CSC TrustedSecure Domain Validated SSL" (no space). + private static readonly Dictionary CodeToProductIdMap = new(StringComparer.OrdinalIgnoreCase) + { + // Premium + ["0"] = "CSC TrustedSecure Premium Certificate", + ["CSC TrustedSecure Premium Certificate"] = "CSC TrustedSecure Premium Certificate", + ["CSC Trusted Secure Premium Certificate"] = "CSC TrustedSecure Premium Certificate", + // Premium Wildcard + ["1"] = "CSC TrustedSecure Premium Wildcard Certificate", + ["CSC TrustedSecure Premium Wildcard Certificate"] = "CSC TrustedSecure Premium Wildcard Certificate", + ["CSC Trusted Secure Premium Wildcard Certificate"] = "CSC TrustedSecure Premium Wildcard Certificate", + // UC + ["2"] = "CSC TrustedSecure UC Certificate", + ["CSC TrustedSecure UC Certificate"] = "CSC TrustedSecure UC Certificate", + ["CSC Trusted Secure UC Certificate"] = "CSC TrustedSecure UC Certificate", + // EV + ["3"] = "CSC TrustedSecure EV Certificate", + ["CSC TrustedSecure EV Certificate"] = "CSC TrustedSecure EV Certificate", + ["CSC Trusted Secure EV Certificate"] = "CSC TrustedSecure EV Certificate", + // DV SSL — product ID has no space, but CSC API returns with space + ["4"] = "CSC TrustedSecure Domain Validated SSL", + ["CSC TrustedSecure Domain Validated SSL"] = "CSC TrustedSecure Domain Validated SSL", + ["CSC Trusted Secure Domain Validated SSL"] = "CSC TrustedSecure Domain Validated SSL", + // DV Wildcard — product ID has space (matches CSC API) + ["5"] = "CSC Trusted Secure Domain Validated Wildcard SSL", + ["CSC Trusted Secure Domain Validated Wildcard SSL"] = "CSC Trusted Secure Domain Validated Wildcard SSL", + ["CSC TrustedSecure Domain Validated Wildcard SSL"] = "CSC Trusted Secure Domain Validated Wildcard SSL", + // DV UC — product ID has space (matches CSC API) + ["6"] = "CSC Trusted Secure Domain Validated UC Certificate", + ["CSC Trusted Secure Domain Validated UC Certificate"] = "CSC Trusted Secure Domain Validated UC Certificate", + ["CSC TrustedSecure Domain Validated UC Certificate"] = "CSC Trusted Secure Domain Validated UC Certificate", + }; + private string GetCertificateType(string productId) { - switch (productId) + Logger.LogTrace("GetCertificateType: productId='{ProductId}'", productId ?? "(null)"); + if (!string.IsNullOrEmpty(productId) && ProductIdToCodeMap.TryGetValue(productId, out var code)) { - case "CSC TrustedSecure Premium Certificate": - return "0"; - case "CSC TrustedSecure EV Certificate": - return "3"; - case "CSC TrustedSecure UC Certificate": - return "2"; - case "CSC TrustedSecure Premium Wildcard Certificate": - 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": - return "4"; - case "CSC TrustedSecure Domain Validated Wildcard SSL": - return "5"; - case "CSC TrustedSecure Domain Validated UC Certificate": - return "6"; + Logger.LogTrace("GetCertificateType: mapped '{ProductId}' -> '{Code}'", productId, code); + return code; } - + Logger.LogWarning("GetCertificateType: no mapping found for '{ProductId}', returning -1.", productId); return "-1"; } + /// + /// Maps a CSC API certificateType value back to a Keyfactor product ID. + /// Handles numeric codes, descriptive strings, and passthrough of already-correct values. + /// + public string MapCertificateTypeToProductId(string cscCertificateType) + { + Logger.LogTrace("MapCertificateTypeToProductId: input='{CscCertType}'", cscCertificateType ?? "(null)"); + if (!string.IsNullOrEmpty(cscCertificateType) && CodeToProductIdMap.TryGetValue(cscCertificateType, out var productId)) + { + Logger.LogTrace("MapCertificateTypeToProductId: mapped '{CscCertType}' -> '{ProductId}'", cscCertificateType, productId); + return productId; + } + Logger.LogWarning("MapCertificateTypeToProductId: no mapping for '{CscCertType}', passing through as-is.", cscCertificateType); + return cscCertificateType ?? "CscGlobal"; + } + public Notifications GetNotifications(EnrollmentProductInfo productInfo) { + Logger.LogTrace("GetNotifications: building notifications."); + var emailsRaw = productInfo?.ProductParameters != null + && productInfo.ProductParameters.ContainsKey("Notification Email(s) Comma Separated") + ? productInfo.ProductParameters["Notification Email(s) Comma Separated"] + : null; + + Logger.LogTrace("GetNotifications: raw notification emails='{Emails}'", emailsRaw ?? "(null)"); + + var emailList = !string.IsNullOrEmpty(emailsRaw) + ? emailsRaw.Split(',').Where(e => !string.IsNullOrWhiteSpace(e)).ToList() + : new List(); + + Logger.LogTrace("GetNotifications: parsed {Count} notification emails.", emailList.Count); + return new Notifications { Enabled = true, - AdditionalNotificationEmails = productInfo.ProductParameters["Notification Email(s) Comma Separated"] - .Split(',').ToList() + AdditionalNotificationEmails = emailList }; } public RenewalRequest GetRenewalRequest(EnrollmentProductInfo productInfo, string uUId, string csr, Dictionary sans, List customFields) { - //var cert = "-----BEGIN CERTIFICATE REQUEST-----\r\n"; - var cert = Pemify(csr); - //cert = cert + "\r\n-----END CERTIFICATE REQUEST-----"; + Logger.LogTrace("GetRenewalRequest: building renewal request. UUID='{Uuid}', ProductID='{ProductId}'", + uUId ?? "(null)", productInfo?.ProductID ?? "(null)"); + + if (productInfo?.ProductParameters == null) + throw new ArgumentNullException(nameof(productInfo), "productInfo or ProductParameters cannot be null."); + if (string.IsNullOrEmpty(csr)) + throw new ArgumentNullException(nameof(csr), "CSR cannot be null or empty."); + if (string.IsNullOrEmpty(uUId)) + throw new ArgumentNullException(nameof(uUId), "uUId cannot be null or empty."); + var cert = Pemify(csr); 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 = productInfo.ProductParameters.ContainsKey("CN DCV Email") + ? productInfo.ProductParameters["CN DCV Email"] : null; + var methodType = productInfo.ProductParameters.ContainsKey("Domain Control Validation Method") + ? productInfo.ProductParameters["Domain Control Validation Method"] : null; var certificateType = GetCertificateType(productInfo.ProductID); + Logger.LogTrace("GetRenewalRequest: cnDcvEmail='{Email}', methodType='{Method}', certType='{CertType}'", + commonNameValidationEmail ?? "(null)", methodType ?? "(null)", certificateType); + return new RenewalRequest { Uuid = uUId, 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 = productInfo.ProductParameters.ContainsKey("Term") ? productInfo.ProductParameters["Term"] : null, + ApplicantFirstName = productInfo.ProductParameters.ContainsKey("Applicant First Name") ? productInfo.ProductParameters["Applicant First Name"] : null, + ApplicantLastName = productInfo.ProductParameters.ContainsKey("Applicant Last Name") ? productInfo.ProductParameters["Applicant Last Name"] : null, + ApplicantEmailAddress = productInfo.ProductParameters.ContainsKey("Applicant Email Address") ? productInfo.ProductParameters["Applicant Email Address"] : null, + ApplicantPhoneNumber = productInfo.ProductParameters.ContainsKey("Applicant Phone") ? productInfo.ProductParameters["Applicant Phone"] : null, DomainControlValidation = GetDomainControlValidation(methodType, commonNameValidationEmail), Notifications = GetNotifications(productInfo), - OrganizationContact = productInfo.ProductParameters["Organization Contact"], - BusinessUnit = productInfo.ProductParameters["Business Unit"], + OrganizationContact = productInfo.ProductParameters.ContainsKey("Organization Contact") ? productInfo.ProductParameters["Organization Contact"] : null, + BusinessUnit = productInfo.ProductParameters.ContainsKey("Business Unit") ? productInfo.ProductParameters["Business Unit"] : null, ShowPrice = true, SubjectAlternativeNames = certificateType == "2" ? GetSubjectAlternativeNames(productInfo, sans) : null, CustomFields = GetCustomFields(productInfo, customFields), @@ -259,54 +534,107 @@ public RenewalRequest GetRenewalRequest(EnrollmentProductInfo productInfo, strin private List GetSubjectAlternativeNames(EnrollmentProductInfo productInfo, Dictionary sans) { + Logger.LogTrace("GetSubjectAlternativeNames: building SANs."); var subjectNameList = new List(); - var methodType = productInfo.ProductParameters["Domain Control Validation Method"]; - foreach (var v in sans["dnsname"]) + if (sans == null || !sans.ContainsKey("dnsname")) { + Logger.LogTrace("GetSubjectAlternativeNames: no 'dnsname' key in SANs dictionary, returning empty list."); + return subjectNameList; + } + + var dnsNames = sans["dnsname"]; + if (dnsNames == null || dnsNames.Length == 0) + { + Logger.LogTrace("GetSubjectAlternativeNames: 'dnsname' array is null or empty, returning empty list."); + return subjectNameList; + } + + var methodType = productInfo?.ProductParameters != null + && productInfo.ProductParameters.ContainsKey("Domain Control Validation Method") + ? productInfo.ProductParameters["Domain Control Validation Method"] + : null; + + Logger.LogTrace("GetSubjectAlternativeNames: processing {Count} DNS names, methodType='{MethodType}'", + dnsNames.Length, methodType ?? "(null)"); + + foreach (var v in dnsNames) + { + if (string.IsNullOrEmpty(v)) + { + Logger.LogTrace("GetSubjectAlternativeNames: skipping null/empty DNS name."); + continue; + } + 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") + Logger.LogTrace("GetSubjectAlternativeNames: processing domain='{Domain}'", domainName); + + if (!string.IsNullOrEmpty(methodType) && methodType.ToUpper() == "EMAIL") + { + var emailsRaw = productInfo.ProductParameters.ContainsKey("Addtl Sans Comma Separated DVC Emails") + ? productInfo.ProductParameters["Addtl Sans Comma Separated DVC Emails"] + : null; + var emailAddresses = !string.IsNullOrEmpty(emailsRaw) ? emailsRaw.Split(',') : Array.Empty(); + Logger.LogTrace("GetSubjectAlternativeNames: EMAIL validation, {Count} email addresses for domain='{Domain}'", + emailAddresses.Length, domainName); san.DomainControlValidation = GetDomainControlValidation(methodType, emailAddresses, domainName); - else //it is a CNAME validation so no email is needed + } + else + { + Logger.LogTrace("GetSubjectAlternativeNames: CNAME/other validation for domain='{Domain}'", domainName); san.DomainControlValidation = GetDomainControlValidation(methodType, ""); + } subjectNameList.Add(san); } + Logger.LogTrace("GetSubjectAlternativeNames: returning {Count} SANs.", subjectNameList.Count); return subjectNameList; } public ReissueRequest GetReissueRequest(EnrollmentProductInfo productInfo, string uUId, string csr, Dictionary sans, List customFields) { - //var cert = "-----BEGIN CERTIFICATE REQUEST-----\r\n"; - var cert = Pemify(csr); - //cert = cert + "\r\n-----END CERTIFICATE REQUEST-----"; + Logger.LogTrace("GetReissueRequest: building reissue request. UUID='{Uuid}', ProductID='{ProductId}'", + uUId ?? "(null)", productInfo?.ProductID ?? "(null)"); + + if (productInfo?.ProductParameters == null) + throw new ArgumentNullException(nameof(productInfo), "productInfo or ProductParameters cannot be null."); + if (string.IsNullOrEmpty(csr)) + throw new ArgumentNullException(nameof(csr), "CSR cannot be null or empty."); + if (string.IsNullOrEmpty(uUId)) + throw new ArgumentNullException(nameof(uUId), "uUId cannot be null or empty."); + var cert = Pemify(csr); 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 = productInfo.ProductParameters.ContainsKey("CN DCV Email") + ? productInfo.ProductParameters["CN DCV Email"] : null; + var methodType = productInfo.ProductParameters.ContainsKey("Domain Control Validation Method") + ? productInfo.ProductParameters["Domain Control Validation Method"] : null; var certificateType = GetCertificateType(productInfo.ProductID); + Logger.LogTrace("GetReissueRequest: cnDcvEmail='{Email}', methodType='{Method}', certType='{CertType}'", + commonNameValidationEmail ?? "(null)", methodType ?? "(null)", certificateType); + return new ReissueRequest { 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 = productInfo.ProductParameters.ContainsKey("Term") ? productInfo.ProductParameters["Term"] : null, + ApplicantFirstName = productInfo.ProductParameters.ContainsKey("Applicant First Name") ? productInfo.ProductParameters["Applicant First Name"] : null, + ApplicantLastName = productInfo.ProductParameters.ContainsKey("Applicant Last Name") ? productInfo.ProductParameters["Applicant Last Name"] : null, + ApplicantEmailAddress = productInfo.ProductParameters.ContainsKey("Applicant Email Address") ? productInfo.ProductParameters["Applicant Email Address"] : null, + ApplicantPhoneNumber = productInfo.ProductParameters.ContainsKey("Applicant Phone") ? productInfo.ProductParameters["Applicant Phone"] : null, DomainControlValidation = GetDomainControlValidation(methodType, commonNameValidationEmail), Notifications = GetNotifications(productInfo), - OrganizationContact = productInfo.ProductParameters["Organization Contact"], - BusinessUnit = productInfo.ProductParameters["Business Unit"], + OrganizationContact = productInfo.ProductParameters.ContainsKey("Organization Contact") ? productInfo.ProductParameters["Organization Contact"] : null, + BusinessUnit = productInfo.ProductParameters.ContainsKey("Business Unit") ? productInfo.ProductParameters["Business Unit"] : null, ShowPrice = true, SubjectAlternativeNames = certificateType == "2" ? GetSubjectAlternativeNames(productInfo, sans) : null, CustomFields = GetCustomFields(productInfo, customFields), @@ -316,15 +644,28 @@ public ReissueRequest GetReissueRequest(EnrollmentProductInfo productInfo, strin private EvCertificateDetails GetEvCertificateDetails(EnrollmentProductInfo productInfo) { + Logger.LogTrace("GetEvCertificateDetails: building EV details."); + var country = productInfo?.ProductParameters != null + && productInfo.ProductParameters.ContainsKey("Organization Country") + ? productInfo.ProductParameters["Organization Country"] + : null; + Logger.LogTrace("GetEvCertificateDetails: country='{Country}'", country ?? "(null)"); var evDetails = new EvCertificateDetails(); - evDetails.Country = productInfo.ProductParameters["Organization Country"]; + evDetails.Country = country; return evDetails; } public int MapReturnStatus(string cscGlobalStatus) { - var returnStatus = 0; + Logger.LogTrace("MapReturnStatus: input status='{Status}'", cscGlobalStatus ?? "(null)"); + + if (string.IsNullOrEmpty(cscGlobalStatus)) + { + Logger.LogWarning("MapReturnStatus: status is null or empty, returning FAILED."); + return (int)EndEntityStatus.FAILED; + } + int returnStatus; switch (cscGlobalStatus) { case "ACTIVE": @@ -340,10 +681,12 @@ public int MapReturnStatus(string cscGlobalStatus) returnStatus = (int)EndEntityStatus.REVOKED; break; default: + Logger.LogWarning("MapReturnStatus: unrecognized status '{Status}', returning FAILED.", cscGlobalStatus); returnStatus = (int)EndEntityStatus.FAILED; break; } + Logger.LogTrace("MapReturnStatus: mapped '{Status}' to {Result}", cscGlobalStatus, returnStatus); return returnStatus; } } \ No newline at end of file diff --git a/docsource/configuration.md b/docsource/configuration.md index d8c196e..5dee9d3 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -10,6 +10,115 @@ This integration is tested and confirmed as working for Anygateway REST 24.2 and The Root certificates for installation on the Anygateway server machine should be obtained from CSC. +## CA Connection Configuration + +When defining the Certificate Authority in the AnyCA Gateway REST portal, configure the following fields on the **CA Connection** tab: + +CONFIG ELEMENT | DESCRIPTION | DEFAULT +---------------|-------------|-------- +Enabled | Flag to Enable or Disable gateway functionality. Set to `false` to allow creating the CA record before configuration information is available; the plugin then short-circuits Ping, Sync, Enroll, and Revoke with a warning until it is re-enabled. | `true` +CscGlobalUrl | The base URL for the CSCGlobal API (e.g. `https://apis.cscglobal.com`) | (required) +ApiKey | Your CSCGlobal API key | (required) +BearerToken | Your CSCGlobal Bearer token for authentication | (required) +DefaultPageSize | Page size for API list requests | 100 +SyncFilterDays | Number of days from today used to filter certificates by expiration date during **incremental** sync. Only certificates expiring within this window are returned. Does not apply to full sync. | 5 +RenewalWindowDays | Number of days before the annual order expiry date within which a **RenewOrReissue** request triggers a paid **Renewal** rather than a free **Reissue**. See [Renewal vs. Reissue Logic](#renewal-vs-reissue-logic) below. | 30 +DcvPollTimeoutSeconds | Max seconds to synchronously poll CSC for certificate issuance after submitting an order. `0` disables polling (enrollment returns pending immediately; cert arrives on the next sync). When `>0`, fast-validating orders can return the issued cert directly in the enrollment response. See [Synchronous Issuance Polling](#synchronous-issuance-polling) below. | 0 + +> **Note:** DNS auto-publishing for CNAME DCV is handled by the AnyCA Gateway REST framework's Domain Validation system (gateway 3.3+). It's configured in the gateway UI under **Domain Validation Configurations**, not on the CA Connection tab. See [DNS Auto-Publishing (CNAME DCV)](#dns-auto-publishing-cname-dcv). + +## Renewal vs. Reissue Logic + +CSC Global subscriptions are annual orders. When Keyfactor Command sends a **RenewOrReissue** request, the plugin must decide whether to submit a **Renewal** (a new paid order) or a **Reissue** (a free re-key under the existing active order). + +The decision is based on the **RenewalWindowDays** setting and works as follows: + +1. The plugin fetches the original certificate from CSC and reads its `orderDate`. +2. It computes the **order expiry** as `orderDate + 1 year`. +3. It calculates **days remaining** until the order expires. +4. If `days remaining <= RenewalWindowDays`, the request is treated as a **Renewal** (new paid order). +5. If `days remaining > RenewalWindowDays`, the request is treated as a **Reissue** (free under the active order). + +**Example with default RenewalWindowDays = 30:** + +``` +Order Date: 2025-04-08 +Order Expiry: 2026-04-08 +Today: 2026-03-15 +Days Left: 24 + +24 <= 30 --> RENEWAL (new paid order) +``` + +``` +Order Date: 2025-04-08 +Order Expiry: 2026-04-08 +Today: 2025-09-01 +Days Left: 219 + +219 > 30 --> REISSUE (free under active order) +``` + +**Fallback behavior:** If the plugin cannot retrieve the `orderDate` from CSC (e.g., API error or missing field), it falls back to checking the certificate's expiration date. If the certificate is already expired, it treats the request as a Renewal. + +**Note:** Both Renewal and Reissue submissions are asynchronous at CSC. The plugin returns a "pending" status and the issued certificate will appear in Keyfactor after the next sync cycle. + +## DNS Auto-Publishing (CNAME DCV) + +CSC supports two Domain Control Validation (DCV) methods: **EMAIL** and **CNAME**. With CNAME validation, CSC returns a CNAME record (name → target) that must exist in DNS before they will validate the order. + +By default this plugin returns the CNAME details to Keyfactor Command for **manual publishing**. To fully automate enrollment, the plugin uses the **AnyCA Gateway REST framework's built-in DNS provider system** (available in framework 3.3 and later). The framework discovers DNS provider plugins deployed alongside the CA plugin and routes each CNAME to whichever provider claims the matching DNS zone. + +### Requirements + +* AnyCA Gateway REST framework **3.3 or later** (the `IDomainValidatorFactory` interface ships in `Keyfactor.AnyGateway.IAnyCAPlugin` 3.3+). +* At least one DNS provider DLL (e.g. GoDaddy, Cloudflare, Route 53, Azure) deployed in the gateway `Extensions` folder. +* A Domain Validation Configuration registered in the gateway UI that maps your domain(s) to the deployed provider (for example, `*.example.com` → GoDaddy). + +### How It Works + +1. CSC returns the CNAME `name → target` details in the enrollment response. +2. For each CNAME entry, the plugin calls `IDomainValidatorFactory.ResolveDomainValidator(recordName, "cname")`. +3. The framework returns the `IDomainValidator` whose Domain Validation Configuration matches the record's zone (or `null` if no match). +4. The plugin calls `validator.StageValidation(recordName, cnameTarget, ct)` to publish the record. +5. CSC asynchronously validates the CNAME; the issued certificate appears on the next sync. + +### Behavior + +* **Resolution is per record, not per CA.** One CA can drive multiple DNS providers (GoDaddy for some domains, Route 53 for others) with no per-CA configuration. +* **Only invoked for CNAME DCV.** Templates configured with EMAIL validation are unaffected — no DNS publishing occurs. +* **Best-effort.** If no provider claims the zone, the publish call fails, or the factory wasn't injected (gateway pre-3.3), the enrollment still succeeds and the CNAME details remain in the Keyfactor request so a human can publish manually as a fallback. +* **Trace-logged.** Every resolution (matched/unresolved) and publish attempt (success/failure) is logged at Info/Trace level. +* **Validation type string.** The plugin passes `"cname"` to `ResolveDomainValidator`. CSC's DCV requires a **CNAME** record, which is different from ACME's `"dns-01"` challenge (a TXT record). A single DNS provider DLL can ship multiple validator classes — one advertising `"dns-01"` (publishes TXT, for ACME) and one advertising `"cname"` (publishes CNAME, for CSC). You must deploy and configure a validator that advertises `"cname"` or no provider will match. +* **Trailing dots normalized.** CSC returns FQDN-canonical names with a trailing dot (e.g. `_token.example.com.`). The plugin strips the trailing dot before resolution and publishing, because Domain Validation Configurations and DNS provider APIs expect names without it. + +### Configuration in the Gateway UI + +In the AnyCA Gateway REST portal, under **Domain Validation Configurations**: + +1. **Add** a new configuration. +2. Pick a **Domain Validator Type** that publishes **CNAME** records and advertises validation type `cname`. For GoDaddy this is `GoDaddyCnameDomainValidator` (the `GoDaddyDomainValidator` variant publishes TXT for ACME and will **not** work for CSC). +3. Add one or more **domain patterns** (e.g. `*.example.com`). +4. Fill out the provider-specific **Configuration Settings** (API keys, base URL, etc.). +5. Save. + +Once configured, any CSC enrollment for a domain matching one of those patterns will have its CNAME auto-published. + +> **Common pitfall:** If you configure the TXT/`dns-01` validator (e.g. `GoDaddyDomainValidator`) for a CSC domain, the record will publish as a **TXT** and CSC's CNAME validation will never succeed. Make sure you select the **CNAME** validator variant. + +## Synchronous Issuance Polling + +CSC validates domain control asynchronously — after an order is submitted (and the CNAME DCV record published), CSC/Sectigo polls public DNS on its own schedule and issues the certificate once validation passes. By default this plugin returns a **pending** (`EXTERNALVALIDATION`) result immediately and the issued certificate is picked up on the next gateway **sync** cycle. + +For environments where DNS is published automatically (see [DNS Auto-Publishing](#dns-auto-publishing-cname-dcv)) and validation tends to complete quickly, you can have the plugin **poll CSC synchronously** at the end of enrollment and return the issued certificate directly — avoiding the wait for the next sync. + +* Set **`DcvPollTimeoutSeconds`** to the maximum number of seconds to poll (e.g. `60`). `0` (default) disables polling entirely. +* The plugin polls CSC every 10 seconds until the order is issued or the timeout is reached. +* If the certificate issues within the window, the enrollment returns it immediately with a success status. +* If the window expires, the plugin falls back to the **pending** result and the certificate arrives on the next sync — exactly as it would with polling disabled. + +**Tradeoff:** Polling blocks the enrollment request for up to `DcvPollTimeoutSeconds`. CSC validation frequently takes minutes to hours, so most orders will still fall through to pending — keep the timeout small (30–90s) to catch only the fast cases without hanging callers. This applies to New enrollments, Renewals, and Reissues. + ## Certificate Template Creation Step PLEASE NOTE, AT THIS TIME THE RAPID_SSL TEMPLATE IS NOT SUPPORTED BY THE CSC API AND WILL NOT WORK WITH THIS INTEGRATION diff --git a/integration-manifest.json b/integration-manifest.json index 2666c3b..e6c5243 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -3,11 +3,11 @@ "integration_type": "anyca-plugin", "name": "CSCGlobal CAPlugin REST Gateway Plugin", "support_level": "kf-supported", - "status": "pilot", + "status": "production", "link_github": true, "update_catalog": true, "description": "CSCGlobal CAPlugin for the AnyCA REST Gateway framework", - "gateway_framework": "24.2.0", + "gateway_framework": "26.2.0", "release_project": "cscglobal-caplugin/CSCGlobalCAPlugin.csproj", "release_dir": "cscglobal-caplugin/bin/Release", "about": { @@ -29,13 +29,17 @@ "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." + }, + { + "name": "RenewalWindowDays", + "description": "Number of days before the annual order expiry within which a RenewOrReissue triggers a paid Renewal rather than a free Reissue. Default is 30." + }, + { + "name": "DcvPollTimeoutSeconds", + "description": "Max seconds to synchronously poll CSC for issuance after submitting an order (and publishing CNAME DCV). 0 disables polling (enrollment returns pending immediately; cert arrives on next sync). When >0, fast-validating orders can return the cert directly. Keep small to avoid long-blocking enrollment requests." } ], "enrollment_config": [ @@ -94,8 +98,8 @@ "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 Trusted Secure Domain Validated Wildcard SSL", + "CSC Trusted Secure Domain Validated UC Certificate" ] } } From 0a1e9571f125ec38a6b29dcb27002a897ca9372b Mon Sep 17 00:00:00 2001 From: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:18:45 -0400 Subject: [PATCH 31/42] Revert main to 1.1.2 (#16) Rolls main's content back to the 1.1.2 release; the 2.0.0 work remains available on the release-2.0 branch and the 2.0.0 tag. --- .claude/settings.json | 8 - .../keyfactor-bootstrap-workflow-v3.yml | 11 +- README.md | 564 +++----- cscglobal-caplugin/CSCGlobalCAPlugin.cs | 1252 ++--------------- cscglobal-caplugin/CSCGlobalCAPlugin.csproj | 16 +- cscglobal-caplugin/Client/CscGlobalClient.cs | 269 +--- cscglobal-caplugin/Constants.cs | 8 +- cscglobal-caplugin/FlowLogger.cs | 241 ---- cscglobal-caplugin/RequestManager.cs | 533 ++----- docsource/configuration.md | 109 -- integration-manifest.json | 22 +- 11 files changed, 553 insertions(+), 2480 deletions(-) delete mode 100644 .claude/settings.json delete mode 100644 cscglobal-caplugin/FlowLogger.cs diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index c64f0fd..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(git fetch:*)", - "Bash(git checkout:*)" - ] - } -} diff --git a/.github/workflows/keyfactor-bootstrap-workflow-v3.yml b/.github/workflows/keyfactor-bootstrap-workflow-v3.yml index 0f3d3ae..042ba5a 100644 --- a/.github/workflows/keyfactor-bootstrap-workflow-v3.yml +++ b/.github/workflows/keyfactor-bootstrap-workflow-v3.yml @@ -11,17 +11,10 @@ on: jobs: call-starter-workflow: - 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 }} + uses: keyfactor/actions/.github/workflows/starter.yml@v3.1.2 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 }} diff --git a/README.md b/README.md index 5effcf2..c68aac4 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

-Integration Status: production +Integration Status: pilot Release Issues GitHub Downloads (all assets, all releases) @@ -14,7 +14,7 @@ Support - + · Requirements @@ -33,14 +33,15 @@

+ This integration allows for the Synchronization, Enrollment, and Revocation of certificates from the CSCGlobal. This is the AnyGateway REST version. ## Compatibility -The CSCGlobal CAPlugin AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 26.2.0 and later. +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 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 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. > 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. @@ -54,15 +55,16 @@ 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 `net10.0`) to the Extensions directory: +3. Copy the unzipped directory (usually called `net6.0` or `net8.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\net10.0\Extensions + Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net6.0\Extensions + Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net8.0\Extensions ``` - > The directory containing the CSCGlobal CAPlugin AnyCA Gateway REST plugin DLLs (`net10.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`) can be named anything, as long as it is unique within the `Extensions` directory. 4. Restart the AnyCA Gateway REST service. @@ -80,343 +82,235 @@ 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 - * **SyncFilterDays** - Number of days from today to filter certificates by expiration date during incremental sync. - * **RenewalWindowDays** - Number of days before the annual order expiry within which a RenewOrReissue triggers a paid Renewal rather than a free Reissue. Default is 30. - * **DcvPollTimeoutSeconds** - Max seconds to synchronously poll CSC for issuance after submitting an order (and publishing CNAME DCV). 0 disables polling (enrollment returns pending immediately; cert arrives on next sync). When >0, fast-validating orders can return the cert directly. Keep small to avoid long-blocking enrollment requests. + * **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 + 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 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 - -## CA Connection Configuration - -When defining the Certificate Authority in the AnyCA Gateway REST portal, configure the following fields on the **CA Connection** tab: - -CONFIG ELEMENT | DESCRIPTION | DEFAULT ----------------|-------------|-------- -Enabled | Flag to Enable or Disable gateway functionality. Set to `false` to allow creating the CA record before configuration information is available; the plugin then short-circuits Ping, Sync, Enroll, and Revoke with a warning until it is re-enabled. | `true` -CscGlobalUrl | The base URL for the CSCGlobal API (e.g. `https://apis.cscglobal.com`) | (required) -ApiKey | Your CSCGlobal API key | (required) -BearerToken | Your CSCGlobal Bearer token for authentication | (required) -DefaultPageSize | Page size for API list requests | 100 -SyncFilterDays | Number of days from today used to filter certificates by expiration date during **incremental** sync. Only certificates expiring within this window are returned. Does not apply to full sync. | 5 -RenewalWindowDays | Number of days before the annual order expiry date within which a **RenewOrReissue** request triggers a paid **Renewal** rather than a free **Reissue**. See [Renewal vs. Reissue Logic](#renewal-vs-reissue-logic) below. | 30 -DcvPollTimeoutSeconds | Max seconds to synchronously poll CSC for certificate issuance after submitting an order. `0` disables polling (enrollment returns pending immediately; cert arrives on the next sync). When `>0`, fast-validating orders can return the issued cert directly in the enrollment response. See [Synchronous Issuance Polling](#synchronous-issuance-polling) below. | 0 - -> **Note:** DNS auto-publishing for CNAME DCV is handled by the AnyCA Gateway REST framework's Domain Validation system (gateway 3.3+). It's configured in the gateway UI under **Domain Validation Configurations**, not on the CA Connection tab. See [DNS Auto-Publishing (CNAME DCV)](#dns-auto-publishing-cname-dcv). - -## Renewal vs. Reissue Logic - -CSC Global subscriptions are annual orders. When Keyfactor Command sends a **RenewOrReissue** request, the plugin must decide whether to submit a **Renewal** (a new paid order) or a **Reissue** (a free re-key under the existing active order). - -The decision is based on the **RenewalWindowDays** setting and works as follows: - -1. The plugin fetches the original certificate from CSC and reads its `orderDate`. -2. It computes the **order expiry** as `orderDate + 1 year`. -3. It calculates **days remaining** until the order expires. -4. If `days remaining <= RenewalWindowDays`, the request is treated as a **Renewal** (new paid order). -5. If `days remaining > RenewalWindowDays`, the request is treated as a **Reissue** (free under the active order). - -**Example with default RenewalWindowDays = 30:** - -``` -Order Date: 2025-04-08 -Order Expiry: 2026-04-08 -Today: 2026-03-15 -Days Left: 24 - -24 <= 30 --> RENEWAL (new paid order) -``` - -``` -Order Date: 2025-04-08 -Order Expiry: 2026-04-08 -Today: 2025-09-01 -Days Left: 219 - -219 > 30 --> REISSUE (free under active order) -``` - -**Fallback behavior:** If the plugin cannot retrieve the `orderDate` from CSC (e.g., API error or missing field), it falls back to checking the certificate's expiration date. If the certificate is already expired, it treats the request as a Renewal. - -**Note:** Both Renewal and Reissue submissions are asynchronous at CSC. The plugin returns a "pending" status and the issued certificate will appear in Keyfactor after the next sync cycle. - -## DNS Auto-Publishing (CNAME DCV) - -CSC supports two Domain Control Validation (DCV) methods: **EMAIL** and **CNAME**. With CNAME validation, CSC returns a CNAME record (name → target) that must exist in DNS before they will validate the order. - -By default this plugin returns the CNAME details to Keyfactor Command for **manual publishing**. To fully automate enrollment, the plugin uses the **AnyCA Gateway REST framework's built-in DNS provider system** (available in framework 3.3 and later). The framework discovers DNS provider plugins deployed alongside the CA plugin and routes each CNAME to whichever provider claims the matching DNS zone. - -### Requirements - -* AnyCA Gateway REST framework **3.3 or later** (the `IDomainValidatorFactory` interface ships in `Keyfactor.AnyGateway.IAnyCAPlugin` 3.3+). -* At least one DNS provider DLL (e.g. GoDaddy, Cloudflare, Route 53, Azure) deployed in the gateway `Extensions` folder. -* A Domain Validation Configuration registered in the gateway UI that maps your domain(s) to the deployed provider (for example, `*.example.com` → GoDaddy). - -### How It Works - -1. CSC returns the CNAME `name → target` details in the enrollment response. -2. For each CNAME entry, the plugin calls `IDomainValidatorFactory.ResolveDomainValidator(recordName, "cname")`. -3. The framework returns the `IDomainValidator` whose Domain Validation Configuration matches the record's zone (or `null` if no match). -4. The plugin calls `validator.StageValidation(recordName, cnameTarget, ct)` to publish the record. -5. CSC asynchronously validates the CNAME; the issued certificate appears on the next sync. - -### Behavior - -* **Resolution is per record, not per CA.** One CA can drive multiple DNS providers (GoDaddy for some domains, Route 53 for others) with no per-CA configuration. -* **Only invoked for CNAME DCV.** Templates configured with EMAIL validation are unaffected — no DNS publishing occurs. -* **Best-effort.** If no provider claims the zone, the publish call fails, or the factory wasn't injected (gateway pre-3.3), the enrollment still succeeds and the CNAME details remain in the Keyfactor request so a human can publish manually as a fallback. -* **Trace-logged.** Every resolution (matched/unresolved) and publish attempt (success/failure) is logged at Info/Trace level. -* **Validation type string.** The plugin passes `"cname"` to `ResolveDomainValidator`. CSC's DCV requires a **CNAME** record, which is different from ACME's `"dns-01"` challenge (a TXT record). A single DNS provider DLL can ship multiple validator classes — one advertising `"dns-01"` (publishes TXT, for ACME) and one advertising `"cname"` (publishes CNAME, for CSC). You must deploy and configure a validator that advertises `"cname"` or no provider will match. -* **Trailing dots normalized.** CSC returns FQDN-canonical names with a trailing dot (e.g. `_token.example.com.`). The plugin strips the trailing dot before resolution and publishing, because Domain Validation Configurations and DNS provider APIs expect names without it. - -### Configuration in the Gateway UI - -In the AnyCA Gateway REST portal, under **Domain Validation Configurations**: - -1. **Add** a new configuration. -2. Pick a **Domain Validator Type** that publishes **CNAME** records and advertises validation type `cname`. For GoDaddy this is `GoDaddyCnameDomainValidator` (the `GoDaddyDomainValidator` variant publishes TXT for ACME and will **not** work for CSC). -3. Add one or more **domain patterns** (e.g. `*.example.com`). -4. Fill out the provider-specific **Configuration Settings** (API keys, base URL, etc.). -5. Save. - -Once configured, any CSC enrollment for a domain matching one of those patterns will have its CNAME auto-published. - -> **Common pitfall:** If you configure the TXT/`dns-01` validator (e.g. `GoDaddyDomainValidator`) for a CSC domain, the record will publish as a **TXT** and CSC's CNAME validation will never succeed. Make sure you select the **CNAME** validator variant. - -## Synchronous Issuance Polling - -CSC validates domain control asynchronously — after an order is submitted (and the CNAME DCV record published), CSC/Sectigo polls public DNS on its own schedule and issues the certificate once validation passes. By default this plugin returns a **pending** (`EXTERNALVALIDATION`) result immediately and the issued certificate is picked up on the next gateway **sync** cycle. - -For environments where DNS is published automatically (see [DNS Auto-Publishing](#dns-auto-publishing-cname-dcv)) and validation tends to complete quickly, you can have the plugin **poll CSC synchronously** at the end of enrollment and return the issued certificate directly — avoiding the wait for the next sync. + * **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 -* Set **`DcvPollTimeoutSeconds`** to the maximum number of seconds to poll (e.g. `60`). `0` (default) disables polling entirely. -* The plugin polls CSC every 10 seconds until the order is issued or the timeout is reached. -* If the certificate issues within the window, the enrollment returns it immediately with a success status. -* If the window expires, the plugin falls back to the **pending** result and the certificate arrives on the next sync — exactly as it would with polling disabled. -**Tradeoff:** Polling blocks the enrollment request for up to `DcvPollTimeoutSeconds`. CSC validation frequently takes minutes to hours, so most orders will still fall through to pending — keep the timeout small (30–90s) to catch only the fast cases without hanging callers. This applies to New enrollments, Renewals, and Reissues. ## License @@ -424,4 +318,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). +See all [Keyfactor Any CA Gateways (REST)](https://github.com/orgs/Keyfactor/repositories?q=anycagateway). \ No newline at end of file diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index c22aa32..e1af2f0 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -23,300 +23,80 @@ namespace Keyfactor.Extensions.CAPlugin.CSCGlobal; public class CSCGlobalCAPlugin : IAnyCAPlugin { - /// - /// Validation type string passed to . - /// CSC's Domain Control Validation publishes a CNAME record, so we resolve a DNS provider - /// that advertises the "cname" validation type (e.g. GoDaddy's GoDaddyCnameDomainValidator). - /// This is distinct from ACME's "dns-01" challenge, which publishes TXT records — a single - /// DNS provider DLL can ship separate validator classes for each type. - /// - private const string DNS_VALIDATION_TYPE = "cname"; - - /// Delay between CSC status polls while waiting for DCV to complete. - private static readonly TimeSpan DcvPollInterval = TimeSpan.FromSeconds(10); - private readonly RequestManager _requestManager; private readonly ILogger Logger; - private readonly IDomainValidatorFactory? _validatorFactory; private ICertificateDataReader _certificateDataReader; - /// - /// Parameterless constructor retained for compatibility with older gateway hosts that don't - /// perform DI. When constructed this way the plugin runs without DNS auto-publishing. - /// public CSCGlobalCAPlugin() { Logger = LogHandler.GetClassLogger(); _requestManager = new RequestManager(); - _validatorFactory = null; - } - - /// - /// DI constructor used by AnyCA Gateway 3.3+ which injects the framework's domain validator - /// factory. When non-null, CNAME DCV records returned by CSC are auto-published via the - /// framework's registered DNS providers (resolved per-domain). - /// - public CSCGlobalCAPlugin(IDomainValidatorFactory validatorFactory) - { - Logger = LogHandler.GetClassLogger(); - _requestManager = new RequestManager(); - _validatorFactory = validatorFactory; } private ICscGlobalClient CscGlobalClient { get; set; } - /// - /// Whether the CA is enabled. When false, the plugin returns early from Ping, - /// ValidateCAConnectionInfo, ValidateProductInfo, Synchronize, Enroll, and Revoke without - /// calling CSC. Primarily used to allow creation of the CA record prior to configuration - /// information being available (standard field across Keyfactor CA plugins). Defaults to true - /// so existing deployments that don't set this key continue to function. - /// - public bool Enabled { get; set; } = true; + public bool EnableTemplateSync { get; set; } public int SyncFilterDays { get; set; } - public int RenewalWindowDays { get; set; } - - /// - /// Maximum seconds to synchronously poll CSC for certificate issuance after submitting an - /// order (and publishing CNAME DCV). 0 disables polling — the enrollment returns "pending" - /// immediately and the cert is picked up on the next sync. When > 0, fast-validating - /// orders can return the issued cert directly in the enrollment response. - /// - public int DcvPollTimeoutSeconds { get; set; } - //done public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDataReader certificateDataReader) { - using var flow = new FlowLogger(Logger, "Initialize"); Logger.MethodEntry(LogLevel.Debug); - Logger.LogTrace("Initialize called. configProvider is {Null}, certificateDataReader is {Null2}", - configProvider == null ? "NULL" : "present", - certificateDataReader == null ? "NULL" : "present"); - - flow.Step("ValidateInputs", () => - { - if (configProvider == null) - throw new ArgumentNullException(nameof(configProvider), "configProvider cannot be null in Initialize"); - if (certificateDataReader == null) - throw new ArgumentNullException(nameof(certificateDataReader), "certificateDataReader cannot be null in Initialize"); - }); - _certificateDataReader = certificateDataReader; + CscGlobalClient = new CscGlobalClient(configProvider); + var templateSync = configProvider.CAConnectionData["TemplateSync"].ToString(); + if (templateSync.ToUpper() == "ON") EnableTemplateSync = true; - flow.Step("ValidateConnectionData", () => + if (configProvider.CAConnectionData.ContainsKey(Constants.SyncFilterDays)) { - if (configProvider.CAConnectionData == null) + var syncFilterDaysStr = configProvider.CAConnectionData[Constants.SyncFilterDays]?.ToString(); + if (int.TryParse(syncFilterDaysStr, out var syncFilterDays)) { - Logger.LogError("CAConnectionData is null. Cannot read configuration."); - throw new InvalidOperationException("CAConnectionData is null on configProvider."); + SyncFilterDays = syncFilterDays; + Logger.LogDebug($"SyncFilterDays configured to {SyncFilterDays} days"); } - Logger.LogTrace("CAConnectionData keys: {Keys}", string.Join(", ", configProvider.CAConnectionData.Keys)); - }); - - flow.Step("ReadEnabled", () => - { - Enabled = true; // default - if (configProvider.CAConnectionData.TryGetValue(Constants.Enabled, out var enabledObj)) - { - Logger.LogTrace("Enabled raw value: '{Value}'", enabledObj?.ToString() ?? "(null)"); - if (bool.TryParse(enabledObj?.ToString(), out var parsed)) - Enabled = parsed; - else - Logger.LogWarning("Enabled value '{Value}' could not be parsed as bool, defaulting to true.", enabledObj); - } - else - { - Logger.LogTrace("Enabled key not found in CAConnectionData, defaulting to true."); - } - Logger.LogInformation("CA is {State}.", Enabled ? "Enabled" : "Disabled"); - }, $"Enabled={Enabled}"); - - // Construct the CSC client only when enabled. When disabled we allow Initialize to complete - // without valid API credentials — this is the whole point of the Enabled toggle (so ops can - // create the CA record before credentials are available). - if (Enabled) - { - flow.Step("CreateCscGlobalClient", () => - { - Logger.LogTrace("Creating CscGlobalClient from configProvider..."); - CscGlobalClient = new CscGlobalClient(configProvider); - Logger.LogTrace("CscGlobalClient created successfully."); - }); } - else - { - flow.Skip("CreateCscGlobalClient", "CA is Disabled"); - } - - flow.Step("ReadSyncFilterDays", () => - { - if (configProvider.CAConnectionData.ContainsKey(Constants.SyncFilterDays)) - { - var syncFilterDaysStr = configProvider.CAConnectionData[Constants.SyncFilterDays]?.ToString(); - Logger.LogTrace("SyncFilterDays raw value: '{Value}'", syncFilterDaysStr ?? "(null)"); - if (int.TryParse(syncFilterDaysStr, out var syncFilterDays)) - { - SyncFilterDays = syncFilterDays; - Logger.LogDebug("SyncFilterDays configured to {Days} days", SyncFilterDays); - } - else - { - Logger.LogWarning("SyncFilterDays value '{Value}' could not be parsed as int, using default 0.", syncFilterDaysStr); - } - } - else - { - Logger.LogTrace("SyncFilterDays key not found in CAConnectionData, using default 0."); - } - }); - - flow.Step("ReadRenewalWindowDays", () => - { - RenewalWindowDays = 30; // default - if (configProvider.CAConnectionData.TryGetValue(Constants.RenewalWindowDays, out var renewalWindowObj)) - { - Logger.LogTrace("RenewalWindowDays raw value: '{Value}'", renewalWindowObj?.ToString() ?? "(null)"); - if (int.TryParse(renewalWindowObj?.ToString(), out var renewalWindowDays) && renewalWindowDays > 0) - RenewalWindowDays = renewalWindowDays; - else - Logger.LogWarning("RenewalWindowDays value '{Value}' could not be parsed or was <= 0, using default 30.", renewalWindowObj); - } - else - { - Logger.LogTrace("RenewalWindowDays key not found in CAConnectionData, using default 30."); - } - Logger.LogDebug("RenewalWindowDays configured to {Days} days", RenewalWindowDays); - }, $"RenewalWindowDays={RenewalWindowDays}"); - - flow.Step("ReadDcvPollTimeoutSeconds", () => - { - DcvPollTimeoutSeconds = 0; // default: disabled - if (configProvider.CAConnectionData.TryGetValue(Constants.DcvPollTimeoutSeconds, out var pollObj)) - { - Logger.LogTrace("DcvPollTimeoutSeconds raw value: '{Value}'", pollObj?.ToString() ?? "(null)"); - if (int.TryParse(pollObj?.ToString(), out var pollSeconds) && pollSeconds >= 0) - DcvPollTimeoutSeconds = pollSeconds; - else - Logger.LogWarning("DcvPollTimeoutSeconds value '{Value}' could not be parsed or was < 0, using default 0 (disabled).", pollObj); - } - else - { - Logger.LogTrace("DcvPollTimeoutSeconds key not found in CAConnectionData, using default 0 (disabled)."); - } - Logger.LogDebug("DcvPollTimeoutSeconds configured to {Seconds}s ({State})", - DcvPollTimeoutSeconds, DcvPollTimeoutSeconds > 0 ? "enabled" : "disabled"); - }); - - flow.Step("CheckDnsValidatorFactory", () => - { - if (_validatorFactory == null) - Logger.LogInformation( - "No IDomainValidatorFactory was injected by the gateway host. CNAME DCV records will require manual publishing."); - else - Logger.LogInformation( - "IDomainValidatorFactory available from gateway host. CNAME DCV records will be auto-published per-domain via the framework's registered DNS providers (validation type '{Type}').", - DNS_VALIDATION_TYPE); - }); - Logger.MethodExit(LogLevel.Debug); } //done public async Task GetSingleRecord(string caRequestID) { - using var flow = new FlowLogger(Logger, $"GetSingleRecord({caRequestID ?? "null"})"); - Logger.MethodEntry(LogLevel.Debug); - Logger.LogTrace("GetSingleRecord called with caRequestID='{CaRequestId}'", caRequestID ?? "(null)"); - - flow.Step("ValidateInput", () => - { - if (string.IsNullOrEmpty(caRequestID)) - throw new ArgumentNullException(nameof(caRequestID), "caRequestID cannot be null or empty."); - if (caRequestID.Length < 36) - throw new ArgumentException($"caRequestID '{caRequestID}' is too short to extract a UUID (need at least 36 chars).", nameof(caRequestID)); - }); - try { - var keyfactorCaId = caRequestID.Substring(0, 36); - flow.Step("ExtractUUID", $"keyfactorCaId={keyfactorCaId}"); + Logger.MethodEntry(LogLevel.Debug); + 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)) + .Result; - CertificateResponse certificateResponse = null; - await flow.StepAsync("FetchCertFromCSC", async () => - { - certificateResponse = await CscGlobalClient.SubmitGetCertificateAsync(keyfactorCaId); - }); - - if (certificateResponse == null) - { - flow.Fail("ParseResponse", "API returned null"); - Logger.LogWarning("GetSingleRecord: SubmitGetCertificateAsync returned null for keyfactorCaId='{KeyfactorCaId}'", keyfactorCaId); - return new AnyCAPluginCertificate - { - CARequestID = keyfactorCaId, - Certificate = string.Empty, - Status = _requestManager.MapReturnStatus(null) - }; - } + Logger.LogTrace($"Single Cert JSON: {JsonConvert.SerializeObject(certificateResponse)}"); - flow.Step("ParseResponse", $"Status={certificateResponse.Status ?? "(null)"}"); - Logger.LogTrace("Single Cert JSON: {Json}", JsonConvert.SerializeObject(certificateResponse)); + var fileContent = + Encoding.ASCII.GetString( + Convert.FromBase64String(certificateResponse?.Certificate ?? string.Empty)); - var rawCert = certificateResponse.Certificate ?? string.Empty; - string fileContent = string.Empty; - flow.Step("DecodeBase64", () => - { - try - { - fileContent = Encoding.ASCII.GetString(Convert.FromBase64String(rawCert)); - } - catch (FormatException fex) - { - Logger.LogError(fex, "GetSingleRecord: Failed to decode Base64 certificate content for keyfactorCaId='{KeyfactorCaId}'", keyfactorCaId); - fileContent = string.Empty; - } - }, $"length={rawCert.Length}"); - - var certData = fileContent.Replace("\r\n", string.Empty); + Logger.LogTrace($"File Content {fileContent}"); + var certData = fileContent?.Replace("\r\n", string.Empty); var certString = string.Empty; if (!string.IsNullOrEmpty(certData)) - { - flow.Step("ExtractLeafCert", () => - { - certString = GetEndEntityCertificate(certData); - }, $"inputLength={certData.Length}"); - } - else - { - flow.Skip("ExtractLeafCert", "certData empty after cleanup"); - } - - var mappedStatus = _requestManager.MapReturnStatus(certificateResponse.Status); - flow.Step("MapStatus", $"{certificateResponse.Status ?? "(null)"} -> {mappedStatus}"); + certString = GetEndEntityCertificate(certData); + Logger.LogTrace($"Cert String Content {certString}"); Logger.MethodExit(LogLevel.Debug); return new AnyCAPluginCertificate { CARequestID = keyfactorCaId, - Certificate = certString ?? string.Empty, - Status = mappedStatus + Certificate = certString, + Status = _requestManager.MapReturnStatus(certificateResponse?.Status) }; } - catch (AggregateException ae) - { - var inner = ae.Flatten().InnerException; - flow.Fail("UNHANDLED", inner?.Message ?? ae.Message); - Logger.LogError(inner, "GetSingleRecord: AggregateException for caRequestID='{CaRequestId}': {Message}", caRequestID, inner?.Message ?? ae.Message); - throw new Exception($"Error Occurred getting single cert for '{caRequestID}': {inner?.Message ?? ae.Message}", inner ?? ae); - } catch (Exception e) { - flow.Fail("UNHANDLED", e.Message); - Logger.LogError(e, "GetSingleRecord: Exception for caRequestID='{CaRequestId}': {Message}", caRequestID, e.Message); - throw new Exception($"Error Occurred getting single cert for '{caRequestID}': {e.Message}", e); + throw new Exception($"Error Occurred getting single cert {e.Message}"); } } @@ -324,64 +104,31 @@ await flow.StepAsync("FetchCertFromCSC", async () => public async Task Synchronize(BlockingCollection blockingBuffer, DateTime? lastSync, bool fullSync, CancellationToken cancelToken) { - var syncType = fullSync ? "Full" : "Incremental"; - using var flow = new FlowLogger(Logger, $"Synchronize-{syncType}"); + Logger.LogTrace($"Full Sync? {fullSync.ToString()}"); Logger.MethodEntry(); - Logger.LogTrace("Synchronize called. fullSync={FullSync}, lastSync={LastSync}, blockingBuffer is {Null}", - fullSync, lastSync?.ToString("o") ?? "(null)", - blockingBuffer == null ? "NULL" : "present"); - - if (blockingBuffer == null) - throw new ArgumentNullException(nameof(blockingBuffer), "blockingBuffer cannot be null in 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) { - flow.Step("DetermineFilter", "Full sync - no date filter"); - await flow.StepAsync("FetchAndProcessCerts", async () => - { - await SyncCertificates(blockingBuffer, cancelToken, null); - }); + Logger.LogDebug("Performing full sync - no date filter applied"); + await SyncCertificates(blockingBuffer, cancelToken, null); } else { var filterDays = SyncFilterDays > 0 ? SyncFilterDays : 5; var filterDate = DateTime.Today.Subtract(TimeSpan.FromDays(filterDays)); var dateFilter = filterDate.ToString("yyyy/MM/dd"); - flow.Step("DetermineFilter", $"Incremental, filterDays={filterDays}, cutoff={dateFilter}"); - await flow.StepAsync("FetchAndProcessCerts", async () => - { - await SyncCertificates(blockingBuffer, cancelToken, dateFilter); - }); + Logger.LogDebug($"Performing incremental sync with expiration date filter: {dateFilter}"); + await SyncCertificates(blockingBuffer, cancelToken, dateFilter); } - flow.Step("CompleteAdding"); blockingBuffer.CompleteAdding(); } - catch (OperationCanceledException) - { - flow.Fail("Cancelled", "operation was cancelled"); - Logger.LogWarning("Synchronize: operation was cancelled."); - if (!blockingBuffer.IsAddingCompleted) - blockingBuffer.CompleteAdding(); - throw; - } catch (Exception e) { - flow.Fail("SyncError", e.Message); - Logger.LogError(e, "Csc Global Synchronize Task failed! {FlatException}", LogHandler.FlattenException(e)); - if (!blockingBuffer.IsAddingCompleted) - blockingBuffer.CompleteAdding(); + Logger.LogError($"Csc Global Synchronize Task failed! {LogHandler.FlattenException(e)}"); Logger.MethodExit(); + blockingBuffer.CompleteAdding(); throw; } @@ -391,188 +138,70 @@ await flow.StepAsync("FetchAndProcessCerts", async () => private async Task SyncCertificates(BlockingCollection blockingBuffer, CancellationToken cancelToken, string? dateFilter) { - Logger.LogTrace("SyncCertificates: calling SubmitCertificateListRequestAsync with dateFilter='{DateFilter}'", dateFilter ?? "(null)"); var certs = await CscGlobalClient.SubmitCertificateListRequestAsync(dateFilter); - if (certs == null) - { - Logger.LogWarning("SyncCertificates: SubmitCertificateListRequestAsync returned null."); - return; - } - - if (certs.Results == null) - { - Logger.LogWarning("SyncCertificates: certificate list response Results collection is null."); - return; - } - - Logger.LogTrace("SyncCertificates: received {Count} certificate results.", certs.Results.Count); - var processedCount = 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); - if (currentResponseItem == null) - { - Logger.LogTrace("SyncCertificates: skipping null result item."); - skippedCount++; - continue; - } - - Logger.LogTrace("SyncCertificates: processing certificate UUID={Uuid}, Status='{Status}', CertificateType='{CertType}'", - currentResponseItem.Uuid ?? "(null)", - currentResponseItem.Status ?? "(null)", - currentResponseItem.CertificateType ?? "(null)"); - - var certStatus = _requestManager.MapReturnStatus(currentResponseItem.Status); - Logger.LogTrace("SyncCertificates: mapped status for UUID={Uuid}: {MappedStatus}", currentResponseItem.Uuid ?? "(null)", certStatus); - + //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)) { - var productId = _requestManager.MapCertificateTypeToProductId(currentResponseItem.CertificateType); + //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; - Logger.LogTrace("SyncCertificates: UUID={Uuid} qualifies for sync. CertificateType='{CertType}' -> ProductId='{ProductId}'", - currentResponseItem.Uuid, currentResponseItem.CertificateType ?? "(null)", productId); - - string fileContent; - try - { - fileContent = PreparePemTextFromApi(currentResponseItem.Certificate ?? string.Empty); - } - catch (Exception ex) - { - Logger.LogError(ex, "SyncCertificates: PreparePemTextFromApi failed for UUID={Uuid}", currentResponseItem.Uuid); - skippedCount++; - continue; - } + var fileContent = + PreparePemTextFromApi( + currentResponseItem?.Certificate ?? string.Empty); if (fileContent.Length > 0) { - Logger.LogTrace("SyncCertificates: fileContent length={Length} for UUID={Uuid}", fileContent.Length, currentResponseItem.Uuid); + Logger.LogTrace($"File Content {fileContent}"); var certData = fileContent.Replace("\r\n", string.Empty); - string certString; - try - { - certString = GetEndEntityCertificate(certData); - } - catch (Exception ex) - { - Logger.LogError(ex, "SyncCertificates: GetEndEntityCertificate failed for UUID={Uuid}", currentResponseItem.Uuid); - skippedCount++; - continue; - } - - if (!string.IsNullOrEmpty(certString)) - { + var certString = GetEndEntityCertificate(certData); + if (certString.Length > 0) blockingBuffer.Add(new AnyCAPluginCertificate { - CARequestID = $"{currentResponseItem.Uuid}", + CARequestID = $"{currentResponseItem?.Uuid}", Certificate = certString, Status = certStatus, ProductID = productId }, cancelToken); - processedCount++; - Logger.LogTrace("SyncCertificates: added UUID={Uuid} to buffer.", currentResponseItem.Uuid); - } - else - { - Logger.LogTrace("SyncCertificates: certString was empty for UUID={Uuid}, skipping.", currentResponseItem.Uuid); - skippedCount++; - } - } - else - { - Logger.LogTrace("SyncCertificates: fileContent was empty for UUID={Uuid}, skipping.", currentResponseItem.Uuid); - skippedCount++; } } - else - { - Logger.LogTrace("SyncCertificates: UUID={Uuid} status {Status} not eligible for sync, skipping.", currentResponseItem.Uuid, certStatus); - skippedCount++; - } } - - Logger.LogDebug("SyncCertificates: completed. Processed={Processed}, Skipped={Skipped}, Total={Total}", - processedCount, skippedCount, certs.Results.Count); } //done public async Task Revoke(string caRequestID, string hexSerialNumber, uint revocationReason) { - using var flow = new FlowLogger(Logger, $"Revoke({caRequestID ?? "null"})"); - Logger.MethodEntry(LogLevel.Debug); - Logger.LogTrace("Revoke called with caRequestID='{CaRequestId}', hexSerialNumber='{SerialNumber}', revocationReason={Reason}", - caRequestID ?? "(null)", hexSerialNumber ?? "(null)", revocationReason); - - 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."); - } - - flow.Step("ValidateInput", () => - { - if (string.IsNullOrEmpty(caRequestID)) - throw new ArgumentNullException(nameof(caRequestID), "caRequestID cannot be null or empty for Revoke."); - if (caRequestID.Length < 36) - throw new ArgumentException($"caRequestID '{caRequestID}' is too short to extract a UUID.", nameof(caRequestID)); - }); - try { - var uuid = caRequestID.Substring(0, 36); - flow.Step("ExtractUUID", $"uuid={uuid}"); - - RevokeResponse revokeResponse = null; - await flow.StepAsync("SubmitRevokeToCSC", async () => - { - revokeResponse = await CscGlobalClient.SubmitRevokeCertificateAsync(uuid); - }); - - if (revokeResponse == null) - { - flow.Fail("ParseResponse", "API returned null"); - throw new InvalidOperationException($"Revoke received null response for UUID '{uuid}'."); - } + 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.LogTrace("Revoke Response JSON: {Json}", JsonConvert.SerializeObject(revokeResponse)); + Logger.LogTrace($"Revoke Response JSON: {JsonConvert.SerializeObject(revokeResponse)}"); + Logger.MethodExit(LogLevel.Debug); var revokeResult = _requestManager.GetRevokeResult(revokeResponse); - flow.Step("MapResult", $"result={revokeResult}"); if (revokeResult == (int)EndEntityStatus.FAILED) - { - var errorDesc = revokeResponse.RegistrationError?.Description; - flow.Fail("RevokeResult", errorDesc ?? "(no description)"); - Logger.LogError("Revoke: failed for UUID='{Uuid}'. Error description: '{ErrorDesc}'", - uuid, errorDesc ?? "(no description)"); - if (!string.IsNullOrEmpty(errorDesc)) - throw new HttpRequestException($"Revoke Failed with message {errorDesc}"); - } + if (!string.IsNullOrEmpty(revokeResponse?.RegistrationError?.Description)) + throw new HttpRequestException( + $"Revoke Failed with message {revokeResponse?.RegistrationError?.Description}"); - Logger.MethodExit(LogLevel.Debug); return revokeResult; } - catch (AggregateException ae) - { - var inner = ae.Flatten().InnerException; - flow.Fail("UNHANDLED", inner?.Message ?? ae.Message); - Logger.LogError(inner, "Revoke: AggregateException for caRequestID='{CaRequestId}': {Message}", caRequestID, inner?.Message ?? ae.Message); - throw new Exception($"Revoke Failed for '{caRequestID}' with message {inner?.Message ?? ae.Message}", inner ?? ae); - } - catch (HttpRequestException) - { - throw; // already logged in flow above - } catch (Exception e) { - flow.Fail("UNHANDLED", e.Message); - Logger.LogError(e, "Revoke: Exception for caRequestID='{CaRequestId}': {Message}", caRequestID, e.Message); - throw new Exception($"Revoke Failed for '{caRequestID}' with message {e.Message}", e); + throw new Exception($"Revoke Failed with message {e?.Message}"); } } @@ -580,431 +209,128 @@ await flow.StepAsync("SubmitRevokeToCSC", async () => public async Task Enroll(string csr, string subject, Dictionary san, EnrollmentProductInfo productInfo, RequestFormat requestFormat, EnrollmentType enrollmentType) { - using var flow = new FlowLogger(Logger, $"Enroll-{enrollmentType}"); Logger.MethodEntry(LogLevel.Debug); - Logger.LogTrace("Enroll called. enrollmentType={EnrollmentType}, subject='{Subject}', productId='{ProductId}', requestFormat={RequestFormat}", - enrollmentType, subject ?? "(null)", - productInfo?.ProductID ?? "(null)", requestFormat); - Logger.LogTrace("Enroll: csr is {CsrStatus}, san has {SanCount} entries, productInfo is {PiStatus}", - string.IsNullOrEmpty(csr) ? "empty/null" : $"present ({csr.Length} chars)", - san?.Count ?? 0, - productInfo == null ? "NULL" : "present"); - - if (!Enabled) - { - flow.Fail("Disabled", "CA is Disabled"); - Logger.LogWarning("The CA is currently in the Disabled state. It must be Enabled to perform operations. Rejecting Enroll."); - return new EnrollmentResult - { - Status = (int)EndEntityStatus.FAILED, - StatusMessage = "The CSC Global CA is in the Disabled state. Enable it to perform enrollments." - }; - } - - flow.Step("ValidateInputs", () => - { - if (productInfo == null) - throw new ArgumentNullException(nameof(productInfo), "productInfo cannot be null for Enroll."); - if (productInfo.ProductParameters == null) - throw new ArgumentNullException(nameof(productInfo), "productInfo.ProductParameters cannot be null for Enroll."); - if (string.IsNullOrEmpty(csr)) - throw new ArgumentNullException(nameof(csr), "CSR cannot be null or empty for Enroll."); - }); - - Logger.LogTrace("Enroll: ProductParameters keys: [{Keys}]", - string.Join(", ", productInfo.ProductParameters.Keys)); 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(); - flow.Step("CheckPriorCertSN", () => + switch (enrollmentType) { - if (productInfo.ProductParameters.ContainsKey("priorcertsn")) - { - if (productInfo.ProductParameters.ContainsKey("PriorCertSN")) + 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")) { - priorSn = productInfo.ProductParameters["PriorCertSN"]; - Logger.LogDebug("Enroll: Prior cert SN: '{PriorSn}'", priorSn ?? "(null)"); + 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 { - Logger.LogWarning("Enroll: 'priorcertsn' key exists but 'PriorCertSN' (case-sensitive) not found."); - } - } - }, string.IsNullOrEmpty(priorSn) ? "none" : $"SN={priorSn}"); - - string uUId; - List customFields = null; - await flow.StepAsync("FetchCustomFields", async () => - { - customFields = await CscGlobalClient.SubmitGetCustomFields(); - }, $"count={customFields?.Count ?? 0}"); - - if (customFields == null) - { - Logger.LogWarning("Enroll: SubmitGetCustomFields returned null, using empty list."); - customFields = new List(); - } - - try - { - switch (enrollmentType) - { - case EnrollmentType.New: - flow.Step("SelectPath", "New Enrollment"); - IRegistrationResponse enrollmentResponse; - if (!productInfo.ProductParameters.ContainsKey("PriorCertSN")) - { - enrollmentRequest = null; - flow.Step("BuildRegistrationRequest", () => - { - enrollmentRequest = _requestManager.GetRegistrationRequest(productInfo, csr, san, customFields); - }); - Logger.LogTrace("Enrollment Request JSON: {Json}", JsonConvert.SerializeObject(enrollmentRequest)); - - RegistrationResponse regResponse = null; - await flow.StepAsync("SubmitRegistrationToCSC", async () => - { - regResponse = await CscGlobalClient.SubmitRegistrationAsync(enrollmentRequest); - }); - enrollmentResponse = regResponse; - - if (enrollmentResponse == null) - { - flow.Fail("ParseResponse", "API returned null"); - return new EnrollmentResult - { - Status = 30, - StatusMessage = "Enrollment failed: CSC API returned a null response." - }; - } - flow.Step("ParseResponse", $"error={enrollmentResponse.RegistrationError != null}"); - Logger.LogTrace("Enrollment Response JSON: {Json}", JsonConvert.SerializeObject(enrollmentResponse)); - } - else - { - flow.Fail("RejectExpiredRenew", "PriorCertSN present on New enrollment"); - return new EnrollmentResult - { - Status = 30, - StatusMessage = "You cannot renew an expired cert please perform an new enrollment." - }; - } - - var enrollResult = _requestManager.GetEnrollmentResult(enrollmentResponse); - flow.Step("MapResult", $"Status={enrollResult?.Status}, ID={enrollResult?.CARequestID ?? "(null)"}"); - - await flow.StepAsync("PublishCnameDcv", async () => - { - await TryPublishCnameDcvAsync(productInfo, enrollResult); - }); - - EnrollmentResult? newPolled = null; - await flow.StepAsync("PollForIssuance", async () => - { - newPolled = await TryPollForIssuedCertAsync(enrollResult?.CARequestID); - }); - if (newPolled != null) - { - flow.Step("PollResult", "issued during poll window"); - Logger.MethodExit(LogLevel.Debug); - return newPolled; - } - - Logger.MethodExit(LogLevel.Debug); - return enrollResult; - - case EnrollmentType.RenewOrReissue: - flow.Step("SelectPath", "RenewOrReissue"); - - if (string.IsNullOrEmpty(priorSn)) - { - flow.Fail("ValidatePriorSN", "PriorCertSN is empty"); - return new EnrollmentResult - { - Status = 30, - StatusMessage = "RenewOrReissue failed: PriorCertSN is required but was not provided." - }; - } - - string order_id = null; - await flow.StepAsync("LookupOrderId", async () => - { - order_id = await _certificateDataReader.GetRequestIDBySerialNumber(priorSn); - }, $"orderId={order_id ?? "(null)"}"); - - if (string.IsNullOrEmpty(order_id)) - { - flow.Fail("ValidateOrderId", $"no order found for SN={priorSn}"); - return new EnrollmentResult - { - Status = 30, - StatusMessage = $"RenewOrReissue failed: could not find order ID for serial number '{priorSn}'." - }; - } - - if (order_id.Length < 36) - { - flow.Fail("ValidateOrderId", $"order_id too short ({order_id.Length} chars)"); - return new EnrollmentResult - { - Status = 30, - StatusMessage = $"RenewOrReissue failed: order ID '{order_id}' is too short to extract a UUID." - }; - } - flow.Step("ValidateOrderId", $"orderId={order_id}"); - - // Determine renew vs reissue based on order expiry window. - var renewal = false; - try - { - CertificateResponse liveCert = null; - await flow.StepAsync("FetchLiveCertForDecision", async () => - { - liveCert = await 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("ComputeRenewalDecision", - $"orderDate={liveCert.OrderDate}, expiry={orderExpiry:dd-MMM-yyyy}, daysLeft={(int)daysUntilOrderExpiry}, window={RenewalWindowDays}, isRenewal={renewal}"); - } - else - { - flow.Skip("ComputeRenewalDecision", "orderDate unavailable, falling back to cert expiry"); - var expirationDate = _certificateDataReader.GetExpirationDateByRequestId(order_id) - ?? (await GetSingleRecord(order_id)).RevocationDate; - renewal = expirationDate < DateTime.Now; - flow.Step("FallbackExpiryCheck", $"expirationDate={expirationDate?.ToString("o") ?? "(null)"}, isRenewal={renewal}"); - } - } - catch (Exception ex) + return new EnrollmentResult { - flow.Fail("FetchLiveCertForDecision", $"falling back: {ex.Message}"); - Logger.LogWarning(ex, "RenewOrReissue: failed to fetch live cert, falling back to cert expiry."); - try - { - var expirationDate = _certificateDataReader.GetExpirationDateByRequestId(order_id) - ?? (await GetSingleRecord(order_id)).RevocationDate; - renewal = expirationDate < DateTime.Now; - flow.Step("FallbackExpiryCheck", $"isRenewal={renewal}"); - } - catch (Exception fallbackEx) - { - flow.Fail("FallbackExpiryCheck", fallbackEx.Message); - return new EnrollmentResult - { - Status = 30, - StatusMessage = $"RenewOrReissue failed: unable to determine renewal status for order '{order_id}'. {fallbackEx.Message}" - }; - } - } - - flow.Step("RenewalDecision", renewal ? "RENEWAL (paid order)" : "REISSUE (free under active order)"); + Status = 30, //failure + StatusMessage = "You cannot renew an expired cert please perform an new enrollment." + }; + } - if (renewal) - { - if (productInfo.ProductParameters.ContainsKey("Applicant Last Name")) - { - uUId = null; - await flow.StepAsync("LookupRenewalUUID", async () => - { - uUId = await _certificateDataReader.GetRequestIDBySerialNumber( - productInfo.ProductParameters["PriorCertSN"]); - }); - - if (string.IsNullOrEmpty(uUId)) - { - flow.Fail("ValidateRenewalUUID", "could not resolve PriorCertSN"); - return new EnrollmentResult - { - Status = 30, - StatusMessage = "Renewal failed: could not resolve prior certificate serial number to a request ID." - }; - } - flow.Step("ValidateRenewalUUID", $"uuid={uUId}"); - - RenewalRequest builtRenewRequest = null; - flow.Step("BuildRenewalRequest", () => - { - builtRenewRequest = _requestManager.GetRenewalRequest(productInfo, uUId, csr, san, customFields); - }); - renewRequest = builtRenewRequest; - Logger.LogTrace("Renewal Request JSON: {Json}", JsonConvert.SerializeObject(renewRequest)); - - RenewalResponse renewResponse = null; - await flow.StepAsync("SubmitRenewalToCSC", async () => - { - renewResponse = await CscGlobalClient.SubmitRenewalAsync(renewRequest); - }); - - if (renewResponse == null) - { - flow.Fail("ParseRenewalResponse", "API returned null"); - return new EnrollmentResult - { - Status = 30, - StatusMessage = "Renewal failed: CSC API returned a null response." - }; - } - - Logger.LogTrace("Renewal Response JSON: {Json}", JsonConvert.SerializeObject(renewResponse)); - var renewResult = _requestManager.GetRenewResponse(renewResponse); - flow.Step("MapRenewalResult", $"Status={renewResult?.Status}, Message={renewResult?.StatusMessage ?? "(null)"}"); - - EnrollmentResult? renewPolled = null; - await flow.StepAsync("PollForIssuance", async () => - { - renewPolled = await TryPollForIssuedCertAsync(renewResult?.CARequestID); - }); - Logger.MethodExit(LogLevel.Debug); - return renewPolled ?? renewResult; - } - - flow.Fail("MissingEnrollmentParams", "Applicant Last Name not present — one-click renew unavailable"); - return new EnrollmentResult - { - Status = 30, - StatusMessage = - "One click Renew Is Not Available for this Certificate Type. Use the configure button instead." - }; - } + 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; + } - // Reissue path + if (expirationDate < DateTime.Now) renewal = true; + if (renewal) + { + //One click won't work for this implementation b/c we are missing enrollment params if (productInfo.ProductParameters.ContainsKey("Applicant Last Name")) { - string requestid = null; - await flow.StepAsync("LookupReissueRequestId", async () => - { - requestid = await _certificateDataReader.GetRequestIDBySerialNumber( - productInfo.ProductParameters["PriorCertSN"]); - }); - - if (string.IsNullOrEmpty(requestid)) - { - flow.Fail("ValidateReissueRequestId", "could not resolve PriorCertSN"); - return new EnrollmentResult - { - Status = 30, - StatusMessage = "Reissue failed: could not resolve prior certificate serial number to a request ID." - }; - } - - if (requestid.Length < 36) - { - flow.Fail("ValidateReissueRequestId", $"requestid too short ({requestid.Length} chars)"); - return new EnrollmentResult - { - Status = 30, - StatusMessage = $"Reissue failed: request ID '{requestid}' is too short to extract a UUID." - }; - } - - uUId = requestid.Substring(0, 36); - flow.Step("ExtractReissueUUID", $"uuid={uUId}"); - - ReissueRequest builtReissueRequest = null; - flow.Step("BuildReissueRequest", () => - { - builtReissueRequest = _requestManager.GetReissueRequest(productInfo, uUId, csr, san, customFields); - }); - reissueRequest = builtReissueRequest; - Logger.LogTrace("Reissue JSON: {Json}", JsonConvert.SerializeObject(reissueRequest)); - - ReissueResponse reissueResponse = null; - await flow.StepAsync("SubmitReissueToCSC", async () => - { - reissueResponse = await CscGlobalClient.SubmitReissueAsync(reissueRequest); - }); - - if (reissueResponse == null) - { - flow.Fail("ParseReissueResponse", "API returned null"); - return new EnrollmentResult - { - Status = 30, - StatusMessage = "Reissue failed: CSC API returned a null response." - }; - } - - Logger.LogTrace("Reissue Response JSON: {Json}", JsonConvert.SerializeObject(reissueResponse)); - var reissueResult = _requestManager.GetReIssueResult(reissueResponse); - flow.Step("MapReissueResult", $"Status={reissueResult?.Status}, Message={reissueResult?.StatusMessage ?? "(null)"}"); - - EnrollmentResult? reissuePolled = null; - await flow.StepAsync("PollForIssuance", async () => - { - reissuePolled = await TryPollForIssuedCertAsync(reissueResult?.CARequestID); - }); + //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 = Task.Run(async () => await CscGlobalClient.SubmitRenewalAsync(renewRequest)) + .Result; + Logger.LogTrace($"Renewal Response JSON: {JsonConvert.SerializeObject(renewResponse)}"); Logger.MethodExit(LogLevel.Debug); - return reissuePolled ?? reissueResult; + return _requestManager.GetRenewResponse(renewResponse); } - flow.Fail("MissingEnrollmentParams", "Applicant Last Name not present — one-click reissue unavailable"); return new EnrollmentResult { - Status = 30, + Status = 30, //failure StatusMessage = "One click Renew Is Not Available for this Certificate Type. Use the configure button instead." }; + } - default: - flow.Fail("UnhandledType", $"enrollmentType={enrollmentType}"); - return new EnrollmentResult - { - Status = 30, - StatusMessage = $"Enroll failed: unhandled enrollment type '{enrollmentType}'." - }; - } - } - catch (AggregateException ae) - { - var inner = ae.Flatten().InnerException; - flow.Fail("UNHANDLED", inner?.Message ?? ae.Message); - Logger.LogError(inner, "Enroll: AggregateException during {EnrollmentType}: {Message}", enrollmentType, inner?.Message ?? ae.Message); - return new EnrollmentResult - { - Status = 30, - StatusMessage = $"Enrollment failed with error: {inner?.Message ?? ae.Message}" - }; - } - catch (Exception ex) - { - flow.Fail("UNHANDLED", ex.Message); - Logger.LogError(ex, "Enroll: unhandled exception during {EnrollmentType}: {Message}", enrollmentType, ex.Message); - return new EnrollmentResult - { - Status = 30, - StatusMessage = $"Enrollment failed with error: {ex.Message}" - }; + 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.MethodExit(LogLevel.Debug); + return null; } //done public async Task Ping() { Logger.MethodEntry(); - Logger.LogTrace("Ping: Enabled={Enabled}, CscGlobalClient is {Null}", Enabled, CscGlobalClient == null ? "NULL" : "present"); - - 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"); } catch (Exception e) { - Logger.LogError(e, "There was an error contacting CSCGlobal: {Message}", e.Message); + Logger.LogError($"There was an error contacting CSCGlobal: {e.Message}."); throw new Exception($"Error attempting to ping CSCGlobal: {e.Message}.", e); } @@ -1014,83 +340,19 @@ public async Task Ping() //do public async Task ValidateCAConnectionInfo(Dictionary connectionInfo) { - Logger.MethodEntry(LogLevel.Debug); - Logger.LogTrace("ValidateCAConnectionInfo called. connectionInfo is {Null}, keys=[{Keys}]", - connectionInfo == null ? "NULL" : "present", - connectionInfo != null ? string.Join(", ", connectionInfo.Keys) : ""); - - if (connectionInfo == null) - { - Logger.LogError("ValidateCAConnectionInfo: connectionInfo is null."); - throw new ArgumentNullException(nameof(connectionInfo), "connectionInfo cannot be null."); - } - - // 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.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); } //do public async Task ValidateProductInfo(EnrollmentProductInfo productInfo, Dictionary connectionInfo) { - Logger.MethodEntry(LogLevel.Debug); - Logger.LogTrace("ValidateProductInfo called. productInfo is {Null}, productId='{ProductId}'", - productInfo == null ? "NULL" : "present", - productInfo?.ProductID ?? "(null)"); - - if (productInfo == null) - { - Logger.LogError("ValidateProductInfo: productInfo is null."); - throw new ArgumentNullException(nameof(productInfo), "productInfo cannot be null."); - } - - // 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; - } - - if (string.IsNullOrEmpty(productInfo.ProductID)) - { - Logger.LogError("ValidateProductInfo: productInfo.ProductID is null or empty."); - throw new ArgumentException("ProductID cannot be null or empty.", nameof(productInfo)); - } - var certType = ProductIDs.productIds.Find(x => x.Equals(productInfo.ProductID, StringComparison.InvariantCultureIgnoreCase)); - if (certType == null) - { - Logger.LogError("ValidateProductInfo: cannot find product ID '{ProductId}'. Known IDs: [{KnownIds}]", - productInfo.ProductID, string.Join(", ", ProductIDs.productIds)); - throw new ArgumentException($"Cannot find {productInfo.ProductID}", "ProductId"); - } + if (certType == null) throw new ArgumentException($"Cannot find {productInfo.ProductID}", "ProductId"); + + Logger.LogInformation($"Validated {certType} ({certType})configured for AnyGateway"); - Logger.LogInformation("Validated {CertType} configured for AnyGateway", certType); - Logger.MethodExit(LogLevel.Debug); } //done @@ -1098,13 +360,6 @@ public Dictionary GetCAConnectorAnnotations() { return new Dictionary { - [Constants.Enabled] = new() - { - Comments = "Flag to Enable or Disable gateway functionality. Disabling is primarily used to allow creation of the CA prior to configuration information being available.", - Hidden = false, - DefaultValue = true, - Type = "Boolean" - }, [Constants.CscGlobalUrl] = new() { Comments = "CSCGlobal API URL", @@ -1133,25 +388,18 @@ public Dictionary GetCAConnectorAnnotations() DefaultValue = "100", Type = "String" }, - [Constants.SyncFilterDays] = new() + [Constants.TemplateSync] = new() { - Comments = "Number of days from today to filter certificates by expiration date during incremental sync.", + Comments = "Enable template sync.", Hidden = false, - DefaultValue = "5", - Type = "Number" + DefaultValue = "false", + Type = "Bool" }, - [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" - }, - [Constants.DcvPollTimeoutSeconds] = new() + [Constants.SyncFilterDays] = new() { - Comments = "Max seconds to synchronously poll CSC for issuance after submitting an order (and publishing CNAME DCV). 0 disables polling (enrollment returns pending immediately; cert arrives on next sync). When >0, fast-validating orders can return the cert directly. Keep small to avoid long-blocking enrollment requests.", + Comments = "Number of days from today to filter certificates by expiration date during incremental sync.", Hidden = false, - DefaultValue = "0", + DefaultValue = "5", Type = "Number" } }; @@ -1269,206 +517,6 @@ public List GetProductIds() #region PRIVATE - /// - /// Strip a single trailing dot from a DNS name. CSC returns FQDN-canonical names with - /// a trailing dot but the framework's Domain Validation Configurations are stored without - /// one, so the strings have to be normalized before lookup or the equality check fails. - /// - private static string StripTrailingDot(string? s) - { - if (string.IsNullOrEmpty(s)) return s ?? string.Empty; - return s.EndsWith('.') ? s[..^1] : s; - } - - /// - /// Synchronously poll CSC for issuance of the order identified by , - /// up to . Returns a GENERATED - /// carrying the issued leaf certificate if CSC issues within the window, or null if the - /// window expires (in which case the caller falls back to its pending/EXTERNALVALIDATION result). - /// No-op (returns null) when polling is disabled or the uuid is missing. - /// - private async Task TryPollForIssuedCertAsync(string? uuid) - { - if (DcvPollTimeoutSeconds <= 0) - { - Logger.LogTrace("TryPollForIssuedCertAsync: polling disabled (DcvPollTimeoutSeconds=0), skipping."); - return null; - } - - if (string.IsNullOrEmpty(uuid)) - { - Logger.LogWarning("TryPollForIssuedCertAsync: no UUID/CARequestID to poll, skipping."); - return null; - } - - var deadline = DateTime.UtcNow.AddSeconds(DcvPollTimeoutSeconds); - Logger.LogInformation("TryPollForIssuedCertAsync: polling CSC for issuance of '{Uuid}' for up to {Seconds}s (interval {Interval}s).", - uuid, DcvPollTimeoutSeconds, (int)DcvPollInterval.TotalSeconds); - - var attempt = 0; - while (DateTime.UtcNow < deadline) - { - attempt++; - AnyCAPluginCertificate record; - try - { - record = await GetSingleRecord(uuid); - } - catch (Exception ex) - { - Logger.LogWarning(ex, "TryPollForIssuedCertAsync: poll attempt {Attempt} for '{Uuid}' threw, will retry. {Error}", - attempt, uuid, ex.Message); - record = null; - } - - if (record != null) - { - Logger.LogTrace("TryPollForIssuedCertAsync: attempt {Attempt} for '{Uuid}' — status={Status}, cert={CertState}.", - attempt, uuid, record.Status, string.IsNullOrEmpty(record.Certificate) ? "empty" : "present"); - - if (record.Status == (int)EndEntityStatus.GENERATED && !string.IsNullOrEmpty(record.Certificate)) - { - Logger.LogInformation("TryPollForIssuedCertAsync: '{Uuid}' issued after {Attempt} poll(s); returning cert directly.", uuid, attempt); - return new EnrollmentResult - { - Status = (int)EndEntityStatus.GENERATED, - CARequestID = uuid, - Certificate = record.Certificate, - StatusMessage = $"Certificate issued and retrieved for order {uuid}." - }; - } - } - - // Don't sleep past the deadline. - if (DateTime.UtcNow.Add(DcvPollInterval) >= deadline) - break; - - await Task.Delay(DcvPollInterval); - } - - Logger.LogInformation("TryPollForIssuedCertAsync: '{Uuid}' not issued within {Seconds}s after {Attempts} attempt(s); falling back to pending.", - uuid, DcvPollTimeoutSeconds, attempt); - return null; - } - - /// - /// Publishes CNAME DCV records via the gateway framework's . - /// Per-record resolution: each record is routed to whichever DNS provider plugin the framework - /// resolves for its domain. No-op if the factory wasn't injected, the cert isn't using CNAME - /// validation, or the response contains no CNAME details. Failures are logged but never thrown — - /// manual publishing remains a fallback so the enrollment result is still returned to Keyfactor. - /// - private async Task TryPublishCnameDcvAsync(EnrollmentProductInfo productInfo, EnrollmentResult? enrollResult) - { - if (_validatorFactory == null) - { - Logger.LogTrace("TryPublishCnameDcvAsync: no IDomainValidatorFactory was injected, skipping auto-publish."); - return; - } - - if (enrollResult?.EnrollmentContext == null || enrollResult.EnrollmentContext.Count == 0) - { - Logger.LogTrace("TryPublishCnameDcvAsync: no CNAME entries in EnrollmentContext, skipping."); - return; - } - - var dcvMethod = productInfo?.ProductParameters != null - && productInfo.ProductParameters.TryGetValue(EnrollmentConfigConstants.DomainControlValidationMethod, out var m) - ? m - : null; - - if (string.IsNullOrEmpty(dcvMethod) || - !string.Equals(dcvMethod, "CNAME", StringComparison.OrdinalIgnoreCase)) - { - Logger.LogTrace("TryPublishCnameDcvAsync: DCV method '{Method}' is not CNAME, skipping auto-publish.", dcvMethod ?? "(null)"); - return; - } - - Logger.LogInformation( - "TryPublishCnameDcvAsync: attempting to publish {Count} CNAME record(s) via framework DNS providers (validation type '{Type}').", - enrollResult.EnrollmentContext.Count, DNS_VALIDATION_TYPE); - - var successCount = 0; - var failCount = 0; - var unresolvedCount = 0; - - foreach (var entry in enrollResult.EnrollmentContext) - { - var rawRecordName = entry.Key; - var rawCnameTarget = entry.Value; - - // CSC may also surface DCV email entries in this dictionary (key == value). Skip those. - if (string.Equals(rawRecordName, rawCnameTarget, StringComparison.OrdinalIgnoreCase)) - { - Logger.LogTrace("TryPublishCnameDcvAsync: skipping entry '{Key}' (looks like an email DCV passthrough, not a CNAME).", rawRecordName); - continue; - } - - // CSC returns FQDN-canonical names with trailing dots (e.g. "foo.example.com."). - // The framework's Domain Validation Configuration stores domain patterns without - // the trailing dot, so strip it before resolution and publishing or no provider - // will match (the framework will look up "*.example.com." which won't equal "*.example.com"). - var recordName = StripTrailingDot(rawRecordName); - var cnameTarget = StripTrailingDot(rawCnameTarget); - - if (recordName != rawRecordName) - Logger.LogTrace("TryPublishCnameDcvAsync: normalized record name '{Raw}' -> '{Normalized}'.", rawRecordName, recordName); - - IDomainValidator? validator; - try - { - validator = _validatorFactory.ResolveDomainValidator(recordName, DNS_VALIDATION_TYPE); - } - catch (Exception ex) - { - unresolvedCount++; - Logger.LogWarning(ex, "ResolveDomainValidator threw for '{Record}' (type '{Type}'): {Error}", - recordName, DNS_VALIDATION_TYPE, ex.Message); - continue; - } - - if (validator == null) - { - unresolvedCount++; - Logger.LogWarning( - "No DNS provider matched domain '{Record}' for validation type '{Type}'. Manual publish required for this record.", - recordName, DNS_VALIDATION_TYPE); - continue; - } - - try - { - Logger.LogTrace("StageValidation: '{Name}' -> '{Target}' via validator type '{ValType}'.", - recordName, cnameTarget, validator.GetValidationType()); - var result = await validator.StageValidation(recordName, cnameTarget, CancellationToken.None); - - if (result?.Success == true) - { - successCount++; - Logger.LogInformation("Published CNAME '{Name}' -> '{Target}' (status='{Status}').", - recordName, cnameTarget, result.Status ?? "(none)"); - } - else - { - failCount++; - Logger.LogWarning( - "StageValidation reported failure for CNAME '{Name}'. Status='{Status}', Error='{Error}'. Manual publish may be required.", - recordName, result?.Status ?? "(none)", result?.ErrorMessage ?? "(none)"); - } - } - catch (Exception ex) - { - failCount++; - Logger.LogError(ex, "StageValidation threw publishing CNAME '{Name}'. Manual publish may be required. {Error}", - recordName, ex.Message); - } - } - - Logger.LogInformation( - "TryPublishCnameDcvAsync: complete. Published={Published}, Failed={Failed}, Unresolved={Unresolved}", - successCount, failCount, unresolvedCount); - } - //Trying to fix leaf extraction private static readonly Regex PemBlock = new( "-----BEGIN CERTIFICATE-----\\s*(?[A-Za-z0-9+/=\\r\\n]+?)\\s*-----END CERTIFICATE-----", diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.csproj b/cscglobal-caplugin/CSCGlobalCAPlugin.csproj index e5f5ff7..5118677 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.csproj +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.csproj @@ -3,7 +3,7 @@ true - net10.0 + net6.0;net8.0 Keyfactor.Extensions.CAPlugin.CSCGlobal true enable @@ -16,16 +16,24 @@ - - + + + + + + + + + + Always - + \ No newline at end of file diff --git a/cscglobal-caplugin/Client/CscGlobalClient.cs b/cscglobal-caplugin/Client/CscGlobalClient.cs index 032f535..0a5c7c5 100644 --- a/cscglobal-caplugin/Client/CscGlobalClient.cs +++ b/cscglobal-caplugin/Client/CscGlobalClient.cs @@ -23,58 +23,13 @@ public sealed class CscGlobalClient : ICscGlobalClient public CscGlobalClient(IAnyCAPluginConfigProvider config) { - Logger = LogHandler.GetClassLogger(); - - if (config == null) - throw new ArgumentNullException(nameof(config), "config cannot be null in CscGlobalClient constructor."); - - if (config.CAConnectionData == null) - throw new InvalidOperationException("CAConnectionData is null on config provider."); - - Logger.LogTrace("CscGlobalClient: CAConnectionData keys=[{Keys}]", string.Join(", ", config.CAConnectionData.Keys)); - + Logger = LogHandler.GetClassLogger(); if (config.CAConnectionData.ContainsKey(Constants.CscGlobalApiKey)) { - var rawUrl = config.CAConnectionData.ContainsKey(Constants.CscGlobalUrl) - ? config.CAConnectionData[Constants.CscGlobalUrl]?.ToString() - : null; - if (string.IsNullOrEmpty(rawUrl)) - { - Logger.LogError("CscGlobalClient: CscGlobalUrl is missing or empty in CAConnectionData."); - throw new InvalidOperationException("CscGlobalUrl is required but was not configured."); - } - - Logger.LogTrace("CscGlobalClient: BaseUrl='{BaseUrl}'", rawUrl); - BaseUrl = new Uri(rawUrl); - - ApiKey = config.CAConnectionData[Constants.CscGlobalApiKey]?.ToString(); - if (string.IsNullOrEmpty(ApiKey)) - { - Logger.LogError("CscGlobalClient: ApiKey is empty or null."); - throw new InvalidOperationException("ApiKey is required but was not configured."); - } - Logger.LogTrace("CscGlobalClient: ApiKey is present (length={Length}).", ApiKey.Length); - - if (!config.CAConnectionData.ContainsKey(Constants.BearerToken)) - { - Logger.LogError("CscGlobalClient: BearerToken key not found in CAConnectionData."); - throw new InvalidOperationException("BearerToken is required but was not configured."); - } - Authorization = config.CAConnectionData[Constants.BearerToken]?.ToString(); - if (string.IsNullOrEmpty(Authorization)) - { - Logger.LogError("CscGlobalClient: BearerToken is empty or null."); - throw new InvalidOperationException("BearerToken is required but was empty."); - } - Logger.LogTrace("CscGlobalClient: BearerToken is present (length={Length}).", Authorization.Length); - + BaseUrl = new Uri(config.CAConnectionData[Constants.CscGlobalUrl].ToString()); + ApiKey = config.CAConnectionData[Constants.CscGlobalApiKey].ToString(); + Authorization = config.CAConnectionData[Constants.BearerToken].ToString(); RestClient = ConfigureRestClient(); - Logger.LogTrace("CscGlobalClient: RestClient configured successfully."); - } - else - { - Logger.LogError("CscGlobalClient: ApiKey key '{Key}' not found in CAConnectionData. Client will not be functional.", Constants.CscGlobalApiKey); - throw new InvalidOperationException($"Required key '{Constants.CscGlobalApiKey}' not found in CAConnectionData."); } } @@ -86,42 +41,25 @@ public CscGlobalClient(IAnyCAPluginConfigProvider config) public async Task SubmitRegistrationAsync( RegistrationRequest registerRequest) { - Logger.LogTrace("SubmitRegistrationAsync: sending registration request..."); - if (registerRequest == null) - throw new ArgumentNullException(nameof(registerRequest)); - - var requestJson = JsonConvert.SerializeObject(registerRequest); - Logger.LogTrace("SubmitRegistrationAsync: request JSON: {Json}", requestJson); - using (var resp = await RestClient.PostAsync("/dbs/api/v2/tls/registration", new StringContent( - requestJson, Encoding.ASCII, "application/json"))) + JsonConvert.SerializeObject(registerRequest), Encoding.ASCII, "application/json"))) { - var rawBody = await resp.Content.ReadAsStringAsync(); - Logger.LogTrace("SubmitRegistrationAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); - Logger.LogTrace("SubmitRegistrationAsync: response body: {Body}", rawBody ?? "(null)"); - + Logger.LogTrace(JsonConvert.SerializeObject(registerRequest)); var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; - if (resp.StatusCode == HttpStatusCode.BadRequest) + if (resp.StatusCode == HttpStatusCode.BadRequest) //Csc Sends Errors back in 400 Json Response { - Logger.LogWarning("SubmitRegistrationAsync: received 400 BadRequest."); - var errorResponse = JsonConvert.DeserializeObject(rawBody ?? "{}", settings); - Logger.LogTrace("SubmitRegistrationAsync: error description='{Desc}'", errorResponse?.Description ?? "(null)"); + var errorResponse = + JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync(), + settings); var response = new RegistrationResponse(); response.RegistrationError = errorResponse; response.Result = null; return response; } - if (!resp.IsSuccessStatusCode) - { - Logger.LogError("SubmitRegistrationAsync: unexpected HTTP {StatusCode}: {Body}", (int)resp.StatusCode, rawBody); - throw new HttpRequestException($"SubmitRegistrationAsync failed with HTTP {(int)resp.StatusCode}: {rawBody}"); - } - - var registrationResponse = JsonConvert.DeserializeObject(rawBody ?? "{}", settings); - Logger.LogTrace("SubmitRegistrationAsync: deserialized response. Result is {Null}, RegistrationError is {Null2}", - registrationResponse?.Result == null ? "null" : "present", - registrationResponse?.RegistrationError == null ? "null" : "present"); + var registrationResponse = + JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync(), + settings); return registrationResponse; } } @@ -129,42 +67,31 @@ public async Task SubmitRegistrationAsync( public async Task SubmitRenewalAsync( RenewalRequest renewalRequest) { - Logger.LogTrace("SubmitRenewalAsync: sending renewal request..."); - if (renewalRequest == null) - throw new ArgumentNullException(nameof(renewalRequest)); - - var requestJson = JsonConvert.SerializeObject(renewalRequest); - Logger.LogTrace("SubmitRenewalAsync: request JSON: {Json}", requestJson); - using (var resp = await RestClient.PostAsync("/dbs/api/v2/tls/renewal", new StringContent( - requestJson, Encoding.ASCII, "application/json"))) + JsonConvert.SerializeObject(renewalRequest), Encoding.ASCII, "application/json"))) { - var rawBody = await resp.Content.ReadAsStringAsync(); - Logger.LogTrace("SubmitRenewalAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); - Logger.LogTrace("SubmitRenewalAsync: response body: {Body}", rawBody ?? "(null)"); + Logger.LogTrace(JsonConvert.SerializeObject(renewalRequest)); var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; - if (resp.StatusCode == HttpStatusCode.BadRequest) - { - Logger.LogWarning("SubmitRenewalAsync: received 400 BadRequest."); - var errorResponse = JsonConvert.DeserializeObject(rawBody ?? "{}", settings); - Logger.LogTrace("SubmitRenewalAsync: error description='{Desc}'", errorResponse?.Description ?? "(null)"); + if (resp.StatusCode == HttpStatusCode.BadRequest) //Csc Sends Errors back in 400 Json Response + { + var rawErrorResponse = await resp.Content.ReadAsStringAsync(); + Logger.LogTrace("Logging Error Response Raw"); + Logger.LogTrace(rawErrorResponse); + var errorResponse = + JsonConvert.DeserializeObject(rawErrorResponse, + settings); var response = new RenewalResponse(); response.RegistrationError = errorResponse; response.Result = null; return response; } - if (!resp.IsSuccessStatusCode) - { - Logger.LogError("SubmitRenewalAsync: unexpected HTTP {StatusCode}: {Body}", (int)resp.StatusCode, rawBody); - throw new HttpRequestException($"SubmitRenewalAsync failed with HTTP {(int)resp.StatusCode}: {rawBody}"); - } - - var renewalResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); - Logger.LogTrace("SubmitRenewalAsync: deserialized response. Result is {Null}, RegistrationError is {Null2}", - renewalResponse?.Result == null ? "null" : "present", - renewalResponse?.RegistrationError == null ? "null" : "present"); + var rawRenewResponse = await resp.Content.ReadAsStringAsync(); + Logger.LogTrace("Logging Success Response Raw"); + Logger.LogTrace(rawRenewResponse); + var renewalResponse = + JsonConvert.DeserializeObject(rawRenewResponse); return renewalResponse; } } @@ -172,145 +99,69 @@ public async Task SubmitRenewalAsync( public async Task SubmitReissueAsync( ReissueRequest reissueRequest) { - Logger.LogTrace("SubmitReissueAsync: sending reissue request..."); - if (reissueRequest == null) - throw new ArgumentNullException(nameof(reissueRequest)); - - var requestJson = JsonConvert.SerializeObject(reissueRequest); - Logger.LogTrace("SubmitReissueAsync: request JSON: {Json}", requestJson); - using (var resp = await RestClient.PostAsync("/dbs/api/v2/tls/reissue", new StringContent( - requestJson, Encoding.ASCII, "application/json"))) + JsonConvert.SerializeObject(reissueRequest), Encoding.ASCII, "application/json"))) { - var rawBody = await resp.Content.ReadAsStringAsync(); - Logger.LogTrace("SubmitReissueAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); - Logger.LogTrace("SubmitReissueAsync: response body: {Body}", rawBody ?? "(null)"); + Logger.LogTrace(JsonConvert.SerializeObject(reissueRequest)); var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; - if (resp.StatusCode == HttpStatusCode.BadRequest) + if (resp.StatusCode == HttpStatusCode.BadRequest) //Csc Sends Errors back in 400 Json Response { - Logger.LogWarning("SubmitReissueAsync: received 400 BadRequest."); - var errorResponse = JsonConvert.DeserializeObject(rawBody ?? "{}", settings); - Logger.LogTrace("SubmitReissueAsync: error description='{Desc}'", errorResponse?.Description ?? "(null)"); + var errorResponse = + JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync(), + settings); var response = new ReissueResponse(); response.RegistrationError = errorResponse; response.Result = null; return response; } - if (!resp.IsSuccessStatusCode) - { - Logger.LogError("SubmitReissueAsync: unexpected HTTP {StatusCode}: {Body}", (int)resp.StatusCode, rawBody); - throw new HttpRequestException($"SubmitReissueAsync failed with HTTP {(int)resp.StatusCode}: {rawBody}"); - } - - var reissueResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); - Logger.LogTrace("SubmitReissueAsync: deserialized response. Result is {Null}, RegistrationError is {Null2}", - reissueResponse?.Result == null ? "null" : "present", - reissueResponse?.RegistrationError == null ? "null" : "present"); + var reissueResponse = + JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); return reissueResponse; } } public async Task SubmitGetCertificateAsync(string certificateId) { - Logger.LogTrace("SubmitGetCertificateAsync: fetching certificate for id='{CertificateId}'", certificateId ?? "(null)"); - - if (string.IsNullOrEmpty(certificateId)) - throw new ArgumentNullException(nameof(certificateId), "certificateId cannot be null or empty."); - using (var resp = await RestClient.GetAsync($"/dbs/api/v2/tls/certificate/{certificateId}")) { - var rawBody = await resp.Content.ReadAsStringAsync(); - Logger.LogTrace("SubmitGetCertificateAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); - - if (!resp.IsSuccessStatusCode) - { - Logger.LogError("SubmitGetCertificateAsync: HTTP {StatusCode} for certificateId='{CertificateId}': {Body}", - (int)resp.StatusCode, certificateId, rawBody); - resp.EnsureSuccessStatusCode(); // will throw - } - - Logger.LogTrace("SubmitGetCertificateAsync: response body: {Body}", rawBody ?? "(null)"); - var getCertificateResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); - Logger.LogTrace("SubmitGetCertificateAsync: deserialized. Status='{Status}', OrderDate='{OrderDate}', Certificate is {Null}", - getCertificateResponse?.Status ?? "(null)", - getCertificateResponse?.OrderDate ?? "(null)", - string.IsNullOrEmpty(getCertificateResponse?.Certificate) ? "empty/null" : "present"); + resp.EnsureSuccessStatusCode(); + var getCertificateResponse = + JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); return getCertificateResponse; } } public async Task> SubmitGetCustomFields() { - Logger.LogTrace("SubmitGetCustomFields: fetching custom fields..."); - using (var resp = await RestClient.GetAsync("/dbs/api/v2/admin/customfields")) { - var rawBody = await resp.Content.ReadAsStringAsync(); - Logger.LogTrace("SubmitGetCustomFields: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); - - if (!resp.IsSuccessStatusCode) - { - Logger.LogError("SubmitGetCustomFields: HTTP {StatusCode}: {Body}", (int)resp.StatusCode, rawBody); - resp.EnsureSuccessStatusCode(); // will throw - } - - Logger.LogTrace("SubmitGetCustomFields: response body: {Body}", rawBody ?? "(null)"); - var getCustomFieldsResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); - - if (getCustomFieldsResponse == null) - { - Logger.LogWarning("SubmitGetCustomFields: deserialized response is null, returning empty list."); - return new List(); - } - - if (getCustomFieldsResponse.CustomFields == null) - { - Logger.LogWarning("SubmitGetCustomFields: CustomFields property is null, returning empty list."); - return new List(); - } - - Logger.LogTrace("SubmitGetCustomFields: received {Count} custom fields.", getCustomFieldsResponse.CustomFields.Count); + resp.EnsureSuccessStatusCode(); + var getCustomFieldsResponse = + JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); return getCustomFieldsResponse.CustomFields; } } public async Task SubmitRevokeCertificateAsync(string uuId) { - Logger.LogTrace("SubmitRevokeCertificateAsync: revoking certificate UUID='{Uuid}'", uuId ?? "(null)"); - - if (string.IsNullOrEmpty(uuId)) - throw new ArgumentNullException(nameof(uuId), "uuId cannot be null or empty."); - using (var resp = await RestClient.PutAsync($"/dbs/api/v2/tls/revoke/{uuId}", new StringContent(""))) { - var rawBody = await resp.Content.ReadAsStringAsync(); - Logger.LogTrace("SubmitRevokeCertificateAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); - Logger.LogTrace("SubmitRevokeCertificateAsync: response body: {Body}", rawBody ?? "(null)"); - var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; - if (resp.StatusCode == HttpStatusCode.BadRequest) + if (resp.StatusCode == HttpStatusCode.BadRequest) //Csc Sends Errors back in 400 Json Response { - Logger.LogWarning("SubmitRevokeCertificateAsync: received 400 BadRequest for UUID='{Uuid}'.", uuId); - var errorResponse = JsonConvert.DeserializeObject(rawBody ?? "{}", settings); - Logger.LogTrace("SubmitRevokeCertificateAsync: error description='{Desc}'", errorResponse?.Description ?? "(null)"); + var errorResponse = + JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync(), + settings); var response = new RevokeResponse(); response.RegistrationError = errorResponse; response.RevokeSuccess = null; return response; } - if (!resp.IsSuccessStatusCode) - { - Logger.LogError("SubmitRevokeCertificateAsync: unexpected HTTP {StatusCode} for UUID='{Uuid}': {Body}", (int)resp.StatusCode, uuId, rawBody); - throw new HttpRequestException($"SubmitRevokeCertificateAsync failed with HTTP {(int)resp.StatusCode}: {rawBody}"); - } - - var getRevokeResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); - Logger.LogTrace("SubmitRevokeCertificateAsync: deserialized. RevokeSuccess is {Null}, RegistrationError is {Null2}", - getRevokeResponse?.RevokeSuccess == null ? "null" : "present", - getRevokeResponse?.RegistrationError == null ? "null" : "present"); + var getRevokeResponse = + JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); return getRevokeResponse; } } @@ -318,37 +169,23 @@ public async Task SubmitRevokeCertificateAsync(string uuId) public async Task SubmitCertificateListRequestAsync(string? dateFilter = null) { Logger.MethodEntry(LogLevel.Debug); - Logger.LogTrace("SubmitCertificateListRequestAsync: dateFilter='{DateFilter}'", dateFilter ?? "(null)"); - var filterQuery = "filter=status=in=(ACTIVE,REVOKED)"; if (!string.IsNullOrEmpty(dateFilter)) { filterQuery += $";effectiveDate=ge={dateFilter}"; } - Logger.LogTrace("SubmitCertificateListRequestAsync: filter query: {FilterQuery}", filterQuery); - + Logger.LogTrace($"Certificate list filter query: {filterQuery}"); var resp = RestClient.GetAsync($"/dbs/api/v2/tls/certificate?{filterQuery}").Result; - var rawBody = await resp.Content.ReadAsStringAsync(); - Logger.LogTrace("SubmitCertificateListRequestAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); if (!resp.IsSuccessStatusCode) { + var responseMessage = resp.Content.ReadAsStringAsync().Result; Logger.LogError( - "SubmitCertificateListRequestAsync: failed request. StatusCode={StatusCode}, Body={Body}", - (int)resp.StatusCode, rawBody); - } - - var certificateListResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); - - if (certificateListResponse == null) - { - Logger.LogWarning("SubmitCertificateListRequestAsync: deserialized response is null."); - return new CertificateListResponse(); + $"Failed Request to Keyfactor. Retrying request. Status Code {resp.StatusCode} | Message: {responseMessage}"); } - Logger.LogTrace("SubmitCertificateListRequestAsync: Results count={Count}", - certificateListResponse.Results?.Count ?? 0); - Logger.MethodExit(LogLevel.Debug); + var certificateListResponse = + JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); return certificateListResponse; } diff --git a/cscglobal-caplugin/Constants.cs b/cscglobal-caplugin/Constants.cs index dc10866..4d6b4da 100644 --- a/cscglobal-caplugin/Constants.cs +++ b/cscglobal-caplugin/Constants.cs @@ -9,14 +9,12 @@ 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"; public static string DefaultPageSize = "DefaultPageSize"; + public static string TemplateSync = "TemplateSync"; public static string SyncFilterDays = "SyncFilterDays"; - public static string RenewalWindowDays = "RenewalWindowDays"; - public static string DcvPollTimeoutSeconds = "DcvPollTimeoutSeconds"; } public class ProductIDs @@ -28,8 +26,8 @@ public class ProductIDs "CSC TrustedSecure UC Certificate", "CSC TrustedSecure Premium Wildcard Certificate", "CSC TrustedSecure Domain Validated SSL", - "CSC Trusted Secure Domain Validated Wildcard SSL", - "CSC Trusted Secure Domain Validated UC Certificate" + "CSC TrustedSecure Domain Validated Wildcard SSL", + "CSC TrustedSecure Domain Validated UC Certificate" }; } diff --git a/cscglobal-caplugin/FlowLogger.cs b/cscglobal-caplugin/FlowLogger.cs deleted file mode 100644 index 5696fcd..0000000 --- a/cscglobal-caplugin/FlowLogger.cs +++ /dev/null @@ -1,241 +0,0 @@ -// 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; - -public enum FlowStepStatus -{ - Success, - Failed, - Skipped, - InProgress -} - -public class FlowStep -{ - public string Name { get; set; } - public FlowStepStatus Status { get; set; } - public string Detail { get; set; } - public long ElapsedMs { get; set; } - public List Children { get; } = new(); -} - -/// -/// Tracks high-level operation flow and renders a visual step diagram to Trace logs. -/// Usage: -/// using var flow = new FlowLogger(logger, "Enroll-New"); -/// flow.Step("ParseCSR"); -/// flow.Step("ValidateCSR", () => { ... }); -/// flow.Fail("CreateOrder", "API returned 400"); -/// // flow renders automatically on Dispose -/// -public sealed class FlowLogger : IDisposable -{ - private readonly ILogger _logger; - private readonly string _flowName; - private readonly Stopwatch _totalTimer; - private readonly List _steps = new(); - private FlowStep _currentParent; - private bool _disposed; - - public FlowLogger(ILogger logger, string flowName) - { - _logger = logger; - _flowName = flowName; - _totalTimer = Stopwatch.StartNew(); - _logger.LogTrace("===== FLOW START: {FlowName} =====", _flowName); - } - - /// Record a completed step. - public FlowLogger Step(string name, string detail = null) - { - var step = new FlowStep { Name = name, Status = FlowStepStatus.Success, Detail = detail }; - AddStep(step); - _logger.LogTrace(" [{FlowName}] {StepName} ... OK{Detail}", - _flowName, name, detail != null ? $" ({detail})" : ""); - return this; - } - - /// Record a step that executes an action and times it. - public FlowLogger Step(string name, Action action, string detail = null) - { - var sw = Stopwatch.StartNew(); - var step = new FlowStep { Name = name, Detail = detail }; - try - { - _logger.LogTrace(" [{FlowName}] {StepName} ...", _flowName, name); - action(); - sw.Stop(); - step.Status = FlowStepStatus.Success; - step.ElapsedMs = sw.ElapsedMilliseconds; - AddStep(step); - _logger.LogTrace(" [{FlowName}] {StepName} ... OK ({Elapsed}ms){Detail}", - _flowName, name, sw.ElapsedMilliseconds, detail != null ? $" {detail}" : ""); - } - catch (Exception ex) - { - sw.Stop(); - step.Status = FlowStepStatus.Failed; - step.ElapsedMs = sw.ElapsedMilliseconds; - step.Detail = ex.Message; - AddStep(step); - _logger.LogTrace(" [{FlowName}] {StepName} ... FAILED ({Elapsed}ms): {Error}", - _flowName, name, sw.ElapsedMilliseconds, ex.Message); - throw; - } - return this; - } - - /// Record an async step that executes and times it. - public async Task StepAsync(string name, Func action, string detail = null) - { - var sw = Stopwatch.StartNew(); - var step = new FlowStep { Name = name, Detail = detail }; - try - { - _logger.LogTrace(" [{FlowName}] {StepName} ...", _flowName, name); - await action(); - sw.Stop(); - step.Status = FlowStepStatus.Success; - step.ElapsedMs = sw.ElapsedMilliseconds; - AddStep(step); - _logger.LogTrace(" [{FlowName}] {StepName} ... OK ({Elapsed}ms){Detail}", - _flowName, name, sw.ElapsedMilliseconds, detail != null ? $" {detail}" : ""); - } - catch (Exception ex) - { - sw.Stop(); - step.Status = FlowStepStatus.Failed; - step.ElapsedMs = sw.ElapsedMilliseconds; - step.Detail = ex.Message; - AddStep(step); - _logger.LogTrace(" [{FlowName}] {StepName} ... FAILED ({Elapsed}ms): {Error}", - _flowName, name, sw.ElapsedMilliseconds, ex.Message); - throw; - } - return this; - } - - /// Record a failed step without throwing. - public FlowLogger Fail(string name, string reason = null) - { - var step = new FlowStep { Name = name, Status = FlowStepStatus.Failed, Detail = reason }; - AddStep(step); - _logger.LogTrace(" [{FlowName}] {StepName} ... FAILED{Reason}", - _flowName, name, reason != null ? $": {reason}" : ""); - return this; - } - - /// Record a skipped step. - public FlowLogger Skip(string name, string reason = null) - { - var step = new FlowStep { Name = name, Status = FlowStepStatus.Skipped, Detail = reason }; - AddStep(step); - _logger.LogTrace(" [{FlowName}] {StepName} ... SKIPPED{Reason}", - _flowName, name, reason != null ? $": {reason}" : ""); - return this; - } - - /// Start a branch (group of child steps). - public FlowLogger Branch(string name) - { - var step = new FlowStep { Name = name, Status = FlowStepStatus.InProgress }; - AddStep(step); - _currentParent = step; - _logger.LogTrace(" [{FlowName}] >> Branch: {BranchName}", _flowName, name); - return this; - } - - /// End the current branch. - public FlowLogger EndBranch() - { - _currentParent = null; - return this; - } - - private void AddStep(FlowStep step) - { - if (_currentParent != null) - _currentParent.Children.Add(step); - else - _steps.Add(step); - } - - /// Render the visual flow diagram to Trace log. - private string RenderFlow() - { - var sb = new StringBuilder(); - sb.AppendLine(); - sb.AppendLine($" ===== FLOW: {_flowName} ({_totalTimer.ElapsedMilliseconds}ms total) ====="); - sb.AppendLine(); - - for (var i = 0; i < _steps.Count; i++) - { - var step = _steps[i]; - var icon = GetStatusIcon(step.Status); - var elapsed = step.ElapsedMs > 0 ? $" ({step.ElapsedMs}ms)" : ""; - var detail = !string.IsNullOrEmpty(step.Detail) ? $" [{step.Detail}]" : ""; - - sb.AppendLine($" {icon} {step.Name}{elapsed}{detail}"); - - // Render children (branch) - if (step.Children.Count > 0) - { - for (var j = 0; j < step.Children.Count; j++) - { - var child = step.Children[j]; - var childIcon = GetStatusIcon(child.Status); - var childElapsed = child.ElapsedMs > 0 ? $" ({child.ElapsedMs}ms)" : ""; - var childDetail = !string.IsNullOrEmpty(child.Detail) ? $" [{child.Detail}]" : ""; - var connector = j < step.Children.Count - 1 ? "| " : " "; - sb.AppendLine($" |"); - sb.AppendLine($" +-- {childIcon} {child.Name}{childElapsed}{childDetail}"); - } - } - - // Connector between top-level steps - if (i < _steps.Count - 1) - { - sb.AppendLine(" |"); - sb.AppendLine(" v"); - } - } - - sb.AppendLine(); - - // Final status line - var finalStatus = _steps.Count > 0 && _steps.Last().Status == FlowStepStatus.Failed - ? "FAILED" : _steps.Any(s => s.Status == FlowStepStatus.Failed) ? "PARTIAL FAILURE" : "SUCCESS"; - sb.AppendLine($" ===== FLOW RESULT: {finalStatus} ====="); - - return sb.ToString(); - } - - private static string GetStatusIcon(FlowStepStatus status) - { - return status switch - { - FlowStepStatus.Success => "[OK]", - FlowStepStatus.Failed => "[FAIL]", - FlowStepStatus.Skipped => "[SKIP]", - FlowStepStatus.InProgress => "[...]", - _ => "[?]" - }; - } - - public void Dispose() - { - if (_disposed) return; - _disposed = true; - _totalTimer.Stop(); - _logger.LogTrace(RenderFlow()); - } -} diff --git a/cscglobal-caplugin/RequestManager.cs b/cscglobal-caplugin/RequestManager.cs index faf02ac..776902c 100644 --- a/cscglobal-caplugin/RequestManager.cs +++ b/cscglobal-caplugin/RequestManager.cs @@ -10,48 +10,19 @@ 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)); private List GetCustomFields(EnrollmentProductInfo productInfo, List customFields) { - Logger.LogTrace("GetCustomFields: productInfo is {Null}, customFields count={Count}", - productInfo == null ? "NULL" : "present", - customFields?.Count ?? 0); - var customFieldList = new List(); - if (customFields == null || productInfo?.ProductParameters == null) - { - Logger.LogTrace("GetCustomFields: returning empty list (null customFields or ProductParameters)."); - return customFieldList; - } - foreach (var field in customFields) - { - if (field == null) - { - Logger.LogTrace("GetCustomFields: skipping null field entry."); - continue; - } - - Logger.LogTrace("GetCustomFields: checking field Label='{Label}', Mandatory={Mandatory}", - field.Label ?? "(null)", field.Mandatory); - - if (string.IsNullOrEmpty(field.Label)) - { - Logger.LogTrace("GetCustomFields: skipping field with null/empty label."); - continue; - } - if (productInfo.ProductParameters.ContainsKey(field.Label)) { var newField = new CustomField @@ -59,60 +30,32 @@ private List GetCustomFields(EnrollmentProductInfo productInfo, Lis Name = field.Label, Value = productInfo.ProductParameters[field.Label] }; - Logger.LogTrace("GetCustomFields: matched field '{Label}' = '{Value}'", field.Label, newField.Value ?? "(null)"); customFieldList.Add(newField); } else if (field.Mandatory) { - Logger.LogError("GetCustomFields: mandatory field '{Label}' was not supplied. Available keys: [{Keys}]", - field.Label, string.Join(", ", productInfo.ProductParameters.Keys)); throw new Exception( $"Custom field {field.Label} is marked as mandatory, but was not supplied in the request."); } - else - { - Logger.LogTrace("GetCustomFields: optional field '{Label}' not found in ProductParameters, skipping.", field.Label); - } - } - Logger.LogTrace("GetCustomFields: returning {Count} custom fields.", customFieldList.Count); return customFieldList; } public EnrollmentResult GetRenewResponse(RenewalResponse renewResponse) { - Logger.LogTrace("GetRenewResponse: renewResponse is {Null}", renewResponse == null ? "NULL" : "present"); - - if (renewResponse == null) - { - Logger.LogError("GetRenewResponse: renewResponse is null."); - return new EnrollmentResult - { - Status = (int)EndEntityStatus.FAILED, - StatusMessage = "Renewal failed: received null response from CSC." - }; - } - if (renewResponse.RegistrationError != null) - { - Logger.LogWarning("GetRenewResponse: RegistrationError present. Description='{Desc}'", - renewResponse.RegistrationError.Description ?? "(null)"); return new EnrollmentResult { - Status = (int)EndEntityStatus.FAILED, - CARequestID = renewResponse.Result?.Status?.Uuid, - StatusMessage = renewResponse.RegistrationError.Description ?? "Renewal failed with unknown error." + Status = (int)EndEntityStatus.FAILED, //failure + CARequestID = renewResponse?.Result?.Status?.Uuid, + StatusMessage = renewResponse.RegistrationError.Description }; - } - var commonName = renewResponse.Result?.CommonName ?? "(unknown)"; - var uuid = renewResponse.Result?.Status?.Uuid; - Logger.LogTrace("GetRenewResponse: renewal succeeded for CommonName='{CommonName}', UUID='{Uuid}'", commonName, uuid ?? "(null)"); return new EnrollmentResult { - Status = (int)EndEntityStatus.EXTERNALVALIDATION, - CARequestID = uuid, - StatusMessage = $"Renewal Successfully Submitted For {commonName}. Certificate will be available after next sync." + Status = (int)EndEntityStatus.GENERATED, //success + + StatusMessage = $"Renewal Successfully Completed For {renewResponse.Result.CommonName}" }; } @@ -121,210 +64,77 @@ public EnrollmentResult GetEnrollmentResult( IRegistrationResponse registrationResponse) { - Logger.LogTrace("GetEnrollmentResult: registrationResponse is {Null}", registrationResponse == null ? "NULL" : "present"); - - if (registrationResponse == null) - { - Logger.LogError("GetEnrollmentResult: registrationResponse is null."); - return new EnrollmentResult - { - Status = (int)EndEntityStatus.FAILED, - StatusMessage = "Enrollment failed: received null response from CSC." - }; - } - if (registrationResponse.RegistrationError != null) - { - Logger.LogWarning("GetEnrollmentResult: RegistrationError present. Description='{Desc}'", - registrationResponse.RegistrationError.Description ?? "(null)"); - return new EnrollmentResult - { - Status = (int)EndEntityStatus.FAILED, - StatusMessage = registrationResponse.RegistrationError.Description ?? "Enrollment failed with unknown error." - }; - } - - if (registrationResponse.Result == null) - { - Logger.LogError("GetEnrollmentResult: Result is null but no RegistrationError present."); return new EnrollmentResult { - Status = (int)EndEntityStatus.FAILED, - StatusMessage = "Enrollment failed: response Result is null." + Status = (int)EndEntityStatus.FAILED, //failure + StatusMessage = registrationResponse.RegistrationError.Description }; - } var cnames = new Dictionary(); if (registrationResponse.Result.DcvDetails != null && registrationResponse.Result.DcvDetails.Count > 0) - { - Logger.LogTrace("GetEnrollmentResult: processing {Count} DcvDetails.", registrationResponse.Result.DcvDetails.Count); foreach (var dcv in registrationResponse.Result.DcvDetails) { - if (dcv == null) - { - Logger.LogTrace("GetEnrollmentResult: skipping null DcvDetail."); - continue; - } - if (dcv.CName != null && !string.IsNullOrEmpty(dcv.CName.Name) && !string.IsNullOrEmpty(dcv.CName.Value)) { - if (!cnames.ContainsKey(dcv.CName.Name)) - { - Logger.LogTrace("GetEnrollmentResult: adding CName '{Name}'='{Value}'", dcv.CName.Name, dcv.CName.Value); - cnames.Add(dcv.CName.Name, dcv.CName.Value); - } - else - { - Logger.LogTrace("GetEnrollmentResult: duplicate CName key '{Name}', skipping.", dcv.CName.Name); - } + cnames.Add(dcv.CName.Name, dcv.CName.Value); } if (!string.IsNullOrEmpty(dcv.Email) && !cnames.ContainsKey(dcv.Email)) { - if (!cnames.ContainsKey(dcv.Email)) - { - Logger.LogTrace("GetEnrollmentResult: adding DCV email '{Email}'", dcv.Email); - cnames.Add(dcv.Email, dcv.Email); - } - else - { - Logger.LogTrace("GetEnrollmentResult: duplicate email key '{Email}', skipping.", dcv.Email); - } + cnames.Add(dcv.Email, dcv.Email); } } - } - else - { - Logger.LogTrace("GetEnrollmentResult: no DcvDetails to process."); - } - - var uuid = registrationResponse.Result.Status?.Uuid; - var commonName = registrationResponse.Result.CommonName ?? "(unknown)"; - Logger.LogTrace("GetEnrollmentResult: success. UUID='{Uuid}', CommonName='{CommonName}', cnames count={Count}", - uuid ?? "(null)", commonName, cnames.Count); - + return new EnrollmentResult { - Status = (int)EndEntityStatus.EXTERNALVALIDATION, - CARequestID = uuid, + Status = (int)EndEntityStatus.EXTERNALVALIDATION, //success + CARequestID = registrationResponse.Result.Status.Uuid, StatusMessage = - $"Order Successfully Created With Order Number {commonName}", + $"Order Successfully Created With Order Number {registrationResponse.Result.CommonName}", EnrollmentContext = cnames.Count > 0 ? cnames : null }; } public int GetRevokeResult(IRevokeResponse revokeResponse) { - Logger.LogTrace("GetRevokeResult: revokeResponse is {Null}", revokeResponse == null ? "NULL" : "present"); - - if (revokeResponse == null) - { - Logger.LogError("GetRevokeResult: revokeResponse is null, returning FAILED."); - return (int)EndEntityStatus.FAILED; - } - if (revokeResponse.RegistrationError != null) - { - Logger.LogWarning("GetRevokeResult: RegistrationError present. Description='{Desc}'", - revokeResponse.RegistrationError.Description ?? "(null)"); return (int)EndEntityStatus.FAILED; - } - Logger.LogTrace("GetRevokeResult: returning REVOKED."); return (int)EndEntityStatus.REVOKED; } public EnrollmentResult GetReIssueResult(IReissueResponse reissueResponse) { - Logger.LogTrace("GetReIssueResult: reissueResponse is {Null}", reissueResponse == null ? "NULL" : "present"); - - if (reissueResponse == null) - { - Logger.LogError("GetReIssueResult: reissueResponse is null."); - return new EnrollmentResult - { - Status = (int)EndEntityStatus.FAILED, - StatusMessage = "Reissue failed: received null response from CSC." - }; - } - if (reissueResponse.RegistrationError != null) - { - Logger.LogWarning("GetReIssueResult: RegistrationError present. Description='{Desc}'", - reissueResponse.RegistrationError.Description ?? "(null)"); - return new EnrollmentResult - { - Status = (int)EndEntityStatus.FAILED, - StatusMessage = reissueResponse.RegistrationError.Description ?? "Reissue failed with unknown error." - }; - } - - if (reissueResponse.Result == null) - { - Logger.LogError("GetReIssueResult: Result is null but no RegistrationError present."); return new EnrollmentResult { - Status = (int)EndEntityStatus.FAILED, - StatusMessage = "Reissue failed: response Result is null." + Status = (int)EndEntityStatus.FAILED, //failure + StatusMessage = reissueResponse.RegistrationError.Description }; - } - - var uuid = reissueResponse.Result.Status?.Uuid; - var commonName = reissueResponse.Result.CommonName ?? "(unknown)"; - Logger.LogTrace("GetReIssueResult: success. UUID='{Uuid}', CommonName='{CommonName}'", uuid ?? "(null)", commonName); return new EnrollmentResult { - Status = (int)EndEntityStatus.EXTERNALVALIDATION, - CARequestID = uuid, - StatusMessage = $"Reissue Successfully Submitted For {commonName}. Certificate will be available after next sync." + Status = (int)EndEntityStatus.GENERATED, //success + CARequestID = reissueResponse.Result.Status.Uuid, + StatusMessage = $"Reissue Successfully Completed For {reissueResponse.Result.CommonName}" }; } public DomainControlValidation GetDomainControlValidation(string methodType, string[] emailAddress, string domainName) { - Logger.LogTrace("GetDomainControlValidation(array): methodType='{MethodType}', domainName='{DomainName}', emailAddress count={Count}", - methodType ?? "(null)", domainName ?? "(null)", emailAddress?.Length ?? 0); - - if (emailAddress == null || emailAddress.Length == 0) - { - Logger.LogTrace("GetDomainControlValidation(array): no email addresses provided, returning null."); - return null; - } - foreach (var address in emailAddress) { - if (string.IsNullOrEmpty(address)) - { - Logger.LogTrace("GetDomainControlValidation(array): skipping null/empty email address."); - continue; - } - - try - { - var email = new MailAddress(address); - var hostPart = email.Host?.Split('.')[0] ?? ""; - Logger.LogTrace("GetDomainControlValidation(array): checking email='{Email}', hostPart='{HostPart}' against domain='{Domain}'", - address, hostPart, domainName); - - if (!string.IsNullOrEmpty(domainName) && domainName.Contains(hostPart)) + var email = new MailAddress(address); + if (domainName.Contains(email.Host.Split('.')[0])) + return new DomainControlValidation { - Logger.LogTrace("GetDomainControlValidation(array): matched! Returning email='{Email}'", email.ToString()); - return new DomainControlValidation - { - MethodType = methodType, - EmailAddress = email.ToString() - }; - } - } - catch (FormatException fex) - { - Logger.LogWarning("GetDomainControlValidation(array): invalid email address '{Address}': {Message}", address, fex.Message); - } + MethodType = methodType, + EmailAddress = email.ToString() + }; } - Logger.LogTrace("GetDomainControlValidation(array): no matching email found, returning null."); return null; } @@ -340,190 +150,105 @@ public DomainControlValidation GetDomainControlValidation(string methodType, str public RegistrationRequest GetRegistrationRequest(EnrollmentProductInfo productInfo, string csr, Dictionary sans, List customFields) { - Logger.LogTrace("GetRegistrationRequest: building registration request. ProductID='{ProductId}'", productInfo?.ProductID ?? "(null)"); + //var cert = "-----BEGIN CERTIFICATE REQUEST-----\r\n"; + var cert = Pemify(csr); + //cert = cert + "\r\n-----END CERTIFICATE REQUEST-----"; - if (productInfo?.ProductParameters == null) - throw new ArgumentNullException(nameof(productInfo), "productInfo or ProductParameters cannot be null."); - if (string.IsNullOrEmpty(csr)) - throw new ArgumentNullException(nameof(csr), "CSR cannot be null or empty."); - var cert = Pemify(csr); var bytes = Encoding.UTF8.GetBytes(cert); var encodedString = Convert.ToBase64String(bytes); - Logger.LogTrace("GetRegistrationRequest: CSR encoded, length={Length}", encodedString.Length); - - var commonNameValidationEmail = productInfo.ProductParameters.ContainsKey("CN DCV Email") - ? productInfo.ProductParameters["CN DCV Email"] : null; - var methodType = productInfo.ProductParameters.ContainsKey("Domain Control Validation Method") - ? productInfo.ProductParameters["Domain Control Validation Method"] : null; + var commonNameValidationEmail = productInfo.ProductParameters["CN DCV Email"]; + var methodType = productInfo.ProductParameters["Domain Control Validation Method"]; var certificateType = GetCertificateType(productInfo.ProductID); - Logger.LogTrace("GetRegistrationRequest: cnDcvEmail='{Email}', methodType='{Method}', certType='{CertType}'", - commonNameValidationEmail ?? "(null)", methodType ?? "(null)", certificateType); - return new RegistrationRequest { Csr = encodedString, - ServerSoftware = "-1", + ServerSoftware = "-1", //Just default to other, user does not need to fill this in CertificateType = certificateType, - Term = productInfo.ProductParameters.ContainsKey("Term") ? productInfo.ProductParameters["Term"] : null, - ApplicantFirstName = productInfo.ProductParameters.ContainsKey("Applicant First Name") ? productInfo.ProductParameters["Applicant First Name"] : null, - ApplicantLastName = productInfo.ProductParameters.ContainsKey("Applicant Last Name") ? productInfo.ProductParameters["Applicant Last Name"] : null, - ApplicantEmailAddress = productInfo.ProductParameters.ContainsKey("Applicant Email Address") ? productInfo.ProductParameters["Applicant Email Address"] : null, - ApplicantPhoneNumber = productInfo.ProductParameters.ContainsKey("Applicant Phone") ? productInfo.ProductParameters["Applicant Phone"] : null, + 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"], DomainControlValidation = GetDomainControlValidation(methodType, commonNameValidationEmail), Notifications = GetNotifications(productInfo), - OrganizationContact = productInfo.ProductParameters.ContainsKey("Organization Contact") ? productInfo.ProductParameters["Organization Contact"] : null, - BusinessUnit = productInfo.ProductParameters.ContainsKey("Business Unit") ? productInfo.ProductParameters["Business Unit"] : null, - ShowPrice = true, + OrganizationContact = productInfo.ProductParameters["Organization Contact"], + 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 }; } - // Maps Keyfactor product ID -> CSC API certificate type code (used for enrollment requests) - private static readonly Dictionary ProductIdToCodeMap = new(StringComparer.OrdinalIgnoreCase) - { - ["CSC TrustedSecure Premium Certificate"] = "0", - ["CSC TrustedSecure Premium Wildcard Certificate"] = "1", - ["CSC TrustedSecure UC Certificate"] = "2", - ["CSC TrustedSecure EV Certificate"] = "3", - ["CSC TrustedSecure Domain Validated SSL"] = "4", - ["CSC Trusted Secure Domain Validated SSL"] = "4", - ["CSC Trusted Secure Domain Validated Wildcard SSL"] = "5", - ["CSC Trusted Secure Domain Validated UC Certificate"] = "6", - }; - - // Reverse map: CSC API certificateType string -> Keyfactor product ID (used during sync) - // Note: CSC naming is inconsistent — first 4 types use "TrustedSecure" (no space), - // DV Wildcard and DV UC use "Trusted Secure" (with space), - // but CSC API returns DV SSL as "CSC Trusted Secure Domain Validated SSL" (with space) - // while the product ID is "CSC TrustedSecure Domain Validated SSL" (no space). - private static readonly Dictionary CodeToProductIdMap = new(StringComparer.OrdinalIgnoreCase) - { - // Premium - ["0"] = "CSC TrustedSecure Premium Certificate", - ["CSC TrustedSecure Premium Certificate"] = "CSC TrustedSecure Premium Certificate", - ["CSC Trusted Secure Premium Certificate"] = "CSC TrustedSecure Premium Certificate", - // Premium Wildcard - ["1"] = "CSC TrustedSecure Premium Wildcard Certificate", - ["CSC TrustedSecure Premium Wildcard Certificate"] = "CSC TrustedSecure Premium Wildcard Certificate", - ["CSC Trusted Secure Premium Wildcard Certificate"] = "CSC TrustedSecure Premium Wildcard Certificate", - // UC - ["2"] = "CSC TrustedSecure UC Certificate", - ["CSC TrustedSecure UC Certificate"] = "CSC TrustedSecure UC Certificate", - ["CSC Trusted Secure UC Certificate"] = "CSC TrustedSecure UC Certificate", - // EV - ["3"] = "CSC TrustedSecure EV Certificate", - ["CSC TrustedSecure EV Certificate"] = "CSC TrustedSecure EV Certificate", - ["CSC Trusted Secure EV Certificate"] = "CSC TrustedSecure EV Certificate", - // DV SSL — product ID has no space, but CSC API returns with space - ["4"] = "CSC TrustedSecure Domain Validated SSL", - ["CSC TrustedSecure Domain Validated SSL"] = "CSC TrustedSecure Domain Validated SSL", - ["CSC Trusted Secure Domain Validated SSL"] = "CSC TrustedSecure Domain Validated SSL", - // DV Wildcard — product ID has space (matches CSC API) - ["5"] = "CSC Trusted Secure Domain Validated Wildcard SSL", - ["CSC Trusted Secure Domain Validated Wildcard SSL"] = "CSC Trusted Secure Domain Validated Wildcard SSL", - ["CSC TrustedSecure Domain Validated Wildcard SSL"] = "CSC Trusted Secure Domain Validated Wildcard SSL", - // DV UC — product ID has space (matches CSC API) - ["6"] = "CSC Trusted Secure Domain Validated UC Certificate", - ["CSC Trusted Secure Domain Validated UC Certificate"] = "CSC Trusted Secure Domain Validated UC Certificate", - ["CSC TrustedSecure Domain Validated UC Certificate"] = "CSC Trusted Secure Domain Validated UC Certificate", - }; - private string GetCertificateType(string productId) { - Logger.LogTrace("GetCertificateType: productId='{ProductId}'", productId ?? "(null)"); - if (!string.IsNullOrEmpty(productId) && ProductIdToCodeMap.TryGetValue(productId, out var code)) + switch (productId) { - Logger.LogTrace("GetCertificateType: mapped '{ProductId}' -> '{Code}'", productId, code); - return code; + case "CSC TrustedSecure Premium Certificate": + return "0"; + case "CSC TrustedSecure EV Certificate": + return "3"; + case "CSC TrustedSecure UC Certificate": + return "2"; + case "CSC TrustedSecure Premium Wildcard Certificate": + 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": + return "4"; + case "CSC TrustedSecure Domain Validated Wildcard SSL": + return "5"; + case "CSC TrustedSecure Domain Validated UC Certificate": + return "6"; } - Logger.LogWarning("GetCertificateType: no mapping found for '{ProductId}', returning -1.", productId); - return "-1"; - } - /// - /// Maps a CSC API certificateType value back to a Keyfactor product ID. - /// Handles numeric codes, descriptive strings, and passthrough of already-correct values. - /// - public string MapCertificateTypeToProductId(string cscCertificateType) - { - Logger.LogTrace("MapCertificateTypeToProductId: input='{CscCertType}'", cscCertificateType ?? "(null)"); - if (!string.IsNullOrEmpty(cscCertificateType) && CodeToProductIdMap.TryGetValue(cscCertificateType, out var productId)) - { - Logger.LogTrace("MapCertificateTypeToProductId: mapped '{CscCertType}' -> '{ProductId}'", cscCertificateType, productId); - return productId; - } - Logger.LogWarning("MapCertificateTypeToProductId: no mapping for '{CscCertType}', passing through as-is.", cscCertificateType); - return cscCertificateType ?? "CscGlobal"; + return "-1"; } public Notifications GetNotifications(EnrollmentProductInfo productInfo) { - Logger.LogTrace("GetNotifications: building notifications."); - var emailsRaw = productInfo?.ProductParameters != null - && productInfo.ProductParameters.ContainsKey("Notification Email(s) Comma Separated") - ? productInfo.ProductParameters["Notification Email(s) Comma Separated"] - : null; - - Logger.LogTrace("GetNotifications: raw notification emails='{Emails}'", emailsRaw ?? "(null)"); - - var emailList = !string.IsNullOrEmpty(emailsRaw) - ? emailsRaw.Split(',').Where(e => !string.IsNullOrWhiteSpace(e)).ToList() - : new List(); - - Logger.LogTrace("GetNotifications: parsed {Count} notification emails.", emailList.Count); - return new Notifications { Enabled = true, - AdditionalNotificationEmails = emailList + AdditionalNotificationEmails = productInfo.ProductParameters["Notification Email(s) Comma Separated"] + .Split(',').ToList() }; } public RenewalRequest GetRenewalRequest(EnrollmentProductInfo productInfo, string uUId, string csr, Dictionary sans, List customFields) { - Logger.LogTrace("GetRenewalRequest: building renewal request. UUID='{Uuid}', ProductID='{ProductId}'", - uUId ?? "(null)", productInfo?.ProductID ?? "(null)"); - - if (productInfo?.ProductParameters == null) - throw new ArgumentNullException(nameof(productInfo), "productInfo or ProductParameters cannot be null."); - if (string.IsNullOrEmpty(csr)) - throw new ArgumentNullException(nameof(csr), "CSR cannot be null or empty."); - if (string.IsNullOrEmpty(uUId)) - throw new ArgumentNullException(nameof(uUId), "uUId cannot be null or empty."); - + //var cert = "-----BEGIN CERTIFICATE REQUEST-----\r\n"; var cert = Pemify(csr); + //cert = cert + "\r\n-----END CERTIFICATE REQUEST-----"; + var bytes = Encoding.UTF8.GetBytes(cert); var encodedString = Convert.ToBase64String(bytes); - - var commonNameValidationEmail = productInfo.ProductParameters.ContainsKey("CN DCV Email") - ? productInfo.ProductParameters["CN DCV Email"] : null; - var methodType = productInfo.ProductParameters.ContainsKey("Domain Control Validation Method") - ? productInfo.ProductParameters["Domain Control Validation Method"] : null; + var commonNameValidationEmail = productInfo.ProductParameters["CN DCV Email"]; + var methodType = productInfo.ProductParameters["Domain Control Validation Method"]; var certificateType = GetCertificateType(productInfo.ProductID); - Logger.LogTrace("GetRenewalRequest: cnDcvEmail='{Email}', methodType='{Method}', certType='{CertType}'", - commonNameValidationEmail ?? "(null)", methodType ?? "(null)", certificateType); - return new RenewalRequest { Uuid = uUId, Csr = encodedString, ServerSoftware = "-1", CertificateType = certificateType, - Term = productInfo.ProductParameters.ContainsKey("Term") ? productInfo.ProductParameters["Term"] : null, - ApplicantFirstName = productInfo.ProductParameters.ContainsKey("Applicant First Name") ? productInfo.ProductParameters["Applicant First Name"] : null, - ApplicantLastName = productInfo.ProductParameters.ContainsKey("Applicant Last Name") ? productInfo.ProductParameters["Applicant Last Name"] : null, - ApplicantEmailAddress = productInfo.ProductParameters.ContainsKey("Applicant Email Address") ? productInfo.ProductParameters["Applicant Email Address"] : null, - ApplicantPhoneNumber = productInfo.ProductParameters.ContainsKey("Applicant Phone") ? productInfo.ProductParameters["Applicant Phone"] : null, + 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"], DomainControlValidation = GetDomainControlValidation(methodType, commonNameValidationEmail), Notifications = GetNotifications(productInfo), - OrganizationContact = productInfo.ProductParameters.ContainsKey("Organization Contact") ? productInfo.ProductParameters["Organization Contact"] : null, - BusinessUnit = productInfo.ProductParameters.ContainsKey("Business Unit") ? productInfo.ProductParameters["Business Unit"] : null, + OrganizationContact = productInfo.ProductParameters["Organization Contact"], + BusinessUnit = productInfo.ProductParameters["Business Unit"], ShowPrice = true, SubjectAlternativeNames = certificateType == "2" ? GetSubjectAlternativeNames(productInfo, sans) : null, CustomFields = GetCustomFields(productInfo, customFields), @@ -534,107 +259,54 @@ public RenewalRequest GetRenewalRequest(EnrollmentProductInfo productInfo, strin private List GetSubjectAlternativeNames(EnrollmentProductInfo productInfo, Dictionary sans) { - Logger.LogTrace("GetSubjectAlternativeNames: building SANs."); var subjectNameList = new List(); + var methodType = productInfo.ProductParameters["Domain Control Validation Method"]; - if (sans == null || !sans.ContainsKey("dnsname")) + foreach (var v in sans["dnsname"]) { - Logger.LogTrace("GetSubjectAlternativeNames: no 'dnsname' key in SANs dictionary, returning empty list."); - return subjectNameList; - } - - var dnsNames = sans["dnsname"]; - if (dnsNames == null || dnsNames.Length == 0) - { - Logger.LogTrace("GetSubjectAlternativeNames: 'dnsname' array is null or empty, returning empty list."); - return subjectNameList; - } - - var methodType = productInfo?.ProductParameters != null - && productInfo.ProductParameters.ContainsKey("Domain Control Validation Method") - ? productInfo.ProductParameters["Domain Control Validation Method"] - : null; - - Logger.LogTrace("GetSubjectAlternativeNames: processing {Count} DNS names, methodType='{MethodType}'", - dnsNames.Length, methodType ?? "(null)"); - - foreach (var v in dnsNames) - { - if (string.IsNullOrEmpty(v)) - { - Logger.LogTrace("GetSubjectAlternativeNames: skipping null/empty DNS name."); - continue; - } - var domainName = v; var san = new SubjectAlternativeName(); san.DomainName = domainName; - Logger.LogTrace("GetSubjectAlternativeNames: processing domain='{Domain}'", domainName); - - if (!string.IsNullOrEmpty(methodType) && methodType.ToUpper() == "EMAIL") - { - var emailsRaw = productInfo.ProductParameters.ContainsKey("Addtl Sans Comma Separated DVC Emails") - ? productInfo.ProductParameters["Addtl Sans Comma Separated DVC Emails"] - : null; - var emailAddresses = !string.IsNullOrEmpty(emailsRaw) ? emailsRaw.Split(',') : Array.Empty(); - Logger.LogTrace("GetSubjectAlternativeNames: EMAIL validation, {Count} email addresses for domain='{Domain}'", - emailAddresses.Length, domainName); + var emailAddresses = productInfo.ProductParameters["Addtl Sans Comma Separated DVC Emails"].Split(','); + if (methodType.ToUpper() == "EMAIL") san.DomainControlValidation = GetDomainControlValidation(methodType, emailAddresses, domainName); - } - else - { - Logger.LogTrace("GetSubjectAlternativeNames: CNAME/other validation for domain='{Domain}'", domainName); + else //it is a CNAME validation so no email is needed san.DomainControlValidation = GetDomainControlValidation(methodType, ""); - } subjectNameList.Add(san); } - Logger.LogTrace("GetSubjectAlternativeNames: returning {Count} SANs.", subjectNameList.Count); return subjectNameList; } public ReissueRequest GetReissueRequest(EnrollmentProductInfo productInfo, string uUId, string csr, Dictionary sans, List customFields) { - Logger.LogTrace("GetReissueRequest: building reissue request. UUID='{Uuid}', ProductID='{ProductId}'", - uUId ?? "(null)", productInfo?.ProductID ?? "(null)"); - - if (productInfo?.ProductParameters == null) - throw new ArgumentNullException(nameof(productInfo), "productInfo or ProductParameters cannot be null."); - if (string.IsNullOrEmpty(csr)) - throw new ArgumentNullException(nameof(csr), "CSR cannot be null or empty."); - if (string.IsNullOrEmpty(uUId)) - throw new ArgumentNullException(nameof(uUId), "uUId cannot be null or empty."); - + //var cert = "-----BEGIN CERTIFICATE REQUEST-----\r\n"; var cert = Pemify(csr); + //cert = cert + "\r\n-----END CERTIFICATE REQUEST-----"; + var bytes = Encoding.UTF8.GetBytes(cert); var encodedString = Convert.ToBase64String(bytes); - - var commonNameValidationEmail = productInfo.ProductParameters.ContainsKey("CN DCV Email") - ? productInfo.ProductParameters["CN DCV Email"] : null; - var methodType = productInfo.ProductParameters.ContainsKey("Domain Control Validation Method") - ? productInfo.ProductParameters["Domain Control Validation Method"] : null; + var commonNameValidationEmail = productInfo.ProductParameters["CN DCV Email"]; + var methodType = productInfo.ProductParameters["Domain Control Validation Method"]; var certificateType = GetCertificateType(productInfo.ProductID); - Logger.LogTrace("GetReissueRequest: cnDcvEmail='{Email}', methodType='{Method}', certType='{CertType}'", - commonNameValidationEmail ?? "(null)", methodType ?? "(null)", certificateType); - return new ReissueRequest { Uuid = uUId, Csr = encodedString, ServerSoftware = "-1", - CertificateType = certificateType, - Term = productInfo.ProductParameters.ContainsKey("Term") ? productInfo.ProductParameters["Term"] : null, - ApplicantFirstName = productInfo.ProductParameters.ContainsKey("Applicant First Name") ? productInfo.ProductParameters["Applicant First Name"] : null, - ApplicantLastName = productInfo.ProductParameters.ContainsKey("Applicant Last Name") ? productInfo.ProductParameters["Applicant Last Name"] : null, - ApplicantEmailAddress = productInfo.ProductParameters.ContainsKey("Applicant Email Address") ? productInfo.ProductParameters["Applicant Email Address"] : null, - ApplicantPhoneNumber = productInfo.ProductParameters.ContainsKey("Applicant Phone") ? productInfo.ProductParameters["Applicant Phone"] : null, + 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"], DomainControlValidation = GetDomainControlValidation(methodType, commonNameValidationEmail), Notifications = GetNotifications(productInfo), - OrganizationContact = productInfo.ProductParameters.ContainsKey("Organization Contact") ? productInfo.ProductParameters["Organization Contact"] : null, - BusinessUnit = productInfo.ProductParameters.ContainsKey("Business Unit") ? productInfo.ProductParameters["Business Unit"] : null, + OrganizationContact = productInfo.ProductParameters["Organization Contact"], + BusinessUnit = productInfo.ProductParameters["Business Unit"], ShowPrice = true, SubjectAlternativeNames = certificateType == "2" ? GetSubjectAlternativeNames(productInfo, sans) : null, CustomFields = GetCustomFields(productInfo, customFields), @@ -644,28 +316,15 @@ public ReissueRequest GetReissueRequest(EnrollmentProductInfo productInfo, strin private EvCertificateDetails GetEvCertificateDetails(EnrollmentProductInfo productInfo) { - Logger.LogTrace("GetEvCertificateDetails: building EV details."); - var country = productInfo?.ProductParameters != null - && productInfo.ProductParameters.ContainsKey("Organization Country") - ? productInfo.ProductParameters["Organization Country"] - : null; - Logger.LogTrace("GetEvCertificateDetails: country='{Country}'", country ?? "(null)"); var evDetails = new EvCertificateDetails(); - evDetails.Country = country; + evDetails.Country = productInfo.ProductParameters["Organization Country"]; return evDetails; } public int MapReturnStatus(string cscGlobalStatus) { - Logger.LogTrace("MapReturnStatus: input status='{Status}'", cscGlobalStatus ?? "(null)"); - - if (string.IsNullOrEmpty(cscGlobalStatus)) - { - Logger.LogWarning("MapReturnStatus: status is null or empty, returning FAILED."); - return (int)EndEntityStatus.FAILED; - } + var returnStatus = 0; - int returnStatus; switch (cscGlobalStatus) { case "ACTIVE": @@ -681,12 +340,10 @@ public int MapReturnStatus(string cscGlobalStatus) returnStatus = (int)EndEntityStatus.REVOKED; break; default: - Logger.LogWarning("MapReturnStatus: unrecognized status '{Status}', returning FAILED.", cscGlobalStatus); returnStatus = (int)EndEntityStatus.FAILED; break; } - Logger.LogTrace("MapReturnStatus: mapped '{Status}' to {Result}", cscGlobalStatus, returnStatus); return returnStatus; } } \ No newline at end of file diff --git a/docsource/configuration.md b/docsource/configuration.md index 5dee9d3..d8c196e 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -10,115 +10,6 @@ This integration is tested and confirmed as working for Anygateway REST 24.2 and The Root certificates for installation on the Anygateway server machine should be obtained from CSC. -## CA Connection Configuration - -When defining the Certificate Authority in the AnyCA Gateway REST portal, configure the following fields on the **CA Connection** tab: - -CONFIG ELEMENT | DESCRIPTION | DEFAULT ----------------|-------------|-------- -Enabled | Flag to Enable or Disable gateway functionality. Set to `false` to allow creating the CA record before configuration information is available; the plugin then short-circuits Ping, Sync, Enroll, and Revoke with a warning until it is re-enabled. | `true` -CscGlobalUrl | The base URL for the CSCGlobal API (e.g. `https://apis.cscglobal.com`) | (required) -ApiKey | Your CSCGlobal API key | (required) -BearerToken | Your CSCGlobal Bearer token for authentication | (required) -DefaultPageSize | Page size for API list requests | 100 -SyncFilterDays | Number of days from today used to filter certificates by expiration date during **incremental** sync. Only certificates expiring within this window are returned. Does not apply to full sync. | 5 -RenewalWindowDays | Number of days before the annual order expiry date within which a **RenewOrReissue** request triggers a paid **Renewal** rather than a free **Reissue**. See [Renewal vs. Reissue Logic](#renewal-vs-reissue-logic) below. | 30 -DcvPollTimeoutSeconds | Max seconds to synchronously poll CSC for certificate issuance after submitting an order. `0` disables polling (enrollment returns pending immediately; cert arrives on the next sync). When `>0`, fast-validating orders can return the issued cert directly in the enrollment response. See [Synchronous Issuance Polling](#synchronous-issuance-polling) below. | 0 - -> **Note:** DNS auto-publishing for CNAME DCV is handled by the AnyCA Gateway REST framework's Domain Validation system (gateway 3.3+). It's configured in the gateway UI under **Domain Validation Configurations**, not on the CA Connection tab. See [DNS Auto-Publishing (CNAME DCV)](#dns-auto-publishing-cname-dcv). - -## Renewal vs. Reissue Logic - -CSC Global subscriptions are annual orders. When Keyfactor Command sends a **RenewOrReissue** request, the plugin must decide whether to submit a **Renewal** (a new paid order) or a **Reissue** (a free re-key under the existing active order). - -The decision is based on the **RenewalWindowDays** setting and works as follows: - -1. The plugin fetches the original certificate from CSC and reads its `orderDate`. -2. It computes the **order expiry** as `orderDate + 1 year`. -3. It calculates **days remaining** until the order expires. -4. If `days remaining <= RenewalWindowDays`, the request is treated as a **Renewal** (new paid order). -5. If `days remaining > RenewalWindowDays`, the request is treated as a **Reissue** (free under the active order). - -**Example with default RenewalWindowDays = 30:** - -``` -Order Date: 2025-04-08 -Order Expiry: 2026-04-08 -Today: 2026-03-15 -Days Left: 24 - -24 <= 30 --> RENEWAL (new paid order) -``` - -``` -Order Date: 2025-04-08 -Order Expiry: 2026-04-08 -Today: 2025-09-01 -Days Left: 219 - -219 > 30 --> REISSUE (free under active order) -``` - -**Fallback behavior:** If the plugin cannot retrieve the `orderDate` from CSC (e.g., API error or missing field), it falls back to checking the certificate's expiration date. If the certificate is already expired, it treats the request as a Renewal. - -**Note:** Both Renewal and Reissue submissions are asynchronous at CSC. The plugin returns a "pending" status and the issued certificate will appear in Keyfactor after the next sync cycle. - -## DNS Auto-Publishing (CNAME DCV) - -CSC supports two Domain Control Validation (DCV) methods: **EMAIL** and **CNAME**. With CNAME validation, CSC returns a CNAME record (name → target) that must exist in DNS before they will validate the order. - -By default this plugin returns the CNAME details to Keyfactor Command for **manual publishing**. To fully automate enrollment, the plugin uses the **AnyCA Gateway REST framework's built-in DNS provider system** (available in framework 3.3 and later). The framework discovers DNS provider plugins deployed alongside the CA plugin and routes each CNAME to whichever provider claims the matching DNS zone. - -### Requirements - -* AnyCA Gateway REST framework **3.3 or later** (the `IDomainValidatorFactory` interface ships in `Keyfactor.AnyGateway.IAnyCAPlugin` 3.3+). -* At least one DNS provider DLL (e.g. GoDaddy, Cloudflare, Route 53, Azure) deployed in the gateway `Extensions` folder. -* A Domain Validation Configuration registered in the gateway UI that maps your domain(s) to the deployed provider (for example, `*.example.com` → GoDaddy). - -### How It Works - -1. CSC returns the CNAME `name → target` details in the enrollment response. -2. For each CNAME entry, the plugin calls `IDomainValidatorFactory.ResolveDomainValidator(recordName, "cname")`. -3. The framework returns the `IDomainValidator` whose Domain Validation Configuration matches the record's zone (or `null` if no match). -4. The plugin calls `validator.StageValidation(recordName, cnameTarget, ct)` to publish the record. -5. CSC asynchronously validates the CNAME; the issued certificate appears on the next sync. - -### Behavior - -* **Resolution is per record, not per CA.** One CA can drive multiple DNS providers (GoDaddy for some domains, Route 53 for others) with no per-CA configuration. -* **Only invoked for CNAME DCV.** Templates configured with EMAIL validation are unaffected — no DNS publishing occurs. -* **Best-effort.** If no provider claims the zone, the publish call fails, or the factory wasn't injected (gateway pre-3.3), the enrollment still succeeds and the CNAME details remain in the Keyfactor request so a human can publish manually as a fallback. -* **Trace-logged.** Every resolution (matched/unresolved) and publish attempt (success/failure) is logged at Info/Trace level. -* **Validation type string.** The plugin passes `"cname"` to `ResolveDomainValidator`. CSC's DCV requires a **CNAME** record, which is different from ACME's `"dns-01"` challenge (a TXT record). A single DNS provider DLL can ship multiple validator classes — one advertising `"dns-01"` (publishes TXT, for ACME) and one advertising `"cname"` (publishes CNAME, for CSC). You must deploy and configure a validator that advertises `"cname"` or no provider will match. -* **Trailing dots normalized.** CSC returns FQDN-canonical names with a trailing dot (e.g. `_token.example.com.`). The plugin strips the trailing dot before resolution and publishing, because Domain Validation Configurations and DNS provider APIs expect names without it. - -### Configuration in the Gateway UI - -In the AnyCA Gateway REST portal, under **Domain Validation Configurations**: - -1. **Add** a new configuration. -2. Pick a **Domain Validator Type** that publishes **CNAME** records and advertises validation type `cname`. For GoDaddy this is `GoDaddyCnameDomainValidator` (the `GoDaddyDomainValidator` variant publishes TXT for ACME and will **not** work for CSC). -3. Add one or more **domain patterns** (e.g. `*.example.com`). -4. Fill out the provider-specific **Configuration Settings** (API keys, base URL, etc.). -5. Save. - -Once configured, any CSC enrollment for a domain matching one of those patterns will have its CNAME auto-published. - -> **Common pitfall:** If you configure the TXT/`dns-01` validator (e.g. `GoDaddyDomainValidator`) for a CSC domain, the record will publish as a **TXT** and CSC's CNAME validation will never succeed. Make sure you select the **CNAME** validator variant. - -## Synchronous Issuance Polling - -CSC validates domain control asynchronously — after an order is submitted (and the CNAME DCV record published), CSC/Sectigo polls public DNS on its own schedule and issues the certificate once validation passes. By default this plugin returns a **pending** (`EXTERNALVALIDATION`) result immediately and the issued certificate is picked up on the next gateway **sync** cycle. - -For environments where DNS is published automatically (see [DNS Auto-Publishing](#dns-auto-publishing-cname-dcv)) and validation tends to complete quickly, you can have the plugin **poll CSC synchronously** at the end of enrollment and return the issued certificate directly — avoiding the wait for the next sync. - -* Set **`DcvPollTimeoutSeconds`** to the maximum number of seconds to poll (e.g. `60`). `0` (default) disables polling entirely. -* The plugin polls CSC every 10 seconds until the order is issued or the timeout is reached. -* If the certificate issues within the window, the enrollment returns it immediately with a success status. -* If the window expires, the plugin falls back to the **pending** result and the certificate arrives on the next sync — exactly as it would with polling disabled. - -**Tradeoff:** Polling blocks the enrollment request for up to `DcvPollTimeoutSeconds`. CSC validation frequently takes minutes to hours, so most orders will still fall through to pending — keep the timeout small (30–90s) to catch only the fast cases without hanging callers. This applies to New enrollments, Renewals, and Reissues. - ## Certificate Template Creation Step PLEASE NOTE, AT THIS TIME THE RAPID_SSL TEMPLATE IS NOT SUPPORTED BY THE CSC API AND WILL NOT WORK WITH THIS INTEGRATION diff --git a/integration-manifest.json b/integration-manifest.json index e6c5243..2b4b8c4 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -2,12 +2,12 @@ "$schema": "https://keyfactor.github.io/integration-manifest-schema.json", "integration_type": "anyca-plugin", "name": "CSCGlobal CAPlugin REST Gateway Plugin", + "status": "pilot", "support_level": "kf-supported", - "status": "production", "link_github": true, "update_catalog": true, "description": "CSCGlobal CAPlugin for the AnyCA REST Gateway framework", - "gateway_framework": "26.2.0", + "gateway_framework": "24.2.0", "release_project": "cscglobal-caplugin/CSCGlobalCAPlugin.csproj", "release_dir": "cscglobal-caplugin/bin/Release", "about": { @@ -30,16 +30,12 @@ "description": "Default page size for use with the API. Default is 100" }, { - "name": "SyncFilterDays", - "description": "Number of days from today to filter certificates by expiration date during incremental sync." - }, - { - "name": "RenewalWindowDays", - "description": "Number of days before the annual order expiry within which a RenewOrReissue triggers a paid Renewal rather than a free Reissue. Default is 30." + "name": "TemplateSync", + "description": "Enable template sync." }, { - "name": "DcvPollTimeoutSeconds", - "description": "Max seconds to synchronously poll CSC for issuance after submitting an order (and publishing CNAME DCV). 0 disables polling (enrollment returns pending immediately; cert arrives on next sync). When >0, fast-validating orders can return the cert directly. Keep small to avoid long-blocking enrollment requests." + "name": "SyncFilterDays", + "description": "Number of days from today to filter certificates by expiration date during incremental sync." } ], "enrollment_config": [ @@ -98,9 +94,9 @@ "CSC TrustedSecure UC Certificate", "CSC TrustedSecure Premium Wildcard Certificate", "CSC TrustedSecure Domain Validated SSL", - "CSC Trusted Secure Domain Validated Wildcard SSL", - "CSC Trusted Secure Domain Validated UC Certificate" + "CSC TrustedSecure Domain Validated Wildcard SSL", + "CSC TrustedSecure Domain Validated UC Certificate" ] } } -} +} \ No newline at end of file From f490a1d54f0314cc924a17b89c42b0f9e1e5a2c0 Mon Sep 17 00:00:00 2001 From: Morgan Gangwere <470584+indrora@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:10:41 -0700 Subject: [PATCH 32/42] Merge 1.1.3 to main (#19) * Add custom field support * changelog * support cname return from enrollment * Update generated docs * feat: release 1.1.1 * Update generated docs * Fix for issues with * Test * Added template parameter configuration via REST gateway. Fixed bug with email used for verification. Changed docs and enrollment field/template parameter names. See changelog. * Update generated docs * Fixed broken logging. * Incremental sync support added using csc date filter so sync timing can run faster that default full sync periods * Update generated docs * Fixes for Incremental Sync * Update CHANGELOG.md --------- Co-authored-by: Mikey Henderson <4452096+fiddlermikey@users.noreply.github.com> Co-authored-by: Sean <1661003+spbsoluble@users.noreply.github.com> Co-authored-by: Keyfactor Co-authored-by: Brian Hill Co-authored-by: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> * Fix NullReferenceException in GetEnrollmentResult for null DCV email (#9) * Fix NullReferenceException in GetEnrollmentResult for null DCV email The condition for adding DCV email entries to the cnames dictionary was inverted (string.IsNullOrEmpty instead of !string.IsNullOrEmpty), causing cnames.Add(null, null) and an ArgumentNullException on every enrollment where CSC returned a DcvDetail with email=null (typical for EMAIL DCV orders that have actionNeeded=N, and for CNAME-only DCV). Inverts the condition and adds a ContainsKey guard to mirror the existing CName branch. * Update generated docs --------- Co-authored-by: Keyfactor * Fix/san email dictionary key (#17) * 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. * Add changelog entries for 1.1.2 and 1.1.3 * Update keyfactor-bootstrap-workflow-v3.yml * docs: auto-generate README and documentation [skip ci] * 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. * Note second dnsname KeyNotFoundException fix in 1.1.3 changelog entry --------- Co-authored-by: github-actions[bot] --------- Co-authored-by: David Galey Co-authored-by: Keyfactor Co-authored-by: Mark Kachkaev <37276742+mkachk@users.noreply.github.com> Co-authored-by: Mikey Henderson <4452096+fiddlermikey@users.noreply.github.com> Co-authored-by: Sean <1661003+spbsoluble@users.noreply.github.com> Co-authored-by: Brian Hill Co-authored-by: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Co-authored-by: github-actions[bot] --- .../keyfactor-bootstrap-workflow-v3.yml | 11 +- CHANGELOG.md | 7 + README.md | 447 +++++++++--------- cscglobal-caplugin/RequestManager.cs | 10 +- 4 files changed, 246 insertions(+), 229 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 }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c6124b..dda4e33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +v1.1.3 +- Fixed KeyNotFoundException during enrollment when the optional "Addtl Sans Comma Separated DVC Emails" field was not set +- Fixed KeyNotFoundException during enrollment when no SANs were supplied for a UC certificate + +v1.1.2 +- Fixed NullReferenceException in GetEnrollmentResult when CSC returned a DCV email of null (typical for EMAIL DCV orders with actionNeeded=N, and for CNAME-only DCV) + 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/README.md b/README.md index c68aac4..dcd1bc2 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,235 +81,233 @@ 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 +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 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 @@ -318,4 +315,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). diff --git a/cscglobal-caplugin/RequestManager.cs b/cscglobal-caplugin/RequestManager.cs index 776902c..00b217a 100644 --- a/cscglobal-caplugin/RequestManager.cs +++ b/cscglobal-caplugin/RequestManager.cs @@ -262,14 +262,20 @@ 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(); 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 07cfb6f48fd9d87653780c21711a3f13d38f189d Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Mon, 21 Sep 2026 15:43:15 -0400 Subject: [PATCH 33/42] Port product rename, new certificate types, and bug fixes from feature/ev-ov-dv-multiname-certs Ports the following from feature/ev-ov-dv-multiname-certs, adapted to this branch's existing patterns (ProductIdToCodeMap/CodeToProductIdMap, structured logging style) rather than overwriting them: - Renamed all certificate product IDs to CSC's current certificate type names, with pre-1.2.0 legacy names still accepted (added as additional entries in the existing product-id maps, not a separate alias layer) so existing Certificate Templates in Command keep working. - Added the 3 new certificate products: CSC TrustedSecure EV, Multiple Names; OV Wildcard, Multiple Names; DV Wildcard, Multiple Names (types 7/8/9). - Replaced the hardcoded certificateType == "2"/"3" checks with MultiNameCertificateTypes/EvCertificateTypes sets covering all applicable types. - ValidateProductInfo now calls RequestManager.IsKnownProductId instead of checking a separate list, so accepted names can't drift out of sync with what GetCertificateType actually resolves. - Fixed the "Addtl Sans Comma Separated DCV Emails" field never being read (typo'd lookup key: "DVC" instead of "DCV"), and added a fallback to the primary CN's DCV email when no per-domain SAN email override matches (CSC Global rejects requests with a SAN missing domainControlValidation). - Fixed a case-sensitivity bug ("priorcertsn" vs "PriorCertSN") that silently prevented PriorCertSN from ever being read during Renew/Reissue. - Made Price.Total nullable to fix a JSON deserialization crash when CSC Global returns "price.total": null. - Updated integration-manifest.json and docsource/configuration.md to match. Explicitly NOT ported (per discussion - these don't fit this branch): - FlowLogger changes/redesign - this branch's FlowLogger has an incompatible tree-based design already wired into DNS-01 CNAME auto-publish call sites; left untouched. - The EnrollmentContext "Flow Summary" UX feature - this branch's TryPublishCnameDcvAsync treats EnrollmentContext entries as real DNS records to auto-publish; adding non-DNS entries there would be actively harmful. - .NET 6/8 multi-targeting - this branch already moved to net10.0-only with newer package versions; not reintroducing the older targets. - The Renew/Reissue GENERATED-vs-EXTERNALVALIDATION fix - already present independently on this branch. Also adds a new xUnit test project (this branch had none), with fresh tests written against this branch's actual code shape rather than adapted from the other branch's now-incompatible test suite: 40 tests covering certificate type/SAN/EV routing for all 10 canonical + 7 legacy product names, IsKnownProductId, the DCV email fallback fix, Price.Total null deserialization, and Renew/Reissue status codes. --- CHANGELOG.md | 8 + .../CSCGlobalCAPlugin.Tests.csproj | 25 ++ .../CSCGlobalCAPluginTests.cs | 54 ++++ .../RequestManagerTests.cs | 255 ++++++++++++++++++ cscglobal-caplugin.sln | 47 +++- cscglobal-caplugin/CSCGlobalCAPlugin.cs | 23 +- cscglobal-caplugin/Client/Models/Price.cs | 2 +- cscglobal-caplugin/Constants.cs | 17 +- cscglobal-caplugin/Interfaces/IPrice.cs | 2 +- cscglobal-caplugin/RequestManager.cs | 74 ++++- docsource/configuration.md | 156 ++++++++--- integration-manifest.json | 17 +- 12 files changed, 603 insertions(+), 77 deletions(-) create mode 100644 cscglobal-caplugin.Tests/CSCGlobalCAPlugin.Tests.csproj create mode 100644 cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs create mode 100644 cscglobal-caplugin.Tests/RequestManagerTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c6124b..b8781cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +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 +- 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. +- 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. +- Fixed a case-sensitivity bug ("priorcertsn" vs "PriorCertSN") that prevented PriorCertSN from ever being read during Renew/Reissue enrollment. +- 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. +- Added an xUnit test suite covering certificate type/SAN/EV routing, legacy product name backward compatibility, and the fixes above. + 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.Tests/CSCGlobalCAPlugin.Tests.csproj b/cscglobal-caplugin.Tests/CSCGlobalCAPlugin.Tests.csproj new file mode 100644 index 0000000..823e49f --- /dev/null +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPlugin.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + true + CSCGlobalCAPlugin.Tests + CscGlobalCAPluginTests + + + + + + + + + + + + + + + diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs new file mode 100644 index 0000000..08e2798 --- /dev/null +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs @@ -0,0 +1,54 @@ +// 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 Xunit; + +namespace CscGlobalCAPluginTests; + +public class CSCGlobalCAPluginTests +{ + private static EnrollmentProductInfo ProductInfo(string productId) => + new EnrollmentProductInfo { ProductID = productId, ProductParameters = new Dictionary() }; + + [Theory] + [InlineData("CSC TrustedSecure DV")] + [InlineData("CSC TrustedSecure DV Wildcard, Multiple Names")] + public async Task ValidateProductInfo_CanonicalProductName_DoesNotThrow(string productId) + { + var plugin = new CSCGlobalCAPlugin(); + // Parameterless constructor per plugin's own doc comment: runs without DNS + // auto-publishing, which ValidateProductInfo does not depend on. + await plugin.ValidateProductInfo(ProductInfo(productId), new Dictionary()); + } + + [Theory] + [InlineData("CSC TrustedSecure UC Certificate")] + [InlineData("CSC TrustedSecure Domain Validated SSL")] + [InlineData("CSC Trusted Secure Domain Validated Wildcard SSL")] + public async Task ValidateProductInfo_LegacyProductName_DoesNotThrow(string legacyProductId) + { + var plugin = new CSCGlobalCAPlugin(); + await plugin.ValidateProductInfo(ProductInfo(legacyProductId), new Dictionary()); + } + + [Fact] + public async Task ValidateProductInfo_UnknownProduct_Throws() + { + var plugin = new CSCGlobalCAPlugin(); + await Assert.ThrowsAsync(() => + plugin.ValidateProductInfo(ProductInfo("Not A Real Product"), new Dictionary())); + } + + [Fact] + public async Task ValidateProductInfo_DisabledConnector_SkipsValidationEvenForUnknownProduct() + { + var plugin = new CSCGlobalCAPlugin(); + 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); + } +} diff --git a/cscglobal-caplugin.Tests/RequestManagerTests.cs b/cscglobal-caplugin.Tests/RequestManagerTests.cs new file mode 100644 index 0000000..0a92286 --- /dev/null +++ b/cscglobal-caplugin.Tests/RequestManagerTests.cs @@ -0,0 +1,255 @@ +// 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 Newtonsoft.Json; +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(); + + // --------------------------------------------------------------------- + // Certificate type routing - canonical (1.2.0+) names, all 10 products. + // --------------------------------------------------------------------- + + [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 Unrecognized Product", "-1", false, false)] + public void GetRegistrationRequest_CanonicalProductNames_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); + } + + // --------------------------------------------------------------------- + // Certificate type routing - pre-1.2.0 legacy names must resolve identically to their + // canonical replacement, so existing Certificate Templates in Command keep working. + // --------------------------------------------------------------------- + + [Theory] + [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 Trusted Secure Domain Validated Wildcard SSL", "5", false, false)] + [InlineData("CSC Trusted Secure Domain Validated UC Certificate", "6", true, false)] + public void GetRegistrationRequest_LegacyProductNames_ResolveToSameCertificateType( + string legacyProductId, string expectedType, bool expectSans, bool expectEv) + { + var sans = new Dictionary { ["dnsname"] = new[] { "www.example.com" } }; + var productInfo = ProductInfo(legacyProductId, 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); + } + + [Fact] + public void GetRegistrationRequest_LegacyAndCanonicalName_ProduceIdenticalCertificateType() + { + var legacy = ProductInfo("CSC TrustedSecure UC Certificate"); + var canonical = ProductInfo("CSC TrustedSecure OV, Multiple Names"); + + var legacyRequest = Manager.GetRegistrationRequest(legacy, SampleCsr, new Dictionary(), new List()); + var canonicalRequest = Manager.GetRegistrationRequest(canonical, SampleCsr, new Dictionary(), new List()); + + Assert.Equal(canonicalRequest.CertificateType, legacyRequest.CertificateType); + } + + // --------------------------------------------------------------------- + // IsKnownProductId - backs ValidateProductInfo. Must recognize both canonical and legacy + // names from the same source of truth GetCertificateType uses, so the two can't drift. + // --------------------------------------------------------------------- + + [Theory] + [InlineData("CSC TrustedSecure DV")] + [InlineData("CSC TrustedSecure DV Wildcard, Multiple Names")] + [InlineData("CSC TrustedSecure Domain Validated SSL")] + [InlineData("csc trustedsecure dv")] + public void IsKnownProductId_RecognizedName_ReturnsTrue(string productId) + { + Assert.True(Manager.IsKnownProductId(productId)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("Not A Real Product")] + public void IsKnownProductId_UnrecognizedOrEmpty_ReturnsFalse(string? productId) + { + Assert.False(Manager.IsKnownProductId(productId!)); + } + + // --------------------------------------------------------------------- + // GetSubjectAlternativeNames (exercised via GetRegistrationRequest) - DCV email resolution. + // --------------------------------------------------------------------- + + [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", + [EnrollmentConfigConstants.AdditionalSansCommaSeparatedDcvEmails] = "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_MultiNameEmailMethodNoAddtlSanMatch_FallsBackToCommonNameDcvEmail() + { + // CSC Global rejects the request if a SAN entry has no domainControlValidation, so a SAN + // domain unrelated to any configured "Addtl Sans" email must fall back to the primary + // CN's DCV email rather than being left null. + var sans = new Dictionary { ["dnsname"] = new[] { "www.unrelated-domain.io" } }; + var productInfo = ProductInfo("CSC TrustedSecure OV, Multiple Names", new Dictionary + { + ["Domain Control Validation Method"] = "EMAIL", + [EnrollmentConfigConstants.CnDcvEmail] = "cn@example.com" + }); + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, sans, new List()); + + Assert.Single(request.SubjectAlternativeNames); + var san = request.SubjectAlternativeNames[0]; + Assert.NotNull(san.DomainControlValidation); + Assert.Equal("cn@example.com", san.DomainControlValidation.EmailAddress); + } + + [Fact] + public void GetRegistrationRequest_MultiNameCnameMethod_MirrorsCommonNameDcv() + { + 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); + Assert.NotNull(request.SubjectAlternativeNames[0].DomainControlValidation); + Assert.Equal("CNAME", request.SubjectAlternativeNames[0].DomainControlValidation.MethodType); + } + + [Fact] + public void GetRegistrationRequest_WildcardMultiNameProduct_AcceptsUnrelatedDomainSans() + { + // Types 8/9 are wildcard + multi-name (the underlying Sectigo Multi-Domain Wildcard + // product) - additional SANs are not restricted to the CN's own base domain. + var sans = new Dictionary + { + ["dnsname"] = new[] { "*.example2.com", "*.example3.com" } + }; + var productInfo = ProductInfo("CSC TrustedSecure DV Wildcard, Multiple Names", new Dictionary + { + ["Domain Control Validation Method"] = "CNAME" + }); + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, sans, new List()); + + Assert.Equal(2, request.SubjectAlternativeNames.Count); + Assert.Equal("*.example2.com", request.SubjectAlternativeNames[0].DomainName); + Assert.Equal("*.example3.com", request.SubjectAlternativeNames[1].DomainName); + } + + // --------------------------------------------------------------------- + // Price.Total nullability - CSC Global returns "price.total": null for orders that cannot + // be processed. Total must be nullable or Newtonsoft throws mid-deserialization, before the + // caller ever sees the RegistrationError/order status CSC was actually trying to report. + // --------------------------------------------------------------------- + + [Fact] + public void RegistrationResponse_NullPriceTotal_DeserializesWithoutThrowing() + { + const string json = "{\"result\":{\"commonName\":\"order-1\",\"price\":{\"currency\":\"\",\"total\":null}}}"; + + var response = JsonConvert.DeserializeObject(json); + + Assert.NotNull(response?.Result?.Price); + Assert.Null(response!.Result!.Price!.Total); + } + + // --------------------------------------------------------------------- + // GetRenewResponse / GetReIssueResult - CSC never returns an issued certificate on these + // responses (only order/DCV status), so success must report EXTERNALVALIDATION, not + // GENERATED, or the gateway host will try to parse a certificate that doesn't exist. + // --------------------------------------------------------------------- + + [Fact] + public void GetRenewResponse_Success_ReturnsExternalValidation() + { + var response = new RenewalResponse + { + Result = new Result { CommonName = "renewed.example.com", Status = new Status { Uuid = "uuid-1" } } + }; + + var result = Manager.GetRenewResponse(response); + + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Equal("uuid-1", result.CARequestID); + } + + [Fact] + public void GetReIssueResult_Success_ReturnsExternalValidation() + { + var response = new ReissueResponse + { + Result = new Result { CommonName = "reissued.example.com", Status = new Status { Uuid = "uuid-2" } } + }; + + var result = Manager.GetReIssueResult(response); + + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Equal("uuid-2", result.CARequestID); + } +} diff --git a/cscglobal-caplugin.sln b/cscglobal-caplugin.sln index 220a2cd..9594f70 100644 --- a/cscglobal-caplugin.sln +++ b/cscglobal-caplugin.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 -VisualStudioVersion = 18.0.11217.181 d18.0 +VisualStudioVersion = 18.0.11217.181 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSCGlobalCAPlugin", "cscglobal-caplugin\CSCGlobalCAPlugin.csproj", "{01DDFD6F-275D-46E7-B522-E0C965D1BF9C}" EndProject @@ -12,23 +12,68 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution integration-manifest.json = integration-manifest.json EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "cscglobal-caplugin.Tests", "cscglobal-caplugin.Tests", "{BE4C3E19-CFA0-7860-C455-A18FD2267928}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSCGlobalCAPlugin.Tests", "cscglobal-caplugin.Tests\CSCGlobalCAPlugin.Tests.csproj", "{1FE36805-D1BD-4552-8B19-17358C5F19E3}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "cscglobal-caplugin", "cscglobal-caplugin", "{9EDFC34F-9707-CEB2-9158-E7368508D81D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 DebugAndPush|Any CPU = DebugAndPush|Any CPU + DebugAndPush|x64 = DebugAndPush|x64 + DebugAndPush|x86 = DebugAndPush|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Debug|x64.ActiveCfg = Debug|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Debug|x64.Build.0 = Debug|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Debug|x86.ActiveCfg = Debug|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Debug|x86.Build.0 = Debug|Any CPU {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|Any CPU.ActiveCfg = DebugAndPush|Any CPU {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|Any CPU.Build.0 = DebugAndPush|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|x64.ActiveCfg = DebugAndPush|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|x64.Build.0 = DebugAndPush|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|x86.ActiveCfg = DebugAndPush|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|x86.Build.0 = DebugAndPush|Any CPU {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Release|Any CPU.ActiveCfg = Release|Any CPU {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Release|Any CPU.Build.0 = Release|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Release|x64.ActiveCfg = Release|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Release|x64.Build.0 = Release|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Release|x86.ActiveCfg = Release|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Release|x86.Build.0 = Release|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Debug|x64.ActiveCfg = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Debug|x64.Build.0 = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Debug|x86.ActiveCfg = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Debug|x86.Build.0 = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.DebugAndPush|Any CPU.ActiveCfg = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.DebugAndPush|Any CPU.Build.0 = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.DebugAndPush|x64.ActiveCfg = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.DebugAndPush|x64.Build.0 = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.DebugAndPush|x86.ActiveCfg = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.DebugAndPush|x86.Build.0 = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Release|Any CPU.Build.0 = Release|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Release|x64.ActiveCfg = Release|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Release|x64.Build.0 = Release|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Release|x86.ActiveCfg = Release|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {1FE36805-D1BD-4552-8B19-17358C5F19E3} = {BE4C3E19-CFA0-7860-C455-A18FD2267928} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {8861D2F4-FEE8-4D70-8172-DF321704F12D} EndGlobalSection diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index c22aa32..1bc6a44 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -621,17 +621,13 @@ public async Task Enroll(string csr, string subject, Dictionar flow.Step("CheckPriorCertSN", () => { - if (productInfo.ProductParameters.ContainsKey("priorcertsn")) + // Command sends this key as "PriorCertSN" (proper case) - a prior version of this + // check gated on "priorcertsn" (lowercase) instead, which Command never actually + // sends, so this block silently never ran and PriorCertSN was never populated. + if (productInfo.ProductParameters.ContainsKey("PriorCertSN")) { - if (productInfo.ProductParameters.ContainsKey("PriorCertSN")) - { - priorSn = productInfo.ProductParameters["PriorCertSN"]; - Logger.LogDebug("Enroll: Prior cert SN: '{PriorSn}'", priorSn ?? "(null)"); - } - else - { - Logger.LogWarning("Enroll: 'priorcertsn' key exists but 'PriorCertSN' (case-sensitive) not found."); - } + priorSn = productInfo.ProductParameters["PriorCertSN"]; + Logger.LogDebug("Enroll: Prior cert SN: '{PriorSn}'", priorSn ?? "(null)"); } }, string.IsNullOrEmpty(priorSn) ? "none" : $"SN={priorSn}"); @@ -1079,17 +1075,14 @@ public async Task ValidateProductInfo(EnrollmentProductInfo productInfo, throw new ArgumentException("ProductID cannot be null or empty.", nameof(productInfo)); } - var certType = ProductIDs.productIds.Find(x => - x.Equals(productInfo.ProductID, StringComparison.InvariantCultureIgnoreCase)); - - if (certType == null) + if (!_requestManager.IsKnownProductId(productInfo.ProductID)) { Logger.LogError("ValidateProductInfo: cannot find product ID '{ProductId}'. Known IDs: [{KnownIds}]", productInfo.ProductID, string.Join(", ", ProductIDs.productIds)); throw new ArgumentException($"Cannot find {productInfo.ProductID}", "ProductId"); } - Logger.LogInformation("Validated {CertType} configured for AnyGateway", certType); + Logger.LogInformation("Validated {ProductId} configured for AnyGateway", productInfo.ProductID); Logger.MethodExit(LogLevel.Debug); } 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/Constants.cs b/cscglobal-caplugin/Constants.cs index dc10866..be33065 100644 --- a/cscglobal-caplugin/Constants.cs +++ b/cscglobal-caplugin/Constants.cs @@ -23,13 +23,16 @@ 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 Trusted Secure Domain Validated Wildcard SSL", - "CSC Trusted Secure 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/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 diff --git a/cscglobal-caplugin/RequestManager.cs b/cscglobal-caplugin/RequestManager.cs index faf02ac..00522b0 100644 --- a/cscglobal-caplugin/RequestManager.cs +++ b/cscglobal-caplugin/RequestManager.cs @@ -22,6 +22,12 @@ public class RequestManager public static Func Pemify = ss => ss.Length <= 64 ? ss : ss.Substring(0, 64) + "\n" + Pemify(ss.Substring(64)); + // Certificate types that carry a list of additional SAN domains, vs. a single CN only. + private static readonly HashSet MultiNameCertificateTypes = new() { "2", "6", "7", "8", "9" }; + + // Certificate types that require EvCertificateDetails (Organization Country, etc.). + private static readonly HashSet EvCertificateTypes = new() { "3", "7" }; + private List GetCustomFields(EnrollmentProductInfo productInfo, List customFields) { Logger.LogTrace("GetCustomFields: productInfo is {Null}, customFields count={Count}", @@ -377,24 +383,45 @@ public RegistrationRequest GetRegistrationRequest(EnrollmentProductInfo productI BusinessUnit = productInfo.ProductParameters.ContainsKey("Business Unit") ? productInfo.ProductParameters["Business Unit"] : null, ShowPrice = true, 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 }; } - // Maps Keyfactor product ID -> CSC API certificate type code (used for enrollment requests) + // Maps Keyfactor product ID -> CSC API certificate type code (used for enrollment requests). + // Each product has an entry for its current (1.2.0+) canonical name and its pre-1.2.0 legacy + // name, so existing Certificate Templates in Command using the old names keep working. + // Types 7/8/9 are new in 1.2.0 and have no legacy name. private static readonly Dictionary ProductIdToCodeMap = new(StringComparer.OrdinalIgnoreCase) { + ["CSC TrustedSecure OV"] = "0", ["CSC TrustedSecure Premium Certificate"] = "0", + ["CSC TrustedSecure OV Wildcard"] = "1", ["CSC TrustedSecure Premium Wildcard Certificate"] = "1", + ["CSC TrustedSecure OV, Multiple Names"] = "2", ["CSC TrustedSecure UC Certificate"] = "2", + ["CSC TrustedSecure EV"] = "3", ["CSC TrustedSecure EV Certificate"] = "3", + ["CSC TrustedSecure DV"] = "4", ["CSC TrustedSecure Domain Validated SSL"] = "4", ["CSC Trusted Secure Domain Validated SSL"] = "4", + ["CSC TrustedSecure DV Wildcard"] = "5", ["CSC Trusted Secure Domain Validated Wildcard SSL"] = "5", + ["CSC TrustedSecure DV, Multiple Names"] = "6", ["CSC Trusted Secure Domain Validated UC Certificate"] = "6", + ["CSC TrustedSecure EV, Multiple Names"] = "7", + ["CSC TrustedSecure OV Wildcard, Multiple Names"] = "8", + ["CSC TrustedSecure DV Wildcard, Multiple Names"] = "9", }; + /// + /// True if productId resolves to a known CSC certificate type - either its canonical + /// (1.2.0+) name or a pre-1.2.0 legacy name. Used by ValidateProductInfo so the list of + /// accepted names can't drift out of sync with what GetCertificateType actually resolves. + /// + public bool IsKnownProductId(string productId) => + !string.IsNullOrEmpty(productId) && ProductIdToCodeMap.ContainsKey(productId); + // Reverse map: CSC API certificateType string -> Keyfactor product ID (used during sync) // Note: CSC naming is inconsistent — first 4 types use "TrustedSecure" (no space), // DV Wildcard and DV UC use "Trusted Secure" (with space), @@ -406,30 +433,46 @@ public RegistrationRequest GetRegistrationRequest(EnrollmentProductInfo productI ["0"] = "CSC TrustedSecure Premium Certificate", ["CSC TrustedSecure Premium Certificate"] = "CSC TrustedSecure Premium Certificate", ["CSC Trusted Secure Premium Certificate"] = "CSC TrustedSecure Premium Certificate", + ["CSC TrustedSecure OV"] = "CSC TrustedSecure Premium Certificate", // Premium Wildcard ["1"] = "CSC TrustedSecure Premium Wildcard Certificate", ["CSC TrustedSecure Premium Wildcard Certificate"] = "CSC TrustedSecure Premium Wildcard Certificate", ["CSC Trusted Secure Premium Wildcard Certificate"] = "CSC TrustedSecure Premium Wildcard Certificate", + ["CSC TrustedSecure OV Wildcard"] = "CSC TrustedSecure Premium Wildcard Certificate", // UC ["2"] = "CSC TrustedSecure UC Certificate", ["CSC TrustedSecure UC Certificate"] = "CSC TrustedSecure UC Certificate", ["CSC Trusted Secure UC Certificate"] = "CSC TrustedSecure UC Certificate", + ["CSC TrustedSecure OV, Multiple Names"] = "CSC TrustedSecure UC Certificate", // EV ["3"] = "CSC TrustedSecure EV Certificate", ["CSC TrustedSecure EV Certificate"] = "CSC TrustedSecure EV Certificate", ["CSC Trusted Secure EV Certificate"] = "CSC TrustedSecure EV Certificate", + ["CSC TrustedSecure EV"] = "CSC TrustedSecure EV Certificate", // DV SSL — product ID has no space, but CSC API returns with space ["4"] = "CSC TrustedSecure Domain Validated SSL", ["CSC TrustedSecure Domain Validated SSL"] = "CSC TrustedSecure Domain Validated SSL", ["CSC Trusted Secure Domain Validated SSL"] = "CSC TrustedSecure Domain Validated SSL", + ["CSC TrustedSecure DV"] = "CSC TrustedSecure Domain Validated SSL", // DV Wildcard — product ID has space (matches CSC API) ["5"] = "CSC Trusted Secure Domain Validated Wildcard SSL", ["CSC Trusted Secure Domain Validated Wildcard SSL"] = "CSC Trusted Secure Domain Validated Wildcard SSL", ["CSC TrustedSecure Domain Validated Wildcard SSL"] = "CSC Trusted Secure Domain Validated Wildcard SSL", + ["CSC TrustedSecure DV Wildcard"] = "CSC Trusted Secure Domain Validated Wildcard SSL", // DV UC — product ID has space (matches CSC API) ["6"] = "CSC Trusted Secure Domain Validated UC Certificate", ["CSC Trusted Secure Domain Validated UC Certificate"] = "CSC Trusted Secure Domain Validated UC Certificate", ["CSC TrustedSecure Domain Validated UC Certificate"] = "CSC Trusted Secure Domain Validated UC Certificate", + ["CSC TrustedSecure DV, Multiple Names"] = "CSC Trusted Secure Domain Validated UC Certificate", + // EV, Multiple Names — new in 1.2.0, no legacy name + ["7"] = "CSC TrustedSecure EV, Multiple Names", + ["CSC TrustedSecure EV, Multiple Names"] = "CSC TrustedSecure EV, Multiple Names", + // OV Wildcard, Multiple Names — new in 1.2.0, no legacy name + ["8"] = "CSC TrustedSecure OV Wildcard, Multiple Names", + ["CSC TrustedSecure OV Wildcard, Multiple Names"] = "CSC TrustedSecure OV Wildcard, Multiple Names", + // DV Wildcard, Multiple Names — new in 1.2.0, no legacy name + ["9"] = "CSC TrustedSecure DV Wildcard, Multiple Names", + ["CSC TrustedSecure DV Wildcard, Multiple Names"] = "CSC TrustedSecure DV Wildcard, Multiple Names", }; private string GetCertificateType(string productId) @@ -525,9 +568,9 @@ public RenewalRequest GetRenewalRequest(EnrollmentProductInfo productInfo, strin OrganizationContact = productInfo.ProductParameters.ContainsKey("Organization Contact") ? productInfo.ProductParameters["Organization Contact"] : null, BusinessUnit = productInfo.ProductParameters.ContainsKey("Business Unit") ? productInfo.ProductParameters["Business Unit"] : null, 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 }; } @@ -555,6 +598,14 @@ private List GetSubjectAlternativeNames(EnrollmentProduc ? productInfo.ProductParameters["Domain Control Validation Method"] : null; + // CSC Global rejects the request if any subjectAlternativeNames entry is missing + // domainControlValidation, so every SAN below must resolve to a non-null value - falling + // back to the primary CN's DCV email when no per-domain override matches. + var commonNameValidationEmail = productInfo?.ProductParameters != null + && productInfo.ProductParameters.ContainsKey(EnrollmentConfigConstants.CnDcvEmail) + ? productInfo.ProductParameters[EnrollmentConfigConstants.CnDcvEmail] + : null; + Logger.LogTrace("GetSubjectAlternativeNames: processing {Count} DNS names, methodType='{MethodType}'", dnsNames.Length, methodType ?? "(null)"); @@ -573,18 +624,19 @@ private List GetSubjectAlternativeNames(EnrollmentProduc if (!string.IsNullOrEmpty(methodType) && methodType.ToUpper() == "EMAIL") { - var emailsRaw = productInfo.ProductParameters.ContainsKey("Addtl Sans Comma Separated DVC Emails") - ? productInfo.ProductParameters["Addtl Sans Comma Separated DVC Emails"] + var emailsRaw = productInfo.ProductParameters.ContainsKey(EnrollmentConfigConstants.AdditionalSansCommaSeparatedDcvEmails) + ? productInfo.ProductParameters[EnrollmentConfigConstants.AdditionalSansCommaSeparatedDcvEmails] : null; var emailAddresses = !string.IsNullOrEmpty(emailsRaw) ? emailsRaw.Split(',') : Array.Empty(); Logger.LogTrace("GetSubjectAlternativeNames: EMAIL validation, {Count} email addresses for domain='{Domain}'", emailAddresses.Length, domainName); - san.DomainControlValidation = GetDomainControlValidation(methodType, emailAddresses, domainName); + san.DomainControlValidation = GetDomainControlValidation(methodType, emailAddresses, domainName) + ?? GetDomainControlValidation(methodType, commonNameValidationEmail); } else { Logger.LogTrace("GetSubjectAlternativeNames: CNAME/other validation for domain='{Domain}'", domainName); - san.DomainControlValidation = GetDomainControlValidation(methodType, ""); + san.DomainControlValidation = GetDomainControlValidation(methodType, commonNameValidationEmail); } subjectNameList.Add(san); @@ -636,9 +688,9 @@ public ReissueRequest GetReissueRequest(EnrollmentProductInfo productInfo, strin OrganizationContact = productInfo.ProductParameters.ContainsKey("Organization Contact") ? productInfo.ProductParameters["Organization Contact"] : null, BusinessUnit = productInfo.ProductParameters.ContainsKey("Business Unit") ? productInfo.ProductParameters["Business Unit"] : null, 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 5dee9d3..122477f 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -129,16 +129,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 -----|--------------|----------------- @@ -153,20 +153,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 EV - 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 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 EV Certificate - Enrollment Fields** +**CSC TrustedSecure EV - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -182,20 +182,20 @@ 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 -----|--------------|----------------- @@ -212,20 +212,20 @@ CN DCV Email | String | N/A Addtl Sans Comma Separated DCV Emails | String | N/A -**CSC TrustedSecure Premium Wildcard Certificate - Details Tab** +**CSC TrustedSecure OV Wildcard - 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 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 Premium Wildcard Certificate - Enrollment Fields** +**CSC TrustedSecure OV Wildcard - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -240,20 +240,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 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 -----|--------------|----------------- @@ -268,20 +268,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 -----|--------------|----------------- @@ -296,20 +296,108 @@ 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 +-----|--------------|----------------- +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 +Addtl Sans Comma Separated DCV Emails | String | N/A +Organization Country | 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 -----|--------------|----------------- diff --git a/integration-manifest.json b/integration-manifest.json index 2fea8e9..5184f0c 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -93,13 +93,16 @@ } ], "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 Trusted Secure Domain Validated Wildcard SSL", - "CSC Trusted Secure 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 ef36792e8d524176c5b680c14de393214605f7ba Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 21 Sep 2026 19:43:56 +0000 Subject: [PATCH 34/42] docs: auto-generate README and documentation [skip ci] --- README.md | 156 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 122 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 5effcf2..c185bec 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 EV - 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 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 EV Certificate - Enrollment Fields** +**CSC TrustedSecure EV - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -149,20 +149,20 @@ 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 -----|--------------|----------------- @@ -179,20 +179,20 @@ CN DCV Email | String | N/A Addtl Sans Comma Separated DCV Emails | String | N/A -**CSC TrustedSecure Premium Wildcard Certificate - Details Tab** +**CSC TrustedSecure OV Wildcard - 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 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 Premium Wildcard Certificate - Enrollment Fields** +**CSC TrustedSecure OV Wildcard - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -207,20 +207,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 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 +235,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 +263,108 @@ 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 +-----|--------------|----------------- +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 +Addtl Sans Comma Separated DCV Emails | String | N/A +Organization Country | 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 -----|--------------|----------------- From ed6b3ad0b58614bc400b5cd42835c40a45609a8c Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Mon, 21 Sep 2026 16:09:42 -0400 Subject: [PATCH 35/42] Raise unit test coverage to ~95% line coverage Adds testability seams (matching the pattern already used for HTTP mocking in feature/ev-ov-dv-multiname-certs): - CSCGlobalCAPlugin.CscGlobalClient property is now internal instead of private, so tests can inject a mock ICscGlobalClient. - CscGlobalClient gets an internal HttpMessageHandler-accepting constructor overload, so tests can supply a fake handler instead of making real HTTP calls. - AssemblyInfo.cs adds InternalsVisibleTo("CSCGlobalCAPlugin.Tests"). Expands the test suite from 40 to 233 tests, covering: - RequestManager: all certificate-type/SAN/EV routing (canonical + legacy names), IsKnownProductId, DCV email fallback, GetRevokeResult, MapReturnStatus/MapCertificateTypeToProductId, custom fields, Price.Total null deserialization. - CscGlobalClient: all 7 API methods (success/400/error paths) via a fake HttpMessageHandler, plus constructor validation. - FlowLogger: Step/StepAsync/Fail/Skip/Branch/Dispose. - CSCGlobalCAPlugin: Initialize, GetSingleRecord, Synchronize/ SyncCertificates, Revoke, Ping, ValidateCAConnectionInfo/ ValidateProductInfo, Enroll (New and RenewOrReissue - renewal vs. reissue decision, DNS-01 CNAME auto-publish via IDomainValidatorFactory, synchronous issuance polling via DcvPollTimeoutSeconds, and the various failure branches), and the PEM/leaf-certificate parsing helpers (GetEndEntityCertificate/ExtractCertificates/FindLeaf). Line coverage: 15.57% -> 95.62%. Remaining gaps are dead code (ExportCollectionToPem, RetryCountExceededException - both already unused before this change) and a few defensive catch blocks for exception types (AggregateException, arbitrary X509 export failures) that aren't reachable through normal async/mock-based testing. --- .../CSCGlobalCAPluginTests.cs | 1651 ++++++++++++++++- .../CscGlobalClientTests.cs | 397 ++++ cscglobal-caplugin.Tests/FlowLoggerTests.cs | 175 ++ .../RequestManagerTests.cs | 371 ++++ cscglobal-caplugin/AssemblyInfo.cs | 6 + cscglobal-caplugin/CSCGlobalCAPlugin.cs | 4 +- cscglobal-caplugin/Client/CscGlobalClient.cs | 15 +- 7 files changed, 2605 insertions(+), 14 deletions(-) create mode 100644 cscglobal-caplugin.Tests/CscGlobalClientTests.cs create mode 100644 cscglobal-caplugin.Tests/FlowLoggerTests.cs create mode 100644 cscglobal-caplugin/AssemblyInfo.cs diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs index 08e2798..7e76322 100644 --- a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs @@ -1,25 +1,603 @@ // Copyright 2021 Keyfactor // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; using Keyfactor.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 static EnrollmentProductInfo ProductInfo(string productId) => - new EnrollmentProductInfo { ProductID = productId, ProductParameters = new Dictionary() }; + private static EnrollmentProductInfo ProductInfo(string productId = "CSC TrustedSecure DV", + Dictionary? parameters = null) => new EnrollmentProductInfo + { + ProductID = productId, + ProductParameters = parameters ?? new Dictionary() + }; + + private static Mock ConfigProviderMock(Dictionary? overrides = null) + { + var data = new Dictionary + { + [Constants.CscGlobalApiKey] = "api-key", + [Constants.CscGlobalUrl] = "https://example.invalid/", + [Constants.BearerToken] = "bearer-token" + }; + if (overrides != null) + foreach (var kv in overrides) + data[kv.Key] = kv.Value; + + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns(data); + return mock; + } + + private static CSCGlobalCAPlugin MakePlugin(Mock? client = null, + Mock? certDataReader = null, Dictionary? configOverrides = null, + IDomainValidatorFactory? validatorFactory = null) + { + var plugin = validatorFactory != null ? new CSCGlobalCAPlugin(validatorFactory) : new CSCGlobalCAPlugin(); + plugin.Initialize(ConfigProviderMock(configOverrides).Object, + (certDataReader ?? new Mock()).Object); + plugin.CscGlobalClient = (client ?? new Mock()).Object; + return plugin; + } + + 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); + } + + // --------------------------------------------------------------------- + // Initialize + // --------------------------------------------------------------------- + + [Fact] + public void Initialize_NullConfigProvider_Throws() + { + var plugin = new CSCGlobalCAPlugin(); + Assert.Throws(() => plugin.Initialize(null!, Mock.Of())); + } + + [Fact] + public void Initialize_NullCertificateDataReader_Throws() + { + var plugin = new CSCGlobalCAPlugin(); + Assert.Throws(() => plugin.Initialize(ConfigProviderMock().Object, null!)); + } + + [Fact] + public void Initialize_NullCAConnectionData_Throws() + { + var plugin = new CSCGlobalCAPlugin(); + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns((Dictionary)null!); + Assert.Throws(() => plugin.Initialize(mock.Object, Mock.Of())); + } + + [Fact] + public void Initialize_EnabledDefault_ConstructsRealClient() + { + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(ConfigProviderMock().Object, Mock.Of()); + Assert.True(plugin.Enabled); + Assert.NotNull(plugin.CscGlobalClient); + } + + [Fact] + public void Initialize_ExplicitlyDisabled_SkipsClientCreation() + { + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.Enabled] = "false" }).Object, + Mock.Of()); + Assert.False(plugin.Enabled); + Assert.Null(plugin.CscGlobalClient); + } + + [Fact] + public void Initialize_UnparsableEnabled_DefaultsToTrue() + { + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.Enabled] = "not-a-bool" }).Object, + Mock.Of()); + Assert.True(plugin.Enabled); + } + + [Fact] + public void Initialize_EnabledButMissingApiKey_Throws() + { + var plugin = new CSCGlobalCAPlugin(); + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns(new Dictionary()); + Assert.Throws(() => plugin.Initialize(mock.Object, Mock.Of())); + } + + [Theory] + [InlineData("10", 10)] + [InlineData("not-a-number", 0)] + public void Initialize_SyncFilterDays_ParsesOrDefaults(string raw, int expected) + { + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.SyncFilterDays] = raw }).Object, + Mock.Of()); + Assert.Equal(expected, plugin.SyncFilterDays); + } + + [Theory] + [InlineData("45", 45)] + [InlineData("not-a-number", 30)] + [InlineData("-5", 30)] + public void Initialize_RenewalWindowDays_ParsesOrDefaults(string raw, int expected) + { + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.RenewalWindowDays] = raw }).Object, + Mock.Of()); + Assert.Equal(expected, plugin.RenewalWindowDays); + } + + [Fact] + public void Initialize_RenewalWindowDaysNotConfigured_DefaultsTo30() + { + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(ConfigProviderMock().Object, Mock.Of()); + Assert.Equal(30, plugin.RenewalWindowDays); + } + + [Theory] + [InlineData("5", 5)] + [InlineData("not-a-number", 0)] + [InlineData("-1", 0)] + public void Initialize_DcvPollTimeoutSeconds_ParsesOrDefaults(string raw, int expected) + { + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.DcvPollTimeoutSeconds] = raw }).Object, + Mock.Of()); + Assert.Equal(expected, plugin.DcvPollTimeoutSeconds); + } + + [Fact] + public void Initialize_WithValidatorFactory_DoesNotThrow() + { + var plugin = new CSCGlobalCAPlugin(Mock.Of()); + plugin.Initialize(ConfigProviderMock().Object, Mock.Of()); + Assert.True(plugin.Enabled); + } + + // --------------------------------------------------------------------- + // GetSingleRecord + // --------------------------------------------------------------------- + + [Fact] + public async Task GetSingleRecord_NullId_Throws() + { + var plugin = MakePlugin(); + await Assert.ThrowsAsync(() => plugin.GetSingleRecord(null!)); + } + + [Fact] + public async Task GetSingleRecord_TooShortId_Throws() + { + var plugin = MakePlugin(); + await Assert.ThrowsAsync(() => plugin.GetSingleRecord("short-id")); + } + + [Fact] + public async Task GetSingleRecord_NullClientResponse_ReturnsFailedMappedStatus() + { + var uuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ReturnsAsync((CertificateResponse)null!); + + var plugin = MakePlugin(mockClient); + var result = await plugin.GetSingleRecord(uuid); + + Assert.Equal(uuid, result.CARequestID); + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + } + + [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_InvalidBase64Certificate_ReturnsEmptyCertificate() + { + var uuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ReturnsAsync(new CertificateResponse + { + Certificate = Convert.ToBase64String(Encoding.ASCII.GetBytes("not valid pem at all")), + Status = "ACTIVE" + }); + + var plugin = MakePlugin(mockClient); + var result = await plugin.GetSingleRecord(uuid); + + Assert.Equal(string.Empty, 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 / SyncCertificates + // --------------------------------------------------------------------- + + [Fact] + public async Task Synchronize_NullBuffer_Throws() + { + var plugin = MakePlugin(); + await Assert.ThrowsAsync(() => plugin.Synchronize(null!, null, true, CancellationToken.None)); + } + + [Fact] + public async Task Synchronize_Disabled_CompletesImmediatelyWithoutCallingClient() + { + var mockClient = new Mock(); + var plugin = MakePlugin(mockClient, configOverrides: new Dictionary { [Constants.Enabled] = "false" }); + var buffer = new System.Collections.Concurrent.BlockingCollection(); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + Assert.True(buffer.IsAddingCompleted); + mockClient.Verify(c => c.SubmitCertificateListRequestAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Synchronize_FullSync_QueuesGeneratedAndRevokedOnly() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(null)).ReturnsAsync(new CertificateListResponse + { + Results = new List + { + new CertificateResponse { Uuid = "u1", Status = "ACTIVE", Certificate = null, CertificateType = "4" }, + new CertificateResponse { Uuid = "u2", Status = "Pending", Certificate = null, CertificateType = "4" }, + null! + } + }); + + var plugin = MakePlugin(mockClient); + var buffer = new System.Collections.Concurrent.BlockingCollection(); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + Assert.True(buffer.IsAddingCompleted); + // Neither item has actual certificate bytes, so both get skipped after status-eligibility + // check; this exercises the eligible-but-empty-content and null-item paths. + Assert.Empty(buffer); + } + + [Fact] + public async Task Synchronize_IncrementalSync_UsesFilterDate() + { + var mockClient = new Mock(); + string? capturedFilter = "not-called"; + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())) + .Callback(f => capturedFilter = f) + .ReturnsAsync(new CertificateListResponse { Results = new List() }); + + var plugin = MakePlugin(mockClient, configOverrides: new Dictionary { [Constants.SyncFilterDays] = "10" }); + var buffer = new System.Collections.Concurrent.BlockingCollection(); + + await plugin.Synchronize(buffer, null, false, CancellationToken.None); + + Assert.NotNull(capturedFilter); + Assert.NotEqual("not-called", capturedFilter); + } + + [Fact] + public async Task Synchronize_NullResultsFromClient_CompletesWithoutError() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())) + .ReturnsAsync((CertificateListResponse)null!); + + var plugin = MakePlugin(mockClient); + var buffer = new System.Collections.Concurrent.BlockingCollection(); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + Assert.True(buffer.IsAddingCompleted); + } + + [Fact] + public async Task Synchronize_NullResultsCollection_CompletesWithoutError() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())) + .ReturnsAsync(new CertificateListResponse { Results = null }); + + var plugin = MakePlugin(mockClient); + var buffer = new System.Collections.Concurrent.BlockingCollection(); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + Assert.True(buffer.IsAddingCompleted); + } + + [Fact] + public async Task Synchronize_ValidCertificateContent_AddsToBufferWithMappedProductId() + { + var (_, pem) = MakeSelfSignedCert(); + var apiBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(pem)); + + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())).ReturnsAsync(new CertificateListResponse + { + Results = new List + { + new CertificateResponse { Uuid = "u1", Status = "ACTIVE", Certificate = apiBase64, CertificateType = "4" } + } + }); + + var plugin = MakePlugin(mockClient); + var buffer = new System.Collections.Concurrent.BlockingCollection(); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var items = buffer.ToArray(); + Assert.Single(items); + Assert.Equal("u1", items[0].CARequestID); + Assert.Equal("CSC TrustedSecure Domain Validated SSL", items[0].ProductID); + } + + [Fact] + public async Task Synchronize_MalformedBase64Certificate_SkipsItem() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())).ReturnsAsync(new CertificateListResponse + { + Results = new List + { + new CertificateResponse { Uuid = "u1", Status = "ACTIVE", Certificate = "not valid base64 at all!!", CertificateType = "4" } + } + }); + + var plugin = MakePlugin(mockClient); + var buffer = new System.Collections.Concurrent.BlockingCollection(); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + Assert.Empty(buffer); + } + + [Fact] + public async Task Synchronize_ValidBase64ButNoPemCertificates_SkipsItem() + { + var apiBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("this is not a PEM certificate")); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())).ReturnsAsync(new CertificateListResponse + { + Results = new List + { + new CertificateResponse { Uuid = "u1", Status = "ACTIVE", Certificate = apiBase64, CertificateType = "4" } + } + }); + + var plugin = MakePlugin(mockClient); + var buffer = new System.Collections.Concurrent.BlockingCollection(); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + Assert.Empty(buffer); + } + + [Fact] + public async Task Synchronize_RevokedStatus_AlsoQualifiesForSync() + { + var (_, pem) = MakeSelfSignedCert(); + var apiBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(pem)); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())).ReturnsAsync(new CertificateListResponse + { + Results = new List + { + new CertificateResponse { Uuid = "u1", Status = "REVOKED", Certificate = apiBase64, CertificateType = "4" } + } + }); + + var plugin = MakePlugin(mockClient); + var buffer = new System.Collections.Concurrent.BlockingCollection(); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + Assert.Single(buffer); + } + + [Fact] + public async Task Synchronize_ClientThrows_PropagatesAndCompletesBuffer() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("boom")); + + var plugin = MakePlugin(mockClient); + var buffer = new System.Collections.Concurrent.BlockingCollection(); + + await Assert.ThrowsAsync(() => plugin.Synchronize(buffer, null, true, CancellationToken.None)); + Assert.True(buffer.IsAddingCompleted); + } + + [Fact] + public async Task Synchronize_Cancelled_ThrowsOperationCanceledAndCompletesBuffer() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())).ReturnsAsync(new CertificateListResponse + { + Results = new List { new CertificateResponse { Uuid = "u1", Status = "ACTIVE" } } + }); + + var plugin = MakePlugin(mockClient); + var buffer = new System.Collections.Concurrent.BlockingCollection(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAsync(() => plugin.Synchronize(buffer, null, true, cts.Token)); + Assert.True(buffer.IsAddingCompleted); + } + + // --------------------------------------------------------------------- + // Revoke + // --------------------------------------------------------------------- + + [Fact] + public async Task Revoke_Disabled_Throws() + { + var plugin = MakePlugin(configOverrides: new Dictionary { [Constants.Enabled] = "false" }); + await Assert.ThrowsAsync(() => + plugin.Revoke(new string('a', 36), "serial", 0)); + } + + [Fact] + public async Task Revoke_TooShortId_Throws() + { + var plugin = MakePlugin(); + await Assert.ThrowsAsync(() => plugin.Revoke("short", "serial", 0)); + } + + [Fact] + public async Task Revoke_NullResponse_Throws() + { + var uuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitRevokeCertificateAsync(uuid)).ReturnsAsync((RevokeResponse)null!); + + var plugin = MakePlugin(mockClient); + // Wrapped by the generic catch (Exception e) at the bottom of Revoke, since + // InvalidOperationException isn't AggregateException or HttpRequestException. + var ex = await Assert.ThrowsAsync(() => plugin.Revoke(uuid, "serial", 0)); + Assert.IsType(ex.InnerException); + } + + [Fact] + public async Task Revoke_Success_ReturnsRevoked() + { + var uuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitRevokeCertificateAsync(uuid)).ReturnsAsync(new RevokeResponse + { + RevokeSuccess = new RevokeSuccessResponse { Status = "REVOKED" } + }); + + var plugin = MakePlugin(mockClient); + var result = await plugin.Revoke(uuid, "serial", 0); + + Assert.Equal((int)EndEntityStatus.REVOKED, result); + } + + [Fact] + public async Task Revoke_ErrorWithDescription_ThrowsHttpRequestException() + { + 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, "serial", 0)); + } + + [Fact] + public async Task Revoke_ClientThrows_WrapsException() + { + var uuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitRevokeCertificateAsync(uuid)).ThrowsAsync(new InvalidOperationException("boom")); + + var plugin = MakePlugin(mockClient); + await Assert.ThrowsAsync(() => plugin.Revoke(uuid, "serial", 0)); + } + + // --------------------------------------------------------------------- + // Ping / ValidateCAConnectionInfo + // --------------------------------------------------------------------- + + [Fact] + public async Task Ping_Enabled_DoesNotThrow() + { + var plugin = MakePlugin(); + await plugin.Ping(); + } + + [Fact] + public async Task Ping_Disabled_DoesNotThrow() + { + var plugin = MakePlugin(configOverrides: new Dictionary { [Constants.Enabled] = "false" }); + await plugin.Ping(); + } + + [Fact] + public async Task ValidateCAConnectionInfo_NullConnectionInfo_Throws() + { + var plugin = MakePlugin(); + await Assert.ThrowsAsync(() => plugin.ValidateCAConnectionInfo(null!)); + } + + [Fact] + public async Task ValidateCAConnectionInfo_Enabled_DoesNotThrow() + { + var plugin = MakePlugin(); + await plugin.ValidateCAConnectionInfo(new Dictionary()); + } + + [Fact] + public async Task ValidateCAConnectionInfo_ExplicitlyDisabled_DoesNotThrow() + { + var plugin = MakePlugin(); + await plugin.ValidateCAConnectionInfo(new Dictionary { [Constants.Enabled] = "false" }); + } + + // --------------------------------------------------------------------- + // ValidateProductInfo + // --------------------------------------------------------------------- [Theory] [InlineData("CSC TrustedSecure DV")] [InlineData("CSC TrustedSecure DV Wildcard, Multiple Names")] public async Task ValidateProductInfo_CanonicalProductName_DoesNotThrow(string productId) { - var plugin = new CSCGlobalCAPlugin(); - // Parameterless constructor per plugin's own doc comment: runs without DNS - // auto-publishing, which ValidateProductInfo does not depend on. + var plugin = MakePlugin(); await plugin.ValidateProductInfo(ProductInfo(productId), new Dictionary()); } @@ -29,26 +607,1083 @@ public async Task ValidateProductInfo_CanonicalProductName_DoesNotThrow(string p [InlineData("CSC Trusted Secure Domain Validated Wildcard SSL")] public async Task ValidateProductInfo_LegacyProductName_DoesNotThrow(string legacyProductId) { - var plugin = new CSCGlobalCAPlugin(); + var plugin = MakePlugin(); await plugin.ValidateProductInfo(ProductInfo(legacyProductId), new Dictionary()); } + [Fact] + public async Task ValidateProductInfo_NullProductInfo_Throws() + { + var plugin = MakePlugin(); + await Assert.ThrowsAsync(() => + plugin.ValidateProductInfo(null!, new Dictionary())); + } + + [Fact] + public async Task ValidateProductInfo_EmptyProductId_Throws() + { + var plugin = MakePlugin(); + await Assert.ThrowsAsync(() => + plugin.ValidateProductInfo(ProductInfo(""), new Dictionary())); + } + [Fact] public async Task ValidateProductInfo_UnknownProduct_Throws() { - var plugin = new CSCGlobalCAPlugin(); + var plugin = MakePlugin(); await Assert.ThrowsAsync(() => plugin.ValidateProductInfo(ProductInfo("Not A Real Product"), new Dictionary())); } + [Fact] + public async Task ValidateProductInfo_NullConnectionInfo_TreatsAsEnabled() + { + var plugin = MakePlugin(); + await Assert.ThrowsAsync(() => + plugin.ValidateProductInfo(ProductInfo("Not A Real Product"), null!)); + } + [Fact] public async Task ValidateProductInfo_DisabledConnector_SkipsValidationEvenForUnknownProduct() { - var plugin = new CSCGlobalCAPlugin(); + 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_UnparsableEnabledValue_TreatsAsEnabled() + { + var plugin = MakePlugin(); + var connectionInfo = new Dictionary { [Constants.Enabled] = "not-a-bool" }; + await Assert.ThrowsAsync(() => + plugin.ValidateProductInfo(ProductInfo("Not A Real Product"), connectionInfo)); + } + + // --------------------------------------------------------------------- + // Annotations / product IDs + // --------------------------------------------------------------------- + + [Fact] + public void GetCAConnectorAnnotations_ReturnsExpectedKeys() + { + var plugin = MakePlugin(); + var annotations = plugin.GetCAConnectorAnnotations(); + Assert.Contains(Constants.Enabled, annotations.Keys); + Assert.Contains(Constants.CscGlobalUrl, annotations.Keys); + Assert.Contains(Constants.DcvPollTimeoutSeconds, annotations.Keys); + } + + [Fact] + public void GetTemplateParameterAnnotations_ReturnsExpectedKeys() + { + var plugin = MakePlugin(); + var annotations = plugin.GetTemplateParameterAnnotations(); + Assert.Contains(EnrollmentConfigConstants.CnDcvEmail, annotations.Keys); + Assert.Contains(EnrollmentConfigConstants.AdditionalSansCommaSeparatedDcvEmails, annotations.Keys); + } + + [Fact] + public void GetProductIds_ReturnsCanonicalTenProducts() + { + var plugin = MakePlugin(); + Assert.Equal(10, plugin.GetProductIds().Count); + } + + // --------------------------------------------------------------------- + // Enroll - validation and New enrollment + // --------------------------------------------------------------------- + + [Fact] + public async Task Enroll_Disabled_ReturnsFailedWithoutCallingClient() + { + var mockClient = new Mock(); + var plugin = MakePlugin(mockClient, configOverrides: new Dictionary { [Constants.Enabled] = "false" }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), + RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + mockClient.Verify(c => c.SubmitGetCustomFields(), Times.Never); + } + + [Fact] + public async Task Enroll_NullProductInfo_Throws() + { + var plugin = MakePlugin(); + await Assert.ThrowsAsync(() => + plugin.Enroll("csr", "CN=test", new Dictionary(), null!, RequestFormat.PKCS10, EnrollmentType.New)); + } + + [Fact] + public async Task Enroll_EmptyCsr_Throws() + { + var plugin = MakePlugin(); + await Assert.ThrowsAsync(() => + plugin.Enroll("", "CN=test", new Dictionary(), ProductInfo(), RequestFormat.PKCS10, EnrollmentType.New)); + } + + [Fact] + public async Task Enroll_New_PriorCertSnPresent_ReturnsFailure() + { + 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.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + mockClient.Verify(c => c.SubmitRegistrationAsync(It.IsAny()), Times.Never); + } + + [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 = "new.example.com", Status = new Status { Uuid = "uuid-new" } } + }); + + 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-new", result.CARequestID); + } + + [Fact] + public async Task Enroll_New_NullClientResponse_ReturnsFailure() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync((RegistrationResponse)null!); + + var plugin = MakePlugin(mockClient); + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), + RequestFormat.PKCS10, EnrollmentType.New); + + Assert.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + } + + [Fact] + public async Task Enroll_New_ClientThrows_ReturnsFailureInsteadOfThrowing() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("boom")); + + var plugin = MakePlugin(mockClient); + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), + RequestFormat.PKCS10, EnrollmentType.New); + + Assert.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Contains("boom", result.StatusMessage); + } + + [Fact] + public async Task Enroll_UnhandledEnrollmentType_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.Renew); + + Assert.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + } + + [Fact] + public async Task Enroll_New_WithPollingEnabledAndFastIssuance_ReturnsGeneratedCertDirectly() + { + var (_, pem) = MakeSelfSignedCert(); + var apiBase64 = Convert.ToBase64String(Encoding.ASCII.GetBytes(pem)); + var uuid = Guid.NewGuid().ToString(); // must be >= 36 chars - GetSingleRecord validates length + + 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 = "fast.example.com", Status = new Status { Uuid = uuid } } + }); + mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ReturnsAsync(new CertificateResponse + { + Status = "ACTIVE", + Certificate = apiBase64 + }); + + // DcvPollTimeoutSeconds < the 10s poll interval means exactly one poll attempt happens + // and the loop then breaks without ever calling Task.Delay - fast and deterministic. + var plugin = MakePlugin(mockClient, configOverrides: new Dictionary { [Constants.DcvPollTimeoutSeconds] = "1" }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), + RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.GENERATED, result.Status); + Assert.Equal(uuid, result.CARequestID); + Assert.NotNull(result.Certificate); + } + + [Fact] + public async Task Enroll_New_PollingEnabledButNotIssued_FallsBackToPendingResult() + { + var uuid = Guid.NewGuid().ToString(); + 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 = "pending.example.com", Status = new Status { Uuid = uuid } } + }); + mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ReturnsAsync(new CertificateResponse { Status = "Pending" }); + + var plugin = MakePlugin(mockClient, configOverrides: new Dictionary { [Constants.DcvPollTimeoutSeconds] = "1" }); + + 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, result.CARequestID); + } + + [Fact] + public async Task Enroll_New_WithDnsValidatorFactory_PublishesCnameRecord() + { + 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 = "cname.example.com", + Status = new Status { Uuid = "uuid-cname" }, + DcvDetails = new List + { + new DcvDetail { CName = new CName { Name = "_dnsauth.example.com.", Value = "target.sectigo.com." } } + } + } + }); + + var mockValidator = new Mock(); + mockValidator.Setup(v => v.GetValidationType()).Returns("cname"); + mockValidator.Setup(v => v.StageValidation(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new DomainValidationResult { Success = true, Status = "staged" }); + + var mockFactory = new Mock(); + mockFactory.Setup(f => f.ResolveDomainValidator(It.IsAny(), "cname")).Returns(mockValidator.Object); + + var plugin = MakePlugin(mockClient, validatorFactory: mockFactory.Object); + var productInfo = ProductInfo(parameters: new Dictionary + { + [EnrollmentConfigConstants.DomainControlValidationMethod] = "CNAME" + }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + // Trailing dots must be stripped before resolution or no provider would match. + mockFactory.Verify(f => f.ResolveDomainValidator("_dnsauth.example.com", "cname"), Times.Once); + mockValidator.Verify(v => v.StageValidation("_dnsauth.example.com", "target.sectigo.com", It.IsAny()), Times.Once); + } + + [Fact] + public async Task Enroll_New_WithDnsValidatorFactoryButEmailMethod_DoesNotAttemptPublish() + { + 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 = "email.example.com", + Status = new Status { Uuid = "uuid-email" }, + DcvDetails = new List { new DcvDetail { Email = "admin@example.com" } } + } + }); + + var mockFactory = new Mock(); + var plugin = MakePlugin(mockClient, validatorFactory: mockFactory.Object); + var productInfo = ProductInfo(parameters: new Dictionary + { + [EnrollmentConfigConstants.DomainControlValidationMethod] = "EMAIL" + }); + + await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.New); + + mockFactory.Verify(f => f.ResolveDomainValidator(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Enroll_New_DnsValidatorUnresolved_DoesNotThrow() + { + 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 = "unresolved.example.com", + Status = new Status { Uuid = "uuid-unresolved" }, + DcvDetails = new List + { + new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "target.sectigo.com" } } + } + } + }); + + var mockFactory = new Mock(); + mockFactory.Setup(f => f.ResolveDomainValidator(It.IsAny(), "cname")).Returns((IDomainValidator)null!); + + var plugin = MakePlugin(mockClient, validatorFactory: mockFactory.Object); + var productInfo = ProductInfo(parameters: new Dictionary + { + [EnrollmentConfigConstants.DomainControlValidationMethod] = "CNAME" + }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + } + + [Fact] + public async Task Enroll_New_DnsValidatorStageValidationThrows_DoesNotThrow() + { + 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 = "err.example.com", + Status = new Status { Uuid = "uuid-err" }, + DcvDetails = new List + { + new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "target.sectigo.com" } } + } + } + }); + + var mockValidator = new Mock(); + mockValidator.Setup(v => v.GetValidationType()).Returns("cname"); + mockValidator.Setup(v => v.StageValidation(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("dns failure")); + + var mockFactory = new Mock(); + mockFactory.Setup(f => f.ResolveDomainValidator(It.IsAny(), "cname")).Returns(mockValidator.Object); + + var plugin = MakePlugin(mockClient, validatorFactory: mockFactory.Object); + var productInfo = ProductInfo(parameters: new Dictionary + { + [EnrollmentConfigConstants.DomainControlValidationMethod] = "CNAME" + }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + } + + [Fact] + public async Task Enroll_New_DnsValidatorReturnsFailure_DoesNotThrow() + { + 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 = "fail.example.com", + Status = new Status { Uuid = "uuid-fail" }, + DcvDetails = new List + { + new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "target.sectigo.com" } } + } + } + }); + + var mockValidator = new Mock(); + mockValidator.Setup(v => v.GetValidationType()).Returns("cname"); + mockValidator.Setup(v => v.StageValidation(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new DomainValidationResult { Success = false, Status = "error", ErrorMessage = "nope" }); + + var mockFactory = new Mock(); + mockFactory.Setup(f => f.ResolveDomainValidator(It.IsAny(), "cname")).Returns(mockValidator.Object); + + var plugin = MakePlugin(mockClient, validatorFactory: mockFactory.Object); + var productInfo = ProductInfo(parameters: new Dictionary + { + [EnrollmentConfigConstants.DomainControlValidationMethod] = "CNAME" + }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + } + + [Fact] + public async Task Enroll_New_DnsFactoryButNoEnrollmentContext_SkipsPublish() + { + 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 = "none.example.com", Status = new Status { Uuid = "uuid-none" } } + }); + + var mockFactory = new Mock(); + var plugin = MakePlugin(mockClient, validatorFactory: mockFactory.Object); + + await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), + RequestFormat.PKCS10, EnrollmentType.New); + + mockFactory.Verify(f => f.ResolveDomainValidator(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Enroll_New_PollingEnabledButNoCARequestId_SkipsPollingWithoutError() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse + { + // No Status/Uuid at all -> enrollResult.CARequestID is null -> TryPollForIssuedCertAsync + // must skip cleanly rather than throw. + Result = new Result { CommonName = "no-uuid.example.com" } + }); + + var plugin = MakePlugin(mockClient, configOverrides: new Dictionary { [Constants.DcvPollTimeoutSeconds] = "1" }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), + RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + mockClient.Verify(c => c.SubmitGetCertificateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Enroll_New_PollingThrowsOnFirstAttempt_FallsBackToPendingResult() + { + var uuid = Guid.NewGuid().ToString(); + 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 = "poll-error.example.com", Status = new Status { Uuid = uuid } } + }); + // GetSingleRecord (called internally by the poll loop) throws - must be caught and retried, + // not propagated, and the loop still falls back to the pending result once time is up. + mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ThrowsAsync(new InvalidOperationException("network blip")); + + var plugin = MakePlugin(mockClient, configOverrides: new Dictionary { [Constants.DcvPollTimeoutSeconds] = "1" }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), + RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + } + + [Fact] + public async Task Enroll_New_CnameMethodWithMixedEmailEntry_SkipsEmailPassthroughEntry() + { + 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 = "mixed.example.com", + Status = new Status { Uuid = "uuid-mixed" }, + DcvDetails = new List + { + new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "target.sectigo.com" } }, + // Key == value: GetEnrollmentResult's email passthrough shape, mixed into the + // same EnrollmentContext even though the product's DCV method is CNAME. + new DcvDetail { Email = "admin@example.com" } + } + } + }); + + var mockValidator = new Mock(); + mockValidator.Setup(v => v.GetValidationType()).Returns("cname"); + mockValidator.Setup(v => v.StageValidation(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new DomainValidationResult { Success = true }); + + var mockFactory = new Mock(); + mockFactory.Setup(f => f.ResolveDomainValidator(It.IsAny(), "cname")).Returns(mockValidator.Object); + + var plugin = MakePlugin(mockClient, validatorFactory: mockFactory.Object); + var productInfo = ProductInfo(parameters: new Dictionary + { + [EnrollmentConfigConstants.DomainControlValidationMethod] = "CNAME" + }); + + await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.New); + + // Only the CNAME entry should have been resolved/staged; the email passthrough is skipped. + mockFactory.Verify(f => f.ResolveDomainValidator(It.IsAny(), "cname"), Times.Once); + } + + // --------------------------------------------------------------------- + // Enroll - RenewOrReissue + // --------------------------------------------------------------------- + + [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.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Contains("PriorCertSN", result.StatusMessage); + } + + [Fact] + public async Task Enroll_RenewOrReissue_NoOrderIdFoundForSerial_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.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + } + + [Fact] + public async Task Enroll_RenewOrReissue_OrderIdTooShort_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"); + + 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.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + } + + [Fact] + public async Task Enroll_RenewOrReissue_RenewalWithApplicantLastName_Succeeds() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + // OrderDate 2 years ago -> well past the 1-year+RenewalWindowDays expiry -> renewal path. + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = DateTime.UtcNow.AddYears(-2).ToString("o") + }); + mockClient.Setup(c => c.SubmitRenewalAsync(It.IsAny())).ReturnsAsync(new RenewalResponse + { + Result = new Result { CommonName = "renewed.example.com", Status = new Status { Uuid = orderUuid } } + }); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); + + 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_RenewalMissingApplicantLastName_ReturnsFailure() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = DateTime.UtcNow.AddYears(-2).ToString("o") + }); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); + + 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.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + mockClient.Verify(c => c.SubmitRenewalAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Enroll_RenewOrReissue_ReissueWithApplicantLastName_Succeeds() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + // OrderDate today -> well within the renewal window -> reissue (free) path. + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = DateTime.UtcNow.ToString("o") + }); + mockClient.Setup(c => c.SubmitReissueAsync(It.IsAny())).ReturnsAsync(new ReissueResponse + { + Result = new Result { CommonName = "reissued.example.com", Status = new Status { Uuid = orderUuid } } + }); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); + + 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_ReissueMissingApplicantLastName_ReturnsFailure() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = DateTime.UtcNow.ToString("o") + }); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); + + 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.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + mockClient.Verify(c => c.SubmitReissueAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Enroll_RenewOrReissue_NoOrderDate_FallsBackToCertificateDataReaderExpiry() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + // No OrderDate at all -> falls back to expiry-based decision. + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse { OrderDate = null }); + mockClient.Setup(c => c.SubmitRenewalAsync(It.IsAny())).ReturnsAsync(new RenewalResponse + { + Result = new Result { CommonName = "expired.example.com", Status = new Status { Uuid = orderUuid } } + }); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); + certDataReader.Setup(r => r.GetExpirationDateByRequestId(orderUuid)).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); + } + + // --------------------------------------------------------------------- + // GetEndEntityCertificate / ExtractCertificates / FindLeaf + // --------------------------------------------------------------------- + + [Fact] + public void GetEndEntityCertificate_EmptyInput_ReturnsEmpty() + { + 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_NoPemBlocks_ReturnsEmpty() + { + var plugin = MakePlugin(); + Assert.Equal(string.Empty, plugin.GetEndEntityCertificate("just some plain text, no PEM fences")); + } + + [Fact] + public void GetEndEntityCertificate_EmptyPemBlockContent_SkipsBlock() + { + var (cert, pem) = MakeSelfSignedCert(); + var emptyBlock = "-----BEGIN CERTIFICATE-----\n \n-----END CERTIFICATE-----\n"; + var plugin = MakePlugin(); + + var result = plugin.GetEndEntityCertificate(emptyBlock + pem); + + Assert.Equal(Convert.ToBase64String(cert.RawData), result); + } + + [Fact] + public void GetEndEntityCertificate_CertWithoutBasicConstraints_TreatedAsNonCa() + { + // A cert with no Basic Constraints extension at all exercises FindLeaf's IsCa "unknown -> + // treat as non-CA" fallback, distinct from an explicit CertificateAuthority=false. + using var rsa = RSA.Create(2048); + var req = new CertificateRequest("CN=no-constraints.example.com", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(365)); + var pem = "-----BEGIN CERTIFICATE-----\n" + + Convert.ToBase64String(cert.RawData, Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE-----\n"; + + var plugin = MakePlugin(); + var result = plugin.GetEndEntityCertificate(pem); + + Assert.Equal(Convert.ToBase64String(cert.RawData), result); + } + + [Fact] + public void GetEndEntityCertificate_MalformedBase64InBlock_SkipsAndReturnsEmpty() + { + var pem = "-----BEGIN CERTIFICATE-----\nNOT!!VALID==BASE64%%CHARS\n-----END CERTIFICATE-----\n"; + var plugin = MakePlugin(); + Assert.Equal(string.Empty, plugin.GetEndEntityCertificate(pem)); + } + + [Fact] + public void GetEndEntityCertificate_ValidBase64ButNotACertificate_SkipsAndReturnsEmpty() + { + var notACert = Convert.ToBase64String(Encoding.UTF8.GetBytes("this decodes fine but is not DER-encoded")); + 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), caCert.NotAfter.AddDays(-1), + 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); + } + + [Fact] + public void GetEndEntityCertificate_NoDeterminableLeaf_ReturnsEmpty() + { + // Two distinct CA certs that (deliberately) share the exact same Subject/Issuer DN + // string: FindLeaf's Issuer/Subject string-matching heuristic treats each as "issuing" + // the other, so neither ends up in nonIssuers nor anyNonCa (both are CA=true) - the + // "give up" path. + string ToPemBlock(X509Certificate2 c) => "-----BEGIN CERTIFICATE-----\n" + + Convert.ToBase64String(c.RawData, Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE-----\n"; + + X509Certificate2 MakeCaCert() + { + using var rsa = RSA.Create(2048); + var req = new CertificateRequest("CN=duplicate.example.com", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + req.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + return req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(365)); + } + + var pem = ToPemBlock(MakeCaCert()) + ToPemBlock(MakeCaCert()); + var plugin = MakePlugin(); + + Assert.Equal(string.Empty, plugin.GetEndEntityCertificate(pem)); + } + + [Fact] + public async Task Enroll_New_CustomFieldsNull_UsesEmptyListInstead() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync((List)null!); + mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse + { + Result = new Result { CommonName = "nullfields.example.com", Status = new Status { Uuid = "uuid-nf" } } + }); + + 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); + } + + [Fact] + public async Task Enroll_RenewOrReissue_FetchLiveCertThrowsAndFallbackAlsoFails_ReturnsFailure() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + // Both the primary live-cert fetch AND the fallback's GetSingleRecord call use the same + // client method, and both fail - forcing the innermost catch(fallbackEx) path. + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ThrowsAsync(new InvalidOperationException("network error")); + + 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.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Contains("unable to determine renewal status", result.StatusMessage); + } + + [Fact] + public async Task Enroll_RenewOrReissue_RenewalUuidLookupFails_ReturnsFailure() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = DateTime.UtcNow.AddYears(-2).ToString("o") // renewal path + }); + + var certDataReader = new Mock(); + // First call resolves the top-level order_id; second (inside the renewal branch, for the + // same PriorCertSN) fails to resolve - exercises ValidateRenewalUUID's failure branch. + certDataReader.SetupSequence(r => r.GetRequestIDBySerialNumber("ABC123")) + .ReturnsAsync(orderUuid) + .ReturnsAsync(string.Empty); + + 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.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Contains("could not resolve prior certificate serial number", result.StatusMessage); + } + + [Fact] + public async Task Enroll_RenewOrReissue_RenewalNullResponse_ReturnsFailure() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = DateTime.UtcNow.AddYears(-2).ToString("o") + }); + mockClient.Setup(c => c.SubmitRenewalAsync(It.IsAny())).ReturnsAsync((RenewalResponse)null!); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); + + 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.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Contains("CSC API returned a null response", result.StatusMessage); + } + + [Fact] + public async Task Enroll_RenewOrReissue_ReissueRequestIdLookupEmpty_ReturnsFailure() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = DateTime.UtcNow.ToString("o") // reissue path + }); + + var certDataReader = new Mock(); + certDataReader.SetupSequence(r => r.GetRequestIDBySerialNumber("ABC123")) + .ReturnsAsync(orderUuid) + .ReturnsAsync(string.Empty); + + 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.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Contains("could not resolve prior certificate serial number", result.StatusMessage); + } + + [Fact] + public async Task Enroll_RenewOrReissue_ReissueRequestIdTooShort_ReturnsFailure() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = DateTime.UtcNow.ToString("o") + }); + + var certDataReader = new Mock(); + certDataReader.SetupSequence(r => r.GetRequestIDBySerialNumber("ABC123")) + .ReturnsAsync(orderUuid) + .ReturnsAsync("too-short"); + + 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.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Contains("too short to extract a UUID", result.StatusMessage); + } + + [Fact] + public async Task Enroll_RenewOrReissue_ReissueNullResponse_ReturnsFailure() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = DateTime.UtcNow.ToString("o") + }); + mockClient.Setup(c => c.SubmitReissueAsync(It.IsAny())).ReturnsAsync((ReissueResponse)null!); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); + + 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.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Contains("CSC API returned a null response", result.StatusMessage); + } + + [Fact] + public async Task Enroll_RenewOrReissue_FetchLiveCertThrows_FallsBackToExpiryCheck() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.SetupSequence(c => c.SubmitGetCertificateAsync(orderUuid)) + .ThrowsAsync(new InvalidOperationException("network error")); + mockClient.Setup(c => c.SubmitReissueAsync(It.IsAny())).ReturnsAsync(new ReissueResponse + { + Result = new Result { CommonName = "fallback.example.com", Status = new Status { Uuid = orderUuid } } + }); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); + certDataReader.Setup(r => r.GetExpirationDateByRequestId(orderUuid)).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); + } } diff --git a/cscglobal-caplugin.Tests/CscGlobalClientTests.cs b/cscglobal-caplugin.Tests/CscGlobalClientTests.cs new file mode 100644 index 0000000..0fa7a81 --- /dev/null +++ b/cscglobal-caplugin.Tests/CscGlobalClientTests.cs @@ -0,0 +1,397 @@ +// 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 Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CSCGlobal; +using Keyfactor.Extensions.CAPlugin.CSCGlobal.Client; +using Keyfactor.Extensions.CAPlugin.CSCGlobal.Client.Models; +using Moq; +using Xunit; + +namespace CscGlobalCAPluginTests; + +public class CscGlobalClientTests +{ + private sealed class FakeHttpMessageHandler : HttpMessageHandler + { + private readonly Func _responder; + public HttpRequestMessage? LastRequest { get; private set; } + + public FakeHttpMessageHandler(Func responder) => _responder = responder; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + return Task.FromResult(_responder(request)); + } + } + + private static HttpResponseMessage JsonResponse(HttpStatusCode code, string json) => + new HttpResponseMessage(code) { Content = new StringContent(json) }; + + private static Mock ValidConfig() + { + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns(new Dictionary + { + [Constants.CscGlobalApiKey] = "api-key", + [Constants.CscGlobalUrl] = "https://example.invalid/", + [Constants.BearerToken] = "bearer-token" + }); + return mock; + } + + private static CscGlobalClient MakeClient(Func responder, out FakeHttpMessageHandler handler) + { + handler = new FakeHttpMessageHandler(responder); + return new CscGlobalClient(ValidConfig().Object, handler); + } + + // --------------------------------------------------------------------- + // Constructor validation + // --------------------------------------------------------------------- + + [Fact] + public void Constructor_NullConfig_Throws() + { + Assert.Throws(() => new CscGlobalClient(null!)); + } + + [Fact] + public void Constructor_NullCAConnectionData_Throws() + { + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns((Dictionary)null!); + Assert.Throws(() => new CscGlobalClient(mock.Object)); + } + + [Fact] + public void Constructor_MissingApiKey_Throws() + { + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns(new Dictionary()); + Assert.Throws(() => new CscGlobalClient(mock.Object)); + } + + [Fact] + public void Constructor_MissingUrl_Throws() + { + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns(new Dictionary + { + [Constants.CscGlobalApiKey] = "api-key" + }); + Assert.Throws(() => new CscGlobalClient(mock.Object)); + } + + [Fact] + public void Constructor_EmptyApiKeyValue_Throws() + { + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns(new Dictionary + { + [Constants.CscGlobalApiKey] = "", + [Constants.CscGlobalUrl] = "https://example.invalid/" + }); + Assert.Throws(() => new CscGlobalClient(mock.Object)); + } + + [Fact] + public void Constructor_EmptyBearerTokenValue_Throws() + { + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns(new Dictionary + { + [Constants.CscGlobalApiKey] = "api-key", + [Constants.CscGlobalUrl] = "https://example.invalid/", + [Constants.BearerToken] = "" + }); + Assert.Throws(() => new CscGlobalClient(mock.Object)); + } + + [Fact] + public void Constructor_MissingBearerToken_Throws() + { + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns(new Dictionary + { + [Constants.CscGlobalApiKey] = "api-key", + [Constants.CscGlobalUrl] = "https://example.invalid/" + }); + Assert.Throws(() => new CscGlobalClient(mock.Object)); + } + + [Fact] + public void Constructor_ValidConfig_DoesNotThrow() + { + var client = new CscGlobalClient(ValidConfig().Object, new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, "{}"))); + Assert.NotNull(client); + } + + // --------------------------------------------------------------------- + // SubmitRegistrationAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task SubmitRegistrationAsync_Success_ReturnsParsedResponse() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, + "{\"result\":{\"commonName\":\"order-1\",\"price\":{\"currency\":\"USD\",\"total\":99.5}}}"), out var handler); + + var response = await client.SubmitRegistrationAsync(new RegistrationRequest()); + + Assert.Equal("order-1", response.Result.CommonName); + Assert.Contains("/dbs/api/v2/tls/registration", handler.LastRequest!.RequestUri!.ToString()); + } + + [Fact] + public async Task SubmitRegistrationAsync_NullRequest_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{}"), out _); + await Assert.ThrowsAsync(() => client.SubmitRegistrationAsync(null!)); + } + + [Fact] + public async Task SubmitRegistrationAsync_BadRequest_ReturnsRegistrationError() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.BadRequest, "{\"description\":\"denied\"}"), out _); + var response = await client.SubmitRegistrationAsync(new RegistrationRequest()); + Assert.Equal("denied", 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())); + } + + // --------------------------------------------------------------------- + // SubmitRenewalAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task SubmitRenewalAsync_Success_ReturnsParsedResponse() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{\"result\":{\"commonName\":\"renewed-1\"}}"), out var handler); + var response = await client.SubmitRenewalAsync(new RenewalRequest()); + Assert.Equal("renewed-1", response.Result.CommonName); + Assert.Contains("/dbs/api/v2/tls/renewal", handler.LastRequest!.RequestUri!.ToString()); + } + + [Fact] + public async Task SubmitRenewalAsync_NullRequest_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{}"), out _); + await Assert.ThrowsAsync(() => client.SubmitRenewalAsync(null!)); + } + + [Fact] + public async Task SubmitRenewalAsync_BadRequest_ReturnsRegistrationError() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.BadRequest, "{\"description\":\"denied\"}"), out _); + var response = await client.SubmitRenewalAsync(new RenewalRequest()); + Assert.Equal("denied", 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())); + } + + // --------------------------------------------------------------------- + // 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_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. + 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() + { + 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())); + } + + // --------------------------------------------------------------------- + // SubmitGetCertificateAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task SubmitGetCertificateAsync_Success_ReturnsParsedResponse() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{\"status\":\"ACTIVE\",\"certificate\":\"abc\"}"), out var handler); + var response = await client.SubmitGetCertificateAsync("uuid-1"); + Assert.Equal("ACTIVE", response.Status); + Assert.Contains("/dbs/api/v2/tls/certificate/uuid-1", handler.LastRequest!.RequestUri!.ToString()); + } + + [Fact] + public async Task SubmitGetCertificateAsync_NullId_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{}"), out _); + await Assert.ThrowsAsync(() => client.SubmitGetCertificateAsync(null!)); + } + + [Fact] + public async Task SubmitGetCertificateAsync_ErrorStatus_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.NotFound, "not found"), out _); + await Assert.ThrowsAsync(() => client.SubmitGetCertificateAsync("uuid-1")); + } + + // --------------------------------------------------------------------- + // 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_NullCustomFieldsProperty_ReturnsEmptyList() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{}"), out _); + var fields = await client.SubmitGetCustomFields(); + Assert.Empty(fields); + } + + [Fact] + public async Task SubmitGetCustomFields_NullResponseBody_ReturnsEmptyList() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "null"), out _); + var fields = await client.SubmitGetCustomFields(); + Assert.Empty(fields); + } + + [Fact] + public async Task SubmitGetCustomFields_ErrorStatus_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.InternalServerError, "boom"), out _); + await Assert.ThrowsAsync(() => client.SubmitGetCustomFields()); + } + + // --------------------------------------------------------------------- + // SubmitRevokeCertificateAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task SubmitRevokeCertificateAsync_Success_ReturnsParsedResponse() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, + "{\"revokeSuccess\":{\"status\":\"REVOKED\"}}"), out var handler); + + var response = await client.SubmitRevokeCertificateAsync("uuid-1"); + + Assert.Equal("REVOKED", response.RevokeSuccess.Status); + Assert.Contains("/dbs/api/v2/tls/revoke/uuid-1", handler.LastRequest!.RequestUri!.ToString()); + } + + [Fact] + public async Task SubmitRevokeCertificateAsync_NullUuid_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{}"), out _); + await Assert.ThrowsAsync(() => client.SubmitRevokeCertificateAsync(null!)); + } + + [Fact] + public async Task SubmitRevokeCertificateAsync_BadRequest_ReturnsRegistrationError() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.BadRequest, "{\"description\":\"already revoked\"}"), out _); + var response = await client.SubmitRevokeCertificateAsync("uuid-1"); + 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("uuid-1")); + } + + // --------------------------------------------------------------------- + // SubmitCertificateListRequestAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task SubmitCertificateListRequestAsync_NoDateFilter_ReturnsResults() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, + "{\"meta\":{\"numResults\":1},\"results\":[{\"uuid\":\"u1\"}]}"), out var handler); + + var response = await client.SubmitCertificateListRequestAsync(); + + Assert.Single(response.Results); + Assert.DoesNotContain("effectiveDate", handler.LastRequest!.RequestUri!.ToString()); + } + + [Fact] + public async Task SubmitCertificateListRequestAsync_WithDateFilter_AppendsFilterToQuery() + { + 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_NullBody_ReturnsEmptyResponse() + { + var client = MakeClient(_ => new HttpResponseMessage(HttpStatusCode.OK), out _); + var response = await client.SubmitCertificateListRequestAsync(); + Assert.NotNull(response); + } + + [Fact] + public async Task SubmitCertificateListRequestAsync_ErrorStatus_DoesNotThrow_ReturnsParsedBody() + { + // Unlike the other Submit* methods, this one only logs on non-success and still parses + // whatever body came back rather than throwing. + var client = MakeClient(_ => JsonResponse(HttpStatusCode.InternalServerError, "{\"results\":[]}"), out _); + var response = await client.SubmitCertificateListRequestAsync(); + Assert.NotNull(response.Results); + Assert.Empty(response.Results); + } +} diff --git a/cscglobal-caplugin.Tests/FlowLoggerTests.cs b/cscglobal-caplugin.Tests/FlowLoggerTests.cs new file mode 100644 index 0000000..2dea519 --- /dev/null +++ b/cscglobal-caplugin.Tests/FlowLoggerTests.cs @@ -0,0 +1,175 @@ +// 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 Step_NoDetail_ChainableAndDoesNotThrow() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + var result = flow.Step("StepOne"); + Assert.Same(flow, result); + } + + [Fact] + public void Step_WithDetail_DoesNotThrow() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Step("StepOne", "some detail"); + } + + [Fact] + public void Step_Action_Success_RunsAction() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + var ran = false; + flow.Step("Action", () => ran = true); + Assert.True(ran); + } + + [Fact] + public void Step_Action_Throws_RecordsFailureAndRethrows() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + Assert.Throws(() => + flow.Step("Action", () => throw new InvalidOperationException("boom"))); + } + + [Fact] + public void Step_ActionWithDetail_Success() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Step("Action", () => { }, "detail"); + } + + [Fact] + public async Task StepAsync_Success_RunsAction() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + var ran = false; + await flow.StepAsync("AsyncStep", () => + { + ran = true; + return Task.CompletedTask; + }); + Assert.True(ran); + } + + [Fact] + public async Task StepAsync_Throws_RecordsFailureAndRethrows() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + await Assert.ThrowsAsync(() => + flow.StepAsync("AsyncStep", () => throw new InvalidOperationException("boom"))); + } + + [Fact] + public async Task StepAsync_WithDetail_Success() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + await flow.StepAsync("AsyncStep", () => Task.CompletedTask, "detail"); + } + + [Fact] + public void Fail_RecordsFailure_DoesNotThrow() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Fail("StepOne"); + flow.Fail("StepTwo", "reason"); + } + + [Fact] + public void Skip_DoesNotThrow() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Skip("StepOne"); + flow.Skip("StepTwo", "not applicable"); + } + + [Fact] + public void Branch_EndBranch_ChildStepsNestUnderBranch() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Branch("Inner"); + flow.Step("NestedStep"); + flow.EndBranch(); + flow.Step("TopLevelStep"); + } + + [Fact] + public void EndBranch_WithoutBranch_DoesNotThrow() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.EndBranch(); + } + + [Fact] + public void Dispose_NoSteps_DoesNotThrow() + { + var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Dispose(); + } + + [Fact] + public void Dispose_AllStepsSuccess_DoesNotThrow() + { + var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Step("Ok1"); + flow.Step("Ok2"); + flow.Dispose(); + } + + [Fact] + public void Dispose_LastStepFailed_DoesNotThrow() + { + var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Step("Ok1"); + flow.Fail("Failed1"); + flow.Dispose(); + } + + [Fact] + public void Dispose_MidStepFailedButLastSucceeded_PartialFailure_DoesNotThrow() + { + var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Fail("Failed1"); + flow.Step("Ok1"); + flow.Dispose(); + } + + [Fact] + public void Dispose_WithBranchChildren_RendersChildrenWithoutThrowing() + { + var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Branch("Branch1"); + flow.Step("Child1"); + flow.Fail("Child2"); + flow.Skip("Child3"); + flow.EndBranch(); + flow.Step("AfterBranch"); + flow.Dispose(); + } + + [Fact] + public void Dispose_CalledTwice_IsIdempotent() + { + var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Step("Ok"); + flow.Dispose(); + flow.Dispose(); // should not throw or double-log + } +} diff --git a/cscglobal-caplugin.Tests/RequestManagerTests.cs b/cscglobal-caplugin.Tests/RequestManagerTests.cs index 0a92286..820cfe0 100644 --- a/cscglobal-caplugin.Tests/RequestManagerTests.cs +++ b/cscglobal-caplugin.Tests/RequestManagerTests.cs @@ -252,4 +252,375 @@ public void GetReIssueResult_Success_ReturnsExternalValidation() Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.EXTERNALVALIDATION, result.Status); Assert.Equal("uuid-2", result.CARequestID); } + + [Fact] + public void GetReIssueResult_NullResponse_ReturnsFailed() + { + var result = Manager.GetReIssueResult(null); + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, result.Status); + } + + [Fact] + public void GetReIssueResult_RegistrationError_ReturnsFailedWithDescription() + { + var response = new ReissueResponse { RegistrationError = new RegistrationError { Description = "rejected" } }; + var result = Manager.GetReIssueResult(response); + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.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)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, result.Status); + } + + [Fact] + public void GetRenewResponse_NullResponse_ReturnsFailed() + { + var result = Manager.GetRenewResponse(null); + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, result.Status); + } + + [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)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, result.Status); + Assert.Equal("abc-123", result.CARequestID); + Assert.Equal("boom", result.StatusMessage); + } + + [Fact] + public void GetRenewResponse_NullResult_StillReturnsExternalValidation() + { + // Unlike GetEnrollmentResult/GetReIssueResult, GetRenewResponse has no explicit + // Result==null guard - it just null-conditionals through to "(unknown)"/null. + var response = new RenewalResponse { Result = null }; + var result = Manager.GetRenewResponse(response); + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Contains("(unknown)", result.StatusMessage); + } + + [Fact] + public void GetEnrollmentResult_NullResponse_ReturnsFailed() + { + var result = Manager.GetEnrollmentResult(null); + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, result.Status); + } + + [Fact] + public void GetEnrollmentResult_RegistrationError_ReturnsFailed() + { + var response = new RegistrationResponse { RegistrationError = new RegistrationError { Description = "denied" } }; + var result = Manager.GetEnrollmentResult(response); + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, result.Status); + Assert.Equal("denied", result.StatusMessage); + } + + [Fact] + public void GetEnrollmentResult_NullResult_ReturnsFailed() + { + var response = new RegistrationResponse { Result = null }; + var result = Manager.GetEnrollmentResult(response); + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.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)Keyfactor.PKI.Enums.EJBCA.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 keys should not throw and should not be added twice. + new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "token" } }, + new DcvDetail { Email = "admin@example.com" }, + // Entry with neither CName nor Email contributes nothing. Null entries are skipped. + new DcvDetail(), + null! + } + } + }; + + var result = Manager.GetEnrollmentResult(response); + + Assert.NotNull(result.EnrollmentContext); + Assert.Equal(2, result.EnrollmentContext.Count); + Assert.Equal("token", result.EnrollmentContext["_dnsauth.example.com"]); + Assert.Equal("admin@example.com", result.EnrollmentContext["admin@example.com"]); + } + + // --------------------------------------------------------------------- + // GetRevokeResult + // --------------------------------------------------------------------- + + [Fact] + public void GetRevokeResult_NullResponse_ReturnsFailed() + { + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, Manager.GetRevokeResult(null)); + } + + [Fact] + public void GetRevokeResult_RegistrationError_ReturnsFailed() + { + var response = new RevokeResponse { RegistrationError = new RegistrationError { Description = "denied" } }; + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, Manager.GetRevokeResult(response)); + } + + [Fact] + public void GetRevokeResult_Success_ReturnsRevoked() + { + var response = new RevokeResponse { RevokeSuccess = new RevokeSuccessResponse { Status = "REVOKED" } }; + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.REVOKED, Manager.GetRevokeResult(response)); + } + + // --------------------------------------------------------------------- + // MapReturnStatus / MapCertificateTypeToProductId + // --------------------------------------------------------------------- + + [Theory] + [InlineData("ACTIVE", Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.GENERATED)] + [InlineData("Initial", Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.INITIALIZED)] + [InlineData("Pending", Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.INPROCESS)] + [InlineData("REVOKED", Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.REVOKED)] + [InlineData("SomethingUnexpected", Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED)] + [InlineData(null, Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED)] + public void MapReturnStatus_MapsExpectedStatus(string? cscStatus, Keyfactor.PKI.Enums.EJBCA.EndEntityStatus expected) + { + Assert.Equal((int)expected, Manager.MapReturnStatus(cscStatus!)); + } + + [Theory] + [InlineData("4", "CSC TrustedSecure Domain Validated SSL")] + [InlineData("CSC TrustedSecure Domain Validated SSL", "CSC TrustedSecure Domain Validated SSL")] + [InlineData("CSC Trusted Secure Domain Validated SSL", "CSC TrustedSecure Domain Validated SSL")] + [InlineData("9", "CSC TrustedSecure DV Wildcard, Multiple Names")] + public void MapCertificateTypeToProductId_KnownValue_MapsToProductId(string cscType, string expectedProductId) + { + Assert.Equal(expectedProductId, Manager.MapCertificateTypeToProductId(cscType)); + } + + [Fact] + public void MapCertificateTypeToProductId_UnknownValue_PassesThrough() + { + Assert.Equal("SomeUnknownType", Manager.MapCertificateTypeToProductId("SomeUnknownType")); + } + + [Fact] + public void MapCertificateTypeToProductId_Null_ReturnsFallback() + { + Assert.Equal("CscGlobal", Manager.MapCertificateTypeToProductId(null!)); + } + + // --------------------------------------------------------------------- + // GetNotifications + // --------------------------------------------------------------------- + + [Fact] + public void GetNotifications_NoEmailsConfigured_ReturnsEmptyList() + { + var notifications = Manager.GetNotifications(ProductInfo("CSC TrustedSecure DV")); + Assert.True(notifications.Enabled); + Assert.Empty(notifications.AdditionalNotificationEmails); + } + + [Fact] + public void GetNotifications_EmailsConfigured_SplitsOnComma() + { + var productInfo = ProductInfo("CSC TrustedSecure DV", + 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); + } + + // --------------------------------------------------------------------- + // GetDomainControlValidation + // --------------------------------------------------------------------- + + [Fact] + public void GetDomainControlValidation_EmptyEmailArray_ReturnsNull() + { + Assert.Null(Manager.GetDomainControlValidation("EMAIL", Array.Empty(), "example.com")); + } + + [Fact] + public void GetDomainControlValidation_NullEmailArray_ReturnsNull() + { + Assert.Null(Manager.GetDomainControlValidation("EMAIL", null!, "example.com")); + } + + [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() + { + Assert.Null(Manager.GetDomainControlValidation("EMAIL", new[] { "admin@other.com" }, "www.example.com")); + } + + [Fact] + public void GetDomainControlValidation_SingleEmailOverload_ReturnsValidationVerbatim() + { + var result = Manager.GetDomainControlValidation("CNAME", "admin@example.com"); + Assert.Equal("CNAME", result.MethodType); + Assert.Equal("admin@example.com", result.EmailAddress); + } + + // --------------------------------------------------------------------- + // GetCustomFields (exercised via GetRegistrationRequest) + // --------------------------------------------------------------------- + + [Fact] + public void GetRegistrationRequest_MandatoryCustomFieldMissing_Throws() + { + var productInfo = ProductInfo("CSC TrustedSecure DV"); + var customFields = new List { new GetCustomField { Label = "Required Field", Mandatory = true } }; + + Assert.Throws(() => + Manager.GetRegistrationRequest(productInfo, SampleCsr, new Dictionary(), customFields)); + } + + [Fact] + public void GetRegistrationRequest_OptionalCustomFieldMissing_DoesNotThrow() + { + var productInfo = ProductInfo("CSC TrustedSecure DV"); + 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 DV", 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); + } + + [Fact] + public void GetRegistrationRequest_NullCustomFieldsList_ReturnsEmptyCustomFields() + { + var request = Manager.GetRegistrationRequest(ProductInfo("CSC TrustedSecure DV"), SampleCsr, + new Dictionary(), null!); + Assert.Empty(request.CustomFields); + } + + // --------------------------------------------------------------------- + // GetRenewalRequest / GetReissueRequest - parity with GetRegistrationRequest + // --------------------------------------------------------------------- + + [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_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); + } + + [Fact] + public void GetRegistrationRequest_NullProductParameters_Throws() + { + var productInfo = new EnrollmentProductInfo { ProductID = "CSC TrustedSecure DV", ProductParameters = null! }; + Assert.Throws(() => + Manager.GetRegistrationRequest(productInfo, SampleCsr, new Dictionary(), new List())); + } + + [Fact] + public void GetRenewalRequest_NullUuid_Throws() + { + Assert.Throws(() => + Manager.GetRenewalRequest(ProductInfo("CSC TrustedSecure DV"), null!, SampleCsr, new Dictionary(), new List())); + } + + [Fact] + public void EvCertificateDetails_AllPropertiesSettable() + { + var details = new EvCertificateDetails + { + Country = "US", + City = "Independence", + State = "OH", + DateOfIncorporation = "2020-01-01", + DoingBusinessAs = "Keyfactor", + BusinessCategory = "Private Organization" + }; + + Assert.Equal("US", details.Country); + Assert.Equal("Independence", details.City); + Assert.Equal("OH", details.State); + Assert.Equal("2020-01-01", details.DateOfIncorporation); + Assert.Equal("Keyfactor", details.DoingBusinessAs); + Assert.Equal("Private Organization", details.BusinessCategory); + } + + [Fact] + public void GetRegistrationRequest_EncodesCsrAsBase64() + { + var request = Manager.GetRegistrationRequest(ProductInfo("CSC TrustedSecure DV"), "hello", new Dictionary(), new List()); + var decoded = Convert.FromBase64String(request.Csr); + Assert.Contains("hello", System.Text.Encoding.UTF8.GetString(decoded)); + } } diff --git a/cscglobal-caplugin/AssemblyInfo.cs b/cscglobal-caplugin/AssemblyInfo.cs new file mode 100644 index 0000000..bd280c9 --- /dev/null +++ b/cscglobal-caplugin/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// 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.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CSCGlobalCAPlugin.Tests")] diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index 1bc6a44..b5ae44f 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -63,7 +63,9 @@ public CSCGlobalCAPlugin(IDomainValidatorFactory validatorFactory) _validatorFactory = validatorFactory; } - private ICscGlobalClient CscGlobalClient { get; set; } + // internal (not private) purely so the test project can inject a mock via + // InternalsVisibleTo, instead of hitting the real CSC Global API in unit tests. + internal ICscGlobalClient CscGlobalClient { get; set; } /// /// Whether the CA is enabled. When false, the plugin returns early from Ping, diff --git a/cscglobal-caplugin/Client/CscGlobalClient.cs b/cscglobal-caplugin/Client/CscGlobalClient.cs index 032f535..7a5b722 100644 --- a/cscglobal-caplugin/Client/CscGlobalClient.cs +++ b/cscglobal-caplugin/Client/CscGlobalClient.cs @@ -21,7 +21,13 @@ public sealed class CscGlobalClient : ICscGlobalClient { private readonly ILogger Logger; - public CscGlobalClient(IAnyCAPluginConfigProvider config) + public CscGlobalClient(IAnyCAPluginConfigProvider config) : this(config, null) + { + } + + // internal so the test project can supply a fake HttpMessageHandler via + // InternalsVisibleTo, instead of the client making real HTTP calls in unit tests. + internal CscGlobalClient(IAnyCAPluginConfigProvider config, HttpMessageHandler? handler) { Logger = LogHandler.GetClassLogger(); @@ -68,7 +74,7 @@ public CscGlobalClient(IAnyCAPluginConfigProvider config) } Logger.LogTrace("CscGlobalClient: BearerToken is present (length={Length}).", Authorization.Length); - RestClient = ConfigureRestClient(); + RestClient = ConfigureRestClient(handler); Logger.LogTrace("CscGlobalClient: RestClient configured successfully."); } else @@ -352,10 +358,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 }; From 82d3228726607dfbd61e611ef47b5113253091b3 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Mon, 21 Sep 2026 16:33:23 -0400 Subject: [PATCH 36/42] Raise unit test branch coverage from ~75% to ~80% Added targeted tests for previously-uncovered conditional branches across RequestManager, CSCGlobalCAPlugin, and CscGlobalClient - optional-field mapping in the registration/renewal/reissue request builders, null-valued config keys, null-object constructor inputs, and a few edge cases in the renewal/reissue decision and DNS auto-publish paths. Left purely diagnostic logging ternaries and practically-unreachable defensive branches alone. --- .../CSCGlobalCAPluginTests.cs | 153 ++++++++++++++++++ .../CscGlobalClientTests.cs | 42 +++++ .../RequestManagerTests.cs | 115 +++++++++++++ 3 files changed, 310 insertions(+) diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs index 7e76322..9f60d0c 100644 --- a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs @@ -170,6 +170,42 @@ public void Initialize_DcvPollTimeoutSeconds_ParsesOrDefaults(string raw, int ex Assert.Equal(expected, plugin.DcvPollTimeoutSeconds); } + [Fact] + public void Initialize_EnabledKeyPresentButNullValue_DefaultsToTrue() + { + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.Enabled] = null! }).Object, + Mock.Of()); + Assert.True(plugin.Enabled); + } + + [Fact] + public void Initialize_SyncFilterDaysKeyPresentButNullValue_DefaultsToZero() + { + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.SyncFilterDays] = null! }).Object, + Mock.Of()); + Assert.Equal(0, plugin.SyncFilterDays); + } + + [Fact] + public void Initialize_RenewalWindowDaysKeyPresentButNullValue_DefaultsTo30() + { + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.RenewalWindowDays] = null! }).Object, + Mock.Of()); + Assert.Equal(30, plugin.RenewalWindowDays); + } + + [Fact] + public void Initialize_DcvPollTimeoutSecondsKeyPresentButNullValue_DefaultsToZero() + { + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.DcvPollTimeoutSeconds] = null! }).Object, + Mock.Of()); + Assert.Equal(0, plugin.DcvPollTimeoutSeconds); + } + [Fact] public void Initialize_WithValidatorFactory_DoesNotThrow() { @@ -325,6 +361,24 @@ public async Task Synchronize_IncrementalSync_UsesFilterDate() Assert.NotEqual("not-called", capturedFilter); } + [Fact] + public async Task Synchronize_IncrementalSync_SyncFilterDaysNotConfigured_DefaultsToFiveDays() + { + var mockClient = new Mock(); + string? capturedFilter = "not-called"; + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())) + .Callback(f => capturedFilter = f) + .ReturnsAsync(new CertificateListResponse { Results = new List() }); + + var plugin = MakePlugin(mockClient); + var buffer = new System.Collections.Concurrent.BlockingCollection(); + + await plugin.Synchronize(buffer, null, false, CancellationToken.None); + + var expected = DateTime.Today.Subtract(TimeSpan.FromDays(5)).ToString("yyyy/MM/dd"); + Assert.Equal(expected, capturedFilter); + } + [Fact] public async Task Synchronize_NullResultsFromClient_CompletesWithoutError() { @@ -494,6 +548,13 @@ public async Task Revoke_TooShortId_Throws() await Assert.ThrowsAsync(() => plugin.Revoke("short", "serial", 0)); } + [Fact] + public async Task Revoke_NullId_Throws() + { + var plugin = MakePlugin(); + await Assert.ThrowsAsync(() => plugin.Revoke(null!, "serial", 0)); + } + [Fact] public async Task Revoke_NullResponse_Throws() { @@ -588,6 +649,13 @@ public async Task ValidateCAConnectionInfo_ExplicitlyDisabled_DoesNotThrow() await plugin.ValidateCAConnectionInfo(new Dictionary { [Constants.Enabled] = "false" }); } + [Fact] + public async Task ValidateCAConnectionInfo_UnparsableEnabledValue_TreatsAsEnabled() + { + var plugin = MakePlugin(); + await plugin.ValidateCAConnectionInfo(new Dictionary { [Constants.Enabled] = "not-a-bool" }); + } + // --------------------------------------------------------------------- // ValidateProductInfo // --------------------------------------------------------------------- @@ -718,6 +786,15 @@ await Assert.ThrowsAsync(() => plugin.Enroll("csr", "CN=test", new Dictionary(), null!, RequestFormat.PKCS10, EnrollmentType.New)); } + [Fact] + public async Task Enroll_NullProductParameters_Throws() + { + var plugin = MakePlugin(); + var productInfo = new EnrollmentProductInfo { ProductID = "CSC TrustedSecure DV", ProductParameters = null! }; + await Assert.ThrowsAsync(() => + plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, RequestFormat.PKCS10, EnrollmentType.New)); + } + [Fact] public async Task Enroll_EmptyCsr_Throws() { @@ -1031,6 +1108,44 @@ public async Task Enroll_New_DnsValidatorReturnsFailure_DoesNotThrow() Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); } + [Fact] + public async Task Enroll_New_DnsValidatorReturnsNullResult_DoesNotThrow() + { + 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 = "fail-null.example.com", + Status = new Status { Uuid = "uuid-fail-null" }, + DcvDetails = new List + { + new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "target.sectigo.com" } } + } + } + }); + + var mockValidator = new Mock(); + mockValidator.Setup(v => v.GetValidationType()).Returns("cname"); + mockValidator.Setup(v => v.StageValidation(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((DomainValidationResult)null!); + + var mockFactory = new Mock(); + mockFactory.Setup(f => f.ResolveDomainValidator(It.IsAny(), "cname")).Returns(mockValidator.Object); + + var plugin = MakePlugin(mockClient, validatorFactory: mockFactory.Object); + var productInfo = ProductInfo(parameters: new Dictionary + { + [EnrollmentConfigConstants.DomainControlValidationMethod] = "CNAME" + }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + } + [Fact] public async Task Enroll_New_DnsFactoryButNoEnrollmentContext_SkipsPublish() { @@ -1686,4 +1801,42 @@ public async Task Enroll_RenewOrReissue_FetchLiveCertThrows_FallsBackToExpiryChe Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); } + + [Fact] + public async Task Enroll_RenewOrReissue_NoOrderDateAndNoExpirationDateOnReader_FallsThroughToSingleRecordLookup() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + // No OrderDate -> falls back to expiry check. GetExpirationDateByRequestId (below) returns + // null, so the fallback's "??" actually has to call GetSingleRecord for a second time to + // get a RevocationDate - which is never set by GetSingleRecord, so it stays null and the + // nullable "<" comparison evaluates to false (not a renewal). + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = null, + Status = "ACTIVE" + }); + mockClient.Setup(c => c.SubmitReissueAsync(It.IsAny())).ReturnsAsync(new ReissueResponse + { + Result = new Result { CommonName = "reissue.example.com", Status = new Status { Uuid = orderUuid } } + }); + + 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.EXTERNALVALIDATION, result.Status); + mockClient.Verify(c => c.SubmitReissueAsync(It.IsAny()), Times.Once); + } } diff --git a/cscglobal-caplugin.Tests/CscGlobalClientTests.cs b/cscglobal-caplugin.Tests/CscGlobalClientTests.cs index 0fa7a81..9c7b0c1 100644 --- a/cscglobal-caplugin.Tests/CscGlobalClientTests.cs +++ b/cscglobal-caplugin.Tests/CscGlobalClientTests.cs @@ -97,6 +97,33 @@ public void Constructor_EmptyApiKeyValue_Throws() Assert.Throws(() => new CscGlobalClient(mock.Object)); } + [Fact] + public void Constructor_NullApiKeyValue_Throws() + { + // Key present but value is a null object (distinct from a missing key or an empty string - + // exercises the `?.ToString()` null-conditional rather than the ContainsKey check). + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns(new Dictionary + { + [Constants.CscGlobalApiKey] = null!, + [Constants.CscGlobalUrl] = "https://example.invalid/" + }); + Assert.Throws(() => new CscGlobalClient(mock.Object)); + } + + [Fact] + public void Constructor_NullUrlValue_Throws() + { + // Url key present but value is a null object (distinct from a missing key). + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns(new Dictionary + { + [Constants.CscGlobalApiKey] = "api-key", + [Constants.CscGlobalUrl] = null! + }); + Assert.Throws(() => new CscGlobalClient(mock.Object)); + } + [Fact] public void Constructor_EmptyBearerTokenValue_Throws() { @@ -110,6 +137,21 @@ public void Constructor_EmptyBearerTokenValue_Throws() Assert.Throws(() => new CscGlobalClient(mock.Object)); } + [Fact] + public void Constructor_NullBearerTokenValue_Throws() + { + // BearerToken key present but value is a null object (distinct from a missing key or an + // empty string - exercises the `?.ToString()` null-conditional rather than ContainsKey). + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns(new Dictionary + { + [Constants.CscGlobalApiKey] = "api-key", + [Constants.CscGlobalUrl] = "https://example.invalid/", + [Constants.BearerToken] = null! + }); + Assert.Throws(() => new CscGlobalClient(mock.Object)); + } + [Fact] public void Constructor_MissingBearerToken_Throws() { diff --git a/cscglobal-caplugin.Tests/RequestManagerTests.cs b/cscglobal-caplugin.Tests/RequestManagerTests.cs index 820cfe0..4bed6c6 100644 --- a/cscglobal-caplugin.Tests/RequestManagerTests.cs +++ b/cscglobal-caplugin.Tests/RequestManagerTests.cs @@ -546,6 +546,23 @@ public void GetRegistrationRequest_NullCustomFieldsList_ReturnsEmptyCustomFields Assert.Empty(request.CustomFields); } + [Fact] + public void GetRegistrationRequest_CustomFieldsWithNullEntryAndBlankLabel_SkipsBoth() + { + var productInfo = ProductInfo("CSC TrustedSecure DV", new Dictionary { ["Custom Field"] = "value" }); + var customFields = new List + { + null!, + new GetCustomField { Label = "", Mandatory = false }, + 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); + } + // --------------------------------------------------------------------- // GetRenewalRequest / GetReissueRequest - parity with GetRegistrationRequest // --------------------------------------------------------------------- @@ -580,6 +597,81 @@ public void GetReissueRequest_EvMultiNameProduct_PopulatesBothSansAndEvDetails() Assert.Equal("GB", request.EvCertificateDetails.Country); } + [Fact] + public void GetRegistrationRequest_AllOptionalParametersSupplied_MapsEachField() + { + var productInfo = ProductInfo("CSC TrustedSecure DV", new Dictionary + { + ["Term"] = "12", + ["Applicant First Name"] = "Jane", + ["Applicant Last Name"] = "Doe", + ["Applicant Email Address"] = "jane.doe@example.com", + ["Applicant Phone"] = "555-1234", + ["Organization Contact"] = "contact-1", + ["Business Unit"] = "IT" + }); + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, new Dictionary(), new List()); + + Assert.Equal("12", request.Term); + Assert.Equal("Jane", request.ApplicantFirstName); + Assert.Equal("Doe", request.ApplicantLastName); + Assert.Equal("jane.doe@example.com", request.ApplicantEmailAddress); + Assert.Equal("555-1234", request.ApplicantPhoneNumber); + Assert.Equal("contact-1", request.OrganizationContact); + Assert.Equal("IT", request.BusinessUnit); + } + + [Fact] + public void GetRenewalRequest_AllOptionalParametersSupplied_MapsEachField() + { + var productInfo = ProductInfo("CSC TrustedSecure DV", new Dictionary + { + ["Term"] = "24", + ["Applicant First Name"] = "John", + ["Applicant Last Name"] = "Smith", + ["Applicant Email Address"] = "john.smith@example.com", + ["Applicant Phone"] = "555-5678", + ["Organization Contact"] = "contact-2", + ["Business Unit"] = "Legal" + }); + + var request = Manager.GetRenewalRequest(productInfo, "uuid-renewal", SampleCsr, new Dictionary(), new List()); + + Assert.Equal("24", request.Term); + Assert.Equal("John", request.ApplicantFirstName); + Assert.Equal("Smith", request.ApplicantLastName); + Assert.Equal("john.smith@example.com", request.ApplicantEmailAddress); + Assert.Equal("555-5678", request.ApplicantPhoneNumber); + Assert.Equal("contact-2", request.OrganizationContact); + Assert.Equal("Legal", request.BusinessUnit); + } + + [Fact] + public void GetReissueRequest_AllOptionalParametersSupplied_MapsEachField() + { + var productInfo = ProductInfo("CSC TrustedSecure DV", new Dictionary + { + ["Term"] = "36", + ["Applicant First Name"] = "Alex", + ["Applicant Last Name"] = "Nguyen", + ["Applicant Email Address"] = "alex.nguyen@example.com", + ["Applicant Phone"] = "555-9012", + ["Organization Contact"] = "contact-3", + ["Business Unit"] = "Finance" + }); + + var request = Manager.GetReissueRequest(productInfo, "uuid-reissue", SampleCsr, new Dictionary(), new List()); + + Assert.Equal("36", request.Term); + Assert.Equal("Alex", request.ApplicantFirstName); + Assert.Equal("Nguyen", request.ApplicantLastName); + Assert.Equal("alex.nguyen@example.com", request.ApplicantEmailAddress); + Assert.Equal("555-9012", request.ApplicantPhoneNumber); + Assert.Equal("contact-3", request.OrganizationContact); + Assert.Equal("Finance", request.BusinessUnit); + } + [Fact] public void GetRegistrationRequest_NullProductParameters_Throws() { @@ -588,6 +680,29 @@ public void GetRegistrationRequest_NullProductParameters_Throws() Manager.GetRegistrationRequest(productInfo, SampleCsr, new Dictionary(), new List())); } + [Fact] + public void GetRegistrationRequest_NullProductInfo_Throws() + { + Assert.Throws(() => + Manager.GetRegistrationRequest(null!, SampleCsr, new Dictionary(), new List())); + } + + [Fact] + public void GetRegistrationRequest_NullCsr_Throws() + { + Assert.Throws(() => + Manager.GetRegistrationRequest(ProductInfo("CSC TrustedSecure DV"), null!, new Dictionary(), new List())); + } + + [Fact] + public void GetRegistrationRequest_CsrLongerThan64Chars_WrapsWithPemify() + { + var longCsr = new string('X', 130); + var request = Manager.GetRegistrationRequest(ProductInfo("CSC TrustedSecure DV"), longCsr, new Dictionary(), new List()); + var decoded = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(request.Csr)); + Assert.Contains("\n", decoded); + } + [Fact] public void GetRenewalRequest_NullUuid_Throws() { From ecd8b0780a3f16cc6c5c5b37c827bf048dc3181a Mon Sep 17 00:00:00 2001 From: Morgan Gangwere <470584+indrora@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:08:23 -0700 Subject: [PATCH 37/42] Merge to main (#21) * Add custom field support * changelog * support cname return from enrollment * Update generated docs * feat: release 1.1.1 * Update generated docs * Fix for issues with * Test * Added template parameter configuration via REST gateway. Fixed bug with email used for verification. Changed docs and enrollment field/template parameter names. See changelog. * Update generated docs * Fixed broken logging. * Incremental sync support added using csc date filter so sync timing can run faster that default full sync periods * Update generated docs * Fixes for Incremental Sync * Update CHANGELOG.md --------- Co-authored-by: Mikey Henderson <4452096+fiddlermikey@users.noreply.github.com> Co-authored-by: Sean <1661003+spbsoluble@users.noreply.github.com> Co-authored-by: Keyfactor Co-authored-by: Brian Hill Co-authored-by: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> * 200 day renewal fixes * Update generated docs * Improved logging .net 10 support * Removed Template Sync Logic * Update generated docs * fixed template mapping issue * product fixes * Update generated docs * fixed renewal issue * documentation fixes * Update generated docs * DNS Changes * Update generated docs * dns code updates * Update generated docs * change type * Update generated docs * fixed mismatch * Use 'cname' validation type for CSC CNAME DCV CSC DCV requires a CNAME record, so resolve a DNS provider advertising the 'cname' validation type (e.g. GoDaddyCnameDomainValidator) rather than the ACME 'dns-01'/TXT variant. Docs updated to call out the CNAME validator and warn against selecting the TXT validator for CSC domains. * Update generated docs * added polling to grab cert * Update generated docs * Fix NullReferenceException in GetEnrollmentResult for null DCV email (#9) * Fix NullReferenceException in GetEnrollmentResult for null DCV email The condition for adding DCV email entries to the cnames dictionary was inverted (string.IsNullOrEmpty instead of !string.IsNullOrEmpty), causing cnames.Add(null, null) and an ArgumentNullException on every enrollment where CSC returned a DcvDetail with email=null (typical for EMAIL DCV orders that have actionNeeded=N, and for CNAME-only DCV). Inverts the condition and adds a ContainsKey guard to mirror the existing CName branch. * Update generated docs --------- Co-authored-by: Keyfactor * Update integration-manifest.json * Update CSCGlobalCAPlugin.csproj * Update CSCGlobalCAPlugin.csproj * Update keyfactor-bootstrap-workflow-v3.yml * docs: auto-generate README and documentation [skip ci] * Add Enabled CA connection flag Adds the standard Enabled boolean field to the CA Connection settings, matching the pattern used by every other Keyfactor CA plugin (SSL Store, Digicert, HydrantId, GCP CAS, Idnomic, etc.). Purpose: allow ops to create the CA record before all API credentials are available. When Enabled=false, the plugin short-circuits: - Initialize: skips CscGlobalClient construction (no valid creds needed) - Ping: no-op, logs a warning - ValidateCAConnectionInfo / ValidateProductInfo: skip validation - Synchronize: completes the buffer immediately - Enroll: returns FAILED with a clear message - Revoke: throws InvalidOperationException with a clear message Default is true so existing deployments that don't set the key continue to function without change. Reads the value from incoming connectionInfo in the Validate* methods so an operator editing the CA sees consistent behavior with the current form state. * docs: auto-generate README and documentation [skip ci] * Feature/dns plugins (#12) * 200 day renewal fixes * Update generated docs * Improved logging .net 10 support * Removed Template Sync Logic * Update generated docs * fixed template mapping issue * product fixes * Update generated docs * fixed renewal issue * documentation fixes * Update generated docs * DNS Changes * Update generated docs * dns code updates * Update generated docs * change type * Update generated docs * fixed mismatch * Use 'cname' validation type for CSC CNAME DCV CSC DCV requires a CNAME record, so resolve a DNS provider advertising the 'cname' validation type (e.g. GoDaddyCnameDomainValidator) rather than the ACME 'dns-01'/TXT variant. Docs updated to call out the CNAME validator and warn against selecting the TXT validator for CSC domains. * Update generated docs * added polling to grab cert * Update generated docs * Update integration-manifest.json * Update CSCGlobalCAPlugin.csproj * Update CSCGlobalCAPlugin.csproj * Update keyfactor-bootstrap-workflow-v3.yml * docs: auto-generate README and documentation [skip ci] * Add Enabled CA connection flag Adds the standard Enabled boolean field to the CA Connection settings, matching the pattern used by every other Keyfactor CA plugin (SSL Store, Digicert, HydrantId, GCP CAS, Idnomic, etc.). Purpose: allow ops to create the CA record before all API credentials are available. When Enabled=false, the plugin short-circuits: - Initialize: skips CscGlobalClient construction (no valid creds needed) - Ping: no-op, logs a warning - ValidateCAConnectionInfo / ValidateProductInfo: skip validation - Synchronize: completes the buffer immediately - Enroll: returns FAILED with a clear message - Revoke: throws InvalidOperationException with a clear message Default is true so existing deployments that don't set the key continue to function without change. Reads the value from incoming connectionInfo in the Validate* methods so an operator editing the CA sees consistent behavior with the current form state. * docs: auto-generate README and documentation [skip ci] --------- Co-authored-by: Keyfactor Co-authored-by: github-actions[bot] * Update integration-manifest.json (#14) * Port product rename, new certificate types, and bug fixes from feature/ev-ov-dv-multiname-certs Ports the following from feature/ev-ov-dv-multiname-certs, adapted to this branch's existing patterns (ProductIdToCodeMap/CodeToProductIdMap, structured logging style) rather than overwriting them: - Renamed all certificate product IDs to CSC's current certificate type names, with pre-1.2.0 legacy names still accepted (added as additional entries in the existing product-id maps, not a separate alias layer) so existing Certificate Templates in Command keep working. - Added the 3 new certificate products: CSC TrustedSecure EV, Multiple Names; OV Wildcard, Multiple Names; DV Wildcard, Multiple Names (types 7/8/9). - Replaced the hardcoded certificateType == "2"/"3" checks with MultiNameCertificateTypes/EvCertificateTypes sets covering all applicable types. - ValidateProductInfo now calls RequestManager.IsKnownProductId instead of checking a separate list, so accepted names can't drift out of sync with what GetCertificateType actually resolves. - Fixed the "Addtl Sans Comma Separated DCV Emails" field never being read (typo'd lookup key: "DVC" instead of "DCV"), and added a fallback to the primary CN's DCV email when no per-domain SAN email override matches (CSC Global rejects requests with a SAN missing domainControlValidation). - Fixed a case-sensitivity bug ("priorcertsn" vs "PriorCertSN") that silently prevented PriorCertSN from ever being read during Renew/Reissue. - Made Price.Total nullable to fix a JSON deserialization crash when CSC Global returns "price.total": null. - Updated integration-manifest.json and docsource/configuration.md to match. Explicitly NOT ported (per discussion - these don't fit this branch): - FlowLogger changes/redesign - this branch's FlowLogger has an incompatible tree-based design already wired into DNS-01 CNAME auto-publish call sites; left untouched. - The EnrollmentContext "Flow Summary" UX feature - this branch's TryPublishCnameDcvAsync treats EnrollmentContext entries as real DNS records to auto-publish; adding non-DNS entries there would be actively harmful. - .NET 6/8 multi-targeting - this branch already moved to net10.0-only with newer package versions; not reintroducing the older targets. - The Renew/Reissue GENERATED-vs-EXTERNALVALIDATION fix - already present independently on this branch. Also adds a new xUnit test project (this branch had none), with fresh tests written against this branch's actual code shape rather than adapted from the other branch's now-incompatible test suite: 40 tests covering certificate type/SAN/EV routing for all 10 canonical + 7 legacy product names, IsKnownProductId, the DCV email fallback fix, Price.Total null deserialization, and Renew/Reissue status codes. * docs: auto-generate README and documentation [skip ci] * Raise unit test coverage to ~95% line coverage Adds testability seams (matching the pattern already used for HTTP mocking in feature/ev-ov-dv-multiname-certs): - CSCGlobalCAPlugin.CscGlobalClient property is now internal instead of private, so tests can inject a mock ICscGlobalClient. - CscGlobalClient gets an internal HttpMessageHandler-accepting constructor overload, so tests can supply a fake handler instead of making real HTTP calls. - AssemblyInfo.cs adds InternalsVisibleTo("CSCGlobalCAPlugin.Tests"). Expands the test suite from 40 to 233 tests, covering: - RequestManager: all certificate-type/SAN/EV routing (canonical + legacy names), IsKnownProductId, DCV email fallback, GetRevokeResult, MapReturnStatus/MapCertificateTypeToProductId, custom fields, Price.Total null deserialization. - CscGlobalClient: all 7 API methods (success/400/error paths) via a fake HttpMessageHandler, plus constructor validation. - FlowLogger: Step/StepAsync/Fail/Skip/Branch/Dispose. - CSCGlobalCAPlugin: Initialize, GetSingleRecord, Synchronize/ SyncCertificates, Revoke, Ping, ValidateCAConnectionInfo/ ValidateProductInfo, Enroll (New and RenewOrReissue - renewal vs. reissue decision, DNS-01 CNAME auto-publish via IDomainValidatorFactory, synchronous issuance polling via DcvPollTimeoutSeconds, and the various failure branches), and the PEM/leaf-certificate parsing helpers (GetEndEntityCertificate/ExtractCertificates/FindLeaf). Line coverage: 15.57% -> 95.62%. Remaining gaps are dead code (ExportCollectionToPem, RetryCountExceededException - both already unused before this change) and a few defensive catch blocks for exception types (AggregateException, arbitrary X509 export failures) that aren't reachable through normal async/mock-based testing. * Raise unit test branch coverage from ~75% to ~80% Added targeted tests for previously-uncovered conditional branches across RequestManager, CSCGlobalCAPlugin, and CscGlobalClient - optional-field mapping in the registration/renewal/reissue request builders, null-valued config keys, null-object constructor inputs, and a few edge cases in the renewal/reissue decision and DNS auto-publish paths. Left purely diagnostic logging ternaries and practically-unreachable defensive branches alone. --------- Co-authored-by: David Galey Co-authored-by: Keyfactor Co-authored-by: Mark Kachkaev <37276742+mkachk@users.noreply.github.com> Co-authored-by: Mikey Henderson <4452096+fiddlermikey@users.noreply.github.com> Co-authored-by: Sean <1661003+spbsoluble@users.noreply.github.com> Co-authored-by: Brian Hill Co-authored-by: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Co-authored-by: github-actions[bot] --- .claude/settings.json | 8 + CHANGELOG.md | 13 +- README.md | 279 ++- .../CSCGlobalCAPlugin.Tests.csproj | 25 + .../CSCGlobalCAPluginTests.cs | 1842 +++++++++++++++++ .../CscGlobalClientTests.cs | 439 ++++ cscglobal-caplugin.Tests/FlowLoggerTests.cs | 175 ++ .../RequestManagerTests.cs | 741 +++++++ cscglobal-caplugin.sln | 47 +- cscglobal-caplugin/AssemblyInfo.cs | 6 + cscglobal-caplugin/CSCGlobalCAPlugin.cs | 1253 +++++++++-- cscglobal-caplugin/CSCGlobalCAPlugin.csproj | 16 +- cscglobal-caplugin/Client/CscGlobalClient.cs | 284 ++- cscglobal-caplugin/Client/Models/Price.cs | 2 +- cscglobal-caplugin/Constants.cs | 21 +- cscglobal-caplugin/FlowLogger.cs | 241 +++ cscglobal-caplugin/Interfaces/IPrice.cs | 2 +- cscglobal-caplugin/RequestManager.cs | 603 +++++- docsource/configuration.md | 265 ++- integration-manifest.json | 33 +- 20 files changed, 5860 insertions(+), 435 deletions(-) create mode 100644 .claude/settings.json 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/AssemblyInfo.cs create mode 100644 cscglobal-caplugin/FlowLogger.cs diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..c64f0fd --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(git fetch:*)", + "Bash(git checkout:*)" + ] + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index dda4e33..b8781cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,10 @@ -v1.1.3 -- Fixed KeyNotFoundException during enrollment when the optional "Addtl Sans Comma Separated DVC Emails" field was not set -- Fixed KeyNotFoundException during enrollment when no SANs were supplied for a UC certificate - -v1.1.2 -- Fixed NullReferenceException in GetEnrollmentResult when CSC returned a DCV email of null (typical for EMAIL DCV orders with actionNeeded=N, and for CNAME-only DCV) +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 +- 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. +- 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. +- Fixed a case-sensitivity bug ("priorcertsn" vs "PriorCertSN") that prevented PriorCertSN from ever being read during Renew/Reissue enrollment. +- 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. +- Added an xUnit test suite covering certificate type/SAN/EV routing, legacy product name backward compatibility, and the fixes above. v.1.1.1 - Added Incremental Sync that goes back X Number of days diff --git a/README.md b/README.md index dcd1bc2..c185bec 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

-Integration Status: pilot +Integration Status: production Release Issues GitHub Downloads (all assets, all releases) @@ -37,7 +37,7 @@ This integration allows for the Synchronization, Enrollment, and Revocation of c ## Compatibility -The CSCGlobal CAPlugin AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 24.2.0 and later. +The CSCGlobal CAPlugin AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 26.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 via the Keyfactor Support Portal at https://support.keyfactor.com. @@ -54,16 +54,15 @@ 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 `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 (`net10.0`) can be named anything, as long as it is unique within the `Extensions` directory. 4. Restart the AnyCA Gateway REST service. @@ -85,8 +84,9 @@ 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. + * **RenewalWindowDays** - Number of days before the annual order expiry within which a RenewOrReissue triggers a paid Renewal rather than a free Reissue. Default is 30. + * **DcvPollTimeoutSeconds** - Max seconds to synchronously poll CSC for issuance after submitting an order (and publishing CNAME DCV). 0 disables polling (enrollment returns pending immediately; cert arrives on next sync). When >0, fast-validating orders can return the cert directly. Keep small to avoid long-blocking enrollment requests. 2. PLEASE NOTE, AT THIS TIME THE RAPID_SSL TEMPLATE IS NOT SUPPORTED BY THE CSC API AND WILL NOT WORK WITH THIS INTEGRATION @@ -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 EV - 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 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 EV Certificate - Enrollment Fields** +**CSC TrustedSecure EV - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -149,20 +149,20 @@ 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 -----|--------------|----------------- @@ -179,20 +179,20 @@ CN DCV Email | String | N/A Addtl Sans Comma Separated DCV Emails | String | N/A -**CSC TrustedSecure Premium Wildcard Certificate - Details Tab** +**CSC TrustedSecure OV Wildcard - 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 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 Premium Wildcard Certificate - Enrollment Fields** +**CSC TrustedSecure OV Wildcard - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -207,20 +207,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 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 +235,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 +263,108 @@ 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 +-----|--------------|----------------- +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 +Addtl Sans Comma Separated DCV Emails | String | N/A +Organization Country | 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 -----|--------------|----------------- @@ -309,6 +397,115 @@ Addtl Sans Comma Separated DCV Emails | String | N/A * **Organization Country** - OPTIONAL: Organization Country * **Addtl Sans Comma Separated DCV Emails** - OPTIONAL: Additional SANs DCV Emails, comma separated +## CA Connection Configuration + +When defining the Certificate Authority in the AnyCA Gateway REST portal, configure the following fields on the **CA Connection** tab: + +CONFIG ELEMENT | DESCRIPTION | DEFAULT +---------------|-------------|-------- +Enabled | Flag to Enable or Disable gateway functionality. Set to `false` to allow creating the CA record before configuration information is available; the plugin then short-circuits Ping, Sync, Enroll, and Revoke with a warning until it is re-enabled. | `true` +CscGlobalUrl | The base URL for the CSCGlobal API (e.g. `https://apis.cscglobal.com`) | (required) +ApiKey | Your CSCGlobal API key | (required) +BearerToken | Your CSCGlobal Bearer token for authentication | (required) +DefaultPageSize | Page size for API list requests | 100 +SyncFilterDays | Number of days from today used to filter certificates by expiration date during **incremental** sync. Only certificates expiring within this window are returned. Does not apply to full sync. | 5 +RenewalWindowDays | Number of days before the annual order expiry date within which a **RenewOrReissue** request triggers a paid **Renewal** rather than a free **Reissue**. See [Renewal vs. Reissue Logic](#renewal-vs-reissue-logic) below. | 30 +DcvPollTimeoutSeconds | Max seconds to synchronously poll CSC for certificate issuance after submitting an order. `0` disables polling (enrollment returns pending immediately; cert arrives on the next sync). When `>0`, fast-validating orders can return the issued cert directly in the enrollment response. See [Synchronous Issuance Polling](#synchronous-issuance-polling) below. | 0 + +> **Note:** DNS auto-publishing for CNAME DCV is handled by the AnyCA Gateway REST framework's Domain Validation system (gateway 3.3+). It's configured in the gateway UI under **Domain Validation Configurations**, not on the CA Connection tab. See [DNS Auto-Publishing (CNAME DCV)](#dns-auto-publishing-cname-dcv). + +## Renewal vs. Reissue Logic + +CSC Global subscriptions are annual orders. When Keyfactor Command sends a **RenewOrReissue** request, the plugin must decide whether to submit a **Renewal** (a new paid order) or a **Reissue** (a free re-key under the existing active order). + +The decision is based on the **RenewalWindowDays** setting and works as follows: + +1. The plugin fetches the original certificate from CSC and reads its `orderDate`. +2. It computes the **order expiry** as `orderDate + 1 year`. +3. It calculates **days remaining** until the order expires. +4. If `days remaining <= RenewalWindowDays`, the request is treated as a **Renewal** (new paid order). +5. If `days remaining > RenewalWindowDays`, the request is treated as a **Reissue** (free under the active order). + +**Example with default RenewalWindowDays = 30:** + +``` +Order Date: 2025-04-08 +Order Expiry: 2026-04-08 +Today: 2026-03-15 +Days Left: 24 + +24 <= 30 --> RENEWAL (new paid order) +``` + +``` +Order Date: 2025-04-08 +Order Expiry: 2026-04-08 +Today: 2025-09-01 +Days Left: 219 + +219 > 30 --> REISSUE (free under active order) +``` + +**Fallback behavior:** If the plugin cannot retrieve the `orderDate` from CSC (e.g., API error or missing field), it falls back to checking the certificate's expiration date. If the certificate is already expired, it treats the request as a Renewal. + +**Note:** Both Renewal and Reissue submissions are asynchronous at CSC. The plugin returns a "pending" status and the issued certificate will appear in Keyfactor after the next sync cycle. + +## DNS Auto-Publishing (CNAME DCV) + +CSC supports two Domain Control Validation (DCV) methods: **EMAIL** and **CNAME**. With CNAME validation, CSC returns a CNAME record (name → target) that must exist in DNS before they will validate the order. + +By default this plugin returns the CNAME details to Keyfactor Command for **manual publishing**. To fully automate enrollment, the plugin uses the **AnyCA Gateway REST framework's built-in DNS provider system** (available in framework 3.3 and later). The framework discovers DNS provider plugins deployed alongside the CA plugin and routes each CNAME to whichever provider claims the matching DNS zone. + +### Requirements + +* AnyCA Gateway REST framework **3.3 or later** (the `IDomainValidatorFactory` interface ships in `Keyfactor.AnyGateway.IAnyCAPlugin` 3.3+). +* At least one DNS provider DLL (e.g. GoDaddy, Cloudflare, Route 53, Azure) deployed in the gateway `Extensions` folder. +* A Domain Validation Configuration registered in the gateway UI that maps your domain(s) to the deployed provider (for example, `*.example.com` → GoDaddy). + +### How It Works + +1. CSC returns the CNAME `name → target` details in the enrollment response. +2. For each CNAME entry, the plugin calls `IDomainValidatorFactory.ResolveDomainValidator(recordName, "cname")`. +3. The framework returns the `IDomainValidator` whose Domain Validation Configuration matches the record's zone (or `null` if no match). +4. The plugin calls `validator.StageValidation(recordName, cnameTarget, ct)` to publish the record. +5. CSC asynchronously validates the CNAME; the issued certificate appears on the next sync. + +### Behavior + +* **Resolution is per record, not per CA.** One CA can drive multiple DNS providers (GoDaddy for some domains, Route 53 for others) with no per-CA configuration. +* **Only invoked for CNAME DCV.** Templates configured with EMAIL validation are unaffected — no DNS publishing occurs. +* **Best-effort.** If no provider claims the zone, the publish call fails, or the factory wasn't injected (gateway pre-3.3), the enrollment still succeeds and the CNAME details remain in the Keyfactor request so a human can publish manually as a fallback. +* **Trace-logged.** Every resolution (matched/unresolved) and publish attempt (success/failure) is logged at Info/Trace level. +* **Validation type string.** The plugin passes `"cname"` to `ResolveDomainValidator`. CSC's DCV requires a **CNAME** record, which is different from ACME's `"dns-01"` challenge (a TXT record). A single DNS provider DLL can ship multiple validator classes — one advertising `"dns-01"` (publishes TXT, for ACME) and one advertising `"cname"` (publishes CNAME, for CSC). You must deploy and configure a validator that advertises `"cname"` or no provider will match. +* **Trailing dots normalized.** CSC returns FQDN-canonical names with a trailing dot (e.g. `_token.example.com.`). The plugin strips the trailing dot before resolution and publishing, because Domain Validation Configurations and DNS provider APIs expect names without it. + +### Configuration in the Gateway UI + +In the AnyCA Gateway REST portal, under **Domain Validation Configurations**: + +1. **Add** a new configuration. +2. Pick a **Domain Validator Type** that publishes **CNAME** records and advertises validation type `cname`. For GoDaddy this is `GoDaddyCnameDomainValidator` (the `GoDaddyDomainValidator` variant publishes TXT for ACME and will **not** work for CSC). +3. Add one or more **domain patterns** (e.g. `*.example.com`). +4. Fill out the provider-specific **Configuration Settings** (API keys, base URL, etc.). +5. Save. + +Once configured, any CSC enrollment for a domain matching one of those patterns will have its CNAME auto-published. + +> **Common pitfall:** If you configure the TXT/`dns-01` validator (e.g. `GoDaddyDomainValidator`) for a CSC domain, the record will publish as a **TXT** and CSC's CNAME validation will never succeed. Make sure you select the **CNAME** validator variant. + +## Synchronous Issuance Polling + +CSC validates domain control asynchronously — after an order is submitted (and the CNAME DCV record published), CSC/Sectigo polls public DNS on its own schedule and issues the certificate once validation passes. By default this plugin returns a **pending** (`EXTERNALVALIDATION`) result immediately and the issued certificate is picked up on the next gateway **sync** cycle. + +For environments where DNS is published automatically (see [DNS Auto-Publishing](#dns-auto-publishing-cname-dcv)) and validation tends to complete quickly, you can have the plugin **poll CSC synchronously** at the end of enrollment and return the issued certificate directly — avoiding the wait for the next sync. + +* Set **`DcvPollTimeoutSeconds`** to the maximum number of seconds to poll (e.g. `60`). `0` (default) disables polling entirely. +* The plugin polls CSC every 10 seconds until the order is issued or the timeout is reached. +* If the certificate issues within the window, the enrollment returns it immediately with a success status. +* If the window expires, the plugin falls back to the **pending** result and the certificate arrives on the next sync — exactly as it would with polling disabled. + +**Tradeoff:** Polling blocks the enrollment request for up to `DcvPollTimeoutSeconds`. CSC validation frequently takes minutes to hours, so most orders will still fall through to pending — keep the timeout small (30–90s) to catch only the fast cases without hanging callers. This applies to New enrollments, Renewals, and Reissues. + ## License Apache License 2.0, see [LICENSE](LICENSE). diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPlugin.Tests.csproj b/cscglobal-caplugin.Tests/CSCGlobalCAPlugin.Tests.csproj new file mode 100644 index 0000000..823e49f --- /dev/null +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPlugin.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + true + CSCGlobalCAPlugin.Tests + CscGlobalCAPluginTests + + + + + + + + + + + + + + + diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs new file mode 100644 index 0000000..9f60d0c --- /dev/null +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs @@ -0,0 +1,1842 @@ +// Copyright 2021 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using Keyfactor.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 static EnrollmentProductInfo ProductInfo(string productId = "CSC TrustedSecure DV", + Dictionary? parameters = null) => new EnrollmentProductInfo + { + ProductID = productId, + ProductParameters = parameters ?? new Dictionary() + }; + + private static Mock ConfigProviderMock(Dictionary? overrides = null) + { + var data = new Dictionary + { + [Constants.CscGlobalApiKey] = "api-key", + [Constants.CscGlobalUrl] = "https://example.invalid/", + [Constants.BearerToken] = "bearer-token" + }; + if (overrides != null) + foreach (var kv in overrides) + data[kv.Key] = kv.Value; + + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns(data); + return mock; + } + + private static CSCGlobalCAPlugin MakePlugin(Mock? client = null, + Mock? certDataReader = null, Dictionary? configOverrides = null, + IDomainValidatorFactory? validatorFactory = null) + { + var plugin = validatorFactory != null ? new CSCGlobalCAPlugin(validatorFactory) : new CSCGlobalCAPlugin(); + plugin.Initialize(ConfigProviderMock(configOverrides).Object, + (certDataReader ?? new Mock()).Object); + plugin.CscGlobalClient = (client ?? new Mock()).Object; + return plugin; + } + + 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); + } + + // --------------------------------------------------------------------- + // Initialize + // --------------------------------------------------------------------- + + [Fact] + public void Initialize_NullConfigProvider_Throws() + { + var plugin = new CSCGlobalCAPlugin(); + Assert.Throws(() => plugin.Initialize(null!, Mock.Of())); + } + + [Fact] + public void Initialize_NullCertificateDataReader_Throws() + { + var plugin = new CSCGlobalCAPlugin(); + Assert.Throws(() => plugin.Initialize(ConfigProviderMock().Object, null!)); + } + + [Fact] + public void Initialize_NullCAConnectionData_Throws() + { + var plugin = new CSCGlobalCAPlugin(); + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns((Dictionary)null!); + Assert.Throws(() => plugin.Initialize(mock.Object, Mock.Of())); + } + + [Fact] + public void Initialize_EnabledDefault_ConstructsRealClient() + { + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(ConfigProviderMock().Object, Mock.Of()); + Assert.True(plugin.Enabled); + Assert.NotNull(plugin.CscGlobalClient); + } + + [Fact] + public void Initialize_ExplicitlyDisabled_SkipsClientCreation() + { + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.Enabled] = "false" }).Object, + Mock.Of()); + Assert.False(plugin.Enabled); + Assert.Null(plugin.CscGlobalClient); + } + + [Fact] + public void Initialize_UnparsableEnabled_DefaultsToTrue() + { + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.Enabled] = "not-a-bool" }).Object, + Mock.Of()); + Assert.True(plugin.Enabled); + } + + [Fact] + public void Initialize_EnabledButMissingApiKey_Throws() + { + var plugin = new CSCGlobalCAPlugin(); + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns(new Dictionary()); + Assert.Throws(() => plugin.Initialize(mock.Object, Mock.Of())); + } + + [Theory] + [InlineData("10", 10)] + [InlineData("not-a-number", 0)] + public void Initialize_SyncFilterDays_ParsesOrDefaults(string raw, int expected) + { + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.SyncFilterDays] = raw }).Object, + Mock.Of()); + Assert.Equal(expected, plugin.SyncFilterDays); + } + + [Theory] + [InlineData("45", 45)] + [InlineData("not-a-number", 30)] + [InlineData("-5", 30)] + public void Initialize_RenewalWindowDays_ParsesOrDefaults(string raw, int expected) + { + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.RenewalWindowDays] = raw }).Object, + Mock.Of()); + Assert.Equal(expected, plugin.RenewalWindowDays); + } + + [Fact] + public void Initialize_RenewalWindowDaysNotConfigured_DefaultsTo30() + { + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(ConfigProviderMock().Object, Mock.Of()); + Assert.Equal(30, plugin.RenewalWindowDays); + } + + [Theory] + [InlineData("5", 5)] + [InlineData("not-a-number", 0)] + [InlineData("-1", 0)] + public void Initialize_DcvPollTimeoutSeconds_ParsesOrDefaults(string raw, int expected) + { + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.DcvPollTimeoutSeconds] = raw }).Object, + Mock.Of()); + Assert.Equal(expected, plugin.DcvPollTimeoutSeconds); + } + + [Fact] + public void Initialize_EnabledKeyPresentButNullValue_DefaultsToTrue() + { + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.Enabled] = null! }).Object, + Mock.Of()); + Assert.True(plugin.Enabled); + } + + [Fact] + public void Initialize_SyncFilterDaysKeyPresentButNullValue_DefaultsToZero() + { + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.SyncFilterDays] = null! }).Object, + Mock.Of()); + Assert.Equal(0, plugin.SyncFilterDays); + } + + [Fact] + public void Initialize_RenewalWindowDaysKeyPresentButNullValue_DefaultsTo30() + { + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.RenewalWindowDays] = null! }).Object, + Mock.Of()); + Assert.Equal(30, plugin.RenewalWindowDays); + } + + [Fact] + public void Initialize_DcvPollTimeoutSecondsKeyPresentButNullValue_DefaultsToZero() + { + var plugin = new CSCGlobalCAPlugin(); + plugin.Initialize(ConfigProviderMock(new Dictionary { [Constants.DcvPollTimeoutSeconds] = null! }).Object, + Mock.Of()); + Assert.Equal(0, plugin.DcvPollTimeoutSeconds); + } + + [Fact] + public void Initialize_WithValidatorFactory_DoesNotThrow() + { + var plugin = new CSCGlobalCAPlugin(Mock.Of()); + plugin.Initialize(ConfigProviderMock().Object, Mock.Of()); + Assert.True(plugin.Enabled); + } + + // --------------------------------------------------------------------- + // GetSingleRecord + // --------------------------------------------------------------------- + + [Fact] + public async Task GetSingleRecord_NullId_Throws() + { + var plugin = MakePlugin(); + await Assert.ThrowsAsync(() => plugin.GetSingleRecord(null!)); + } + + [Fact] + public async Task GetSingleRecord_TooShortId_Throws() + { + var plugin = MakePlugin(); + await Assert.ThrowsAsync(() => plugin.GetSingleRecord("short-id")); + } + + [Fact] + public async Task GetSingleRecord_NullClientResponse_ReturnsFailedMappedStatus() + { + var uuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ReturnsAsync((CertificateResponse)null!); + + var plugin = MakePlugin(mockClient); + var result = await plugin.GetSingleRecord(uuid); + + Assert.Equal(uuid, result.CARequestID); + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + } + + [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_InvalidBase64Certificate_ReturnsEmptyCertificate() + { + var uuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ReturnsAsync(new CertificateResponse + { + Certificate = Convert.ToBase64String(Encoding.ASCII.GetBytes("not valid pem at all")), + Status = "ACTIVE" + }); + + var plugin = MakePlugin(mockClient); + var result = await plugin.GetSingleRecord(uuid); + + Assert.Equal(string.Empty, 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 / SyncCertificates + // --------------------------------------------------------------------- + + [Fact] + public async Task Synchronize_NullBuffer_Throws() + { + var plugin = MakePlugin(); + await Assert.ThrowsAsync(() => plugin.Synchronize(null!, null, true, CancellationToken.None)); + } + + [Fact] + public async Task Synchronize_Disabled_CompletesImmediatelyWithoutCallingClient() + { + var mockClient = new Mock(); + var plugin = MakePlugin(mockClient, configOverrides: new Dictionary { [Constants.Enabled] = "false" }); + var buffer = new System.Collections.Concurrent.BlockingCollection(); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + Assert.True(buffer.IsAddingCompleted); + mockClient.Verify(c => c.SubmitCertificateListRequestAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Synchronize_FullSync_QueuesGeneratedAndRevokedOnly() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(null)).ReturnsAsync(new CertificateListResponse + { + Results = new List + { + new CertificateResponse { Uuid = "u1", Status = "ACTIVE", Certificate = null, CertificateType = "4" }, + new CertificateResponse { Uuid = "u2", Status = "Pending", Certificate = null, CertificateType = "4" }, + null! + } + }); + + var plugin = MakePlugin(mockClient); + var buffer = new System.Collections.Concurrent.BlockingCollection(); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + Assert.True(buffer.IsAddingCompleted); + // Neither item has actual certificate bytes, so both get skipped after status-eligibility + // check; this exercises the eligible-but-empty-content and null-item paths. + Assert.Empty(buffer); + } + + [Fact] + public async Task Synchronize_IncrementalSync_UsesFilterDate() + { + var mockClient = new Mock(); + string? capturedFilter = "not-called"; + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())) + .Callback(f => capturedFilter = f) + .ReturnsAsync(new CertificateListResponse { Results = new List() }); + + var plugin = MakePlugin(mockClient, configOverrides: new Dictionary { [Constants.SyncFilterDays] = "10" }); + var buffer = new System.Collections.Concurrent.BlockingCollection(); + + await plugin.Synchronize(buffer, null, false, CancellationToken.None); + + Assert.NotNull(capturedFilter); + Assert.NotEqual("not-called", capturedFilter); + } + + [Fact] + public async Task Synchronize_IncrementalSync_SyncFilterDaysNotConfigured_DefaultsToFiveDays() + { + var mockClient = new Mock(); + string? capturedFilter = "not-called"; + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())) + .Callback(f => capturedFilter = f) + .ReturnsAsync(new CertificateListResponse { Results = new List() }); + + var plugin = MakePlugin(mockClient); + var buffer = new System.Collections.Concurrent.BlockingCollection(); + + await plugin.Synchronize(buffer, null, false, CancellationToken.None); + + var expected = DateTime.Today.Subtract(TimeSpan.FromDays(5)).ToString("yyyy/MM/dd"); + Assert.Equal(expected, capturedFilter); + } + + [Fact] + public async Task Synchronize_NullResultsFromClient_CompletesWithoutError() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())) + .ReturnsAsync((CertificateListResponse)null!); + + var plugin = MakePlugin(mockClient); + var buffer = new System.Collections.Concurrent.BlockingCollection(); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + Assert.True(buffer.IsAddingCompleted); + } + + [Fact] + public async Task Synchronize_NullResultsCollection_CompletesWithoutError() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())) + .ReturnsAsync(new CertificateListResponse { Results = null }); + + var plugin = MakePlugin(mockClient); + var buffer = new System.Collections.Concurrent.BlockingCollection(); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + Assert.True(buffer.IsAddingCompleted); + } + + [Fact] + public async Task Synchronize_ValidCertificateContent_AddsToBufferWithMappedProductId() + { + var (_, pem) = MakeSelfSignedCert(); + var apiBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(pem)); + + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())).ReturnsAsync(new CertificateListResponse + { + Results = new List + { + new CertificateResponse { Uuid = "u1", Status = "ACTIVE", Certificate = apiBase64, CertificateType = "4" } + } + }); + + var plugin = MakePlugin(mockClient); + var buffer = new System.Collections.Concurrent.BlockingCollection(); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + var items = buffer.ToArray(); + Assert.Single(items); + Assert.Equal("u1", items[0].CARequestID); + Assert.Equal("CSC TrustedSecure Domain Validated SSL", items[0].ProductID); + } + + [Fact] + public async Task Synchronize_MalformedBase64Certificate_SkipsItem() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())).ReturnsAsync(new CertificateListResponse + { + Results = new List + { + new CertificateResponse { Uuid = "u1", Status = "ACTIVE", Certificate = "not valid base64 at all!!", CertificateType = "4" } + } + }); + + var plugin = MakePlugin(mockClient); + var buffer = new System.Collections.Concurrent.BlockingCollection(); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + Assert.Empty(buffer); + } + + [Fact] + public async Task Synchronize_ValidBase64ButNoPemCertificates_SkipsItem() + { + var apiBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("this is not a PEM certificate")); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())).ReturnsAsync(new CertificateListResponse + { + Results = new List + { + new CertificateResponse { Uuid = "u1", Status = "ACTIVE", Certificate = apiBase64, CertificateType = "4" } + } + }); + + var plugin = MakePlugin(mockClient); + var buffer = new System.Collections.Concurrent.BlockingCollection(); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + Assert.Empty(buffer); + } + + [Fact] + public async Task Synchronize_RevokedStatus_AlsoQualifiesForSync() + { + var (_, pem) = MakeSelfSignedCert(); + var apiBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(pem)); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())).ReturnsAsync(new CertificateListResponse + { + Results = new List + { + new CertificateResponse { Uuid = "u1", Status = "REVOKED", Certificate = apiBase64, CertificateType = "4" } + } + }); + + var plugin = MakePlugin(mockClient); + var buffer = new System.Collections.Concurrent.BlockingCollection(); + + await plugin.Synchronize(buffer, null, true, CancellationToken.None); + + Assert.Single(buffer); + } + + [Fact] + public async Task Synchronize_ClientThrows_PropagatesAndCompletesBuffer() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("boom")); + + var plugin = MakePlugin(mockClient); + var buffer = new System.Collections.Concurrent.BlockingCollection(); + + await Assert.ThrowsAsync(() => plugin.Synchronize(buffer, null, true, CancellationToken.None)); + Assert.True(buffer.IsAddingCompleted); + } + + [Fact] + public async Task Synchronize_Cancelled_ThrowsOperationCanceledAndCompletesBuffer() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitCertificateListRequestAsync(It.IsAny())).ReturnsAsync(new CertificateListResponse + { + Results = new List { new CertificateResponse { Uuid = "u1", Status = "ACTIVE" } } + }); + + var plugin = MakePlugin(mockClient); + var buffer = new System.Collections.Concurrent.BlockingCollection(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAsync(() => plugin.Synchronize(buffer, null, true, cts.Token)); + Assert.True(buffer.IsAddingCompleted); + } + + // --------------------------------------------------------------------- + // Revoke + // --------------------------------------------------------------------- + + [Fact] + public async Task Revoke_Disabled_Throws() + { + var plugin = MakePlugin(configOverrides: new Dictionary { [Constants.Enabled] = "false" }); + await Assert.ThrowsAsync(() => + plugin.Revoke(new string('a', 36), "serial", 0)); + } + + [Fact] + public async Task Revoke_TooShortId_Throws() + { + var plugin = MakePlugin(); + await Assert.ThrowsAsync(() => plugin.Revoke("short", "serial", 0)); + } + + [Fact] + public async Task Revoke_NullId_Throws() + { + var plugin = MakePlugin(); + await Assert.ThrowsAsync(() => plugin.Revoke(null!, "serial", 0)); + } + + [Fact] + public async Task Revoke_NullResponse_Throws() + { + var uuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitRevokeCertificateAsync(uuid)).ReturnsAsync((RevokeResponse)null!); + + var plugin = MakePlugin(mockClient); + // Wrapped by the generic catch (Exception e) at the bottom of Revoke, since + // InvalidOperationException isn't AggregateException or HttpRequestException. + var ex = await Assert.ThrowsAsync(() => plugin.Revoke(uuid, "serial", 0)); + Assert.IsType(ex.InnerException); + } + + [Fact] + public async Task Revoke_Success_ReturnsRevoked() + { + var uuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitRevokeCertificateAsync(uuid)).ReturnsAsync(new RevokeResponse + { + RevokeSuccess = new RevokeSuccessResponse { Status = "REVOKED" } + }); + + var plugin = MakePlugin(mockClient); + var result = await plugin.Revoke(uuid, "serial", 0); + + Assert.Equal((int)EndEntityStatus.REVOKED, result); + } + + [Fact] + public async Task Revoke_ErrorWithDescription_ThrowsHttpRequestException() + { + 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, "serial", 0)); + } + + [Fact] + public async Task Revoke_ClientThrows_WrapsException() + { + var uuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitRevokeCertificateAsync(uuid)).ThrowsAsync(new InvalidOperationException("boom")); + + var plugin = MakePlugin(mockClient); + await Assert.ThrowsAsync(() => plugin.Revoke(uuid, "serial", 0)); + } + + // --------------------------------------------------------------------- + // Ping / ValidateCAConnectionInfo + // --------------------------------------------------------------------- + + [Fact] + public async Task Ping_Enabled_DoesNotThrow() + { + var plugin = MakePlugin(); + await plugin.Ping(); + } + + [Fact] + public async Task Ping_Disabled_DoesNotThrow() + { + var plugin = MakePlugin(configOverrides: new Dictionary { [Constants.Enabled] = "false" }); + await plugin.Ping(); + } + + [Fact] + public async Task ValidateCAConnectionInfo_NullConnectionInfo_Throws() + { + var plugin = MakePlugin(); + await Assert.ThrowsAsync(() => plugin.ValidateCAConnectionInfo(null!)); + } + + [Fact] + public async Task ValidateCAConnectionInfo_Enabled_DoesNotThrow() + { + var plugin = MakePlugin(); + await plugin.ValidateCAConnectionInfo(new Dictionary()); + } + + [Fact] + public async Task ValidateCAConnectionInfo_ExplicitlyDisabled_DoesNotThrow() + { + var plugin = MakePlugin(); + await plugin.ValidateCAConnectionInfo(new Dictionary { [Constants.Enabled] = "false" }); + } + + [Fact] + public async Task ValidateCAConnectionInfo_UnparsableEnabledValue_TreatsAsEnabled() + { + var plugin = MakePlugin(); + await plugin.ValidateCAConnectionInfo(new Dictionary { [Constants.Enabled] = "not-a-bool" }); + } + + // --------------------------------------------------------------------- + // ValidateProductInfo + // --------------------------------------------------------------------- + + [Theory] + [InlineData("CSC TrustedSecure DV")] + [InlineData("CSC TrustedSecure DV Wildcard, Multiple Names")] + public async Task ValidateProductInfo_CanonicalProductName_DoesNotThrow(string productId) + { + var plugin = MakePlugin(); + await plugin.ValidateProductInfo(ProductInfo(productId), new Dictionary()); + } + + [Theory] + [InlineData("CSC TrustedSecure UC Certificate")] + [InlineData("CSC TrustedSecure Domain Validated SSL")] + [InlineData("CSC Trusted Secure Domain Validated Wildcard SSL")] + public async Task ValidateProductInfo_LegacyProductName_DoesNotThrow(string legacyProductId) + { + var plugin = MakePlugin(); + await plugin.ValidateProductInfo(ProductInfo(legacyProductId), new Dictionary()); + } + + [Fact] + public async Task ValidateProductInfo_NullProductInfo_Throws() + { + var plugin = MakePlugin(); + await Assert.ThrowsAsync(() => + plugin.ValidateProductInfo(null!, new Dictionary())); + } + + [Fact] + public async Task ValidateProductInfo_EmptyProductId_Throws() + { + var plugin = MakePlugin(); + await Assert.ThrowsAsync(() => + plugin.ValidateProductInfo(ProductInfo(""), new Dictionary())); + } + + [Fact] + public async Task ValidateProductInfo_UnknownProduct_Throws() + { + var plugin = MakePlugin(); + await Assert.ThrowsAsync(() => + plugin.ValidateProductInfo(ProductInfo("Not A Real Product"), new Dictionary())); + } + + [Fact] + public async Task ValidateProductInfo_NullConnectionInfo_TreatsAsEnabled() + { + var plugin = MakePlugin(); + await Assert.ThrowsAsync(() => + plugin.ValidateProductInfo(ProductInfo("Not A Real Product"), null!)); + } + + [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_UnparsableEnabledValue_TreatsAsEnabled() + { + var plugin = MakePlugin(); + var connectionInfo = new Dictionary { [Constants.Enabled] = "not-a-bool" }; + await Assert.ThrowsAsync(() => + plugin.ValidateProductInfo(ProductInfo("Not A Real Product"), connectionInfo)); + } + + // --------------------------------------------------------------------- + // Annotations / product IDs + // --------------------------------------------------------------------- + + [Fact] + public void GetCAConnectorAnnotations_ReturnsExpectedKeys() + { + var plugin = MakePlugin(); + var annotations = plugin.GetCAConnectorAnnotations(); + Assert.Contains(Constants.Enabled, annotations.Keys); + Assert.Contains(Constants.CscGlobalUrl, annotations.Keys); + Assert.Contains(Constants.DcvPollTimeoutSeconds, annotations.Keys); + } + + [Fact] + public void GetTemplateParameterAnnotations_ReturnsExpectedKeys() + { + var plugin = MakePlugin(); + var annotations = plugin.GetTemplateParameterAnnotations(); + Assert.Contains(EnrollmentConfigConstants.CnDcvEmail, annotations.Keys); + Assert.Contains(EnrollmentConfigConstants.AdditionalSansCommaSeparatedDcvEmails, annotations.Keys); + } + + [Fact] + public void GetProductIds_ReturnsCanonicalTenProducts() + { + var plugin = MakePlugin(); + Assert.Equal(10, plugin.GetProductIds().Count); + } + + // --------------------------------------------------------------------- + // Enroll - validation and New enrollment + // --------------------------------------------------------------------- + + [Fact] + public async Task Enroll_Disabled_ReturnsFailedWithoutCallingClient() + { + var mockClient = new Mock(); + var plugin = MakePlugin(mockClient, configOverrides: new Dictionary { [Constants.Enabled] = "false" }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), + RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.FAILED, result.Status); + mockClient.Verify(c => c.SubmitGetCustomFields(), Times.Never); + } + + [Fact] + public async Task Enroll_NullProductInfo_Throws() + { + var plugin = MakePlugin(); + await Assert.ThrowsAsync(() => + plugin.Enroll("csr", "CN=test", new Dictionary(), null!, RequestFormat.PKCS10, EnrollmentType.New)); + } + + [Fact] + public async Task Enroll_NullProductParameters_Throws() + { + var plugin = MakePlugin(); + var productInfo = new EnrollmentProductInfo { ProductID = "CSC TrustedSecure DV", ProductParameters = null! }; + await Assert.ThrowsAsync(() => + plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, RequestFormat.PKCS10, EnrollmentType.New)); + } + + [Fact] + public async Task Enroll_EmptyCsr_Throws() + { + var plugin = MakePlugin(); + await Assert.ThrowsAsync(() => + plugin.Enroll("", "CN=test", new Dictionary(), ProductInfo(), RequestFormat.PKCS10, EnrollmentType.New)); + } + + [Fact] + public async Task Enroll_New_PriorCertSnPresent_ReturnsFailure() + { + 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.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + mockClient.Verify(c => c.SubmitRegistrationAsync(It.IsAny()), Times.Never); + } + + [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 = "new.example.com", Status = new Status { Uuid = "uuid-new" } } + }); + + 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-new", result.CARequestID); + } + + [Fact] + public async Task Enroll_New_NullClientResponse_ReturnsFailure() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync((RegistrationResponse)null!); + + var plugin = MakePlugin(mockClient); + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), + RequestFormat.PKCS10, EnrollmentType.New); + + Assert.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + } + + [Fact] + public async Task Enroll_New_ClientThrows_ReturnsFailureInsteadOfThrowing() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("boom")); + + var plugin = MakePlugin(mockClient); + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), + RequestFormat.PKCS10, EnrollmentType.New); + + Assert.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Contains("boom", result.StatusMessage); + } + + [Fact] + public async Task Enroll_UnhandledEnrollmentType_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.Renew); + + Assert.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + } + + [Fact] + public async Task Enroll_New_WithPollingEnabledAndFastIssuance_ReturnsGeneratedCertDirectly() + { + var (_, pem) = MakeSelfSignedCert(); + var apiBase64 = Convert.ToBase64String(Encoding.ASCII.GetBytes(pem)); + var uuid = Guid.NewGuid().ToString(); // must be >= 36 chars - GetSingleRecord validates length + + 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 = "fast.example.com", Status = new Status { Uuid = uuid } } + }); + mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ReturnsAsync(new CertificateResponse + { + Status = "ACTIVE", + Certificate = apiBase64 + }); + + // DcvPollTimeoutSeconds < the 10s poll interval means exactly one poll attempt happens + // and the loop then breaks without ever calling Task.Delay - fast and deterministic. + var plugin = MakePlugin(mockClient, configOverrides: new Dictionary { [Constants.DcvPollTimeoutSeconds] = "1" }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), + RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.GENERATED, result.Status); + Assert.Equal(uuid, result.CARequestID); + Assert.NotNull(result.Certificate); + } + + [Fact] + public async Task Enroll_New_PollingEnabledButNotIssued_FallsBackToPendingResult() + { + var uuid = Guid.NewGuid().ToString(); + 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 = "pending.example.com", Status = new Status { Uuid = uuid } } + }); + mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ReturnsAsync(new CertificateResponse { Status = "Pending" }); + + var plugin = MakePlugin(mockClient, configOverrides: new Dictionary { [Constants.DcvPollTimeoutSeconds] = "1" }); + + 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, result.CARequestID); + } + + [Fact] + public async Task Enroll_New_WithDnsValidatorFactory_PublishesCnameRecord() + { + 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 = "cname.example.com", + Status = new Status { Uuid = "uuid-cname" }, + DcvDetails = new List + { + new DcvDetail { CName = new CName { Name = "_dnsauth.example.com.", Value = "target.sectigo.com." } } + } + } + }); + + var mockValidator = new Mock(); + mockValidator.Setup(v => v.GetValidationType()).Returns("cname"); + mockValidator.Setup(v => v.StageValidation(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new DomainValidationResult { Success = true, Status = "staged" }); + + var mockFactory = new Mock(); + mockFactory.Setup(f => f.ResolveDomainValidator(It.IsAny(), "cname")).Returns(mockValidator.Object); + + var plugin = MakePlugin(mockClient, validatorFactory: mockFactory.Object); + var productInfo = ProductInfo(parameters: new Dictionary + { + [EnrollmentConfigConstants.DomainControlValidationMethod] = "CNAME" + }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + // Trailing dots must be stripped before resolution or no provider would match. + mockFactory.Verify(f => f.ResolveDomainValidator("_dnsauth.example.com", "cname"), Times.Once); + mockValidator.Verify(v => v.StageValidation("_dnsauth.example.com", "target.sectigo.com", It.IsAny()), Times.Once); + } + + [Fact] + public async Task Enroll_New_WithDnsValidatorFactoryButEmailMethod_DoesNotAttemptPublish() + { + 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 = "email.example.com", + Status = new Status { Uuid = "uuid-email" }, + DcvDetails = new List { new DcvDetail { Email = "admin@example.com" } } + } + }); + + var mockFactory = new Mock(); + var plugin = MakePlugin(mockClient, validatorFactory: mockFactory.Object); + var productInfo = ProductInfo(parameters: new Dictionary + { + [EnrollmentConfigConstants.DomainControlValidationMethod] = "EMAIL" + }); + + await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.New); + + mockFactory.Verify(f => f.ResolveDomainValidator(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Enroll_New_DnsValidatorUnresolved_DoesNotThrow() + { + 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 = "unresolved.example.com", + Status = new Status { Uuid = "uuid-unresolved" }, + DcvDetails = new List + { + new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "target.sectigo.com" } } + } + } + }); + + var mockFactory = new Mock(); + mockFactory.Setup(f => f.ResolveDomainValidator(It.IsAny(), "cname")).Returns((IDomainValidator)null!); + + var plugin = MakePlugin(mockClient, validatorFactory: mockFactory.Object); + var productInfo = ProductInfo(parameters: new Dictionary + { + [EnrollmentConfigConstants.DomainControlValidationMethod] = "CNAME" + }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + } + + [Fact] + public async Task Enroll_New_DnsValidatorStageValidationThrows_DoesNotThrow() + { + 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 = "err.example.com", + Status = new Status { Uuid = "uuid-err" }, + DcvDetails = new List + { + new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "target.sectigo.com" } } + } + } + }); + + var mockValidator = new Mock(); + mockValidator.Setup(v => v.GetValidationType()).Returns("cname"); + mockValidator.Setup(v => v.StageValidation(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("dns failure")); + + var mockFactory = new Mock(); + mockFactory.Setup(f => f.ResolveDomainValidator(It.IsAny(), "cname")).Returns(mockValidator.Object); + + var plugin = MakePlugin(mockClient, validatorFactory: mockFactory.Object); + var productInfo = ProductInfo(parameters: new Dictionary + { + [EnrollmentConfigConstants.DomainControlValidationMethod] = "CNAME" + }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + } + + [Fact] + public async Task Enroll_New_DnsValidatorReturnsFailure_DoesNotThrow() + { + 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 = "fail.example.com", + Status = new Status { Uuid = "uuid-fail" }, + DcvDetails = new List + { + new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "target.sectigo.com" } } + } + } + }); + + var mockValidator = new Mock(); + mockValidator.Setup(v => v.GetValidationType()).Returns("cname"); + mockValidator.Setup(v => v.StageValidation(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new DomainValidationResult { Success = false, Status = "error", ErrorMessage = "nope" }); + + var mockFactory = new Mock(); + mockFactory.Setup(f => f.ResolveDomainValidator(It.IsAny(), "cname")).Returns(mockValidator.Object); + + var plugin = MakePlugin(mockClient, validatorFactory: mockFactory.Object); + var productInfo = ProductInfo(parameters: new Dictionary + { + [EnrollmentConfigConstants.DomainControlValidationMethod] = "CNAME" + }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + } + + [Fact] + public async Task Enroll_New_DnsValidatorReturnsNullResult_DoesNotThrow() + { + 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 = "fail-null.example.com", + Status = new Status { Uuid = "uuid-fail-null" }, + DcvDetails = new List + { + new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "target.sectigo.com" } } + } + } + }); + + var mockValidator = new Mock(); + mockValidator.Setup(v => v.GetValidationType()).Returns("cname"); + mockValidator.Setup(v => v.StageValidation(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((DomainValidationResult)null!); + + var mockFactory = new Mock(); + mockFactory.Setup(f => f.ResolveDomainValidator(It.IsAny(), "cname")).Returns(mockValidator.Object); + + var plugin = MakePlugin(mockClient, validatorFactory: mockFactory.Object); + var productInfo = ProductInfo(parameters: new Dictionary + { + [EnrollmentConfigConstants.DomainControlValidationMethod] = "CNAME" + }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + } + + [Fact] + public async Task Enroll_New_DnsFactoryButNoEnrollmentContext_SkipsPublish() + { + 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 = "none.example.com", Status = new Status { Uuid = "uuid-none" } } + }); + + var mockFactory = new Mock(); + var plugin = MakePlugin(mockClient, validatorFactory: mockFactory.Object); + + await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), + RequestFormat.PKCS10, EnrollmentType.New); + + mockFactory.Verify(f => f.ResolveDomainValidator(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Enroll_New_PollingEnabledButNoCARequestId_SkipsPollingWithoutError() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse + { + // No Status/Uuid at all -> enrollResult.CARequestID is null -> TryPollForIssuedCertAsync + // must skip cleanly rather than throw. + Result = new Result { CommonName = "no-uuid.example.com" } + }); + + var plugin = MakePlugin(mockClient, configOverrides: new Dictionary { [Constants.DcvPollTimeoutSeconds] = "1" }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), + RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + mockClient.Verify(c => c.SubmitGetCertificateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Enroll_New_PollingThrowsOnFirstAttempt_FallsBackToPendingResult() + { + var uuid = Guid.NewGuid().ToString(); + 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 = "poll-error.example.com", Status = new Status { Uuid = uuid } } + }); + // GetSingleRecord (called internally by the poll loop) throws - must be caught and retried, + // not propagated, and the loop still falls back to the pending result once time is up. + mockClient.Setup(c => c.SubmitGetCertificateAsync(uuid)).ThrowsAsync(new InvalidOperationException("network blip")); + + var plugin = MakePlugin(mockClient, configOverrides: new Dictionary { [Constants.DcvPollTimeoutSeconds] = "1" }); + + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), + RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + } + + [Fact] + public async Task Enroll_New_CnameMethodWithMixedEmailEntry_SkipsEmailPassthroughEntry() + { + 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 = "mixed.example.com", + Status = new Status { Uuid = "uuid-mixed" }, + DcvDetails = new List + { + new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "target.sectigo.com" } }, + // Key == value: GetEnrollmentResult's email passthrough shape, mixed into the + // same EnrollmentContext even though the product's DCV method is CNAME. + new DcvDetail { Email = "admin@example.com" } + } + } + }); + + var mockValidator = new Mock(); + mockValidator.Setup(v => v.GetValidationType()).Returns("cname"); + mockValidator.Setup(v => v.StageValidation(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new DomainValidationResult { Success = true }); + + var mockFactory = new Mock(); + mockFactory.Setup(f => f.ResolveDomainValidator(It.IsAny(), "cname")).Returns(mockValidator.Object); + + var plugin = MakePlugin(mockClient, validatorFactory: mockFactory.Object); + var productInfo = ProductInfo(parameters: new Dictionary + { + [EnrollmentConfigConstants.DomainControlValidationMethod] = "CNAME" + }); + + await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + RequestFormat.PKCS10, EnrollmentType.New); + + // Only the CNAME entry should have been resolved/staged; the email passthrough is skipped. + mockFactory.Verify(f => f.ResolveDomainValidator(It.IsAny(), "cname"), Times.Once); + } + + // --------------------------------------------------------------------- + // Enroll - RenewOrReissue + // --------------------------------------------------------------------- + + [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.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Contains("PriorCertSN", result.StatusMessage); + } + + [Fact] + public async Task Enroll_RenewOrReissue_NoOrderIdFoundForSerial_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.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + } + + [Fact] + public async Task Enroll_RenewOrReissue_OrderIdTooShort_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"); + + 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.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + } + + [Fact] + public async Task Enroll_RenewOrReissue_RenewalWithApplicantLastName_Succeeds() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + // OrderDate 2 years ago -> well past the 1-year+RenewalWindowDays expiry -> renewal path. + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = DateTime.UtcNow.AddYears(-2).ToString("o") + }); + mockClient.Setup(c => c.SubmitRenewalAsync(It.IsAny())).ReturnsAsync(new RenewalResponse + { + Result = new Result { CommonName = "renewed.example.com", Status = new Status { Uuid = orderUuid } } + }); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); + + 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_RenewalMissingApplicantLastName_ReturnsFailure() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = DateTime.UtcNow.AddYears(-2).ToString("o") + }); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); + + 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.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + mockClient.Verify(c => c.SubmitRenewalAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Enroll_RenewOrReissue_ReissueWithApplicantLastName_Succeeds() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + // OrderDate today -> well within the renewal window -> reissue (free) path. + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = DateTime.UtcNow.ToString("o") + }); + mockClient.Setup(c => c.SubmitReissueAsync(It.IsAny())).ReturnsAsync(new ReissueResponse + { + Result = new Result { CommonName = "reissued.example.com", Status = new Status { Uuid = orderUuid } } + }); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); + + 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_ReissueMissingApplicantLastName_ReturnsFailure() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = DateTime.UtcNow.ToString("o") + }); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); + + 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.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + mockClient.Verify(c => c.SubmitReissueAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Enroll_RenewOrReissue_NoOrderDate_FallsBackToCertificateDataReaderExpiry() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + // No OrderDate at all -> falls back to expiry-based decision. + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse { OrderDate = null }); + mockClient.Setup(c => c.SubmitRenewalAsync(It.IsAny())).ReturnsAsync(new RenewalResponse + { + Result = new Result { CommonName = "expired.example.com", Status = new Status { Uuid = orderUuid } } + }); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); + certDataReader.Setup(r => r.GetExpirationDateByRequestId(orderUuid)).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); + } + + // --------------------------------------------------------------------- + // GetEndEntityCertificate / ExtractCertificates / FindLeaf + // --------------------------------------------------------------------- + + [Fact] + public void GetEndEntityCertificate_EmptyInput_ReturnsEmpty() + { + 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_NoPemBlocks_ReturnsEmpty() + { + var plugin = MakePlugin(); + Assert.Equal(string.Empty, plugin.GetEndEntityCertificate("just some plain text, no PEM fences")); + } + + [Fact] + public void GetEndEntityCertificate_EmptyPemBlockContent_SkipsBlock() + { + var (cert, pem) = MakeSelfSignedCert(); + var emptyBlock = "-----BEGIN CERTIFICATE-----\n \n-----END CERTIFICATE-----\n"; + var plugin = MakePlugin(); + + var result = plugin.GetEndEntityCertificate(emptyBlock + pem); + + Assert.Equal(Convert.ToBase64String(cert.RawData), result); + } + + [Fact] + public void GetEndEntityCertificate_CertWithoutBasicConstraints_TreatedAsNonCa() + { + // A cert with no Basic Constraints extension at all exercises FindLeaf's IsCa "unknown -> + // treat as non-CA" fallback, distinct from an explicit CertificateAuthority=false. + using var rsa = RSA.Create(2048); + var req = new CertificateRequest("CN=no-constraints.example.com", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(365)); + var pem = "-----BEGIN CERTIFICATE-----\n" + + Convert.ToBase64String(cert.RawData, Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE-----\n"; + + var plugin = MakePlugin(); + var result = plugin.GetEndEntityCertificate(pem); + + Assert.Equal(Convert.ToBase64String(cert.RawData), result); + } + + [Fact] + public void GetEndEntityCertificate_MalformedBase64InBlock_SkipsAndReturnsEmpty() + { + var pem = "-----BEGIN CERTIFICATE-----\nNOT!!VALID==BASE64%%CHARS\n-----END CERTIFICATE-----\n"; + var plugin = MakePlugin(); + Assert.Equal(string.Empty, plugin.GetEndEntityCertificate(pem)); + } + + [Fact] + public void GetEndEntityCertificate_ValidBase64ButNotACertificate_SkipsAndReturnsEmpty() + { + var notACert = Convert.ToBase64String(Encoding.UTF8.GetBytes("this decodes fine but is not DER-encoded")); + 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), caCert.NotAfter.AddDays(-1), + 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); + } + + [Fact] + public void GetEndEntityCertificate_NoDeterminableLeaf_ReturnsEmpty() + { + // Two distinct CA certs that (deliberately) share the exact same Subject/Issuer DN + // string: FindLeaf's Issuer/Subject string-matching heuristic treats each as "issuing" + // the other, so neither ends up in nonIssuers nor anyNonCa (both are CA=true) - the + // "give up" path. + string ToPemBlock(X509Certificate2 c) => "-----BEGIN CERTIFICATE-----\n" + + Convert.ToBase64String(c.RawData, Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE-----\n"; + + X509Certificate2 MakeCaCert() + { + using var rsa = RSA.Create(2048); + var req = new CertificateRequest("CN=duplicate.example.com", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + req.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + return req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(365)); + } + + var pem = ToPemBlock(MakeCaCert()) + ToPemBlock(MakeCaCert()); + var plugin = MakePlugin(); + + Assert.Equal(string.Empty, plugin.GetEndEntityCertificate(pem)); + } + + [Fact] + public async Task Enroll_New_CustomFieldsNull_UsesEmptyListInstead() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync((List)null!); + mockClient.Setup(c => c.SubmitRegistrationAsync(It.IsAny())).ReturnsAsync(new RegistrationResponse + { + Result = new Result { CommonName = "nullfields.example.com", Status = new Status { Uuid = "uuid-nf" } } + }); + + 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); + } + + [Fact] + public async Task Enroll_RenewOrReissue_FetchLiveCertThrowsAndFallbackAlsoFails_ReturnsFailure() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + // Both the primary live-cert fetch AND the fallback's GetSingleRecord call use the same + // client method, and both fail - forcing the innermost catch(fallbackEx) path. + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ThrowsAsync(new InvalidOperationException("network error")); + + 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.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Contains("unable to determine renewal status", result.StatusMessage); + } + + [Fact] + public async Task Enroll_RenewOrReissue_RenewalUuidLookupFails_ReturnsFailure() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = DateTime.UtcNow.AddYears(-2).ToString("o") // renewal path + }); + + var certDataReader = new Mock(); + // First call resolves the top-level order_id; second (inside the renewal branch, for the + // same PriorCertSN) fails to resolve - exercises ValidateRenewalUUID's failure branch. + certDataReader.SetupSequence(r => r.GetRequestIDBySerialNumber("ABC123")) + .ReturnsAsync(orderUuid) + .ReturnsAsync(string.Empty); + + 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.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Contains("could not resolve prior certificate serial number", result.StatusMessage); + } + + [Fact] + public async Task Enroll_RenewOrReissue_RenewalNullResponse_ReturnsFailure() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = DateTime.UtcNow.AddYears(-2).ToString("o") + }); + mockClient.Setup(c => c.SubmitRenewalAsync(It.IsAny())).ReturnsAsync((RenewalResponse)null!); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); + + 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.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Contains("CSC API returned a null response", result.StatusMessage); + } + + [Fact] + public async Task Enroll_RenewOrReissue_ReissueRequestIdLookupEmpty_ReturnsFailure() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = DateTime.UtcNow.ToString("o") // reissue path + }); + + var certDataReader = new Mock(); + certDataReader.SetupSequence(r => r.GetRequestIDBySerialNumber("ABC123")) + .ReturnsAsync(orderUuid) + .ReturnsAsync(string.Empty); + + 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.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Contains("could not resolve prior certificate serial number", result.StatusMessage); + } + + [Fact] + public async Task Enroll_RenewOrReissue_ReissueRequestIdTooShort_ReturnsFailure() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = DateTime.UtcNow.ToString("o") + }); + + var certDataReader = new Mock(); + certDataReader.SetupSequence(r => r.GetRequestIDBySerialNumber("ABC123")) + .ReturnsAsync(orderUuid) + .ReturnsAsync("too-short"); + + 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.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Contains("too short to extract a UUID", result.StatusMessage); + } + + [Fact] + public async Task Enroll_RenewOrReissue_ReissueNullResponse_ReturnsFailure() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = DateTime.UtcNow.ToString("o") + }); + mockClient.Setup(c => c.SubmitReissueAsync(It.IsAny())).ReturnsAsync((ReissueResponse)null!); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); + + 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.NotEqual((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Contains("CSC API returned a null response", result.StatusMessage); + } + + [Fact] + public async Task Enroll_RenewOrReissue_FetchLiveCertThrows_FallsBackToExpiryCheck() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + mockClient.SetupSequence(c => c.SubmitGetCertificateAsync(orderUuid)) + .ThrowsAsync(new InvalidOperationException("network error")); + mockClient.Setup(c => c.SubmitReissueAsync(It.IsAny())).ReturnsAsync(new ReissueResponse + { + Result = new Result { CommonName = "fallback.example.com", Status = new Status { Uuid = orderUuid } } + }); + + var certDataReader = new Mock(); + certDataReader.Setup(r => r.GetRequestIDBySerialNumber("ABC123")).ReturnsAsync(orderUuid); + certDataReader.Setup(r => r.GetExpirationDateByRequestId(orderUuid)).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); + } + + [Fact] + public async Task Enroll_RenewOrReissue_NoOrderDateAndNoExpirationDateOnReader_FallsThroughToSingleRecordLookup() + { + var orderUuid = Guid.NewGuid().ToString(); + var mockClient = new Mock(); + mockClient.Setup(c => c.SubmitGetCustomFields()).ReturnsAsync(new List()); + // No OrderDate -> falls back to expiry check. GetExpirationDateByRequestId (below) returns + // null, so the fallback's "??" actually has to call GetSingleRecord for a second time to + // get a RevocationDate - which is never set by GetSingleRecord, so it stays null and the + // nullable "<" comparison evaluates to false (not a renewal). + mockClient.Setup(c => c.SubmitGetCertificateAsync(orderUuid)).ReturnsAsync(new CertificateResponse + { + OrderDate = null, + Status = "ACTIVE" + }); + mockClient.Setup(c => c.SubmitReissueAsync(It.IsAny())).ReturnsAsync(new ReissueResponse + { + Result = new Result { CommonName = "reissue.example.com", Status = new Status { Uuid = orderUuid } } + }); + + 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.EXTERNALVALIDATION, result.Status); + mockClient.Verify(c => c.SubmitReissueAsync(It.IsAny()), Times.Once); + } +} diff --git a/cscglobal-caplugin.Tests/CscGlobalClientTests.cs b/cscglobal-caplugin.Tests/CscGlobalClientTests.cs new file mode 100644 index 0000000..9c7b0c1 --- /dev/null +++ b/cscglobal-caplugin.Tests/CscGlobalClientTests.cs @@ -0,0 +1,439 @@ +// 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 Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.CSCGlobal; +using Keyfactor.Extensions.CAPlugin.CSCGlobal.Client; +using Keyfactor.Extensions.CAPlugin.CSCGlobal.Client.Models; +using Moq; +using Xunit; + +namespace CscGlobalCAPluginTests; + +public class CscGlobalClientTests +{ + private sealed class FakeHttpMessageHandler : HttpMessageHandler + { + private readonly Func _responder; + public HttpRequestMessage? LastRequest { get; private set; } + + public FakeHttpMessageHandler(Func responder) => _responder = responder; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + return Task.FromResult(_responder(request)); + } + } + + private static HttpResponseMessage JsonResponse(HttpStatusCode code, string json) => + new HttpResponseMessage(code) { Content = new StringContent(json) }; + + private static Mock ValidConfig() + { + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns(new Dictionary + { + [Constants.CscGlobalApiKey] = "api-key", + [Constants.CscGlobalUrl] = "https://example.invalid/", + [Constants.BearerToken] = "bearer-token" + }); + return mock; + } + + private static CscGlobalClient MakeClient(Func responder, out FakeHttpMessageHandler handler) + { + handler = new FakeHttpMessageHandler(responder); + return new CscGlobalClient(ValidConfig().Object, handler); + } + + // --------------------------------------------------------------------- + // Constructor validation + // --------------------------------------------------------------------- + + [Fact] + public void Constructor_NullConfig_Throws() + { + Assert.Throws(() => new CscGlobalClient(null!)); + } + + [Fact] + public void Constructor_NullCAConnectionData_Throws() + { + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns((Dictionary)null!); + Assert.Throws(() => new CscGlobalClient(mock.Object)); + } + + [Fact] + public void Constructor_MissingApiKey_Throws() + { + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns(new Dictionary()); + Assert.Throws(() => new CscGlobalClient(mock.Object)); + } + + [Fact] + public void Constructor_MissingUrl_Throws() + { + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns(new Dictionary + { + [Constants.CscGlobalApiKey] = "api-key" + }); + Assert.Throws(() => new CscGlobalClient(mock.Object)); + } + + [Fact] + public void Constructor_EmptyApiKeyValue_Throws() + { + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns(new Dictionary + { + [Constants.CscGlobalApiKey] = "", + [Constants.CscGlobalUrl] = "https://example.invalid/" + }); + Assert.Throws(() => new CscGlobalClient(mock.Object)); + } + + [Fact] + public void Constructor_NullApiKeyValue_Throws() + { + // Key present but value is a null object (distinct from a missing key or an empty string - + // exercises the `?.ToString()` null-conditional rather than the ContainsKey check). + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns(new Dictionary + { + [Constants.CscGlobalApiKey] = null!, + [Constants.CscGlobalUrl] = "https://example.invalid/" + }); + Assert.Throws(() => new CscGlobalClient(mock.Object)); + } + + [Fact] + public void Constructor_NullUrlValue_Throws() + { + // Url key present but value is a null object (distinct from a missing key). + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns(new Dictionary + { + [Constants.CscGlobalApiKey] = "api-key", + [Constants.CscGlobalUrl] = null! + }); + Assert.Throws(() => new CscGlobalClient(mock.Object)); + } + + [Fact] + public void Constructor_EmptyBearerTokenValue_Throws() + { + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns(new Dictionary + { + [Constants.CscGlobalApiKey] = "api-key", + [Constants.CscGlobalUrl] = "https://example.invalid/", + [Constants.BearerToken] = "" + }); + Assert.Throws(() => new CscGlobalClient(mock.Object)); + } + + [Fact] + public void Constructor_NullBearerTokenValue_Throws() + { + // BearerToken key present but value is a null object (distinct from a missing key or an + // empty string - exercises the `?.ToString()` null-conditional rather than ContainsKey). + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns(new Dictionary + { + [Constants.CscGlobalApiKey] = "api-key", + [Constants.CscGlobalUrl] = "https://example.invalid/", + [Constants.BearerToken] = null! + }); + Assert.Throws(() => new CscGlobalClient(mock.Object)); + } + + [Fact] + public void Constructor_MissingBearerToken_Throws() + { + var mock = new Mock(); + mock.Setup(c => c.CAConnectionData).Returns(new Dictionary + { + [Constants.CscGlobalApiKey] = "api-key", + [Constants.CscGlobalUrl] = "https://example.invalid/" + }); + Assert.Throws(() => new CscGlobalClient(mock.Object)); + } + + [Fact] + public void Constructor_ValidConfig_DoesNotThrow() + { + var client = new CscGlobalClient(ValidConfig().Object, new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, "{}"))); + Assert.NotNull(client); + } + + // --------------------------------------------------------------------- + // SubmitRegistrationAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task SubmitRegistrationAsync_Success_ReturnsParsedResponse() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, + "{\"result\":{\"commonName\":\"order-1\",\"price\":{\"currency\":\"USD\",\"total\":99.5}}}"), out var handler); + + var response = await client.SubmitRegistrationAsync(new RegistrationRequest()); + + Assert.Equal("order-1", response.Result.CommonName); + Assert.Contains("/dbs/api/v2/tls/registration", handler.LastRequest!.RequestUri!.ToString()); + } + + [Fact] + public async Task SubmitRegistrationAsync_NullRequest_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{}"), out _); + await Assert.ThrowsAsync(() => client.SubmitRegistrationAsync(null!)); + } + + [Fact] + public async Task SubmitRegistrationAsync_BadRequest_ReturnsRegistrationError() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.BadRequest, "{\"description\":\"denied\"}"), out _); + var response = await client.SubmitRegistrationAsync(new RegistrationRequest()); + Assert.Equal("denied", 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())); + } + + // --------------------------------------------------------------------- + // SubmitRenewalAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task SubmitRenewalAsync_Success_ReturnsParsedResponse() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{\"result\":{\"commonName\":\"renewed-1\"}}"), out var handler); + var response = await client.SubmitRenewalAsync(new RenewalRequest()); + Assert.Equal("renewed-1", response.Result.CommonName); + Assert.Contains("/dbs/api/v2/tls/renewal", handler.LastRequest!.RequestUri!.ToString()); + } + + [Fact] + public async Task SubmitRenewalAsync_NullRequest_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{}"), out _); + await Assert.ThrowsAsync(() => client.SubmitRenewalAsync(null!)); + } + + [Fact] + public async Task SubmitRenewalAsync_BadRequest_ReturnsRegistrationError() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.BadRequest, "{\"description\":\"denied\"}"), out _); + var response = await client.SubmitRenewalAsync(new RenewalRequest()); + Assert.Equal("denied", 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())); + } + + // --------------------------------------------------------------------- + // 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_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. + 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() + { + 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())); + } + + // --------------------------------------------------------------------- + // SubmitGetCertificateAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task SubmitGetCertificateAsync_Success_ReturnsParsedResponse() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{\"status\":\"ACTIVE\",\"certificate\":\"abc\"}"), out var handler); + var response = await client.SubmitGetCertificateAsync("uuid-1"); + Assert.Equal("ACTIVE", response.Status); + Assert.Contains("/dbs/api/v2/tls/certificate/uuid-1", handler.LastRequest!.RequestUri!.ToString()); + } + + [Fact] + public async Task SubmitGetCertificateAsync_NullId_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{}"), out _); + await Assert.ThrowsAsync(() => client.SubmitGetCertificateAsync(null!)); + } + + [Fact] + public async Task SubmitGetCertificateAsync_ErrorStatus_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.NotFound, "not found"), out _); + await Assert.ThrowsAsync(() => client.SubmitGetCertificateAsync("uuid-1")); + } + + // --------------------------------------------------------------------- + // 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_NullCustomFieldsProperty_ReturnsEmptyList() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{}"), out _); + var fields = await client.SubmitGetCustomFields(); + Assert.Empty(fields); + } + + [Fact] + public async Task SubmitGetCustomFields_NullResponseBody_ReturnsEmptyList() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "null"), out _); + var fields = await client.SubmitGetCustomFields(); + Assert.Empty(fields); + } + + [Fact] + public async Task SubmitGetCustomFields_ErrorStatus_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.InternalServerError, "boom"), out _); + await Assert.ThrowsAsync(() => client.SubmitGetCustomFields()); + } + + // --------------------------------------------------------------------- + // SubmitRevokeCertificateAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task SubmitRevokeCertificateAsync_Success_ReturnsParsedResponse() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, + "{\"revokeSuccess\":{\"status\":\"REVOKED\"}}"), out var handler); + + var response = await client.SubmitRevokeCertificateAsync("uuid-1"); + + Assert.Equal("REVOKED", response.RevokeSuccess.Status); + Assert.Contains("/dbs/api/v2/tls/revoke/uuid-1", handler.LastRequest!.RequestUri!.ToString()); + } + + [Fact] + public async Task SubmitRevokeCertificateAsync_NullUuid_Throws() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, "{}"), out _); + await Assert.ThrowsAsync(() => client.SubmitRevokeCertificateAsync(null!)); + } + + [Fact] + public async Task SubmitRevokeCertificateAsync_BadRequest_ReturnsRegistrationError() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.BadRequest, "{\"description\":\"already revoked\"}"), out _); + var response = await client.SubmitRevokeCertificateAsync("uuid-1"); + 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("uuid-1")); + } + + // --------------------------------------------------------------------- + // SubmitCertificateListRequestAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task SubmitCertificateListRequestAsync_NoDateFilter_ReturnsResults() + { + var client = MakeClient(_ => JsonResponse(HttpStatusCode.OK, + "{\"meta\":{\"numResults\":1},\"results\":[{\"uuid\":\"u1\"}]}"), out var handler); + + var response = await client.SubmitCertificateListRequestAsync(); + + Assert.Single(response.Results); + Assert.DoesNotContain("effectiveDate", handler.LastRequest!.RequestUri!.ToString()); + } + + [Fact] + public async Task SubmitCertificateListRequestAsync_WithDateFilter_AppendsFilterToQuery() + { + 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_NullBody_ReturnsEmptyResponse() + { + var client = MakeClient(_ => new HttpResponseMessage(HttpStatusCode.OK), out _); + var response = await client.SubmitCertificateListRequestAsync(); + Assert.NotNull(response); + } + + [Fact] + public async Task SubmitCertificateListRequestAsync_ErrorStatus_DoesNotThrow_ReturnsParsedBody() + { + // Unlike the other Submit* methods, this one only logs on non-success and still parses + // whatever body came back rather than throwing. + var client = MakeClient(_ => JsonResponse(HttpStatusCode.InternalServerError, "{\"results\":[]}"), out _); + var response = await client.SubmitCertificateListRequestAsync(); + Assert.NotNull(response.Results); + Assert.Empty(response.Results); + } +} diff --git a/cscglobal-caplugin.Tests/FlowLoggerTests.cs b/cscglobal-caplugin.Tests/FlowLoggerTests.cs new file mode 100644 index 0000000..2dea519 --- /dev/null +++ b/cscglobal-caplugin.Tests/FlowLoggerTests.cs @@ -0,0 +1,175 @@ +// 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 Step_NoDetail_ChainableAndDoesNotThrow() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + var result = flow.Step("StepOne"); + Assert.Same(flow, result); + } + + [Fact] + public void Step_WithDetail_DoesNotThrow() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Step("StepOne", "some detail"); + } + + [Fact] + public void Step_Action_Success_RunsAction() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + var ran = false; + flow.Step("Action", () => ran = true); + Assert.True(ran); + } + + [Fact] + public void Step_Action_Throws_RecordsFailureAndRethrows() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + Assert.Throws(() => + flow.Step("Action", () => throw new InvalidOperationException("boom"))); + } + + [Fact] + public void Step_ActionWithDetail_Success() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Step("Action", () => { }, "detail"); + } + + [Fact] + public async Task StepAsync_Success_RunsAction() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + var ran = false; + await flow.StepAsync("AsyncStep", () => + { + ran = true; + return Task.CompletedTask; + }); + Assert.True(ran); + } + + [Fact] + public async Task StepAsync_Throws_RecordsFailureAndRethrows() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + await Assert.ThrowsAsync(() => + flow.StepAsync("AsyncStep", () => throw new InvalidOperationException("boom"))); + } + + [Fact] + public async Task StepAsync_WithDetail_Success() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + await flow.StepAsync("AsyncStep", () => Task.CompletedTask, "detail"); + } + + [Fact] + public void Fail_RecordsFailure_DoesNotThrow() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Fail("StepOne"); + flow.Fail("StepTwo", "reason"); + } + + [Fact] + public void Skip_DoesNotThrow() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Skip("StepOne"); + flow.Skip("StepTwo", "not applicable"); + } + + [Fact] + public void Branch_EndBranch_ChildStepsNestUnderBranch() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Branch("Inner"); + flow.Step("NestedStep"); + flow.EndBranch(); + flow.Step("TopLevelStep"); + } + + [Fact] + public void EndBranch_WithoutBranch_DoesNotThrow() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.EndBranch(); + } + + [Fact] + public void Dispose_NoSteps_DoesNotThrow() + { + var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Dispose(); + } + + [Fact] + public void Dispose_AllStepsSuccess_DoesNotThrow() + { + var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Step("Ok1"); + flow.Step("Ok2"); + flow.Dispose(); + } + + [Fact] + public void Dispose_LastStepFailed_DoesNotThrow() + { + var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Step("Ok1"); + flow.Fail("Failed1"); + flow.Dispose(); + } + + [Fact] + public void Dispose_MidStepFailedButLastSucceeded_PartialFailure_DoesNotThrow() + { + var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Fail("Failed1"); + flow.Step("Ok1"); + flow.Dispose(); + } + + [Fact] + public void Dispose_WithBranchChildren_RendersChildrenWithoutThrowing() + { + var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Branch("Branch1"); + flow.Step("Child1"); + flow.Fail("Child2"); + flow.Skip("Child3"); + flow.EndBranch(); + flow.Step("AfterBranch"); + flow.Dispose(); + } + + [Fact] + public void Dispose_CalledTwice_IsIdempotent() + { + var flow = new FlowLogger(NewLoggerMock().Object, "Flow"); + flow.Step("Ok"); + flow.Dispose(); + flow.Dispose(); // should not throw or double-log + } +} diff --git a/cscglobal-caplugin.Tests/RequestManagerTests.cs b/cscglobal-caplugin.Tests/RequestManagerTests.cs new file mode 100644 index 0000000..4bed6c6 --- /dev/null +++ b/cscglobal-caplugin.Tests/RequestManagerTests.cs @@ -0,0 +1,741 @@ +// 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 Newtonsoft.Json; +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(); + + // --------------------------------------------------------------------- + // Certificate type routing - canonical (1.2.0+) names, all 10 products. + // --------------------------------------------------------------------- + + [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 Unrecognized Product", "-1", false, false)] + public void GetRegistrationRequest_CanonicalProductNames_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); + } + + // --------------------------------------------------------------------- + // Certificate type routing - pre-1.2.0 legacy names must resolve identically to their + // canonical replacement, so existing Certificate Templates in Command keep working. + // --------------------------------------------------------------------- + + [Theory] + [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 Trusted Secure Domain Validated Wildcard SSL", "5", false, false)] + [InlineData("CSC Trusted Secure Domain Validated UC Certificate", "6", true, false)] + public void GetRegistrationRequest_LegacyProductNames_ResolveToSameCertificateType( + string legacyProductId, string expectedType, bool expectSans, bool expectEv) + { + var sans = new Dictionary { ["dnsname"] = new[] { "www.example.com" } }; + var productInfo = ProductInfo(legacyProductId, 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); + } + + [Fact] + public void GetRegistrationRequest_LegacyAndCanonicalName_ProduceIdenticalCertificateType() + { + var legacy = ProductInfo("CSC TrustedSecure UC Certificate"); + var canonical = ProductInfo("CSC TrustedSecure OV, Multiple Names"); + + var legacyRequest = Manager.GetRegistrationRequest(legacy, SampleCsr, new Dictionary(), new List()); + var canonicalRequest = Manager.GetRegistrationRequest(canonical, SampleCsr, new Dictionary(), new List()); + + Assert.Equal(canonicalRequest.CertificateType, legacyRequest.CertificateType); + } + + // --------------------------------------------------------------------- + // IsKnownProductId - backs ValidateProductInfo. Must recognize both canonical and legacy + // names from the same source of truth GetCertificateType uses, so the two can't drift. + // --------------------------------------------------------------------- + + [Theory] + [InlineData("CSC TrustedSecure DV")] + [InlineData("CSC TrustedSecure DV Wildcard, Multiple Names")] + [InlineData("CSC TrustedSecure Domain Validated SSL")] + [InlineData("csc trustedsecure dv")] + public void IsKnownProductId_RecognizedName_ReturnsTrue(string productId) + { + Assert.True(Manager.IsKnownProductId(productId)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("Not A Real Product")] + public void IsKnownProductId_UnrecognizedOrEmpty_ReturnsFalse(string? productId) + { + Assert.False(Manager.IsKnownProductId(productId!)); + } + + // --------------------------------------------------------------------- + // GetSubjectAlternativeNames (exercised via GetRegistrationRequest) - DCV email resolution. + // --------------------------------------------------------------------- + + [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", + [EnrollmentConfigConstants.AdditionalSansCommaSeparatedDcvEmails] = "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_MultiNameEmailMethodNoAddtlSanMatch_FallsBackToCommonNameDcvEmail() + { + // CSC Global rejects the request if a SAN entry has no domainControlValidation, so a SAN + // domain unrelated to any configured "Addtl Sans" email must fall back to the primary + // CN's DCV email rather than being left null. + var sans = new Dictionary { ["dnsname"] = new[] { "www.unrelated-domain.io" } }; + var productInfo = ProductInfo("CSC TrustedSecure OV, Multiple Names", new Dictionary + { + ["Domain Control Validation Method"] = "EMAIL", + [EnrollmentConfigConstants.CnDcvEmail] = "cn@example.com" + }); + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, sans, new List()); + + Assert.Single(request.SubjectAlternativeNames); + var san = request.SubjectAlternativeNames[0]; + Assert.NotNull(san.DomainControlValidation); + Assert.Equal("cn@example.com", san.DomainControlValidation.EmailAddress); + } + + [Fact] + public void GetRegistrationRequest_MultiNameCnameMethod_MirrorsCommonNameDcv() + { + 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); + Assert.NotNull(request.SubjectAlternativeNames[0].DomainControlValidation); + Assert.Equal("CNAME", request.SubjectAlternativeNames[0].DomainControlValidation.MethodType); + } + + [Fact] + public void GetRegistrationRequest_WildcardMultiNameProduct_AcceptsUnrelatedDomainSans() + { + // Types 8/9 are wildcard + multi-name (the underlying Sectigo Multi-Domain Wildcard + // product) - additional SANs are not restricted to the CN's own base domain. + var sans = new Dictionary + { + ["dnsname"] = new[] { "*.example2.com", "*.example3.com" } + }; + var productInfo = ProductInfo("CSC TrustedSecure DV Wildcard, Multiple Names", new Dictionary + { + ["Domain Control Validation Method"] = "CNAME" + }); + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, sans, new List()); + + Assert.Equal(2, request.SubjectAlternativeNames.Count); + Assert.Equal("*.example2.com", request.SubjectAlternativeNames[0].DomainName); + Assert.Equal("*.example3.com", request.SubjectAlternativeNames[1].DomainName); + } + + // --------------------------------------------------------------------- + // Price.Total nullability - CSC Global returns "price.total": null for orders that cannot + // be processed. Total must be nullable or Newtonsoft throws mid-deserialization, before the + // caller ever sees the RegistrationError/order status CSC was actually trying to report. + // --------------------------------------------------------------------- + + [Fact] + public void RegistrationResponse_NullPriceTotal_DeserializesWithoutThrowing() + { + const string json = "{\"result\":{\"commonName\":\"order-1\",\"price\":{\"currency\":\"\",\"total\":null}}}"; + + var response = JsonConvert.DeserializeObject(json); + + Assert.NotNull(response?.Result?.Price); + Assert.Null(response!.Result!.Price!.Total); + } + + // --------------------------------------------------------------------- + // GetRenewResponse / GetReIssueResult - CSC never returns an issued certificate on these + // responses (only order/DCV status), so success must report EXTERNALVALIDATION, not + // GENERATED, or the gateway host will try to parse a certificate that doesn't exist. + // --------------------------------------------------------------------- + + [Fact] + public void GetRenewResponse_Success_ReturnsExternalValidation() + { + var response = new RenewalResponse + { + Result = new Result { CommonName = "renewed.example.com", Status = new Status { Uuid = "uuid-1" } } + }; + + var result = Manager.GetRenewResponse(response); + + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Equal("uuid-1", result.CARequestID); + } + + [Fact] + public void GetReIssueResult_Success_ReturnsExternalValidation() + { + var response = new ReissueResponse + { + Result = new Result { CommonName = "reissued.example.com", Status = new Status { Uuid = "uuid-2" } } + }; + + var result = Manager.GetReIssueResult(response); + + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Equal("uuid-2", result.CARequestID); + } + + [Fact] + public void GetReIssueResult_NullResponse_ReturnsFailed() + { + var result = Manager.GetReIssueResult(null); + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, result.Status); + } + + [Fact] + public void GetReIssueResult_RegistrationError_ReturnsFailedWithDescription() + { + var response = new ReissueResponse { RegistrationError = new RegistrationError { Description = "rejected" } }; + var result = Manager.GetReIssueResult(response); + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.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)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, result.Status); + } + + [Fact] + public void GetRenewResponse_NullResponse_ReturnsFailed() + { + var result = Manager.GetRenewResponse(null); + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, result.Status); + } + + [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)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, result.Status); + Assert.Equal("abc-123", result.CARequestID); + Assert.Equal("boom", result.StatusMessage); + } + + [Fact] + public void GetRenewResponse_NullResult_StillReturnsExternalValidation() + { + // Unlike GetEnrollmentResult/GetReIssueResult, GetRenewResponse has no explicit + // Result==null guard - it just null-conditionals through to "(unknown)"/null. + var response = new RenewalResponse { Result = null }; + var result = Manager.GetRenewResponse(response); + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.EXTERNALVALIDATION, result.Status); + Assert.Contains("(unknown)", result.StatusMessage); + } + + [Fact] + public void GetEnrollmentResult_NullResponse_ReturnsFailed() + { + var result = Manager.GetEnrollmentResult(null); + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, result.Status); + } + + [Fact] + public void GetEnrollmentResult_RegistrationError_ReturnsFailed() + { + var response = new RegistrationResponse { RegistrationError = new RegistrationError { Description = "denied" } }; + var result = Manager.GetEnrollmentResult(response); + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, result.Status); + Assert.Equal("denied", result.StatusMessage); + } + + [Fact] + public void GetEnrollmentResult_NullResult_ReturnsFailed() + { + var response = new RegistrationResponse { Result = null }; + var result = Manager.GetEnrollmentResult(response); + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.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)Keyfactor.PKI.Enums.EJBCA.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 keys should not throw and should not be added twice. + new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "token" } }, + new DcvDetail { Email = "admin@example.com" }, + // Entry with neither CName nor Email contributes nothing. Null entries are skipped. + new DcvDetail(), + null! + } + } + }; + + var result = Manager.GetEnrollmentResult(response); + + Assert.NotNull(result.EnrollmentContext); + Assert.Equal(2, result.EnrollmentContext.Count); + Assert.Equal("token", result.EnrollmentContext["_dnsauth.example.com"]); + Assert.Equal("admin@example.com", result.EnrollmentContext["admin@example.com"]); + } + + // --------------------------------------------------------------------- + // GetRevokeResult + // --------------------------------------------------------------------- + + [Fact] + public void GetRevokeResult_NullResponse_ReturnsFailed() + { + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, Manager.GetRevokeResult(null)); + } + + [Fact] + public void GetRevokeResult_RegistrationError_ReturnsFailed() + { + var response = new RevokeResponse { RegistrationError = new RegistrationError { Description = "denied" } }; + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED, Manager.GetRevokeResult(response)); + } + + [Fact] + public void GetRevokeResult_Success_ReturnsRevoked() + { + var response = new RevokeResponse { RevokeSuccess = new RevokeSuccessResponse { Status = "REVOKED" } }; + Assert.Equal((int)Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.REVOKED, Manager.GetRevokeResult(response)); + } + + // --------------------------------------------------------------------- + // MapReturnStatus / MapCertificateTypeToProductId + // --------------------------------------------------------------------- + + [Theory] + [InlineData("ACTIVE", Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.GENERATED)] + [InlineData("Initial", Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.INITIALIZED)] + [InlineData("Pending", Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.INPROCESS)] + [InlineData("REVOKED", Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.REVOKED)] + [InlineData("SomethingUnexpected", Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED)] + [InlineData(null, Keyfactor.PKI.Enums.EJBCA.EndEntityStatus.FAILED)] + public void MapReturnStatus_MapsExpectedStatus(string? cscStatus, Keyfactor.PKI.Enums.EJBCA.EndEntityStatus expected) + { + Assert.Equal((int)expected, Manager.MapReturnStatus(cscStatus!)); + } + + [Theory] + [InlineData("4", "CSC TrustedSecure Domain Validated SSL")] + [InlineData("CSC TrustedSecure Domain Validated SSL", "CSC TrustedSecure Domain Validated SSL")] + [InlineData("CSC Trusted Secure Domain Validated SSL", "CSC TrustedSecure Domain Validated SSL")] + [InlineData("9", "CSC TrustedSecure DV Wildcard, Multiple Names")] + public void MapCertificateTypeToProductId_KnownValue_MapsToProductId(string cscType, string expectedProductId) + { + Assert.Equal(expectedProductId, Manager.MapCertificateTypeToProductId(cscType)); + } + + [Fact] + public void MapCertificateTypeToProductId_UnknownValue_PassesThrough() + { + Assert.Equal("SomeUnknownType", Manager.MapCertificateTypeToProductId("SomeUnknownType")); + } + + [Fact] + public void MapCertificateTypeToProductId_Null_ReturnsFallback() + { + Assert.Equal("CscGlobal", Manager.MapCertificateTypeToProductId(null!)); + } + + // --------------------------------------------------------------------- + // GetNotifications + // --------------------------------------------------------------------- + + [Fact] + public void GetNotifications_NoEmailsConfigured_ReturnsEmptyList() + { + var notifications = Manager.GetNotifications(ProductInfo("CSC TrustedSecure DV")); + Assert.True(notifications.Enabled); + Assert.Empty(notifications.AdditionalNotificationEmails); + } + + [Fact] + public void GetNotifications_EmailsConfigured_SplitsOnComma() + { + var productInfo = ProductInfo("CSC TrustedSecure DV", + 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); + } + + // --------------------------------------------------------------------- + // GetDomainControlValidation + // --------------------------------------------------------------------- + + [Fact] + public void GetDomainControlValidation_EmptyEmailArray_ReturnsNull() + { + Assert.Null(Manager.GetDomainControlValidation("EMAIL", Array.Empty(), "example.com")); + } + + [Fact] + public void GetDomainControlValidation_NullEmailArray_ReturnsNull() + { + Assert.Null(Manager.GetDomainControlValidation("EMAIL", null!, "example.com")); + } + + [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() + { + Assert.Null(Manager.GetDomainControlValidation("EMAIL", new[] { "admin@other.com" }, "www.example.com")); + } + + [Fact] + public void GetDomainControlValidation_SingleEmailOverload_ReturnsValidationVerbatim() + { + var result = Manager.GetDomainControlValidation("CNAME", "admin@example.com"); + Assert.Equal("CNAME", result.MethodType); + Assert.Equal("admin@example.com", result.EmailAddress); + } + + // --------------------------------------------------------------------- + // GetCustomFields (exercised via GetRegistrationRequest) + // --------------------------------------------------------------------- + + [Fact] + public void GetRegistrationRequest_MandatoryCustomFieldMissing_Throws() + { + var productInfo = ProductInfo("CSC TrustedSecure DV"); + var customFields = new List { new GetCustomField { Label = "Required Field", Mandatory = true } }; + + Assert.Throws(() => + Manager.GetRegistrationRequest(productInfo, SampleCsr, new Dictionary(), customFields)); + } + + [Fact] + public void GetRegistrationRequest_OptionalCustomFieldMissing_DoesNotThrow() + { + var productInfo = ProductInfo("CSC TrustedSecure DV"); + 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 DV", 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); + } + + [Fact] + public void GetRegistrationRequest_NullCustomFieldsList_ReturnsEmptyCustomFields() + { + var request = Manager.GetRegistrationRequest(ProductInfo("CSC TrustedSecure DV"), SampleCsr, + new Dictionary(), null!); + Assert.Empty(request.CustomFields); + } + + [Fact] + public void GetRegistrationRequest_CustomFieldsWithNullEntryAndBlankLabel_SkipsBoth() + { + var productInfo = ProductInfo("CSC TrustedSecure DV", new Dictionary { ["Custom Field"] = "value" }); + var customFields = new List + { + null!, + new GetCustomField { Label = "", Mandatory = false }, + 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); + } + + // --------------------------------------------------------------------- + // GetRenewalRequest / GetReissueRequest - parity with GetRegistrationRequest + // --------------------------------------------------------------------- + + [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_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); + } + + [Fact] + public void GetRegistrationRequest_AllOptionalParametersSupplied_MapsEachField() + { + var productInfo = ProductInfo("CSC TrustedSecure DV", new Dictionary + { + ["Term"] = "12", + ["Applicant First Name"] = "Jane", + ["Applicant Last Name"] = "Doe", + ["Applicant Email Address"] = "jane.doe@example.com", + ["Applicant Phone"] = "555-1234", + ["Organization Contact"] = "contact-1", + ["Business Unit"] = "IT" + }); + + var request = Manager.GetRegistrationRequest(productInfo, SampleCsr, new Dictionary(), new List()); + + Assert.Equal("12", request.Term); + Assert.Equal("Jane", request.ApplicantFirstName); + Assert.Equal("Doe", request.ApplicantLastName); + Assert.Equal("jane.doe@example.com", request.ApplicantEmailAddress); + Assert.Equal("555-1234", request.ApplicantPhoneNumber); + Assert.Equal("contact-1", request.OrganizationContact); + Assert.Equal("IT", request.BusinessUnit); + } + + [Fact] + public void GetRenewalRequest_AllOptionalParametersSupplied_MapsEachField() + { + var productInfo = ProductInfo("CSC TrustedSecure DV", new Dictionary + { + ["Term"] = "24", + ["Applicant First Name"] = "John", + ["Applicant Last Name"] = "Smith", + ["Applicant Email Address"] = "john.smith@example.com", + ["Applicant Phone"] = "555-5678", + ["Organization Contact"] = "contact-2", + ["Business Unit"] = "Legal" + }); + + var request = Manager.GetRenewalRequest(productInfo, "uuid-renewal", SampleCsr, new Dictionary(), new List()); + + Assert.Equal("24", request.Term); + Assert.Equal("John", request.ApplicantFirstName); + Assert.Equal("Smith", request.ApplicantLastName); + Assert.Equal("john.smith@example.com", request.ApplicantEmailAddress); + Assert.Equal("555-5678", request.ApplicantPhoneNumber); + Assert.Equal("contact-2", request.OrganizationContact); + Assert.Equal("Legal", request.BusinessUnit); + } + + [Fact] + public void GetReissueRequest_AllOptionalParametersSupplied_MapsEachField() + { + var productInfo = ProductInfo("CSC TrustedSecure DV", new Dictionary + { + ["Term"] = "36", + ["Applicant First Name"] = "Alex", + ["Applicant Last Name"] = "Nguyen", + ["Applicant Email Address"] = "alex.nguyen@example.com", + ["Applicant Phone"] = "555-9012", + ["Organization Contact"] = "contact-3", + ["Business Unit"] = "Finance" + }); + + var request = Manager.GetReissueRequest(productInfo, "uuid-reissue", SampleCsr, new Dictionary(), new List()); + + Assert.Equal("36", request.Term); + Assert.Equal("Alex", request.ApplicantFirstName); + Assert.Equal("Nguyen", request.ApplicantLastName); + Assert.Equal("alex.nguyen@example.com", request.ApplicantEmailAddress); + Assert.Equal("555-9012", request.ApplicantPhoneNumber); + Assert.Equal("contact-3", request.OrganizationContact); + Assert.Equal("Finance", request.BusinessUnit); + } + + [Fact] + public void GetRegistrationRequest_NullProductParameters_Throws() + { + var productInfo = new EnrollmentProductInfo { ProductID = "CSC TrustedSecure DV", ProductParameters = null! }; + Assert.Throws(() => + Manager.GetRegistrationRequest(productInfo, SampleCsr, new Dictionary(), new List())); + } + + [Fact] + public void GetRegistrationRequest_NullProductInfo_Throws() + { + Assert.Throws(() => + Manager.GetRegistrationRequest(null!, SampleCsr, new Dictionary(), new List())); + } + + [Fact] + public void GetRegistrationRequest_NullCsr_Throws() + { + Assert.Throws(() => + Manager.GetRegistrationRequest(ProductInfo("CSC TrustedSecure DV"), null!, new Dictionary(), new List())); + } + + [Fact] + public void GetRegistrationRequest_CsrLongerThan64Chars_WrapsWithPemify() + { + var longCsr = new string('X', 130); + var request = Manager.GetRegistrationRequest(ProductInfo("CSC TrustedSecure DV"), longCsr, new Dictionary(), new List()); + var decoded = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(request.Csr)); + Assert.Contains("\n", decoded); + } + + [Fact] + public void GetRenewalRequest_NullUuid_Throws() + { + Assert.Throws(() => + Manager.GetRenewalRequest(ProductInfo("CSC TrustedSecure DV"), null!, SampleCsr, new Dictionary(), new List())); + } + + [Fact] + public void EvCertificateDetails_AllPropertiesSettable() + { + var details = new EvCertificateDetails + { + Country = "US", + City = "Independence", + State = "OH", + DateOfIncorporation = "2020-01-01", + DoingBusinessAs = "Keyfactor", + BusinessCategory = "Private Organization" + }; + + Assert.Equal("US", details.Country); + Assert.Equal("Independence", details.City); + Assert.Equal("OH", details.State); + Assert.Equal("2020-01-01", details.DateOfIncorporation); + Assert.Equal("Keyfactor", details.DoingBusinessAs); + Assert.Equal("Private Organization", details.BusinessCategory); + } + + [Fact] + public void GetRegistrationRequest_EncodesCsrAsBase64() + { + var request = Manager.GetRegistrationRequest(ProductInfo("CSC TrustedSecure DV"), "hello", new Dictionary(), new List()); + var decoded = Convert.FromBase64String(request.Csr); + Assert.Contains("hello", System.Text.Encoding.UTF8.GetString(decoded)); + } +} diff --git a/cscglobal-caplugin.sln b/cscglobal-caplugin.sln index 220a2cd..9594f70 100644 --- a/cscglobal-caplugin.sln +++ b/cscglobal-caplugin.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 -VisualStudioVersion = 18.0.11217.181 d18.0 +VisualStudioVersion = 18.0.11217.181 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSCGlobalCAPlugin", "cscglobal-caplugin\CSCGlobalCAPlugin.csproj", "{01DDFD6F-275D-46E7-B522-E0C965D1BF9C}" EndProject @@ -12,23 +12,68 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution integration-manifest.json = integration-manifest.json EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "cscglobal-caplugin.Tests", "cscglobal-caplugin.Tests", "{BE4C3E19-CFA0-7860-C455-A18FD2267928}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSCGlobalCAPlugin.Tests", "cscglobal-caplugin.Tests\CSCGlobalCAPlugin.Tests.csproj", "{1FE36805-D1BD-4552-8B19-17358C5F19E3}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "cscglobal-caplugin", "cscglobal-caplugin", "{9EDFC34F-9707-CEB2-9158-E7368508D81D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 DebugAndPush|Any CPU = DebugAndPush|Any CPU + DebugAndPush|x64 = DebugAndPush|x64 + DebugAndPush|x86 = DebugAndPush|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Debug|x64.ActiveCfg = Debug|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Debug|x64.Build.0 = Debug|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Debug|x86.ActiveCfg = Debug|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Debug|x86.Build.0 = Debug|Any CPU {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|Any CPU.ActiveCfg = DebugAndPush|Any CPU {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|Any CPU.Build.0 = DebugAndPush|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|x64.ActiveCfg = DebugAndPush|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|x64.Build.0 = DebugAndPush|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|x86.ActiveCfg = DebugAndPush|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.DebugAndPush|x86.Build.0 = DebugAndPush|Any CPU {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Release|Any CPU.ActiveCfg = Release|Any CPU {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Release|Any CPU.Build.0 = Release|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Release|x64.ActiveCfg = Release|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Release|x64.Build.0 = Release|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Release|x86.ActiveCfg = Release|Any CPU + {01DDFD6F-275D-46E7-B522-E0C965D1BF9C}.Release|x86.Build.0 = Release|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Debug|x64.ActiveCfg = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Debug|x64.Build.0 = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Debug|x86.ActiveCfg = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Debug|x86.Build.0 = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.DebugAndPush|Any CPU.ActiveCfg = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.DebugAndPush|Any CPU.Build.0 = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.DebugAndPush|x64.ActiveCfg = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.DebugAndPush|x64.Build.0 = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.DebugAndPush|x86.ActiveCfg = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.DebugAndPush|x86.Build.0 = Debug|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Release|Any CPU.Build.0 = Release|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Release|x64.ActiveCfg = Release|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Release|x64.Build.0 = Release|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Release|x86.ActiveCfg = Release|Any CPU + {1FE36805-D1BD-4552-8B19-17358C5F19E3}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {1FE36805-D1BD-4552-8B19-17358C5F19E3} = {BE4C3E19-CFA0-7860-C455-A18FD2267928} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {8861D2F4-FEE8-4D70-8172-DF321704F12D} EndGlobalSection diff --git a/cscglobal-caplugin/AssemblyInfo.cs b/cscglobal-caplugin/AssemblyInfo.cs new file mode 100644 index 0000000..bd280c9 --- /dev/null +++ b/cscglobal-caplugin/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// 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.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CSCGlobalCAPlugin.Tests")] diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index e1af2f0..b5ae44f 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -23,80 +23,302 @@ namespace Keyfactor.Extensions.CAPlugin.CSCGlobal; public class CSCGlobalCAPlugin : IAnyCAPlugin { + ///

+ /// Validation type string passed to . + /// CSC's Domain Control Validation publishes a CNAME record, so we resolve a DNS provider + /// that advertises the "cname" validation type (e.g. GoDaddy's GoDaddyCnameDomainValidator). + /// This is distinct from ACME's "dns-01" challenge, which publishes TXT records — a single + /// DNS provider DLL can ship separate validator classes for each type. + /// + private const string DNS_VALIDATION_TYPE = "cname"; + + /// Delay between CSC status polls while waiting for DCV to complete. + private static readonly TimeSpan DcvPollInterval = TimeSpan.FromSeconds(10); + private readonly RequestManager _requestManager; private readonly ILogger Logger; + private readonly IDomainValidatorFactory? _validatorFactory; private ICertificateDataReader _certificateDataReader; + /// + /// Parameterless constructor retained for compatibility with older gateway hosts that don't + /// perform DI. When constructed this way the plugin runs without DNS auto-publishing. + /// public CSCGlobalCAPlugin() { Logger = LogHandler.GetClassLogger(); _requestManager = new RequestManager(); + _validatorFactory = null; + } + + /// + /// DI constructor used by AnyCA Gateway 3.3+ which injects the framework's domain validator + /// factory. When non-null, CNAME DCV records returned by CSC are auto-published via the + /// framework's registered DNS providers (resolved per-domain). + /// + public CSCGlobalCAPlugin(IDomainValidatorFactory validatorFactory) + { + Logger = LogHandler.GetClassLogger(); + _requestManager = new RequestManager(); + _validatorFactory = validatorFactory; } - private ICscGlobalClient CscGlobalClient { get; set; } + // internal (not private) purely so the test project can inject a mock via + // InternalsVisibleTo, instead of hitting the real CSC Global API in unit tests. + internal ICscGlobalClient CscGlobalClient { get; set; } - public bool EnableTemplateSync { get; set; } + /// + /// Whether the CA is enabled. When false, the plugin returns early from Ping, + /// ValidateCAConnectionInfo, ValidateProductInfo, Synchronize, Enroll, and Revoke without + /// calling CSC. Primarily used to allow creation of the CA record prior to configuration + /// information being available (standard field across Keyfactor CA plugins). Defaults to true + /// so existing deployments that don't set this key continue to function. + /// + public bool Enabled { get; set; } = true; public int SyncFilterDays { get; set; } + public int RenewalWindowDays { get; set; } + + /// + /// Maximum seconds to synchronously poll CSC for certificate issuance after submitting an + /// order (and publishing CNAME DCV). 0 disables polling — the enrollment returns "pending" + /// immediately and the cert is picked up on the next sync. When > 0, fast-validating + /// orders can return the issued cert directly in the enrollment response. + /// + public int DcvPollTimeoutSeconds { get; set; } + //done public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDataReader certificateDataReader) { + using var flow = new FlowLogger(Logger, "Initialize"); Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace("Initialize called. configProvider is {Null}, certificateDataReader is {Null2}", + configProvider == null ? "NULL" : "present", + certificateDataReader == null ? "NULL" : "present"); + + flow.Step("ValidateInputs", () => + { + if (configProvider == null) + throw new ArgumentNullException(nameof(configProvider), "configProvider cannot be null in Initialize"); + if (certificateDataReader == null) + throw new ArgumentNullException(nameof(certificateDataReader), "certificateDataReader cannot be null in Initialize"); + }); + _certificateDataReader = certificateDataReader; - CscGlobalClient = new CscGlobalClient(configProvider); - var templateSync = configProvider.CAConnectionData["TemplateSync"].ToString(); - if (templateSync.ToUpper() == "ON") EnableTemplateSync = true; - if (configProvider.CAConnectionData.ContainsKey(Constants.SyncFilterDays)) + flow.Step("ValidateConnectionData", () => { - var syncFilterDaysStr = configProvider.CAConnectionData[Constants.SyncFilterDays]?.ToString(); - if (int.TryParse(syncFilterDaysStr, out var syncFilterDays)) + if (configProvider.CAConnectionData == null) { - SyncFilterDays = syncFilterDays; - Logger.LogDebug($"SyncFilterDays configured to {SyncFilterDays} days"); + Logger.LogError("CAConnectionData is null. Cannot read configuration."); + throw new InvalidOperationException("CAConnectionData is null on configProvider."); } + Logger.LogTrace("CAConnectionData keys: {Keys}", string.Join(", ", configProvider.CAConnectionData.Keys)); + }); + + flow.Step("ReadEnabled", () => + { + Enabled = true; // default + if (configProvider.CAConnectionData.TryGetValue(Constants.Enabled, out var enabledObj)) + { + Logger.LogTrace("Enabled raw value: '{Value}'", enabledObj?.ToString() ?? "(null)"); + if (bool.TryParse(enabledObj?.ToString(), out var parsed)) + Enabled = parsed; + else + Logger.LogWarning("Enabled value '{Value}' could not be parsed as bool, defaulting to true.", enabledObj); + } + else + { + Logger.LogTrace("Enabled key not found in CAConnectionData, defaulting to true."); + } + Logger.LogInformation("CA is {State}.", Enabled ? "Enabled" : "Disabled"); + }, $"Enabled={Enabled}"); + + // Construct the CSC client only when enabled. When disabled we allow Initialize to complete + // without valid API credentials — this is the whole point of the Enabled toggle (so ops can + // create the CA record before credentials are available). + if (Enabled) + { + flow.Step("CreateCscGlobalClient", () => + { + Logger.LogTrace("Creating CscGlobalClient from configProvider..."); + CscGlobalClient = new CscGlobalClient(configProvider); + Logger.LogTrace("CscGlobalClient created successfully."); + }); } + else + { + flow.Skip("CreateCscGlobalClient", "CA is Disabled"); + } + + flow.Step("ReadSyncFilterDays", () => + { + if (configProvider.CAConnectionData.ContainsKey(Constants.SyncFilterDays)) + { + var syncFilterDaysStr = configProvider.CAConnectionData[Constants.SyncFilterDays]?.ToString(); + Logger.LogTrace("SyncFilterDays raw value: '{Value}'", syncFilterDaysStr ?? "(null)"); + if (int.TryParse(syncFilterDaysStr, out var syncFilterDays)) + { + SyncFilterDays = syncFilterDays; + Logger.LogDebug("SyncFilterDays configured to {Days} days", SyncFilterDays); + } + else + { + Logger.LogWarning("SyncFilterDays value '{Value}' could not be parsed as int, using default 0.", syncFilterDaysStr); + } + } + else + { + Logger.LogTrace("SyncFilterDays key not found in CAConnectionData, using default 0."); + } + }); + + flow.Step("ReadRenewalWindowDays", () => + { + RenewalWindowDays = 30; // default + if (configProvider.CAConnectionData.TryGetValue(Constants.RenewalWindowDays, out var renewalWindowObj)) + { + Logger.LogTrace("RenewalWindowDays raw value: '{Value}'", renewalWindowObj?.ToString() ?? "(null)"); + if (int.TryParse(renewalWindowObj?.ToString(), out var renewalWindowDays) && renewalWindowDays > 0) + RenewalWindowDays = renewalWindowDays; + else + Logger.LogWarning("RenewalWindowDays value '{Value}' could not be parsed or was <= 0, using default 30.", renewalWindowObj); + } + else + { + Logger.LogTrace("RenewalWindowDays key not found in CAConnectionData, using default 30."); + } + Logger.LogDebug("RenewalWindowDays configured to {Days} days", RenewalWindowDays); + }, $"RenewalWindowDays={RenewalWindowDays}"); + + flow.Step("ReadDcvPollTimeoutSeconds", () => + { + DcvPollTimeoutSeconds = 0; // default: disabled + if (configProvider.CAConnectionData.TryGetValue(Constants.DcvPollTimeoutSeconds, out var pollObj)) + { + Logger.LogTrace("DcvPollTimeoutSeconds raw value: '{Value}'", pollObj?.ToString() ?? "(null)"); + if (int.TryParse(pollObj?.ToString(), out var pollSeconds) && pollSeconds >= 0) + DcvPollTimeoutSeconds = pollSeconds; + else + Logger.LogWarning("DcvPollTimeoutSeconds value '{Value}' could not be parsed or was < 0, using default 0 (disabled).", pollObj); + } + else + { + Logger.LogTrace("DcvPollTimeoutSeconds key not found in CAConnectionData, using default 0 (disabled)."); + } + Logger.LogDebug("DcvPollTimeoutSeconds configured to {Seconds}s ({State})", + DcvPollTimeoutSeconds, DcvPollTimeoutSeconds > 0 ? "enabled" : "disabled"); + }); + + flow.Step("CheckDnsValidatorFactory", () => + { + if (_validatorFactory == null) + Logger.LogInformation( + "No IDomainValidatorFactory was injected by the gateway host. CNAME DCV records will require manual publishing."); + else + Logger.LogInformation( + "IDomainValidatorFactory available from gateway host. CNAME DCV records will be auto-published per-domain via the framework's registered DNS providers (validation type '{Type}').", + DNS_VALIDATION_TYPE); + }); + Logger.MethodExit(LogLevel.Debug); } //done public async Task GetSingleRecord(string caRequestID) { + using var flow = new FlowLogger(Logger, $"GetSingleRecord({caRequestID ?? "null"})"); + Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace("GetSingleRecord called with caRequestID='{CaRequestId}'", caRequestID ?? "(null)"); + + flow.Step("ValidateInput", () => + { + if (string.IsNullOrEmpty(caRequestID)) + throw new ArgumentNullException(nameof(caRequestID), "caRequestID cannot be null or empty."); + if (caRequestID.Length < 36) + throw new ArgumentException($"caRequestID '{caRequestID}' is too short to extract a UUID (need at least 36 chars).", nameof(caRequestID)); + }); + try { - Logger.MethodEntry(LogLevel.Debug); - 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)) - .Result; + var keyfactorCaId = caRequestID.Substring(0, 36); + flow.Step("ExtractUUID", $"keyfactorCaId={keyfactorCaId}"); + + CertificateResponse certificateResponse = null; + await flow.StepAsync("FetchCertFromCSC", async () => + { + certificateResponse = await CscGlobalClient.SubmitGetCertificateAsync(keyfactorCaId); + }); + + if (certificateResponse == null) + { + flow.Fail("ParseResponse", "API returned null"); + Logger.LogWarning("GetSingleRecord: SubmitGetCertificateAsync returned null for keyfactorCaId='{KeyfactorCaId}'", keyfactorCaId); + return new AnyCAPluginCertificate + { + CARequestID = keyfactorCaId, + Certificate = string.Empty, + Status = _requestManager.MapReturnStatus(null) + }; + } - Logger.LogTrace($"Single Cert JSON: {JsonConvert.SerializeObject(certificateResponse)}"); + flow.Step("ParseResponse", $"Status={certificateResponse.Status ?? "(null)"}"); + Logger.LogTrace("Single Cert JSON: {Json}", JsonConvert.SerializeObject(certificateResponse)); - var fileContent = - Encoding.ASCII.GetString( - Convert.FromBase64String(certificateResponse?.Certificate ?? string.Empty)); + var rawCert = certificateResponse.Certificate ?? string.Empty; + string fileContent = string.Empty; + flow.Step("DecodeBase64", () => + { + try + { + fileContent = Encoding.ASCII.GetString(Convert.FromBase64String(rawCert)); + } + catch (FormatException fex) + { + Logger.LogError(fex, "GetSingleRecord: Failed to decode Base64 certificate content for keyfactorCaId='{KeyfactorCaId}'", keyfactorCaId); + fileContent = string.Empty; + } + }, $"length={rawCert.Length}"); - Logger.LogTrace($"File Content {fileContent}"); - var certData = fileContent?.Replace("\r\n", string.Empty); + var certData = fileContent.Replace("\r\n", string.Empty); var certString = string.Empty; if (!string.IsNullOrEmpty(certData)) - certString = GetEndEntityCertificate(certData); - Logger.LogTrace($"Cert String Content {certString}"); + { + flow.Step("ExtractLeafCert", () => + { + certString = GetEndEntityCertificate(certData); + }, $"inputLength={certData.Length}"); + } + else + { + flow.Skip("ExtractLeafCert", "certData empty after cleanup"); + } + + var mappedStatus = _requestManager.MapReturnStatus(certificateResponse.Status); + flow.Step("MapStatus", $"{certificateResponse.Status ?? "(null)"} -> {mappedStatus}"); Logger.MethodExit(LogLevel.Debug); return new AnyCAPluginCertificate { CARequestID = keyfactorCaId, - Certificate = certString, - Status = _requestManager.MapReturnStatus(certificateResponse?.Status) + Certificate = certString ?? string.Empty, + Status = mappedStatus }; } + catch (AggregateException ae) + { + var inner = ae.Flatten().InnerException; + flow.Fail("UNHANDLED", inner?.Message ?? ae.Message); + Logger.LogError(inner, "GetSingleRecord: AggregateException for caRequestID='{CaRequestId}': {Message}", caRequestID, inner?.Message ?? ae.Message); + throw new Exception($"Error Occurred getting single cert for '{caRequestID}': {inner?.Message ?? ae.Message}", inner ?? ae); + } catch (Exception e) { - throw new Exception($"Error Occurred getting single cert {e.Message}"); + flow.Fail("UNHANDLED", e.Message); + Logger.LogError(e, "GetSingleRecord: Exception for caRequestID='{CaRequestId}': {Message}", caRequestID, e.Message); + throw new Exception($"Error Occurred getting single cert for '{caRequestID}': {e.Message}", e); } } @@ -104,31 +326,64 @@ public async Task GetSingleRecord(string caRequestID) public async Task Synchronize(BlockingCollection blockingBuffer, DateTime? lastSync, bool fullSync, CancellationToken cancelToken) { - Logger.LogTrace($"Full Sync? {fullSync.ToString()}"); + var syncType = fullSync ? "Full" : "Incremental"; + using var flow = new FlowLogger(Logger, $"Synchronize-{syncType}"); Logger.MethodEntry(); + Logger.LogTrace("Synchronize called. fullSync={FullSync}, lastSync={LastSync}, blockingBuffer is {Null}", + fullSync, lastSync?.ToString("o") ?? "(null)", + blockingBuffer == null ? "NULL" : "present"); + + if (blockingBuffer == null) + throw new ArgumentNullException(nameof(blockingBuffer), "blockingBuffer cannot be null in 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) { - Logger.LogDebug("Performing full sync - no date filter applied"); - await SyncCertificates(blockingBuffer, cancelToken, null); + flow.Step("DetermineFilter", "Full sync - no date filter"); + await flow.StepAsync("FetchAndProcessCerts", async () => + { + await SyncCertificates(blockingBuffer, cancelToken, null); + }); } 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); + flow.Step("DetermineFilter", $"Incremental, filterDays={filterDays}, cutoff={dateFilter}"); + await flow.StepAsync("FetchAndProcessCerts", async () => + { + await SyncCertificates(blockingBuffer, cancelToken, dateFilter); + }); } + flow.Step("CompleteAdding"); blockingBuffer.CompleteAdding(); } + catch (OperationCanceledException) + { + flow.Fail("Cancelled", "operation was cancelled"); + Logger.LogWarning("Synchronize: operation was cancelled."); + if (!blockingBuffer.IsAddingCompleted) + blockingBuffer.CompleteAdding(); + throw; + } catch (Exception e) { - Logger.LogError($"Csc Global Synchronize Task failed! {LogHandler.FlattenException(e)}"); + flow.Fail("SyncError", e.Message); + Logger.LogError(e, "Csc Global Synchronize Task failed! {FlatException}", LogHandler.FlattenException(e)); + if (!blockingBuffer.IsAddingCompleted) + blockingBuffer.CompleteAdding(); Logger.MethodExit(); - blockingBuffer.CompleteAdding(); throw; } @@ -138,70 +393,188 @@ public async Task Synchronize(BlockingCollection blockin private async Task SyncCertificates(BlockingCollection blockingBuffer, CancellationToken cancelToken, string? dateFilter) { + Logger.LogTrace("SyncCertificates: calling SubmitCertificateListRequestAsync with dateFilter='{DateFilter}'", dateFilter ?? "(null)"); var certs = await CscGlobalClient.SubmitCertificateListRequestAsync(dateFilter); + if (certs == null) + { + Logger.LogWarning("SyncCertificates: SubmitCertificateListRequestAsync returned null."); + return; + } + + if (certs.Results == null) + { + Logger.LogWarning("SyncCertificates: certificate list response Results collection is null."); + return; + } + + Logger.LogTrace("SyncCertificates: received {Count} certificate results.", certs.Results.Count); + var processedCount = 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); - //Keyfactor sync only seems to work when there is a valid cert and I can only get Active valid certs from Csc Global + if (currentResponseItem == null) + { + Logger.LogTrace("SyncCertificates: skipping null result item."); + skippedCount++; + continue; + } + + Logger.LogTrace("SyncCertificates: processing certificate UUID={Uuid}, Status='{Status}', CertificateType='{CertType}'", + currentResponseItem.Uuid ?? "(null)", + currentResponseItem.Status ?? "(null)", + currentResponseItem.CertificateType ?? "(null)"); + + var certStatus = _requestManager.MapReturnStatus(currentResponseItem.Status); + Logger.LogTrace("SyncCertificates: mapped status for UUID={Uuid}: {MappedStatus}", currentResponseItem.Uuid ?? "(null)", certStatus); + 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 productId = _requestManager.MapCertificateTypeToProductId(currentResponseItem.CertificateType); + + Logger.LogTrace("SyncCertificates: UUID={Uuid} qualifies for sync. CertificateType='{CertType}' -> ProductId='{ProductId}'", + currentResponseItem.Uuid, currentResponseItem.CertificateType ?? "(null)", productId); - var fileContent = - PreparePemTextFromApi( - currentResponseItem?.Certificate ?? string.Empty); + string fileContent; + try + { + fileContent = PreparePemTextFromApi(currentResponseItem.Certificate ?? string.Empty); + } + catch (Exception ex) + { + Logger.LogError(ex, "SyncCertificates: PreparePemTextFromApi failed for UUID={Uuid}", currentResponseItem.Uuid); + skippedCount++; + continue; + } if (fileContent.Length > 0) { - Logger.LogTrace($"File Content {fileContent}"); + Logger.LogTrace("SyncCertificates: fileContent length={Length} for UUID={Uuid}", fileContent.Length, currentResponseItem.Uuid); var certData = fileContent.Replace("\r\n", string.Empty); - var certString = GetEndEntityCertificate(certData); - if (certString.Length > 0) + string certString; + try + { + certString = GetEndEntityCertificate(certData); + } + catch (Exception ex) + { + Logger.LogError(ex, "SyncCertificates: GetEndEntityCertificate failed for UUID={Uuid}", currentResponseItem.Uuid); + skippedCount++; + continue; + } + + if (!string.IsNullOrEmpty(certString)) + { blockingBuffer.Add(new AnyCAPluginCertificate { - CARequestID = $"{currentResponseItem?.Uuid}", + CARequestID = $"{currentResponseItem.Uuid}", Certificate = certString, Status = certStatus, ProductID = productId }, cancelToken); + processedCount++; + Logger.LogTrace("SyncCertificates: added UUID={Uuid} to buffer.", currentResponseItem.Uuid); + } + else + { + Logger.LogTrace("SyncCertificates: certString was empty for UUID={Uuid}, skipping.", currentResponseItem.Uuid); + skippedCount++; + } } + else + { + Logger.LogTrace("SyncCertificates: fileContent was empty for UUID={Uuid}, skipping.", currentResponseItem.Uuid); + skippedCount++; + } + } + else + { + Logger.LogTrace("SyncCertificates: UUID={Uuid} status {Status} not eligible for sync, skipping.", currentResponseItem.Uuid, certStatus); + skippedCount++; } } + + Logger.LogDebug("SyncCertificates: completed. Processed={Processed}, Skipped={Skipped}, Total={Total}", + processedCount, skippedCount, certs.Results.Count); } //done public async Task Revoke(string caRequestID, string hexSerialNumber, uint revocationReason) { + using var flow = new FlowLogger(Logger, $"Revoke({caRequestID ?? "null"})"); + Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace("Revoke called with caRequestID='{CaRequestId}', hexSerialNumber='{SerialNumber}', revocationReason={Reason}", + caRequestID ?? "(null)", hexSerialNumber ?? "(null)", revocationReason); + + 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."); + } + + flow.Step("ValidateInput", () => + { + if (string.IsNullOrEmpty(caRequestID)) + throw new ArgumentNullException(nameof(caRequestID), "caRequestID cannot be null or empty for Revoke."); + if (caRequestID.Length < 36) + throw new ArgumentException($"caRequestID '{caRequestID}' is too short to extract a UUID.", nameof(caRequestID)); + }); + 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 + var uuid = caRequestID.Substring(0, 36); + flow.Step("ExtractUUID", $"uuid={uuid}"); - Logger.LogTrace($"Revoke Response JSON: {JsonConvert.SerializeObject(revokeResponse)}"); - Logger.MethodExit(LogLevel.Debug); + RevokeResponse revokeResponse = null; + await flow.StepAsync("SubmitRevokeToCSC", async () => + { + revokeResponse = await CscGlobalClient.SubmitRevokeCertificateAsync(uuid); + }); + + if (revokeResponse == null) + { + flow.Fail("ParseResponse", "API returned null"); + throw new InvalidOperationException($"Revoke received null response for UUID '{uuid}'."); + } + + Logger.LogTrace("Revoke Response JSON: {Json}", JsonConvert.SerializeObject(revokeResponse)); var revokeResult = _requestManager.GetRevokeResult(revokeResponse); + flow.Step("MapResult", $"result={revokeResult}"); if (revokeResult == (int)EndEntityStatus.FAILED) - if (!string.IsNullOrEmpty(revokeResponse?.RegistrationError?.Description)) - throw new HttpRequestException( - $"Revoke Failed with message {revokeResponse?.RegistrationError?.Description}"); + { + var errorDesc = revokeResponse.RegistrationError?.Description; + flow.Fail("RevokeResult", errorDesc ?? "(no description)"); + Logger.LogError("Revoke: failed for UUID='{Uuid}'. Error description: '{ErrorDesc}'", + uuid, errorDesc ?? "(no description)"); + if (!string.IsNullOrEmpty(errorDesc)) + throw new HttpRequestException($"Revoke Failed with message {errorDesc}"); + } + Logger.MethodExit(LogLevel.Debug); return revokeResult; } + catch (AggregateException ae) + { + var inner = ae.Flatten().InnerException; + flow.Fail("UNHANDLED", inner?.Message ?? ae.Message); + Logger.LogError(inner, "Revoke: AggregateException for caRequestID='{CaRequestId}': {Message}", caRequestID, inner?.Message ?? ae.Message); + throw new Exception($"Revoke Failed for '{caRequestID}' with message {inner?.Message ?? ae.Message}", inner ?? ae); + } + catch (HttpRequestException) + { + throw; // already logged in flow above + } catch (Exception e) { - throw new Exception($"Revoke Failed with message {e?.Message}"); + flow.Fail("UNHANDLED", e.Message); + Logger.LogError(e, "Revoke: Exception for caRequestID='{CaRequestId}': {Message}", caRequestID, e.Message); + throw new Exception($"Revoke Failed for '{caRequestID}' with message {e.Message}", e); } } @@ -209,128 +582,427 @@ await CscGlobalClient.SubmitRevokeCertificateAsync(caRequestID.Substring(0, 36)) public async Task Enroll(string csr, string subject, Dictionary san, EnrollmentProductInfo productInfo, RequestFormat requestFormat, EnrollmentType enrollmentType) { + using var flow = new FlowLogger(Logger, $"Enroll-{enrollmentType}"); Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace("Enroll called. enrollmentType={EnrollmentType}, subject='{Subject}', productId='{ProductId}', requestFormat={RequestFormat}", + enrollmentType, subject ?? "(null)", + productInfo?.ProductID ?? "(null)", requestFormat); + Logger.LogTrace("Enroll: csr is {CsrStatus}, san has {SanCount} entries, productInfo is {PiStatus}", + string.IsNullOrEmpty(csr) ? "empty/null" : $"present ({csr.Length} chars)", + san?.Count ?? 0, + productInfo == null ? "NULL" : "present"); + + if (!Enabled) + { + flow.Fail("Disabled", "CA is Disabled"); + Logger.LogWarning("The CA is currently in the Disabled state. It must be Enabled to perform operations. Rejecting Enroll."); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "The CSC Global CA is in the Disabled state. Enable it to perform enrollments." + }; + } + + flow.Step("ValidateInputs", () => + { + if (productInfo == null) + throw new ArgumentNullException(nameof(productInfo), "productInfo cannot be null for Enroll."); + if (productInfo.ProductParameters == null) + throw new ArgumentNullException(nameof(productInfo), "productInfo.ProductParameters cannot be null for Enroll."); + if (string.IsNullOrEmpty(csr)) + throw new ArgumentNullException(nameof(csr), "CSR cannot be null or empty for Enroll."); + }); + + Logger.LogTrace("Enroll: ProductParameters keys: [{Keys}]", + string.Join(", ", productInfo.ProductParameters.Keys)); RegistrationRequest enrollmentRequest; var priorSn = ""; ReissueRequest reissueRequest; RenewalRequest renewRequest; - if (productInfo.ProductParameters.ContainsKey("priorcertsn")) + + flow.Step("CheckPriorCertSN", () => { - priorSn = productInfo.ProductParameters["PriorCertSN"]; - Logger.LogDebug($"Prior cert sn: {priorSn}"); - } + // Command sends this key as "PriorCertSN" (proper case) - a prior version of this + // check gated on "priorcertsn" (lowercase) instead, which Command never actually + // sends, so this block silently never ran and PriorCertSN was never populated. + if (productInfo.ProductParameters.ContainsKey("PriorCertSN")) + { + priorSn = productInfo.ProductParameters["PriorCertSN"]; + Logger.LogDebug("Enroll: Prior cert SN: '{PriorSn}'", priorSn ?? "(null)"); + } + }, string.IsNullOrEmpty(priorSn) ? "none" : $"SN={priorSn}"); string uUId; - var customFields = await CscGlobalClient.SubmitGetCustomFields(); + List customFields = null; + await flow.StepAsync("FetchCustomFields", async () => + { + customFields = await CscGlobalClient.SubmitGetCustomFields(); + }, $"count={customFields?.Count ?? 0}"); - switch (enrollmentType) + if (customFields == null) { - 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 + Logger.LogWarning("Enroll: SubmitGetCustomFields returned null, using empty list."); + customFields = new List(); + } + + try + { + switch (enrollmentType) + { + case EnrollmentType.New: + flow.Step("SelectPath", "New Enrollment"); + IRegistrationResponse enrollmentResponse; + if (!productInfo.ProductParameters.ContainsKey("PriorCertSN")) { - Status = 30, //failure - StatusMessage = "You cannot renew an expired cert please perform an new enrollment." - }; - } + enrollmentRequest = null; + flow.Step("BuildRegistrationRequest", () => + { + enrollmentRequest = _requestManager.GetRegistrationRequest(productInfo, csr, san, customFields); + }); + Logger.LogTrace("Enrollment Request JSON: {Json}", JsonConvert.SerializeObject(enrollmentRequest)); - 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; - } + RegistrationResponse regResponse = null; + await flow.StepAsync("SubmitRegistrationToCSC", async () => + { + regResponse = await CscGlobalClient.SubmitRegistrationAsync(enrollmentRequest); + }); + enrollmentResponse = regResponse; - if (expirationDate < DateTime.Now) renewal = true; - if (renewal) - { - //One click won't work for this implementation b/c we are missing enrollment params + if (enrollmentResponse == null) + { + flow.Fail("ParseResponse", "API returned null"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = "Enrollment failed: CSC API returned a null response." + }; + } + flow.Step("ParseResponse", $"error={enrollmentResponse.RegistrationError != null}"); + Logger.LogTrace("Enrollment Response JSON: {Json}", JsonConvert.SerializeObject(enrollmentResponse)); + } + else + { + flow.Fail("RejectExpiredRenew", "PriorCertSN present on New enrollment"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = "You cannot renew an expired cert please perform an new enrollment." + }; + } + + var enrollResult = _requestManager.GetEnrollmentResult(enrollmentResponse); + flow.Step("MapResult", $"Status={enrollResult?.Status}, ID={enrollResult?.CARequestID ?? "(null)"}"); + + await flow.StepAsync("PublishCnameDcv", async () => + { + await TryPublishCnameDcvAsync(productInfo, enrollResult); + }); + + EnrollmentResult? newPolled = null; + await flow.StepAsync("PollForIssuance", async () => + { + newPolled = await TryPollForIssuedCertAsync(enrollResult?.CARequestID); + }); + if (newPolled != null) + { + flow.Step("PollResult", "issued during poll window"); + Logger.MethodExit(LogLevel.Debug); + return newPolled; + } + + Logger.MethodExit(LogLevel.Debug); + return enrollResult; + + case EnrollmentType.RenewOrReissue: + flow.Step("SelectPath", "RenewOrReissue"); + + if (string.IsNullOrEmpty(priorSn)) + { + flow.Fail("ValidatePriorSN", "PriorCertSN is empty"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = "RenewOrReissue failed: PriorCertSN is required but was not provided." + }; + } + + string order_id = null; + await flow.StepAsync("LookupOrderId", async () => + { + order_id = await _certificateDataReader.GetRequestIDBySerialNumber(priorSn); + }, $"orderId={order_id ?? "(null)"}"); + + if (string.IsNullOrEmpty(order_id)) + { + flow.Fail("ValidateOrderId", $"no order found for SN={priorSn}"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = $"RenewOrReissue failed: could not find order ID for serial number '{priorSn}'." + }; + } + + if (order_id.Length < 36) + { + flow.Fail("ValidateOrderId", $"order_id too short ({order_id.Length} chars)"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = $"RenewOrReissue failed: order ID '{order_id}' is too short to extract a UUID." + }; + } + flow.Step("ValidateOrderId", $"orderId={order_id}"); + + // Determine renew vs reissue based on order expiry window. + var renewal = false; + try + { + CertificateResponse liveCert = null; + await flow.StepAsync("FetchLiveCertForDecision", async () => + { + liveCert = await 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("ComputeRenewalDecision", + $"orderDate={liveCert.OrderDate}, expiry={orderExpiry:dd-MMM-yyyy}, daysLeft={(int)daysUntilOrderExpiry}, window={RenewalWindowDays}, isRenewal={renewal}"); + } + else + { + flow.Skip("ComputeRenewalDecision", "orderDate unavailable, falling back to cert expiry"); + var expirationDate = _certificateDataReader.GetExpirationDateByRequestId(order_id) + ?? (await GetSingleRecord(order_id)).RevocationDate; + renewal = expirationDate < DateTime.Now; + flow.Step("FallbackExpiryCheck", $"expirationDate={expirationDate?.ToString("o") ?? "(null)"}, isRenewal={renewal}"); + } + } + catch (Exception ex) + { + flow.Fail("FetchLiveCertForDecision", $"falling back: {ex.Message}"); + Logger.LogWarning(ex, "RenewOrReissue: failed to fetch live cert, falling back to cert expiry."); + try + { + var expirationDate = _certificateDataReader.GetExpirationDateByRequestId(order_id) + ?? (await GetSingleRecord(order_id)).RevocationDate; + renewal = expirationDate < DateTime.Now; + flow.Step("FallbackExpiryCheck", $"isRenewal={renewal}"); + } + catch (Exception fallbackEx) + { + flow.Fail("FallbackExpiryCheck", fallbackEx.Message); + return new EnrollmentResult + { + Status = 30, + StatusMessage = $"RenewOrReissue failed: unable to determine renewal status for order '{order_id}'. {fallbackEx.Message}" + }; + } + } + + flow.Step("RenewalDecision", renewal ? "RENEWAL (paid order)" : "REISSUE (free under active order)"); + + if (renewal) + { + if (productInfo.ProductParameters.ContainsKey("Applicant Last Name")) + { + uUId = null; + await flow.StepAsync("LookupRenewalUUID", async () => + { + uUId = await _certificateDataReader.GetRequestIDBySerialNumber( + productInfo.ProductParameters["PriorCertSN"]); + }); + + if (string.IsNullOrEmpty(uUId)) + { + flow.Fail("ValidateRenewalUUID", "could not resolve PriorCertSN"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = "Renewal failed: could not resolve prior certificate serial number to a request ID." + }; + } + flow.Step("ValidateRenewalUUID", $"uuid={uUId}"); + + RenewalRequest builtRenewRequest = null; + flow.Step("BuildRenewalRequest", () => + { + builtRenewRequest = _requestManager.GetRenewalRequest(productInfo, uUId, csr, san, customFields); + }); + renewRequest = builtRenewRequest; + Logger.LogTrace("Renewal Request JSON: {Json}", JsonConvert.SerializeObject(renewRequest)); + + RenewalResponse renewResponse = null; + await flow.StepAsync("SubmitRenewalToCSC", async () => + { + renewResponse = await CscGlobalClient.SubmitRenewalAsync(renewRequest); + }); + + if (renewResponse == null) + { + flow.Fail("ParseRenewalResponse", "API returned null"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = "Renewal failed: CSC API returned a null response." + }; + } + + Logger.LogTrace("Renewal Response JSON: {Json}", JsonConvert.SerializeObject(renewResponse)); + var renewResult = _requestManager.GetRenewResponse(renewResponse); + flow.Step("MapRenewalResult", $"Status={renewResult?.Status}, Message={renewResult?.StatusMessage ?? "(null)"}"); + + EnrollmentResult? renewPolled = null; + await flow.StepAsync("PollForIssuance", async () => + { + renewPolled = await TryPollForIssuedCertAsync(renewResult?.CARequestID); + }); + Logger.MethodExit(LogLevel.Debug); + return renewPolled ?? renewResult; + } + + flow.Fail("MissingEnrollmentParams", "Applicant Last Name not present — one-click renew unavailable"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = + "One click Renew Is Not Available for this Certificate Type. Use the configure button instead." + }; + } + + // Reissue path 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 = Task.Run(async () => await CscGlobalClient.SubmitRenewalAsync(renewRequest)) - .Result; - Logger.LogTrace($"Renewal Response JSON: {JsonConvert.SerializeObject(renewResponse)}"); + string requestid = null; + await flow.StepAsync("LookupReissueRequestId", async () => + { + requestid = await _certificateDataReader.GetRequestIDBySerialNumber( + productInfo.ProductParameters["PriorCertSN"]); + }); + + if (string.IsNullOrEmpty(requestid)) + { + flow.Fail("ValidateReissueRequestId", "could not resolve PriorCertSN"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = "Reissue failed: could not resolve prior certificate serial number to a request ID." + }; + } + + if (requestid.Length < 36) + { + flow.Fail("ValidateReissueRequestId", $"requestid too short ({requestid.Length} chars)"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = $"Reissue failed: request ID '{requestid}' is too short to extract a UUID." + }; + } + + uUId = requestid.Substring(0, 36); + flow.Step("ExtractReissueUUID", $"uuid={uUId}"); + + ReissueRequest builtReissueRequest = null; + flow.Step("BuildReissueRequest", () => + { + builtReissueRequest = _requestManager.GetReissueRequest(productInfo, uUId, csr, san, customFields); + }); + reissueRequest = builtReissueRequest; + Logger.LogTrace("Reissue JSON: {Json}", JsonConvert.SerializeObject(reissueRequest)); + + ReissueResponse reissueResponse = null; + await flow.StepAsync("SubmitReissueToCSC", async () => + { + reissueResponse = await CscGlobalClient.SubmitReissueAsync(reissueRequest); + }); + + if (reissueResponse == null) + { + flow.Fail("ParseReissueResponse", "API returned null"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = "Reissue failed: CSC API returned a null response." + }; + } + + Logger.LogTrace("Reissue Response JSON: {Json}", JsonConvert.SerializeObject(reissueResponse)); + var reissueResult = _requestManager.GetReIssueResult(reissueResponse); + flow.Step("MapReissueResult", $"Status={reissueResult?.Status}, Message={reissueResult?.StatusMessage ?? "(null)"}"); + + EnrollmentResult? reissuePolled = null; + await flow.StepAsync("PollForIssuance", async () => + { + reissuePolled = await TryPollForIssuedCertAsync(reissueResult?.CARequestID); + }); Logger.MethodExit(LogLevel.Debug); - return _requestManager.GetRenewResponse(renewResponse); + return reissuePolled ?? reissueResult; } + flow.Fail("MissingEnrollmentParams", "Applicant Last Name not present — one-click reissue unavailable"); return new EnrollmentResult { - Status = 30, //failure + Status = 30, 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." - }; + default: + flow.Fail("UnhandledType", $"enrollmentType={enrollmentType}"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = $"Enroll failed: unhandled enrollment type '{enrollmentType}'." + }; + } + } + catch (AggregateException ae) + { + var inner = ae.Flatten().InnerException; + flow.Fail("UNHANDLED", inner?.Message ?? ae.Message); + Logger.LogError(inner, "Enroll: AggregateException during {EnrollmentType}: {Message}", enrollmentType, inner?.Message ?? ae.Message); + return new EnrollmentResult + { + Status = 30, + StatusMessage = $"Enrollment failed with error: {inner?.Message ?? ae.Message}" + }; + } + catch (Exception ex) + { + flow.Fail("UNHANDLED", ex.Message); + Logger.LogError(ex, "Enroll: unhandled exception during {EnrollmentType}: {Message}", enrollmentType, ex.Message); + return new EnrollmentResult + { + Status = 30, + StatusMessage = $"Enrollment failed with error: {ex.Message}" + }; } - - Logger.MethodExit(LogLevel.Debug); - return null; } //done public async Task Ping() { Logger.MethodEntry(); + Logger.LogTrace("Ping: Enabled={Enabled}, CscGlobalClient is {Null}", Enabled, CscGlobalClient == null ? "NULL" : "present"); + + 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"); } catch (Exception e) { - Logger.LogError($"There was an error contacting CSCGlobal: {e.Message}."); + Logger.LogError(e, "There was an error contacting CSCGlobal: {Message}", e.Message); throw new Exception($"Error attempting to ping CSCGlobal: {e.Message}.", e); } @@ -340,19 +1012,80 @@ public async Task Ping() //do public async Task ValidateCAConnectionInfo(Dictionary connectionInfo) { + Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace("ValidateCAConnectionInfo called. connectionInfo is {Null}, keys=[{Keys}]", + connectionInfo == null ? "NULL" : "present", + connectionInfo != null ? string.Join(", ", connectionInfo.Keys) : ""); + + if (connectionInfo == null) + { + Logger.LogError("ValidateCAConnectionInfo: connectionInfo is null."); + throw new ArgumentNullException(nameof(connectionInfo), "connectionInfo cannot be null."); + } + + // 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.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); } //do public async Task ValidateProductInfo(EnrollmentProductInfo productInfo, Dictionary connectionInfo) { - var certType = ProductIDs.productIds.Find(x => - x.Equals(productInfo.ProductID, StringComparison.InvariantCultureIgnoreCase)); + Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace("ValidateProductInfo called. productInfo is {Null}, productId='{ProductId}'", + productInfo == null ? "NULL" : "present", + productInfo?.ProductID ?? "(null)"); - if (certType == null) throw new ArgumentException($"Cannot find {productInfo.ProductID}", "ProductId"); + if (productInfo == null) + { + Logger.LogError("ValidateProductInfo: productInfo is null."); + throw new ArgumentNullException(nameof(productInfo), "productInfo cannot be null."); + } - Logger.LogInformation($"Validated {certType} ({certType})configured for AnyGateway"); + // 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; + } + + if (string.IsNullOrEmpty(productInfo.ProductID)) + { + Logger.LogError("ValidateProductInfo: productInfo.ProductID is null or empty."); + throw new ArgumentException("ProductID cannot be null or empty.", nameof(productInfo)); + } + + if (!_requestManager.IsKnownProductId(productInfo.ProductID)) + { + Logger.LogError("ValidateProductInfo: cannot find product ID '{ProductId}'. Known IDs: [{KnownIds}]", + productInfo.ProductID, string.Join(", ", ProductIDs.productIds)); + throw new ArgumentException($"Cannot find {productInfo.ProductID}", "ProductId"); + } + + Logger.LogInformation("Validated {ProductId} configured for AnyGateway", productInfo.ProductID); + Logger.MethodExit(LogLevel.Debug); } //done @@ -360,6 +1093,13 @@ public Dictionary GetCAConnectorAnnotations() { return new Dictionary { + [Constants.Enabled] = new() + { + Comments = "Flag to Enable or Disable gateway functionality. Disabling is primarily used to allow creation of the CA prior to configuration information being available.", + Hidden = false, + DefaultValue = true, + Type = "Boolean" + }, [Constants.CscGlobalUrl] = new() { Comments = "CSCGlobal API URL", @@ -388,19 +1128,26 @@ 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.", 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" + }, + [Constants.DcvPollTimeoutSeconds] = new() + { + Comments = "Max seconds to synchronously poll CSC for issuance after submitting an order (and publishing CNAME DCV). 0 disables polling (enrollment returns pending immediately; cert arrives on next sync). When >0, fast-validating orders can return the cert directly. Keep small to avoid long-blocking enrollment requests.", + Hidden = false, + DefaultValue = "0", + Type = "Number" } }; } @@ -517,6 +1264,206 @@ public List GetProductIds() #region PRIVATE + /// + /// Strip a single trailing dot from a DNS name. CSC returns FQDN-canonical names with + /// a trailing dot but the framework's Domain Validation Configurations are stored without + /// one, so the strings have to be normalized before lookup or the equality check fails. + /// + private static string StripTrailingDot(string? s) + { + if (string.IsNullOrEmpty(s)) return s ?? string.Empty; + return s.EndsWith('.') ? s[..^1] : s; + } + + /// + /// Synchronously poll CSC for issuance of the order identified by , + /// up to . Returns a GENERATED + /// carrying the issued leaf certificate if CSC issues within the window, or null if the + /// window expires (in which case the caller falls back to its pending/EXTERNALVALIDATION result). + /// No-op (returns null) when polling is disabled or the uuid is missing. + /// + private async Task TryPollForIssuedCertAsync(string? uuid) + { + if (DcvPollTimeoutSeconds <= 0) + { + Logger.LogTrace("TryPollForIssuedCertAsync: polling disabled (DcvPollTimeoutSeconds=0), skipping."); + return null; + } + + if (string.IsNullOrEmpty(uuid)) + { + Logger.LogWarning("TryPollForIssuedCertAsync: no UUID/CARequestID to poll, skipping."); + return null; + } + + var deadline = DateTime.UtcNow.AddSeconds(DcvPollTimeoutSeconds); + Logger.LogInformation("TryPollForIssuedCertAsync: polling CSC for issuance of '{Uuid}' for up to {Seconds}s (interval {Interval}s).", + uuid, DcvPollTimeoutSeconds, (int)DcvPollInterval.TotalSeconds); + + var attempt = 0; + while (DateTime.UtcNow < deadline) + { + attempt++; + AnyCAPluginCertificate record; + try + { + record = await GetSingleRecord(uuid); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "TryPollForIssuedCertAsync: poll attempt {Attempt} for '{Uuid}' threw, will retry. {Error}", + attempt, uuid, ex.Message); + record = null; + } + + if (record != null) + { + Logger.LogTrace("TryPollForIssuedCertAsync: attempt {Attempt} for '{Uuid}' — status={Status}, cert={CertState}.", + attempt, uuid, record.Status, string.IsNullOrEmpty(record.Certificate) ? "empty" : "present"); + + if (record.Status == (int)EndEntityStatus.GENERATED && !string.IsNullOrEmpty(record.Certificate)) + { + Logger.LogInformation("TryPollForIssuedCertAsync: '{Uuid}' issued after {Attempt} poll(s); returning cert directly.", uuid, attempt); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.GENERATED, + CARequestID = uuid, + Certificate = record.Certificate, + StatusMessage = $"Certificate issued and retrieved for order {uuid}." + }; + } + } + + // Don't sleep past the deadline. + if (DateTime.UtcNow.Add(DcvPollInterval) >= deadline) + break; + + await Task.Delay(DcvPollInterval); + } + + Logger.LogInformation("TryPollForIssuedCertAsync: '{Uuid}' not issued within {Seconds}s after {Attempts} attempt(s); falling back to pending.", + uuid, DcvPollTimeoutSeconds, attempt); + return null; + } + + /// + /// Publishes CNAME DCV records via the gateway framework's . + /// Per-record resolution: each record is routed to whichever DNS provider plugin the framework + /// resolves for its domain. No-op if the factory wasn't injected, the cert isn't using CNAME + /// validation, or the response contains no CNAME details. Failures are logged but never thrown — + /// manual publishing remains a fallback so the enrollment result is still returned to Keyfactor. + /// + private async Task TryPublishCnameDcvAsync(EnrollmentProductInfo productInfo, EnrollmentResult? enrollResult) + { + if (_validatorFactory == null) + { + Logger.LogTrace("TryPublishCnameDcvAsync: no IDomainValidatorFactory was injected, skipping auto-publish."); + return; + } + + if (enrollResult?.EnrollmentContext == null || enrollResult.EnrollmentContext.Count == 0) + { + Logger.LogTrace("TryPublishCnameDcvAsync: no CNAME entries in EnrollmentContext, skipping."); + return; + } + + var dcvMethod = productInfo?.ProductParameters != null + && productInfo.ProductParameters.TryGetValue(EnrollmentConfigConstants.DomainControlValidationMethod, out var m) + ? m + : null; + + if (string.IsNullOrEmpty(dcvMethod) || + !string.Equals(dcvMethod, "CNAME", StringComparison.OrdinalIgnoreCase)) + { + Logger.LogTrace("TryPublishCnameDcvAsync: DCV method '{Method}' is not CNAME, skipping auto-publish.", dcvMethod ?? "(null)"); + return; + } + + Logger.LogInformation( + "TryPublishCnameDcvAsync: attempting to publish {Count} CNAME record(s) via framework DNS providers (validation type '{Type}').", + enrollResult.EnrollmentContext.Count, DNS_VALIDATION_TYPE); + + var successCount = 0; + var failCount = 0; + var unresolvedCount = 0; + + foreach (var entry in enrollResult.EnrollmentContext) + { + var rawRecordName = entry.Key; + var rawCnameTarget = entry.Value; + + // CSC may also surface DCV email entries in this dictionary (key == value). Skip those. + if (string.Equals(rawRecordName, rawCnameTarget, StringComparison.OrdinalIgnoreCase)) + { + Logger.LogTrace("TryPublishCnameDcvAsync: skipping entry '{Key}' (looks like an email DCV passthrough, not a CNAME).", rawRecordName); + continue; + } + + // CSC returns FQDN-canonical names with trailing dots (e.g. "foo.example.com."). + // The framework's Domain Validation Configuration stores domain patterns without + // the trailing dot, so strip it before resolution and publishing or no provider + // will match (the framework will look up "*.example.com." which won't equal "*.example.com"). + var recordName = StripTrailingDot(rawRecordName); + var cnameTarget = StripTrailingDot(rawCnameTarget); + + if (recordName != rawRecordName) + Logger.LogTrace("TryPublishCnameDcvAsync: normalized record name '{Raw}' -> '{Normalized}'.", rawRecordName, recordName); + + IDomainValidator? validator; + try + { + validator = _validatorFactory.ResolveDomainValidator(recordName, DNS_VALIDATION_TYPE); + } + catch (Exception ex) + { + unresolvedCount++; + Logger.LogWarning(ex, "ResolveDomainValidator threw for '{Record}' (type '{Type}'): {Error}", + recordName, DNS_VALIDATION_TYPE, ex.Message); + continue; + } + + if (validator == null) + { + unresolvedCount++; + Logger.LogWarning( + "No DNS provider matched domain '{Record}' for validation type '{Type}'. Manual publish required for this record.", + recordName, DNS_VALIDATION_TYPE); + continue; + } + + try + { + Logger.LogTrace("StageValidation: '{Name}' -> '{Target}' via validator type '{ValType}'.", + recordName, cnameTarget, validator.GetValidationType()); + var result = await validator.StageValidation(recordName, cnameTarget, CancellationToken.None); + + if (result?.Success == true) + { + successCount++; + Logger.LogInformation("Published CNAME '{Name}' -> '{Target}' (status='{Status}').", + recordName, cnameTarget, result.Status ?? "(none)"); + } + else + { + failCount++; + Logger.LogWarning( + "StageValidation reported failure for CNAME '{Name}'. Status='{Status}', Error='{Error}'. Manual publish may be required.", + recordName, result?.Status ?? "(none)", result?.ErrorMessage ?? "(none)"); + } + } + catch (Exception ex) + { + failCount++; + Logger.LogError(ex, "StageValidation threw publishing CNAME '{Name}'. Manual publish may be required. {Error}", + recordName, ex.Message); + } + } + + Logger.LogInformation( + "TryPublishCnameDcvAsync: complete. Published={Published}, Failed={Failed}, Unresolved={Unresolved}", + successCount, failCount, unresolvedCount); + } + //Trying to fix leaf extraction private static readonly Regex PemBlock = new( "-----BEGIN CERTIFICATE-----\\s*(?[A-Za-z0-9+/=\\r\\n]+?)\\s*-----END CERTIFICATE-----", diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.csproj b/cscglobal-caplugin/CSCGlobalCAPlugin.csproj index 5118677..e5f5ff7 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.csproj +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.csproj @@ -3,7 +3,7 @@ true - net6.0;net8.0 + net10.0 Keyfactor.Extensions.CAPlugin.CSCGlobal true enable @@ -16,24 +16,16 @@ - - - + + - - - - - - - Always - \ No newline at end of file + diff --git a/cscglobal-caplugin/Client/CscGlobalClient.cs b/cscglobal-caplugin/Client/CscGlobalClient.cs index 0a5c7c5..7a5b722 100644 --- a/cscglobal-caplugin/Client/CscGlobalClient.cs +++ b/cscglobal-caplugin/Client/CscGlobalClient.cs @@ -21,15 +21,66 @@ public sealed class CscGlobalClient : ICscGlobalClient { private readonly ILogger Logger; - public CscGlobalClient(IAnyCAPluginConfigProvider config) + public CscGlobalClient(IAnyCAPluginConfigProvider config) : this(config, null) { - Logger = LogHandler.GetClassLogger(); + } + + // internal so the test project can supply a fake HttpMessageHandler via + // InternalsVisibleTo, instead of the client making real HTTP calls in unit tests. + internal CscGlobalClient(IAnyCAPluginConfigProvider config, HttpMessageHandler? handler) + { + Logger = LogHandler.GetClassLogger(); + + if (config == null) + throw new ArgumentNullException(nameof(config), "config cannot be null in CscGlobalClient constructor."); + + if (config.CAConnectionData == null) + throw new InvalidOperationException("CAConnectionData is null on config provider."); + + Logger.LogTrace("CscGlobalClient: CAConnectionData keys=[{Keys}]", string.Join(", ", config.CAConnectionData.Keys)); + 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(); + var rawUrl = config.CAConnectionData.ContainsKey(Constants.CscGlobalUrl) + ? config.CAConnectionData[Constants.CscGlobalUrl]?.ToString() + : null; + if (string.IsNullOrEmpty(rawUrl)) + { + Logger.LogError("CscGlobalClient: CscGlobalUrl is missing or empty in CAConnectionData."); + throw new InvalidOperationException("CscGlobalUrl is required but was not configured."); + } + + Logger.LogTrace("CscGlobalClient: BaseUrl='{BaseUrl}'", rawUrl); + BaseUrl = new Uri(rawUrl); + + ApiKey = config.CAConnectionData[Constants.CscGlobalApiKey]?.ToString(); + if (string.IsNullOrEmpty(ApiKey)) + { + Logger.LogError("CscGlobalClient: ApiKey is empty or null."); + throw new InvalidOperationException("ApiKey is required but was not configured."); + } + Logger.LogTrace("CscGlobalClient: ApiKey is present (length={Length}).", ApiKey.Length); + + if (!config.CAConnectionData.ContainsKey(Constants.BearerToken)) + { + Logger.LogError("CscGlobalClient: BearerToken key not found in CAConnectionData."); + throw new InvalidOperationException("BearerToken is required but was not configured."); + } + Authorization = config.CAConnectionData[Constants.BearerToken]?.ToString(); + if (string.IsNullOrEmpty(Authorization)) + { + Logger.LogError("CscGlobalClient: BearerToken is empty or null."); + throw new InvalidOperationException("BearerToken is required but was empty."); + } + Logger.LogTrace("CscGlobalClient: BearerToken is present (length={Length}).", Authorization.Length); + + RestClient = ConfigureRestClient(handler); + Logger.LogTrace("CscGlobalClient: RestClient configured successfully."); + } + else + { + Logger.LogError("CscGlobalClient: ApiKey key '{Key}' not found in CAConnectionData. Client will not be functional.", Constants.CscGlobalApiKey); + throw new InvalidOperationException($"Required key '{Constants.CscGlobalApiKey}' not found in CAConnectionData."); } } @@ -41,25 +92,42 @@ public CscGlobalClient(IAnyCAPluginConfigProvider config) public async Task SubmitRegistrationAsync( RegistrationRequest registerRequest) { + Logger.LogTrace("SubmitRegistrationAsync: sending registration request..."); + if (registerRequest == null) + throw new ArgumentNullException(nameof(registerRequest)); + + var requestJson = JsonConvert.SerializeObject(registerRequest); + Logger.LogTrace("SubmitRegistrationAsync: request JSON: {Json}", requestJson); + using (var resp = await RestClient.PostAsync("/dbs/api/v2/tls/registration", new StringContent( - JsonConvert.SerializeObject(registerRequest), Encoding.ASCII, "application/json"))) + requestJson, Encoding.ASCII, "application/json"))) { - Logger.LogTrace(JsonConvert.SerializeObject(registerRequest)); + var rawBody = await resp.Content.ReadAsStringAsync(); + Logger.LogTrace("SubmitRegistrationAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); + Logger.LogTrace("SubmitRegistrationAsync: response body: {Body}", rawBody ?? "(null)"); + var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; - if (resp.StatusCode == HttpStatusCode.BadRequest) //Csc Sends Errors back in 400 Json Response + if (resp.StatusCode == HttpStatusCode.BadRequest) { - var errorResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync(), - settings); + Logger.LogWarning("SubmitRegistrationAsync: received 400 BadRequest."); + var errorResponse = JsonConvert.DeserializeObject(rawBody ?? "{}", settings); + Logger.LogTrace("SubmitRegistrationAsync: error description='{Desc}'", errorResponse?.Description ?? "(null)"); var response = new RegistrationResponse(); response.RegistrationError = errorResponse; response.Result = null; return response; } - var registrationResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync(), - settings); + if (!resp.IsSuccessStatusCode) + { + Logger.LogError("SubmitRegistrationAsync: unexpected HTTP {StatusCode}: {Body}", (int)resp.StatusCode, rawBody); + throw new HttpRequestException($"SubmitRegistrationAsync failed with HTTP {(int)resp.StatusCode}: {rawBody}"); + } + + var registrationResponse = JsonConvert.DeserializeObject(rawBody ?? "{}", settings); + Logger.LogTrace("SubmitRegistrationAsync: deserialized response. Result is {Null}, RegistrationError is {Null2}", + registrationResponse?.Result == null ? "null" : "present", + registrationResponse?.RegistrationError == null ? "null" : "present"); return registrationResponse; } } @@ -67,31 +135,42 @@ public async Task SubmitRegistrationAsync( public async Task SubmitRenewalAsync( RenewalRequest renewalRequest) { + Logger.LogTrace("SubmitRenewalAsync: sending renewal request..."); + if (renewalRequest == null) + throw new ArgumentNullException(nameof(renewalRequest)); + + var requestJson = JsonConvert.SerializeObject(renewalRequest); + Logger.LogTrace("SubmitRenewalAsync: request JSON: {Json}", requestJson); + using (var resp = await RestClient.PostAsync("/dbs/api/v2/tls/renewal", new StringContent( - JsonConvert.SerializeObject(renewalRequest), Encoding.ASCII, "application/json"))) + requestJson, Encoding.ASCII, "application/json"))) { - Logger.LogTrace(JsonConvert.SerializeObject(renewalRequest)); + var rawBody = await resp.Content.ReadAsStringAsync(); + Logger.LogTrace("SubmitRenewalAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); + Logger.LogTrace("SubmitRenewalAsync: response body: {Body}", rawBody ?? "(null)"); 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(); - Logger.LogTrace("Logging Error Response Raw"); - Logger.LogTrace(rawErrorResponse); - var errorResponse = - JsonConvert.DeserializeObject(rawErrorResponse, - settings); + if (resp.StatusCode == HttpStatusCode.BadRequest) + { + Logger.LogWarning("SubmitRenewalAsync: received 400 BadRequest."); + var errorResponse = JsonConvert.DeserializeObject(rawBody ?? "{}", settings); + Logger.LogTrace("SubmitRenewalAsync: error description='{Desc}'", errorResponse?.Description ?? "(null)"); var response = new RenewalResponse(); response.RegistrationError = errorResponse; response.Result = null; return response; } - var rawRenewResponse = await resp.Content.ReadAsStringAsync(); - Logger.LogTrace("Logging Success Response Raw"); - Logger.LogTrace(rawRenewResponse); - var renewalResponse = - JsonConvert.DeserializeObject(rawRenewResponse); + if (!resp.IsSuccessStatusCode) + { + Logger.LogError("SubmitRenewalAsync: unexpected HTTP {StatusCode}: {Body}", (int)resp.StatusCode, rawBody); + throw new HttpRequestException($"SubmitRenewalAsync failed with HTTP {(int)resp.StatusCode}: {rawBody}"); + } + + var renewalResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); + Logger.LogTrace("SubmitRenewalAsync: deserialized response. Result is {Null}, RegistrationError is {Null2}", + renewalResponse?.Result == null ? "null" : "present", + renewalResponse?.RegistrationError == null ? "null" : "present"); return renewalResponse; } } @@ -99,69 +178,145 @@ public async Task SubmitRenewalAsync( public async Task SubmitReissueAsync( ReissueRequest reissueRequest) { + Logger.LogTrace("SubmitReissueAsync: sending reissue request..."); + if (reissueRequest == null) + throw new ArgumentNullException(nameof(reissueRequest)); + + var requestJson = JsonConvert.SerializeObject(reissueRequest); + Logger.LogTrace("SubmitReissueAsync: request JSON: {Json}", requestJson); + using (var resp = await RestClient.PostAsync("/dbs/api/v2/tls/reissue", new StringContent( - JsonConvert.SerializeObject(reissueRequest), Encoding.ASCII, "application/json"))) + requestJson, Encoding.ASCII, "application/json"))) { - Logger.LogTrace(JsonConvert.SerializeObject(reissueRequest)); + var rawBody = await resp.Content.ReadAsStringAsync(); + Logger.LogTrace("SubmitReissueAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); + Logger.LogTrace("SubmitReissueAsync: response body: {Body}", rawBody ?? "(null)"); var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; - if (resp.StatusCode == HttpStatusCode.BadRequest) //Csc Sends Errors back in 400 Json Response + if (resp.StatusCode == HttpStatusCode.BadRequest) { - var errorResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync(), - settings); + Logger.LogWarning("SubmitReissueAsync: received 400 BadRequest."); + var errorResponse = JsonConvert.DeserializeObject(rawBody ?? "{}", settings); + Logger.LogTrace("SubmitReissueAsync: error description='{Desc}'", errorResponse?.Description ?? "(null)"); var response = new ReissueResponse(); response.RegistrationError = errorResponse; response.Result = null; return response; } - var reissueResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + if (!resp.IsSuccessStatusCode) + { + Logger.LogError("SubmitReissueAsync: unexpected HTTP {StatusCode}: {Body}", (int)resp.StatusCode, rawBody); + throw new HttpRequestException($"SubmitReissueAsync failed with HTTP {(int)resp.StatusCode}: {rawBody}"); + } + + var reissueResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); + Logger.LogTrace("SubmitReissueAsync: deserialized response. Result is {Null}, RegistrationError is {Null2}", + reissueResponse?.Result == null ? "null" : "present", + reissueResponse?.RegistrationError == null ? "null" : "present"); return reissueResponse; } } public async Task SubmitGetCertificateAsync(string certificateId) { + Logger.LogTrace("SubmitGetCertificateAsync: fetching certificate for id='{CertificateId}'", certificateId ?? "(null)"); + + if (string.IsNullOrEmpty(certificateId)) + throw new ArgumentNullException(nameof(certificateId), "certificateId cannot be null or empty."); + using (var resp = await RestClient.GetAsync($"/dbs/api/v2/tls/certificate/{certificateId}")) { - resp.EnsureSuccessStatusCode(); - var getCertificateResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + var rawBody = await resp.Content.ReadAsStringAsync(); + Logger.LogTrace("SubmitGetCertificateAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); + + if (!resp.IsSuccessStatusCode) + { + Logger.LogError("SubmitGetCertificateAsync: HTTP {StatusCode} for certificateId='{CertificateId}': {Body}", + (int)resp.StatusCode, certificateId, rawBody); + resp.EnsureSuccessStatusCode(); // will throw + } + + Logger.LogTrace("SubmitGetCertificateAsync: response body: {Body}", rawBody ?? "(null)"); + var getCertificateResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); + Logger.LogTrace("SubmitGetCertificateAsync: deserialized. Status='{Status}', OrderDate='{OrderDate}', Certificate is {Null}", + getCertificateResponse?.Status ?? "(null)", + getCertificateResponse?.OrderDate ?? "(null)", + string.IsNullOrEmpty(getCertificateResponse?.Certificate) ? "empty/null" : "present"); return getCertificateResponse; } } public async Task> SubmitGetCustomFields() { + Logger.LogTrace("SubmitGetCustomFields: fetching custom fields..."); + using (var resp = await RestClient.GetAsync("/dbs/api/v2/admin/customfields")) { - resp.EnsureSuccessStatusCode(); - var getCustomFieldsResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + var rawBody = await resp.Content.ReadAsStringAsync(); + Logger.LogTrace("SubmitGetCustomFields: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); + + if (!resp.IsSuccessStatusCode) + { + Logger.LogError("SubmitGetCustomFields: HTTP {StatusCode}: {Body}", (int)resp.StatusCode, rawBody); + resp.EnsureSuccessStatusCode(); // will throw + } + + Logger.LogTrace("SubmitGetCustomFields: response body: {Body}", rawBody ?? "(null)"); + var getCustomFieldsResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); + + if (getCustomFieldsResponse == null) + { + Logger.LogWarning("SubmitGetCustomFields: deserialized response is null, returning empty list."); + return new List(); + } + + if (getCustomFieldsResponse.CustomFields == null) + { + Logger.LogWarning("SubmitGetCustomFields: CustomFields property is null, returning empty list."); + return new List(); + } + + Logger.LogTrace("SubmitGetCustomFields: received {Count} custom fields.", getCustomFieldsResponse.CustomFields.Count); return getCustomFieldsResponse.CustomFields; } } public async Task SubmitRevokeCertificateAsync(string uuId) { + Logger.LogTrace("SubmitRevokeCertificateAsync: revoking certificate UUID='{Uuid}'", uuId ?? "(null)"); + + if (string.IsNullOrEmpty(uuId)) + throw new ArgumentNullException(nameof(uuId), "uuId cannot be null or empty."); + using (var resp = await RestClient.PutAsync($"/dbs/api/v2/tls/revoke/{uuId}", new StringContent(""))) { + var rawBody = await resp.Content.ReadAsStringAsync(); + Logger.LogTrace("SubmitRevokeCertificateAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); + Logger.LogTrace("SubmitRevokeCertificateAsync: response body: {Body}", rawBody ?? "(null)"); + var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; - if (resp.StatusCode == HttpStatusCode.BadRequest) //Csc Sends Errors back in 400 Json Response + if (resp.StatusCode == HttpStatusCode.BadRequest) { - var errorResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync(), - settings); + Logger.LogWarning("SubmitRevokeCertificateAsync: received 400 BadRequest for UUID='{Uuid}'.", uuId); + var errorResponse = JsonConvert.DeserializeObject(rawBody ?? "{}", settings); + Logger.LogTrace("SubmitRevokeCertificateAsync: error description='{Desc}'", errorResponse?.Description ?? "(null)"); var response = new RevokeResponse(); response.RegistrationError = errorResponse; response.RevokeSuccess = null; return response; } - var getRevokeResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + if (!resp.IsSuccessStatusCode) + { + Logger.LogError("SubmitRevokeCertificateAsync: unexpected HTTP {StatusCode} for UUID='{Uuid}': {Body}", (int)resp.StatusCode, uuId, rawBody); + throw new HttpRequestException($"SubmitRevokeCertificateAsync failed with HTTP {(int)resp.StatusCode}: {rawBody}"); + } + + var getRevokeResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); + Logger.LogTrace("SubmitRevokeCertificateAsync: deserialized. RevokeSuccess is {Null}, RegistrationError is {Null2}", + getRevokeResponse?.RevokeSuccess == null ? "null" : "present", + getRevokeResponse?.RegistrationError == null ? "null" : "present"); return getRevokeResponse; } } @@ -169,30 +324,43 @@ public async Task SubmitRevokeCertificateAsync(string uuId) public async Task SubmitCertificateListRequestAsync(string? dateFilter = null) { Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace("SubmitCertificateListRequestAsync: dateFilter='{DateFilter}'", dateFilter ?? "(null)"); + var filterQuery = "filter=status=in=(ACTIVE,REVOKED)"; if (!string.IsNullOrEmpty(dateFilter)) { filterQuery += $";effectiveDate=ge={dateFilter}"; } - Logger.LogTrace($"Certificate list filter query: {filterQuery}"); + Logger.LogTrace("SubmitCertificateListRequestAsync: filter query: {FilterQuery}", filterQuery); + var resp = RestClient.GetAsync($"/dbs/api/v2/tls/certificate?{filterQuery}").Result; + var rawBody = await resp.Content.ReadAsStringAsync(); + Logger.LogTrace("SubmitCertificateListRequestAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); if (!resp.IsSuccessStatusCode) { - var responseMessage = resp.Content.ReadAsStringAsync().Result; Logger.LogError( - $"Failed Request to Keyfactor. Retrying request. Status Code {resp.StatusCode} | Message: {responseMessage}"); + "SubmitCertificateListRequestAsync: failed request. StatusCode={StatusCode}, Body={Body}", + (int)resp.StatusCode, rawBody); + } + + var certificateListResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); + + if (certificateListResponse == null) + { + Logger.LogWarning("SubmitCertificateListRequestAsync: deserialized response is null."); + return new CertificateListResponse(); } - var certificateListResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + Logger.LogTrace("SubmitCertificateListRequestAsync: Results count={Count}", + certificateListResponse.Results?.Count ?? 0); + Logger.MethodExit(LogLevel.Debug); 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/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/Constants.cs b/cscglobal-caplugin/Constants.cs index 4d6b4da..be33065 100644 --- a/cscglobal-caplugin/Constants.cs +++ b/cscglobal-caplugin/Constants.cs @@ -9,25 +9,30 @@ 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"; public static string DefaultPageSize = "DefaultPageSize"; - public static string TemplateSync = "TemplateSync"; public static string SyncFilterDays = "SyncFilterDays"; + public static string RenewalWindowDays = "RenewalWindowDays"; + public static string DcvPollTimeoutSeconds = "DcvPollTimeoutSeconds"; } 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/FlowLogger.cs b/cscglobal-caplugin/FlowLogger.cs new file mode 100644 index 0000000..5696fcd --- /dev/null +++ b/cscglobal-caplugin/FlowLogger.cs @@ -0,0 +1,241 @@ +// 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; + +public enum FlowStepStatus +{ + Success, + Failed, + Skipped, + InProgress +} + +public class FlowStep +{ + public string Name { get; set; } + public FlowStepStatus Status { get; set; } + public string Detail { get; set; } + public long ElapsedMs { get; set; } + public List Children { get; } = new(); +} + +/// +/// Tracks high-level operation flow and renders a visual step diagram to Trace logs. +/// Usage: +/// using var flow = new FlowLogger(logger, "Enroll-New"); +/// flow.Step("ParseCSR"); +/// flow.Step("ValidateCSR", () => { ... }); +/// flow.Fail("CreateOrder", "API returned 400"); +/// // flow renders automatically on Dispose +/// +public sealed class FlowLogger : IDisposable +{ + private readonly ILogger _logger; + private readonly string _flowName; + private readonly Stopwatch _totalTimer; + private readonly List _steps = new(); + private FlowStep _currentParent; + private bool _disposed; + + public FlowLogger(ILogger logger, string flowName) + { + _logger = logger; + _flowName = flowName; + _totalTimer = Stopwatch.StartNew(); + _logger.LogTrace("===== FLOW START: {FlowName} =====", _flowName); + } + + /// Record a completed step. + public FlowLogger Step(string name, string detail = null) + { + var step = new FlowStep { Name = name, Status = FlowStepStatus.Success, Detail = detail }; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... OK{Detail}", + _flowName, name, detail != null ? $" ({detail})" : ""); + return this; + } + + /// Record a step that executes an action and times it. + public FlowLogger Step(string name, Action action, string detail = null) + { + var sw = Stopwatch.StartNew(); + var step = new FlowStep { Name = name, Detail = detail }; + try + { + _logger.LogTrace(" [{FlowName}] {StepName} ...", _flowName, name); + action(); + sw.Stop(); + step.Status = FlowStepStatus.Success; + step.ElapsedMs = sw.ElapsedMilliseconds; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... OK ({Elapsed}ms){Detail}", + _flowName, name, sw.ElapsedMilliseconds, detail != null ? $" {detail}" : ""); + } + catch (Exception ex) + { + sw.Stop(); + step.Status = FlowStepStatus.Failed; + step.ElapsedMs = sw.ElapsedMilliseconds; + step.Detail = ex.Message; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... FAILED ({Elapsed}ms): {Error}", + _flowName, name, sw.ElapsedMilliseconds, ex.Message); + throw; + } + return this; + } + + /// Record an async step that executes and times it. + public async Task StepAsync(string name, Func action, string detail = null) + { + var sw = Stopwatch.StartNew(); + var step = new FlowStep { Name = name, Detail = detail }; + try + { + _logger.LogTrace(" [{FlowName}] {StepName} ...", _flowName, name); + await action(); + sw.Stop(); + step.Status = FlowStepStatus.Success; + step.ElapsedMs = sw.ElapsedMilliseconds; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... OK ({Elapsed}ms){Detail}", + _flowName, name, sw.ElapsedMilliseconds, detail != null ? $" {detail}" : ""); + } + catch (Exception ex) + { + sw.Stop(); + step.Status = FlowStepStatus.Failed; + step.ElapsedMs = sw.ElapsedMilliseconds; + step.Detail = ex.Message; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... FAILED ({Elapsed}ms): {Error}", + _flowName, name, sw.ElapsedMilliseconds, ex.Message); + throw; + } + return this; + } + + /// Record a failed step without throwing. + public FlowLogger Fail(string name, string reason = null) + { + var step = new FlowStep { Name = name, Status = FlowStepStatus.Failed, Detail = reason }; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... FAILED{Reason}", + _flowName, name, reason != null ? $": {reason}" : ""); + return this; + } + + /// Record a skipped step. + public FlowLogger Skip(string name, string reason = null) + { + var step = new FlowStep { Name = name, Status = FlowStepStatus.Skipped, Detail = reason }; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... SKIPPED{Reason}", + _flowName, name, reason != null ? $": {reason}" : ""); + return this; + } + + /// Start a branch (group of child steps). + public FlowLogger Branch(string name) + { + var step = new FlowStep { Name = name, Status = FlowStepStatus.InProgress }; + AddStep(step); + _currentParent = step; + _logger.LogTrace(" [{FlowName}] >> Branch: {BranchName}", _flowName, name); + return this; + } + + /// End the current branch. + public FlowLogger EndBranch() + { + _currentParent = null; + return this; + } + + private void AddStep(FlowStep step) + { + if (_currentParent != null) + _currentParent.Children.Add(step); + else + _steps.Add(step); + } + + /// Render the visual flow diagram to Trace log. + private string RenderFlow() + { + var sb = new StringBuilder(); + sb.AppendLine(); + sb.AppendLine($" ===== FLOW: {_flowName} ({_totalTimer.ElapsedMilliseconds}ms total) ====="); + sb.AppendLine(); + + for (var i = 0; i < _steps.Count; i++) + { + var step = _steps[i]; + var icon = GetStatusIcon(step.Status); + var elapsed = step.ElapsedMs > 0 ? $" ({step.ElapsedMs}ms)" : ""; + var detail = !string.IsNullOrEmpty(step.Detail) ? $" [{step.Detail}]" : ""; + + sb.AppendLine($" {icon} {step.Name}{elapsed}{detail}"); + + // Render children (branch) + if (step.Children.Count > 0) + { + for (var j = 0; j < step.Children.Count; j++) + { + var child = step.Children[j]; + var childIcon = GetStatusIcon(child.Status); + var childElapsed = child.ElapsedMs > 0 ? $" ({child.ElapsedMs}ms)" : ""; + var childDetail = !string.IsNullOrEmpty(child.Detail) ? $" [{child.Detail}]" : ""; + var connector = j < step.Children.Count - 1 ? "| " : " "; + sb.AppendLine($" |"); + sb.AppendLine($" +-- {childIcon} {child.Name}{childElapsed}{childDetail}"); + } + } + + // Connector between top-level steps + if (i < _steps.Count - 1) + { + sb.AppendLine(" |"); + sb.AppendLine(" v"); + } + } + + sb.AppendLine(); + + // Final status line + var finalStatus = _steps.Count > 0 && _steps.Last().Status == FlowStepStatus.Failed + ? "FAILED" : _steps.Any(s => s.Status == FlowStepStatus.Failed) ? "PARTIAL FAILURE" : "SUCCESS"; + sb.AppendLine($" ===== FLOW RESULT: {finalStatus} ====="); + + return sb.ToString(); + } + + private static string GetStatusIcon(FlowStepStatus status) + { + return status switch + { + FlowStepStatus.Success => "[OK]", + FlowStepStatus.Failed => "[FAIL]", + FlowStepStatus.Skipped => "[SKIP]", + FlowStepStatus.InProgress => "[...]", + _ => "[?]" + }; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _totalTimer.Stop(); + _logger.LogTrace(RenderFlow()); + } +} 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 diff --git a/cscglobal-caplugin/RequestManager.cs b/cscglobal-caplugin/RequestManager.cs index 00b217a..00522b0 100644 --- a/cscglobal-caplugin/RequestManager.cs +++ b/cscglobal-caplugin/RequestManager.cs @@ -10,19 +10,54 @@ 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)); + // Certificate types that carry a list of additional SAN domains, vs. a single CN only. + private static readonly HashSet MultiNameCertificateTypes = new() { "2", "6", "7", "8", "9" }; + + // Certificate types that require EvCertificateDetails (Organization Country, etc.). + private static readonly HashSet EvCertificateTypes = new() { "3", "7" }; + private List GetCustomFields(EnrollmentProductInfo productInfo, List customFields) { + Logger.LogTrace("GetCustomFields: productInfo is {Null}, customFields count={Count}", + productInfo == null ? "NULL" : "present", + customFields?.Count ?? 0); + var customFieldList = new List(); + if (customFields == null || productInfo?.ProductParameters == null) + { + Logger.LogTrace("GetCustomFields: returning empty list (null customFields or ProductParameters)."); + return customFieldList; + } + foreach (var field in customFields) + { + if (field == null) + { + Logger.LogTrace("GetCustomFields: skipping null field entry."); + continue; + } + + Logger.LogTrace("GetCustomFields: checking field Label='{Label}', Mandatory={Mandatory}", + field.Label ?? "(null)", field.Mandatory); + + if (string.IsNullOrEmpty(field.Label)) + { + Logger.LogTrace("GetCustomFields: skipping field with null/empty label."); + continue; + } + if (productInfo.ProductParameters.ContainsKey(field.Label)) { var newField = new CustomField @@ -30,32 +65,60 @@ private List GetCustomFields(EnrollmentProductInfo productInfo, Lis Name = field.Label, Value = productInfo.ProductParameters[field.Label] }; + Logger.LogTrace("GetCustomFields: matched field '{Label}' = '{Value}'", field.Label, newField.Value ?? "(null)"); customFieldList.Add(newField); } else if (field.Mandatory) { + Logger.LogError("GetCustomFields: mandatory field '{Label}' was not supplied. Available keys: [{Keys}]", + field.Label, string.Join(", ", productInfo.ProductParameters.Keys)); throw new Exception( $"Custom field {field.Label} is marked as mandatory, but was not supplied in the request."); } + else + { + Logger.LogTrace("GetCustomFields: optional field '{Label}' not found in ProductParameters, skipping.", field.Label); + } + } + Logger.LogTrace("GetCustomFields: returning {Count} custom fields.", customFieldList.Count); return customFieldList; } public EnrollmentResult GetRenewResponse(RenewalResponse renewResponse) { + Logger.LogTrace("GetRenewResponse: renewResponse is {Null}", renewResponse == null ? "NULL" : "present"); + + if (renewResponse == null) + { + Logger.LogError("GetRenewResponse: renewResponse is null."); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "Renewal failed: received null response from CSC." + }; + } + if (renewResponse.RegistrationError != null) + { + Logger.LogWarning("GetRenewResponse: RegistrationError present. Description='{Desc}'", + renewResponse.RegistrationError.Description ?? "(null)"); return new EnrollmentResult { - Status = (int)EndEntityStatus.FAILED, //failure - CARequestID = renewResponse?.Result?.Status?.Uuid, - StatusMessage = renewResponse.RegistrationError.Description + Status = (int)EndEntityStatus.FAILED, + CARequestID = renewResponse.Result?.Status?.Uuid, + StatusMessage = renewResponse.RegistrationError.Description ?? "Renewal failed with unknown error." }; + } + var commonName = renewResponse.Result?.CommonName ?? "(unknown)"; + var uuid = renewResponse.Result?.Status?.Uuid; + Logger.LogTrace("GetRenewResponse: renewal succeeded for CommonName='{CommonName}', UUID='{Uuid}'", commonName, uuid ?? "(null)"); return new EnrollmentResult { - Status = (int)EndEntityStatus.GENERATED, //success - - StatusMessage = $"Renewal Successfully Completed For {renewResponse.Result.CommonName}" + Status = (int)EndEntityStatus.EXTERNALVALIDATION, + CARequestID = uuid, + StatusMessage = $"Renewal Successfully Submitted For {commonName}. Certificate will be available after next sync." }; } @@ -64,77 +127,210 @@ public EnrollmentResult GetEnrollmentResult( IRegistrationResponse registrationResponse) { + Logger.LogTrace("GetEnrollmentResult: registrationResponse is {Null}", registrationResponse == null ? "NULL" : "present"); + + if (registrationResponse == null) + { + Logger.LogError("GetEnrollmentResult: registrationResponse is null."); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "Enrollment failed: received null response from CSC." + }; + } + if (registrationResponse.RegistrationError != null) + { + Logger.LogWarning("GetEnrollmentResult: RegistrationError present. Description='{Desc}'", + registrationResponse.RegistrationError.Description ?? "(null)"); return new EnrollmentResult { - Status = (int)EndEntityStatus.FAILED, //failure - StatusMessage = registrationResponse.RegistrationError.Description + Status = (int)EndEntityStatus.FAILED, + StatusMessage = registrationResponse.RegistrationError.Description ?? "Enrollment failed with unknown error." }; + } + + if (registrationResponse.Result == null) + { + Logger.LogError("GetEnrollmentResult: Result is null but no RegistrationError present."); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "Enrollment failed: response Result is null." + }; + } var cnames = new Dictionary(); if (registrationResponse.Result.DcvDetails != null && registrationResponse.Result.DcvDetails.Count > 0) + { + Logger.LogTrace("GetEnrollmentResult: processing {Count} DcvDetails.", registrationResponse.Result.DcvDetails.Count); foreach (var dcv in registrationResponse.Result.DcvDetails) { + if (dcv == null) + { + Logger.LogTrace("GetEnrollmentResult: skipping null DcvDetail."); + continue; + } + if (dcv.CName != null && !string.IsNullOrEmpty(dcv.CName.Name) && !string.IsNullOrEmpty(dcv.CName.Value)) { - cnames.Add(dcv.CName.Name, dcv.CName.Value); + if (!cnames.ContainsKey(dcv.CName.Name)) + { + Logger.LogTrace("GetEnrollmentResult: adding CName '{Name}'='{Value}'", dcv.CName.Name, dcv.CName.Value); + cnames.Add(dcv.CName.Name, dcv.CName.Value); + } + else + { + Logger.LogTrace("GetEnrollmentResult: duplicate CName key '{Name}', skipping.", dcv.CName.Name); + } } if (!string.IsNullOrEmpty(dcv.Email) && !cnames.ContainsKey(dcv.Email)) { - cnames.Add(dcv.Email, dcv.Email); + if (!cnames.ContainsKey(dcv.Email)) + { + Logger.LogTrace("GetEnrollmentResult: adding DCV email '{Email}'", dcv.Email); + cnames.Add(dcv.Email, dcv.Email); + } + else + { + Logger.LogTrace("GetEnrollmentResult: duplicate email key '{Email}', skipping.", dcv.Email); + } } } - + } + else + { + Logger.LogTrace("GetEnrollmentResult: no DcvDetails to process."); + } + + var uuid = registrationResponse.Result.Status?.Uuid; + var commonName = registrationResponse.Result.CommonName ?? "(unknown)"; + Logger.LogTrace("GetEnrollmentResult: success. UUID='{Uuid}', CommonName='{CommonName}', cnames count={Count}", + uuid ?? "(null)", commonName, cnames.Count); + return new EnrollmentResult { - Status = (int)EndEntityStatus.EXTERNALVALIDATION, //success - CARequestID = registrationResponse.Result.Status.Uuid, + Status = (int)EndEntityStatus.EXTERNALVALIDATION, + CARequestID = uuid, StatusMessage = - $"Order Successfully Created With Order Number {registrationResponse.Result.CommonName}", + $"Order Successfully Created With Order Number {commonName}", EnrollmentContext = cnames.Count > 0 ? cnames : null }; } public int GetRevokeResult(IRevokeResponse revokeResponse) { + Logger.LogTrace("GetRevokeResult: revokeResponse is {Null}", revokeResponse == null ? "NULL" : "present"); + + if (revokeResponse == null) + { + Logger.LogError("GetRevokeResult: revokeResponse is null, returning FAILED."); + return (int)EndEntityStatus.FAILED; + } + if (revokeResponse.RegistrationError != null) + { + Logger.LogWarning("GetRevokeResult: RegistrationError present. Description='{Desc}'", + revokeResponse.RegistrationError.Description ?? "(null)"); return (int)EndEntityStatus.FAILED; + } + Logger.LogTrace("GetRevokeResult: returning REVOKED."); return (int)EndEntityStatus.REVOKED; } public EnrollmentResult GetReIssueResult(IReissueResponse reissueResponse) { + Logger.LogTrace("GetReIssueResult: reissueResponse is {Null}", reissueResponse == null ? "NULL" : "present"); + + if (reissueResponse == null) + { + Logger.LogError("GetReIssueResult: reissueResponse is null."); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "Reissue failed: received null response from CSC." + }; + } + if (reissueResponse.RegistrationError != null) + { + Logger.LogWarning("GetReIssueResult: RegistrationError present. Description='{Desc}'", + reissueResponse.RegistrationError.Description ?? "(null)"); return new EnrollmentResult { - Status = (int)EndEntityStatus.FAILED, //failure - StatusMessage = reissueResponse.RegistrationError.Description + Status = (int)EndEntityStatus.FAILED, + StatusMessage = reissueResponse.RegistrationError.Description ?? "Reissue failed with unknown error." }; + } + + if (reissueResponse.Result == null) + { + Logger.LogError("GetReIssueResult: Result is null but no RegistrationError present."); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "Reissue failed: response Result is null." + }; + } + + var uuid = reissueResponse.Result.Status?.Uuid; + var commonName = reissueResponse.Result.CommonName ?? "(unknown)"; + Logger.LogTrace("GetReIssueResult: success. UUID='{Uuid}', CommonName='{CommonName}'", uuid ?? "(null)", commonName); return new EnrollmentResult { - Status = (int)EndEntityStatus.GENERATED, //success - CARequestID = reissueResponse.Result.Status.Uuid, - StatusMessage = $"Reissue Successfully Completed For {reissueResponse.Result.CommonName}" + Status = (int)EndEntityStatus.EXTERNALVALIDATION, + CARequestID = uuid, + StatusMessage = $"Reissue Successfully Submitted For {commonName}. Certificate will be available after next sync." }; } public DomainControlValidation GetDomainControlValidation(string methodType, string[] emailAddress, string domainName) { + Logger.LogTrace("GetDomainControlValidation(array): methodType='{MethodType}', domainName='{DomainName}', emailAddress count={Count}", + methodType ?? "(null)", domainName ?? "(null)", emailAddress?.Length ?? 0); + + if (emailAddress == null || emailAddress.Length == 0) + { + Logger.LogTrace("GetDomainControlValidation(array): no email addresses provided, returning null."); + return null; + } + foreach (var address in emailAddress) { - var email = new MailAddress(address); - if (domainName.Contains(email.Host.Split('.')[0])) - return new DomainControlValidation + if (string.IsNullOrEmpty(address)) + { + Logger.LogTrace("GetDomainControlValidation(array): skipping null/empty email address."); + continue; + } + + try + { + var email = new MailAddress(address); + var hostPart = email.Host?.Split('.')[0] ?? ""; + Logger.LogTrace("GetDomainControlValidation(array): checking email='{Email}', hostPart='{HostPart}' against domain='{Domain}'", + address, hostPart, domainName); + + if (!string.IsNullOrEmpty(domainName) && domainName.Contains(hostPart)) { - MethodType = methodType, - EmailAddress = email.ToString() - }; + Logger.LogTrace("GetDomainControlValidation(array): matched! Returning email='{Email}'", email.ToString()); + return new DomainControlValidation + { + MethodType = methodType, + EmailAddress = email.ToString() + }; + } + } + catch (FormatException fex) + { + Logger.LogWarning("GetDomainControlValidation(array): invalid email address '{Address}': {Message}", address, fex.Message); + } } + Logger.LogTrace("GetDomainControlValidation(array): no matching email found, returning null."); return null; } @@ -150,187 +346,378 @@ public DomainControlValidation GetDomainControlValidation(string methodType, str public RegistrationRequest GetRegistrationRequest(EnrollmentProductInfo productInfo, string csr, Dictionary sans, List customFields) { - //var cert = "-----BEGIN CERTIFICATE REQUEST-----\r\n"; - var cert = Pemify(csr); - //cert = cert + "\r\n-----END CERTIFICATE REQUEST-----"; + Logger.LogTrace("GetRegistrationRequest: building registration request. ProductID='{ProductId}'", productInfo?.ProductID ?? "(null)"); + if (productInfo?.ProductParameters == null) + throw new ArgumentNullException(nameof(productInfo), "productInfo or ProductParameters cannot be null."); + if (string.IsNullOrEmpty(csr)) + throw new ArgumentNullException(nameof(csr), "CSR cannot be null or empty."); + var cert = Pemify(csr); 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"]; + Logger.LogTrace("GetRegistrationRequest: CSR encoded, length={Length}", encodedString.Length); + + var commonNameValidationEmail = productInfo.ProductParameters.ContainsKey("CN DCV Email") + ? productInfo.ProductParameters["CN DCV Email"] : null; + var methodType = productInfo.ProductParameters.ContainsKey("Domain Control Validation Method") + ? productInfo.ProductParameters["Domain Control Validation Method"] : null; var certificateType = GetCertificateType(productInfo.ProductID); + Logger.LogTrace("GetRegistrationRequest: cnDcvEmail='{Email}', methodType='{Method}', certType='{CertType}'", + commonNameValidationEmail ?? "(null)", methodType ?? "(null)", certificateType); + return new RegistrationRequest { Csr = encodedString, - ServerSoftware = "-1", //Just default to other, user does not need to fill this in + 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 = productInfo.ProductParameters.ContainsKey("Term") ? productInfo.ProductParameters["Term"] : null, + ApplicantFirstName = productInfo.ProductParameters.ContainsKey("Applicant First Name") ? productInfo.ProductParameters["Applicant First Name"] : null, + ApplicantLastName = productInfo.ProductParameters.ContainsKey("Applicant Last Name") ? productInfo.ProductParameters["Applicant Last Name"] : null, + ApplicantEmailAddress = productInfo.ProductParameters.ContainsKey("Applicant Email Address") ? productInfo.ProductParameters["Applicant Email Address"] : null, + ApplicantPhoneNumber = productInfo.ProductParameters.ContainsKey("Applicant Phone") ? productInfo.ProductParameters["Applicant Phone"] : null, DomainControlValidation = GetDomainControlValidation(methodType, commonNameValidationEmail), Notifications = GetNotifications(productInfo), - OrganizationContact = productInfo.ProductParameters["Organization Contact"], - BusinessUnit = productInfo.ProductParameters["Business Unit"], - ShowPrice = true, //User should not have to fill this out + OrganizationContact = productInfo.ProductParameters.ContainsKey("Organization Contact") ? productInfo.ProductParameters["Organization Contact"] : null, + BusinessUnit = productInfo.ProductParameters.ContainsKey("Business Unit") ? productInfo.ProductParameters["Business Unit"] : null, + ShowPrice = true, 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 }; } + // Maps Keyfactor product ID -> CSC API certificate type code (used for enrollment requests). + // Each product has an entry for its current (1.2.0+) canonical name and its pre-1.2.0 legacy + // name, so existing Certificate Templates in Command using the old names keep working. + // Types 7/8/9 are new in 1.2.0 and have no legacy name. + private static readonly Dictionary ProductIdToCodeMap = new(StringComparer.OrdinalIgnoreCase) + { + ["CSC TrustedSecure OV"] = "0", + ["CSC TrustedSecure Premium Certificate"] = "0", + ["CSC TrustedSecure OV Wildcard"] = "1", + ["CSC TrustedSecure Premium Wildcard Certificate"] = "1", + ["CSC TrustedSecure OV, Multiple Names"] = "2", + ["CSC TrustedSecure UC Certificate"] = "2", + ["CSC TrustedSecure EV"] = "3", + ["CSC TrustedSecure EV Certificate"] = "3", + ["CSC TrustedSecure DV"] = "4", + ["CSC TrustedSecure Domain Validated SSL"] = "4", + ["CSC Trusted Secure Domain Validated SSL"] = "4", + ["CSC TrustedSecure DV Wildcard"] = "5", + ["CSC Trusted Secure Domain Validated Wildcard SSL"] = "5", + ["CSC TrustedSecure DV, Multiple Names"] = "6", + ["CSC Trusted Secure Domain Validated UC Certificate"] = "6", + ["CSC TrustedSecure EV, Multiple Names"] = "7", + ["CSC TrustedSecure OV Wildcard, Multiple Names"] = "8", + ["CSC TrustedSecure DV Wildcard, Multiple Names"] = "9", + }; + + /// + /// True if productId resolves to a known CSC certificate type - either its canonical + /// (1.2.0+) name or a pre-1.2.0 legacy name. Used by ValidateProductInfo so the list of + /// accepted names can't drift out of sync with what GetCertificateType actually resolves. + /// + public bool IsKnownProductId(string productId) => + !string.IsNullOrEmpty(productId) && ProductIdToCodeMap.ContainsKey(productId); + + // Reverse map: CSC API certificateType string -> Keyfactor product ID (used during sync) + // Note: CSC naming is inconsistent — first 4 types use "TrustedSecure" (no space), + // DV Wildcard and DV UC use "Trusted Secure" (with space), + // but CSC API returns DV SSL as "CSC Trusted Secure Domain Validated SSL" (with space) + // while the product ID is "CSC TrustedSecure Domain Validated SSL" (no space). + private static readonly Dictionary CodeToProductIdMap = new(StringComparer.OrdinalIgnoreCase) + { + // Premium + ["0"] = "CSC TrustedSecure Premium Certificate", + ["CSC TrustedSecure Premium Certificate"] = "CSC TrustedSecure Premium Certificate", + ["CSC Trusted Secure Premium Certificate"] = "CSC TrustedSecure Premium Certificate", + ["CSC TrustedSecure OV"] = "CSC TrustedSecure Premium Certificate", + // Premium Wildcard + ["1"] = "CSC TrustedSecure Premium Wildcard Certificate", + ["CSC TrustedSecure Premium Wildcard Certificate"] = "CSC TrustedSecure Premium Wildcard Certificate", + ["CSC Trusted Secure Premium Wildcard Certificate"] = "CSC TrustedSecure Premium Wildcard Certificate", + ["CSC TrustedSecure OV Wildcard"] = "CSC TrustedSecure Premium Wildcard Certificate", + // UC + ["2"] = "CSC TrustedSecure UC Certificate", + ["CSC TrustedSecure UC Certificate"] = "CSC TrustedSecure UC Certificate", + ["CSC Trusted Secure UC Certificate"] = "CSC TrustedSecure UC Certificate", + ["CSC TrustedSecure OV, Multiple Names"] = "CSC TrustedSecure UC Certificate", + // EV + ["3"] = "CSC TrustedSecure EV Certificate", + ["CSC TrustedSecure EV Certificate"] = "CSC TrustedSecure EV Certificate", + ["CSC Trusted Secure EV Certificate"] = "CSC TrustedSecure EV Certificate", + ["CSC TrustedSecure EV"] = "CSC TrustedSecure EV Certificate", + // DV SSL — product ID has no space, but CSC API returns with space + ["4"] = "CSC TrustedSecure Domain Validated SSL", + ["CSC TrustedSecure Domain Validated SSL"] = "CSC TrustedSecure Domain Validated SSL", + ["CSC Trusted Secure Domain Validated SSL"] = "CSC TrustedSecure Domain Validated SSL", + ["CSC TrustedSecure DV"] = "CSC TrustedSecure Domain Validated SSL", + // DV Wildcard — product ID has space (matches CSC API) + ["5"] = "CSC Trusted Secure Domain Validated Wildcard SSL", + ["CSC Trusted Secure Domain Validated Wildcard SSL"] = "CSC Trusted Secure Domain Validated Wildcard SSL", + ["CSC TrustedSecure Domain Validated Wildcard SSL"] = "CSC Trusted Secure Domain Validated Wildcard SSL", + ["CSC TrustedSecure DV Wildcard"] = "CSC Trusted Secure Domain Validated Wildcard SSL", + // DV UC — product ID has space (matches CSC API) + ["6"] = "CSC Trusted Secure Domain Validated UC Certificate", + ["CSC Trusted Secure Domain Validated UC Certificate"] = "CSC Trusted Secure Domain Validated UC Certificate", + ["CSC TrustedSecure Domain Validated UC Certificate"] = "CSC Trusted Secure Domain Validated UC Certificate", + ["CSC TrustedSecure DV, Multiple Names"] = "CSC Trusted Secure Domain Validated UC Certificate", + // EV, Multiple Names — new in 1.2.0, no legacy name + ["7"] = "CSC TrustedSecure EV, Multiple Names", + ["CSC TrustedSecure EV, Multiple Names"] = "CSC TrustedSecure EV, Multiple Names", + // OV Wildcard, Multiple Names — new in 1.2.0, no legacy name + ["8"] = "CSC TrustedSecure OV Wildcard, Multiple Names", + ["CSC TrustedSecure OV Wildcard, Multiple Names"] = "CSC TrustedSecure OV Wildcard, Multiple Names", + // DV Wildcard, Multiple Names — new in 1.2.0, no legacy name + ["9"] = "CSC TrustedSecure DV Wildcard, Multiple Names", + ["CSC TrustedSecure DV Wildcard, Multiple Names"] = "CSC TrustedSecure DV Wildcard, Multiple Names", + }; + private string GetCertificateType(string productId) { - switch (productId) + Logger.LogTrace("GetCertificateType: productId='{ProductId}'", productId ?? "(null)"); + if (!string.IsNullOrEmpty(productId) && ProductIdToCodeMap.TryGetValue(productId, out var code)) { - case "CSC TrustedSecure Premium Certificate": - return "0"; - case "CSC TrustedSecure EV Certificate": - return "3"; - case "CSC TrustedSecure UC Certificate": - return "2"; - case "CSC TrustedSecure Premium Wildcard Certificate": - 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": - return "4"; - case "CSC TrustedSecure Domain Validated Wildcard SSL": - return "5"; - case "CSC TrustedSecure Domain Validated UC Certificate": - return "6"; + Logger.LogTrace("GetCertificateType: mapped '{ProductId}' -> '{Code}'", productId, code); + return code; } - + Logger.LogWarning("GetCertificateType: no mapping found for '{ProductId}', returning -1.", productId); return "-1"; } + /// + /// Maps a CSC API certificateType value back to a Keyfactor product ID. + /// Handles numeric codes, descriptive strings, and passthrough of already-correct values. + /// + public string MapCertificateTypeToProductId(string cscCertificateType) + { + Logger.LogTrace("MapCertificateTypeToProductId: input='{CscCertType}'", cscCertificateType ?? "(null)"); + if (!string.IsNullOrEmpty(cscCertificateType) && CodeToProductIdMap.TryGetValue(cscCertificateType, out var productId)) + { + Logger.LogTrace("MapCertificateTypeToProductId: mapped '{CscCertType}' -> '{ProductId}'", cscCertificateType, productId); + return productId; + } + Logger.LogWarning("MapCertificateTypeToProductId: no mapping for '{CscCertType}', passing through as-is.", cscCertificateType); + return cscCertificateType ?? "CscGlobal"; + } + public Notifications GetNotifications(EnrollmentProductInfo productInfo) { + Logger.LogTrace("GetNotifications: building notifications."); + var emailsRaw = productInfo?.ProductParameters != null + && productInfo.ProductParameters.ContainsKey("Notification Email(s) Comma Separated") + ? productInfo.ProductParameters["Notification Email(s) Comma Separated"] + : null; + + Logger.LogTrace("GetNotifications: raw notification emails='{Emails}'", emailsRaw ?? "(null)"); + + var emailList = !string.IsNullOrEmpty(emailsRaw) + ? emailsRaw.Split(',').Where(e => !string.IsNullOrWhiteSpace(e)).ToList() + : new List(); + + Logger.LogTrace("GetNotifications: parsed {Count} notification emails.", emailList.Count); + return new Notifications { Enabled = true, - AdditionalNotificationEmails = productInfo.ProductParameters["Notification Email(s) Comma Separated"] - .Split(',').ToList() + AdditionalNotificationEmails = emailList }; } public RenewalRequest GetRenewalRequest(EnrollmentProductInfo productInfo, string uUId, string csr, Dictionary sans, List customFields) { - //var cert = "-----BEGIN CERTIFICATE REQUEST-----\r\n"; - var cert = Pemify(csr); - //cert = cert + "\r\n-----END CERTIFICATE REQUEST-----"; + Logger.LogTrace("GetRenewalRequest: building renewal request. UUID='{Uuid}', ProductID='{ProductId}'", + uUId ?? "(null)", productInfo?.ProductID ?? "(null)"); + if (productInfo?.ProductParameters == null) + throw new ArgumentNullException(nameof(productInfo), "productInfo or ProductParameters cannot be null."); + if (string.IsNullOrEmpty(csr)) + throw new ArgumentNullException(nameof(csr), "CSR cannot be null or empty."); + if (string.IsNullOrEmpty(uUId)) + throw new ArgumentNullException(nameof(uUId), "uUId cannot be null or empty."); + + var cert = Pemify(csr); 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 = productInfo.ProductParameters.ContainsKey("CN DCV Email") + ? productInfo.ProductParameters["CN DCV Email"] : null; + var methodType = productInfo.ProductParameters.ContainsKey("Domain Control Validation Method") + ? productInfo.ProductParameters["Domain Control Validation Method"] : null; var certificateType = GetCertificateType(productInfo.ProductID); + Logger.LogTrace("GetRenewalRequest: cnDcvEmail='{Email}', methodType='{Method}', certType='{CertType}'", + commonNameValidationEmail ?? "(null)", methodType ?? "(null)", certificateType); + return new RenewalRequest { Uuid = uUId, 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 = productInfo.ProductParameters.ContainsKey("Term") ? productInfo.ProductParameters["Term"] : null, + ApplicantFirstName = productInfo.ProductParameters.ContainsKey("Applicant First Name") ? productInfo.ProductParameters["Applicant First Name"] : null, + ApplicantLastName = productInfo.ProductParameters.ContainsKey("Applicant Last Name") ? productInfo.ProductParameters["Applicant Last Name"] : null, + ApplicantEmailAddress = productInfo.ProductParameters.ContainsKey("Applicant Email Address") ? productInfo.ProductParameters["Applicant Email Address"] : null, + ApplicantPhoneNumber = productInfo.ProductParameters.ContainsKey("Applicant Phone") ? productInfo.ProductParameters["Applicant Phone"] : null, DomainControlValidation = GetDomainControlValidation(methodType, commonNameValidationEmail), Notifications = GetNotifications(productInfo), - OrganizationContact = productInfo.ProductParameters["Organization Contact"], - BusinessUnit = productInfo.ProductParameters["Business Unit"], + OrganizationContact = productInfo.ProductParameters.ContainsKey("Organization Contact") ? productInfo.ProductParameters["Organization Contact"] : null, + BusinessUnit = productInfo.ProductParameters.ContainsKey("Business Unit") ? productInfo.ProductParameters["Business Unit"] : null, 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 }; } private List GetSubjectAlternativeNames(EnrollmentProductInfo productInfo, Dictionary sans) { + Logger.LogTrace("GetSubjectAlternativeNames: building SANs."); var subjectNameList = new List(); - var methodType = productInfo.ProductParameters["Domain Control Validation Method"]; - sans.TryGetValue("dnsname", out var dnsNames); - foreach (var v in dnsNames ?? Array.Empty()) + if (sans == null || !sans.ContainsKey("dnsname")) + { + Logger.LogTrace("GetSubjectAlternativeNames: no 'dnsname' key in SANs dictionary, returning empty list."); + return subjectNameList; + } + + var dnsNames = sans["dnsname"]; + if (dnsNames == null || dnsNames.Length == 0) + { + Logger.LogTrace("GetSubjectAlternativeNames: 'dnsname' array is null or empty, returning empty list."); + return subjectNameList; + } + + var methodType = productInfo?.ProductParameters != null + && productInfo.ProductParameters.ContainsKey("Domain Control Validation Method") + ? productInfo.ProductParameters["Domain Control Validation Method"] + : null; + + // CSC Global rejects the request if any subjectAlternativeNames entry is missing + // domainControlValidation, so every SAN below must resolve to a non-null value - falling + // back to the primary CN's DCV email when no per-domain override matches. + var commonNameValidationEmail = productInfo?.ProductParameters != null + && productInfo.ProductParameters.ContainsKey(EnrollmentConfigConstants.CnDcvEmail) + ? productInfo.ProductParameters[EnrollmentConfigConstants.CnDcvEmail] + : null; + + Logger.LogTrace("GetSubjectAlternativeNames: processing {Count} DNS names, methodType='{MethodType}'", + dnsNames.Length, methodType ?? "(null)"); + + foreach (var v in dnsNames) { + if (string.IsNullOrEmpty(v)) + { + Logger.LogTrace("GetSubjectAlternativeNames: skipping null/empty DNS name."); + continue; + } + var domainName = v; var san = new SubjectAlternativeName(); san.DomainName = domainName; - if (methodType.ToUpper() == "EMAIL") + Logger.LogTrace("GetSubjectAlternativeNames: processing domain='{Domain}'", domainName); + + if (!string.IsNullOrEmpty(methodType) && 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); + var emailsRaw = productInfo.ProductParameters.ContainsKey(EnrollmentConfigConstants.AdditionalSansCommaSeparatedDcvEmails) + ? productInfo.ProductParameters[EnrollmentConfigConstants.AdditionalSansCommaSeparatedDcvEmails] + : null; + var emailAddresses = !string.IsNullOrEmpty(emailsRaw) ? emailsRaw.Split(',') : Array.Empty(); + Logger.LogTrace("GetSubjectAlternativeNames: EMAIL validation, {Count} email addresses for domain='{Domain}'", + emailAddresses.Length, domainName); + san.DomainControlValidation = GetDomainControlValidation(methodType, emailAddresses, domainName) + ?? GetDomainControlValidation(methodType, commonNameValidationEmail); + } + else + { + Logger.LogTrace("GetSubjectAlternativeNames: CNAME/other validation for domain='{Domain}'", domainName); + san.DomainControlValidation = GetDomainControlValidation(methodType, commonNameValidationEmail); } - else //it is a CNAME validation so no email is needed - san.DomainControlValidation = GetDomainControlValidation(methodType, ""); subjectNameList.Add(san); } + Logger.LogTrace("GetSubjectAlternativeNames: returning {Count} SANs.", subjectNameList.Count); return subjectNameList; } public ReissueRequest GetReissueRequest(EnrollmentProductInfo productInfo, string uUId, string csr, Dictionary sans, List customFields) { - //var cert = "-----BEGIN CERTIFICATE REQUEST-----\r\n"; - var cert = Pemify(csr); - //cert = cert + "\r\n-----END CERTIFICATE REQUEST-----"; + Logger.LogTrace("GetReissueRequest: building reissue request. UUID='{Uuid}', ProductID='{ProductId}'", + uUId ?? "(null)", productInfo?.ProductID ?? "(null)"); + + if (productInfo?.ProductParameters == null) + throw new ArgumentNullException(nameof(productInfo), "productInfo or ProductParameters cannot be null."); + if (string.IsNullOrEmpty(csr)) + throw new ArgumentNullException(nameof(csr), "CSR cannot be null or empty."); + if (string.IsNullOrEmpty(uUId)) + throw new ArgumentNullException(nameof(uUId), "uUId cannot be null or empty."); + var cert = Pemify(csr); 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 = productInfo.ProductParameters.ContainsKey("CN DCV Email") + ? productInfo.ProductParameters["CN DCV Email"] : null; + var methodType = productInfo.ProductParameters.ContainsKey("Domain Control Validation Method") + ? productInfo.ProductParameters["Domain Control Validation Method"] : null; var certificateType = GetCertificateType(productInfo.ProductID); + Logger.LogTrace("GetReissueRequest: cnDcvEmail='{Email}', methodType='{Method}', certType='{CertType}'", + commonNameValidationEmail ?? "(null)", methodType ?? "(null)", certificateType); + return new ReissueRequest { 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 = productInfo.ProductParameters.ContainsKey("Term") ? productInfo.ProductParameters["Term"] : null, + ApplicantFirstName = productInfo.ProductParameters.ContainsKey("Applicant First Name") ? productInfo.ProductParameters["Applicant First Name"] : null, + ApplicantLastName = productInfo.ProductParameters.ContainsKey("Applicant Last Name") ? productInfo.ProductParameters["Applicant Last Name"] : null, + ApplicantEmailAddress = productInfo.ProductParameters.ContainsKey("Applicant Email Address") ? productInfo.ProductParameters["Applicant Email Address"] : null, + ApplicantPhoneNumber = productInfo.ProductParameters.ContainsKey("Applicant Phone") ? productInfo.ProductParameters["Applicant Phone"] : null, DomainControlValidation = GetDomainControlValidation(methodType, commonNameValidationEmail), Notifications = GetNotifications(productInfo), - OrganizationContact = productInfo.ProductParameters["Organization Contact"], - BusinessUnit = productInfo.ProductParameters["Business Unit"], + OrganizationContact = productInfo.ProductParameters.ContainsKey("Organization Contact") ? productInfo.ProductParameters["Organization Contact"] : null, + BusinessUnit = productInfo.ProductParameters.ContainsKey("Business Unit") ? productInfo.ProductParameters["Business Unit"] : null, 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 }; } private EvCertificateDetails GetEvCertificateDetails(EnrollmentProductInfo productInfo) { + Logger.LogTrace("GetEvCertificateDetails: building EV details."); + var country = productInfo?.ProductParameters != null + && productInfo.ProductParameters.ContainsKey("Organization Country") + ? productInfo.ProductParameters["Organization Country"] + : null; + Logger.LogTrace("GetEvCertificateDetails: country='{Country}'", country ?? "(null)"); var evDetails = new EvCertificateDetails(); - evDetails.Country = productInfo.ProductParameters["Organization Country"]; + evDetails.Country = country; return evDetails; } public int MapReturnStatus(string cscGlobalStatus) { - var returnStatus = 0; + Logger.LogTrace("MapReturnStatus: input status='{Status}'", cscGlobalStatus ?? "(null)"); + + if (string.IsNullOrEmpty(cscGlobalStatus)) + { + Logger.LogWarning("MapReturnStatus: status is null or empty, returning FAILED."); + return (int)EndEntityStatus.FAILED; + } + int returnStatus; switch (cscGlobalStatus) { case "ACTIVE": @@ -346,10 +733,12 @@ public int MapReturnStatus(string cscGlobalStatus) returnStatus = (int)EndEntityStatus.REVOKED; break; default: + Logger.LogWarning("MapReturnStatus: unrecognized status '{Status}', returning FAILED.", cscGlobalStatus); returnStatus = (int)EndEntityStatus.FAILED; break; } + Logger.LogTrace("MapReturnStatus: mapped '{Status}' to {Result}", cscGlobalStatus, returnStatus); return returnStatus; } } \ No newline at end of file diff --git a/docsource/configuration.md b/docsource/configuration.md index d8c196e..122477f 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -10,6 +10,115 @@ This integration is tested and confirmed as working for Anygateway REST 24.2 and The Root certificates for installation on the Anygateway server machine should be obtained from CSC. +## CA Connection Configuration + +When defining the Certificate Authority in the AnyCA Gateway REST portal, configure the following fields on the **CA Connection** tab: + +CONFIG ELEMENT | DESCRIPTION | DEFAULT +---------------|-------------|-------- +Enabled | Flag to Enable or Disable gateway functionality. Set to `false` to allow creating the CA record before configuration information is available; the plugin then short-circuits Ping, Sync, Enroll, and Revoke with a warning until it is re-enabled. | `true` +CscGlobalUrl | The base URL for the CSCGlobal API (e.g. `https://apis.cscglobal.com`) | (required) +ApiKey | Your CSCGlobal API key | (required) +BearerToken | Your CSCGlobal Bearer token for authentication | (required) +DefaultPageSize | Page size for API list requests | 100 +SyncFilterDays | Number of days from today used to filter certificates by expiration date during **incremental** sync. Only certificates expiring within this window are returned. Does not apply to full sync. | 5 +RenewalWindowDays | Number of days before the annual order expiry date within which a **RenewOrReissue** request triggers a paid **Renewal** rather than a free **Reissue**. See [Renewal vs. Reissue Logic](#renewal-vs-reissue-logic) below. | 30 +DcvPollTimeoutSeconds | Max seconds to synchronously poll CSC for certificate issuance after submitting an order. `0` disables polling (enrollment returns pending immediately; cert arrives on the next sync). When `>0`, fast-validating orders can return the issued cert directly in the enrollment response. See [Synchronous Issuance Polling](#synchronous-issuance-polling) below. | 0 + +> **Note:** DNS auto-publishing for CNAME DCV is handled by the AnyCA Gateway REST framework's Domain Validation system (gateway 3.3+). It's configured in the gateway UI under **Domain Validation Configurations**, not on the CA Connection tab. See [DNS Auto-Publishing (CNAME DCV)](#dns-auto-publishing-cname-dcv). + +## Renewal vs. Reissue Logic + +CSC Global subscriptions are annual orders. When Keyfactor Command sends a **RenewOrReissue** request, the plugin must decide whether to submit a **Renewal** (a new paid order) or a **Reissue** (a free re-key under the existing active order). + +The decision is based on the **RenewalWindowDays** setting and works as follows: + +1. The plugin fetches the original certificate from CSC and reads its `orderDate`. +2. It computes the **order expiry** as `orderDate + 1 year`. +3. It calculates **days remaining** until the order expires. +4. If `days remaining <= RenewalWindowDays`, the request is treated as a **Renewal** (new paid order). +5. If `days remaining > RenewalWindowDays`, the request is treated as a **Reissue** (free under the active order). + +**Example with default RenewalWindowDays = 30:** + +``` +Order Date: 2025-04-08 +Order Expiry: 2026-04-08 +Today: 2026-03-15 +Days Left: 24 + +24 <= 30 --> RENEWAL (new paid order) +``` + +``` +Order Date: 2025-04-08 +Order Expiry: 2026-04-08 +Today: 2025-09-01 +Days Left: 219 + +219 > 30 --> REISSUE (free under active order) +``` + +**Fallback behavior:** If the plugin cannot retrieve the `orderDate` from CSC (e.g., API error or missing field), it falls back to checking the certificate's expiration date. If the certificate is already expired, it treats the request as a Renewal. + +**Note:** Both Renewal and Reissue submissions are asynchronous at CSC. The plugin returns a "pending" status and the issued certificate will appear in Keyfactor after the next sync cycle. + +## DNS Auto-Publishing (CNAME DCV) + +CSC supports two Domain Control Validation (DCV) methods: **EMAIL** and **CNAME**. With CNAME validation, CSC returns a CNAME record (name → target) that must exist in DNS before they will validate the order. + +By default this plugin returns the CNAME details to Keyfactor Command for **manual publishing**. To fully automate enrollment, the plugin uses the **AnyCA Gateway REST framework's built-in DNS provider system** (available in framework 3.3 and later). The framework discovers DNS provider plugins deployed alongside the CA plugin and routes each CNAME to whichever provider claims the matching DNS zone. + +### Requirements + +* AnyCA Gateway REST framework **3.3 or later** (the `IDomainValidatorFactory` interface ships in `Keyfactor.AnyGateway.IAnyCAPlugin` 3.3+). +* At least one DNS provider DLL (e.g. GoDaddy, Cloudflare, Route 53, Azure) deployed in the gateway `Extensions` folder. +* A Domain Validation Configuration registered in the gateway UI that maps your domain(s) to the deployed provider (for example, `*.example.com` → GoDaddy). + +### How It Works + +1. CSC returns the CNAME `name → target` details in the enrollment response. +2. For each CNAME entry, the plugin calls `IDomainValidatorFactory.ResolveDomainValidator(recordName, "cname")`. +3. The framework returns the `IDomainValidator` whose Domain Validation Configuration matches the record's zone (or `null` if no match). +4. The plugin calls `validator.StageValidation(recordName, cnameTarget, ct)` to publish the record. +5. CSC asynchronously validates the CNAME; the issued certificate appears on the next sync. + +### Behavior + +* **Resolution is per record, not per CA.** One CA can drive multiple DNS providers (GoDaddy for some domains, Route 53 for others) with no per-CA configuration. +* **Only invoked for CNAME DCV.** Templates configured with EMAIL validation are unaffected — no DNS publishing occurs. +* **Best-effort.** If no provider claims the zone, the publish call fails, or the factory wasn't injected (gateway pre-3.3), the enrollment still succeeds and the CNAME details remain in the Keyfactor request so a human can publish manually as a fallback. +* **Trace-logged.** Every resolution (matched/unresolved) and publish attempt (success/failure) is logged at Info/Trace level. +* **Validation type string.** The plugin passes `"cname"` to `ResolveDomainValidator`. CSC's DCV requires a **CNAME** record, which is different from ACME's `"dns-01"` challenge (a TXT record). A single DNS provider DLL can ship multiple validator classes — one advertising `"dns-01"` (publishes TXT, for ACME) and one advertising `"cname"` (publishes CNAME, for CSC). You must deploy and configure a validator that advertises `"cname"` or no provider will match. +* **Trailing dots normalized.** CSC returns FQDN-canonical names with a trailing dot (e.g. `_token.example.com.`). The plugin strips the trailing dot before resolution and publishing, because Domain Validation Configurations and DNS provider APIs expect names without it. + +### Configuration in the Gateway UI + +In the AnyCA Gateway REST portal, under **Domain Validation Configurations**: + +1. **Add** a new configuration. +2. Pick a **Domain Validator Type** that publishes **CNAME** records and advertises validation type `cname`. For GoDaddy this is `GoDaddyCnameDomainValidator` (the `GoDaddyDomainValidator` variant publishes TXT for ACME and will **not** work for CSC). +3. Add one or more **domain patterns** (e.g. `*.example.com`). +4. Fill out the provider-specific **Configuration Settings** (API keys, base URL, etc.). +5. Save. + +Once configured, any CSC enrollment for a domain matching one of those patterns will have its CNAME auto-published. + +> **Common pitfall:** If you configure the TXT/`dns-01` validator (e.g. `GoDaddyDomainValidator`) for a CSC domain, the record will publish as a **TXT** and CSC's CNAME validation will never succeed. Make sure you select the **CNAME** validator variant. + +## Synchronous Issuance Polling + +CSC validates domain control asynchronously — after an order is submitted (and the CNAME DCV record published), CSC/Sectigo polls public DNS on its own schedule and issues the certificate once validation passes. By default this plugin returns a **pending** (`EXTERNALVALIDATION`) result immediately and the issued certificate is picked up on the next gateway **sync** cycle. + +For environments where DNS is published automatically (see [DNS Auto-Publishing](#dns-auto-publishing-cname-dcv)) and validation tends to complete quickly, you can have the plugin **poll CSC synchronously** at the end of enrollment and return the issued certificate directly — avoiding the wait for the next sync. + +* Set **`DcvPollTimeoutSeconds`** to the maximum number of seconds to poll (e.g. `60`). `0` (default) disables polling entirely. +* The plugin polls CSC every 10 seconds until the order is issued or the timeout is reached. +* If the certificate issues within the window, the enrollment returns it immediately with a success status. +* If the window expires, the plugin falls back to the **pending** result and the certificate arrives on the next sync — exactly as it would with polling disabled. + +**Tradeoff:** Polling blocks the enrollment request for up to `DcvPollTimeoutSeconds`. CSC validation frequently takes minutes to hours, so most orders will still fall through to pending — keep the timeout small (30–90s) to catch only the fast cases without hanging callers. This applies to New enrollments, Renewals, and Reissues. + ## Certificate Template Creation Step PLEASE NOTE, AT THIS TIME THE RAPID_SSL TEMPLATE IS NOT SUPPORTED BY THE CSC API AND WILL NOT WORK WITH THIS INTEGRATION @@ -20,16 +129,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 +153,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 EV - 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 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 EV Certificate - Enrollment Fields** +**CSC TrustedSecure EV - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -73,20 +182,20 @@ 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 -----|--------------|----------------- @@ -103,20 +212,20 @@ CN DCV Email | String | N/A Addtl Sans Comma Separated DCV Emails | String | N/A -**CSC TrustedSecure Premium Wildcard Certificate - Details Tab** +**CSC TrustedSecure OV Wildcard - 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 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 Premium Wildcard Certificate - Enrollment Fields** +**CSC TrustedSecure OV Wildcard - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- @@ -131,20 +240,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 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 +268,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 +296,108 @@ 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 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 DV, 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 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 +Addtl Sans Comma Separated DCV Emails | String | N/A +Organization Country | 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 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 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 Domain Validated UC Certificate - Enrollment Fields** +**CSC TrustedSecure DV Wildcard, Multiple Names - Enrollment Fields** NAME | DATA TYPE | VALUES -----|--------------|----------------- diff --git a/integration-manifest.json b/integration-manifest.json index 2b4b8c4..978dacc 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -2,12 +2,12 @@ "$schema": "https://keyfactor.github.io/integration-manifest-schema.json", "integration_type": "anyca-plugin", "name": "CSCGlobal CAPlugin REST Gateway Plugin", - "status": "pilot", + "status": "production", "support_level": "kf-supported", "link_github": true, "update_catalog": true, "description": "CSCGlobal CAPlugin for the AnyCA REST Gateway framework", - "gateway_framework": "24.2.0", + "gateway_framework": "26.2.0", "release_project": "cscglobal-caplugin/CSCGlobalCAPlugin.csproj", "release_dir": "cscglobal-caplugin/bin/Release", "about": { @@ -29,13 +29,17 @@ "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." + }, + { + "name": "RenewalWindowDays", + "description": "Number of days before the annual order expiry within which a RenewOrReissue triggers a paid Renewal rather than a free Reissue. Default is 30." + }, + { + "name": "DcvPollTimeoutSeconds", + "description": "Max seconds to synchronously poll CSC for issuance after submitting an order (and publishing CNAME DCV). 0 disables polling (enrollment returns pending immediately; cert arrives on next sync). When >0, fast-validating orders can return the cert directly. Keep small to avoid long-blocking enrollment requests." } ], "enrollment_config": [ @@ -89,13 +93,16 @@ } ], "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 1123ee782d93d2519943aac01b9ce22b6ae47cdb Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Mon, 21 Sep 2026 19:57:11 -0400 Subject: [PATCH 38/42] Sync ProductID directly from CSC's certificateType, drop reverse-mapping table Synced DV/EV/UC/Premium certs weren't mapping to their Certificate Profile in Command because CodeToProductIdMap resolved them back to the old pre-1.2.0 legacy product names, which no longer match the canonical Template Short Names configured in Command. Follow the same fix already proven on feature/ev-ov-dv-multiname-certs: pass CSC's certificateType straight through as ProductID during sync instead of remapping it, since CSC's API already returns the current/canonical product name directly. --- .../CSCGlobalCAPluginTests.cs | 7 +- .../RequestManagerTests.cs | 24 +------ cscglobal-caplugin/CSCGlobalCAPlugin.cs | 6 +- cscglobal-caplugin/RequestManager.cs | 69 ------------------- 4 files changed, 11 insertions(+), 95 deletions(-) diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs index 9f60d0c..c3402e6 100644 --- a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs @@ -420,7 +420,7 @@ public async Task Synchronize_ValidCertificateContent_AddsToBufferWithMappedProd { Results = new List { - new CertificateResponse { Uuid = "u1", Status = "ACTIVE", Certificate = apiBase64, CertificateType = "4" } + new CertificateResponse { Uuid = "u1", Status = "ACTIVE", Certificate = apiBase64, CertificateType = "CSC TrustedSecure DV" } } }); @@ -432,7 +432,10 @@ public async Task Synchronize_ValidCertificateContent_AddsToBufferWithMappedProd var items = buffer.ToArray(); Assert.Single(items); Assert.Equal("u1", items[0].CARequestID); - Assert.Equal("CSC TrustedSecure Domain Validated SSL", items[0].ProductID); + // CSC's list/sync API returns the certificate's current product name directly, so the + // synced ProductID must match it verbatim (and therefore match the canonical Certificate + // Profile name configured in Command) rather than going through a name-remapping table. + Assert.Equal("CSC TrustedSecure DV", items[0].ProductID); } [Fact] diff --git a/cscglobal-caplugin.Tests/RequestManagerTests.cs b/cscglobal-caplugin.Tests/RequestManagerTests.cs index 4bed6c6..0762605 100644 --- a/cscglobal-caplugin.Tests/RequestManagerTests.cs +++ b/cscglobal-caplugin.Tests/RequestManagerTests.cs @@ -402,7 +402,7 @@ public void GetRevokeResult_Success_ReturnsRevoked() } // --------------------------------------------------------------------- - // MapReturnStatus / MapCertificateTypeToProductId + // MapReturnStatus // --------------------------------------------------------------------- [Theory] @@ -417,28 +417,6 @@ public void MapReturnStatus_MapsExpectedStatus(string? cscStatus, Keyfactor.PKI. Assert.Equal((int)expected, Manager.MapReturnStatus(cscStatus!)); } - [Theory] - [InlineData("4", "CSC TrustedSecure Domain Validated SSL")] - [InlineData("CSC TrustedSecure Domain Validated SSL", "CSC TrustedSecure Domain Validated SSL")] - [InlineData("CSC Trusted Secure Domain Validated SSL", "CSC TrustedSecure Domain Validated SSL")] - [InlineData("9", "CSC TrustedSecure DV Wildcard, Multiple Names")] - public void MapCertificateTypeToProductId_KnownValue_MapsToProductId(string cscType, string expectedProductId) - { - Assert.Equal(expectedProductId, Manager.MapCertificateTypeToProductId(cscType)); - } - - [Fact] - public void MapCertificateTypeToProductId_UnknownValue_PassesThrough() - { - Assert.Equal("SomeUnknownType", Manager.MapCertificateTypeToProductId("SomeUnknownType")); - } - - [Fact] - public void MapCertificateTypeToProductId_Null_ReturnsFallback() - { - Assert.Equal("CscGlobal", Manager.MapCertificateTypeToProductId(null!)); - } - // --------------------------------------------------------------------- // GetNotifications // --------------------------------------------------------------------- diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index b5ae44f..96bddaa 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -434,7 +434,11 @@ private async Task SyncCertificates(BlockingCollection b if (certStatus == Convert.ToInt32(EndEntityStatus.GENERATED) || certStatus == Convert.ToInt32(EndEntityStatus.REVOKED)) { - var productId = _requestManager.MapCertificateTypeToProductId(currentResponseItem.CertificateType); + // CSC's list/sync API returns the certificate's current product name directly + // (e.g. "CSC TrustedSecure DV"), which already matches the canonical Product ID + // used for enrollment - no reverse lookup needed, same as the CSC-name-is-truth + // approach taken on feature/ev-ov-dv-multiname-certs. + var productId = currentResponseItem.CertificateType ?? "CscGlobal"; Logger.LogTrace("SyncCertificates: UUID={Uuid} qualifies for sync. CertificateType='{CertType}' -> ProductId='{ProductId}'", currentResponseItem.Uuid, currentResponseItem.CertificateType ?? "(null)", productId); diff --git a/cscglobal-caplugin/RequestManager.cs b/cscglobal-caplugin/RequestManager.cs index 00522b0..9f9bcb9 100644 --- a/cscglobal-caplugin/RequestManager.cs +++ b/cscglobal-caplugin/RequestManager.cs @@ -422,59 +422,6 @@ public RegistrationRequest GetRegistrationRequest(EnrollmentProductInfo productI public bool IsKnownProductId(string productId) => !string.IsNullOrEmpty(productId) && ProductIdToCodeMap.ContainsKey(productId); - // Reverse map: CSC API certificateType string -> Keyfactor product ID (used during sync) - // Note: CSC naming is inconsistent — first 4 types use "TrustedSecure" (no space), - // DV Wildcard and DV UC use "Trusted Secure" (with space), - // but CSC API returns DV SSL as "CSC Trusted Secure Domain Validated SSL" (with space) - // while the product ID is "CSC TrustedSecure Domain Validated SSL" (no space). - private static readonly Dictionary CodeToProductIdMap = new(StringComparer.OrdinalIgnoreCase) - { - // Premium - ["0"] = "CSC TrustedSecure Premium Certificate", - ["CSC TrustedSecure Premium Certificate"] = "CSC TrustedSecure Premium Certificate", - ["CSC Trusted Secure Premium Certificate"] = "CSC TrustedSecure Premium Certificate", - ["CSC TrustedSecure OV"] = "CSC TrustedSecure Premium Certificate", - // Premium Wildcard - ["1"] = "CSC TrustedSecure Premium Wildcard Certificate", - ["CSC TrustedSecure Premium Wildcard Certificate"] = "CSC TrustedSecure Premium Wildcard Certificate", - ["CSC Trusted Secure Premium Wildcard Certificate"] = "CSC TrustedSecure Premium Wildcard Certificate", - ["CSC TrustedSecure OV Wildcard"] = "CSC TrustedSecure Premium Wildcard Certificate", - // UC - ["2"] = "CSC TrustedSecure UC Certificate", - ["CSC TrustedSecure UC Certificate"] = "CSC TrustedSecure UC Certificate", - ["CSC Trusted Secure UC Certificate"] = "CSC TrustedSecure UC Certificate", - ["CSC TrustedSecure OV, Multiple Names"] = "CSC TrustedSecure UC Certificate", - // EV - ["3"] = "CSC TrustedSecure EV Certificate", - ["CSC TrustedSecure EV Certificate"] = "CSC TrustedSecure EV Certificate", - ["CSC Trusted Secure EV Certificate"] = "CSC TrustedSecure EV Certificate", - ["CSC TrustedSecure EV"] = "CSC TrustedSecure EV Certificate", - // DV SSL — product ID has no space, but CSC API returns with space - ["4"] = "CSC TrustedSecure Domain Validated SSL", - ["CSC TrustedSecure Domain Validated SSL"] = "CSC TrustedSecure Domain Validated SSL", - ["CSC Trusted Secure Domain Validated SSL"] = "CSC TrustedSecure Domain Validated SSL", - ["CSC TrustedSecure DV"] = "CSC TrustedSecure Domain Validated SSL", - // DV Wildcard — product ID has space (matches CSC API) - ["5"] = "CSC Trusted Secure Domain Validated Wildcard SSL", - ["CSC Trusted Secure Domain Validated Wildcard SSL"] = "CSC Trusted Secure Domain Validated Wildcard SSL", - ["CSC TrustedSecure Domain Validated Wildcard SSL"] = "CSC Trusted Secure Domain Validated Wildcard SSL", - ["CSC TrustedSecure DV Wildcard"] = "CSC Trusted Secure Domain Validated Wildcard SSL", - // DV UC — product ID has space (matches CSC API) - ["6"] = "CSC Trusted Secure Domain Validated UC Certificate", - ["CSC Trusted Secure Domain Validated UC Certificate"] = "CSC Trusted Secure Domain Validated UC Certificate", - ["CSC TrustedSecure Domain Validated UC Certificate"] = "CSC Trusted Secure Domain Validated UC Certificate", - ["CSC TrustedSecure DV, Multiple Names"] = "CSC Trusted Secure Domain Validated UC Certificate", - // EV, Multiple Names — new in 1.2.0, no legacy name - ["7"] = "CSC TrustedSecure EV, Multiple Names", - ["CSC TrustedSecure EV, Multiple Names"] = "CSC TrustedSecure EV, Multiple Names", - // OV Wildcard, Multiple Names — new in 1.2.0, no legacy name - ["8"] = "CSC TrustedSecure OV Wildcard, Multiple Names", - ["CSC TrustedSecure OV Wildcard, Multiple Names"] = "CSC TrustedSecure OV Wildcard, Multiple Names", - // DV Wildcard, Multiple Names — new in 1.2.0, no legacy name - ["9"] = "CSC TrustedSecure DV Wildcard, Multiple Names", - ["CSC TrustedSecure DV Wildcard, Multiple Names"] = "CSC TrustedSecure DV Wildcard, Multiple Names", - }; - private string GetCertificateType(string productId) { Logger.LogTrace("GetCertificateType: productId='{ProductId}'", productId ?? "(null)"); @@ -487,22 +434,6 @@ private string GetCertificateType(string productId) return "-1"; } - /// - /// Maps a CSC API certificateType value back to a Keyfactor product ID. - /// Handles numeric codes, descriptive strings, and passthrough of already-correct values. - /// - public string MapCertificateTypeToProductId(string cscCertificateType) - { - Logger.LogTrace("MapCertificateTypeToProductId: input='{CscCertType}'", cscCertificateType ?? "(null)"); - if (!string.IsNullOrEmpty(cscCertificateType) && CodeToProductIdMap.TryGetValue(cscCertificateType, out var productId)) - { - Logger.LogTrace("MapCertificateTypeToProductId: mapped '{CscCertType}' -> '{ProductId}'", cscCertificateType, productId); - return productId; - } - Logger.LogWarning("MapCertificateTypeToProductId: no mapping for '{CscCertType}', passing through as-is.", cscCertificateType); - return cscCertificateType ?? "CscGlobal"; - } - public Notifications GetNotifications(EnrollmentProductInfo productInfo) { Logger.LogTrace("GetNotifications: building notifications."); From 1ee7a73d09d2d8afdf23351cdfd0fb454d3c96a4 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Mon, 21 Sep 2026 20:53:21 -0400 Subject: [PATCH 39/42] Fix Enroll failures reporting INPROCESS instead of FAILED, surface flow summary in StatusMessage Every early-return failure path in Enroll (plus the catch-all exception handler) set Status = 30 (INPROCESS) instead of FAILED, so Command treated these as still-pending rather than errors and never surfaced them in the UI. Follow the same fix already proven on feature/ev-ov-dv-multiname-certs: use EndEntityStatus.FAILED throughout, and add FlowLogger.GetSummary() (a concise step list, distinct from the ASCII-tree RenderFlow() used for Trace logs) to prepend what the plugin actually attempted ahead of the terse error text. Also fixes the reissue "one click not available" message incorrectly saying "Renew" instead of "Reissue". --- cscglobal-caplugin/CSCGlobalCAPlugin.cs | 64 ++++++++++++------------- cscglobal-caplugin/FlowLogger.cs | 33 +++++++++++++ 2 files changed, 65 insertions(+), 32 deletions(-) diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index 96bddaa..457ef42 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -678,8 +678,8 @@ await flow.StepAsync("SubmitRegistrationToCSC", async () => flow.Fail("ParseResponse", "API returned null"); return new EnrollmentResult { - Status = 30, - StatusMessage = "Enrollment failed: CSC API returned a null response." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nEnrollment failed: CSC API returned a null response." }; } flow.Step("ParseResponse", $"error={enrollmentResponse.RegistrationError != null}"); @@ -690,8 +690,8 @@ await flow.StepAsync("SubmitRegistrationToCSC", async () => flow.Fail("RejectExpiredRenew", "PriorCertSN present on New enrollment"); return new EnrollmentResult { - Status = 30, - 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." }; } @@ -726,8 +726,8 @@ await flow.StepAsync("PollForIssuance", async () => flow.Fail("ValidatePriorSN", "PriorCertSN is empty"); return new EnrollmentResult { - Status = 30, - StatusMessage = "RenewOrReissue failed: PriorCertSN is required but was not provided." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nRenewOrReissue failed: PriorCertSN is required but was not provided." }; } @@ -742,8 +742,8 @@ await flow.StepAsync("LookupOrderId", async () => flow.Fail("ValidateOrderId", $"no order found for SN={priorSn}"); return new EnrollmentResult { - Status = 30, - StatusMessage = $"RenewOrReissue failed: could not find order ID for serial number '{priorSn}'." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nRenewOrReissue failed: could not find order ID for serial number '{priorSn}'." }; } @@ -752,8 +752,8 @@ await flow.StepAsync("LookupOrderId", async () => flow.Fail("ValidateOrderId", $"order_id too short ({order_id.Length} chars)"); return new EnrollmentResult { - Status = 30, - StatusMessage = $"RenewOrReissue failed: order ID '{order_id}' is too short to extract a UUID." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nRenewOrReissue failed: order ID '{order_id}' is too short to extract a UUID." }; } flow.Step("ValidateOrderId", $"orderId={order_id}"); @@ -801,8 +801,8 @@ await flow.StepAsync("FetchLiveCertForDecision", async () => flow.Fail("FallbackExpiryCheck", fallbackEx.Message); return new EnrollmentResult { - Status = 30, - StatusMessage = $"RenewOrReissue failed: unable to determine renewal status for order '{order_id}'. {fallbackEx.Message}" + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nRenewOrReissue failed: unable to determine renewal status for order '{order_id}'. {fallbackEx.Message}" }; } } @@ -825,8 +825,8 @@ await flow.StepAsync("LookupRenewalUUID", async () => flow.Fail("ValidateRenewalUUID", "could not resolve PriorCertSN"); return new EnrollmentResult { - Status = 30, - StatusMessage = "Renewal failed: could not resolve prior certificate serial number to a request ID." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nRenewal failed: could not resolve prior certificate serial number to a request ID." }; } flow.Step("ValidateRenewalUUID", $"uuid={uUId}"); @@ -850,8 +850,8 @@ await flow.StepAsync("SubmitRenewalToCSC", async () => flow.Fail("ParseRenewalResponse", "API returned null"); return new EnrollmentResult { - Status = 30, - StatusMessage = "Renewal failed: CSC API returned a null response." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nRenewal failed: CSC API returned a null response." }; } @@ -871,9 +871,9 @@ await flow.StepAsync("PollForIssuance", async () => flow.Fail("MissingEnrollmentParams", "Applicant Last Name not present — one-click renew unavailable"); return new EnrollmentResult { - Status = 30, + 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." }; } @@ -892,8 +892,8 @@ await flow.StepAsync("LookupReissueRequestId", async () => flow.Fail("ValidateReissueRequestId", "could not resolve PriorCertSN"); return new EnrollmentResult { - Status = 30, - StatusMessage = "Reissue failed: could not resolve prior certificate serial number to a request ID." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nReissue failed: could not resolve prior certificate serial number to a request ID." }; } @@ -902,8 +902,8 @@ await flow.StepAsync("LookupReissueRequestId", async () => flow.Fail("ValidateReissueRequestId", $"requestid too short ({requestid.Length} chars)"); return new EnrollmentResult { - Status = 30, - StatusMessage = $"Reissue failed: request ID '{requestid}' is too short to extract a UUID." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nReissue failed: request ID '{requestid}' is too short to extract a UUID." }; } @@ -929,8 +929,8 @@ await flow.StepAsync("SubmitReissueToCSC", async () => flow.Fail("ParseReissueResponse", "API returned null"); return new EnrollmentResult { - Status = 30, - StatusMessage = "Reissue failed: CSC API returned a null response." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nReissue failed: CSC API returned a null response." }; } @@ -950,17 +950,17 @@ await flow.StepAsync("PollForIssuance", async () => flow.Fail("MissingEnrollmentParams", "Applicant Last Name not present — one-click reissue unavailable"); return new EnrollmentResult { - Status = 30, + 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." }; default: flow.Fail("UnhandledType", $"enrollmentType={enrollmentType}"); return new EnrollmentResult { - Status = 30, - StatusMessage = $"Enroll failed: unhandled enrollment type '{enrollmentType}'." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nEnroll failed: unhandled enrollment type '{enrollmentType}'." }; } } @@ -971,8 +971,8 @@ await flow.StepAsync("PollForIssuance", async () => Logger.LogError(inner, "Enroll: AggregateException during {EnrollmentType}: {Message}", enrollmentType, inner?.Message ?? ae.Message); return new EnrollmentResult { - Status = 30, - StatusMessage = $"Enrollment failed with error: {inner?.Message ?? ae.Message}" + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nEnrollment failed with error: {inner?.Message ?? ae.Message}" }; } catch (Exception ex) @@ -981,8 +981,8 @@ await flow.StepAsync("PollForIssuance", async () => Logger.LogError(ex, "Enroll: unhandled exception during {EnrollmentType}: {Message}", enrollmentType, ex.Message); return new EnrollmentResult { - Status = 30, - StatusMessage = $"Enrollment failed with error: {ex.Message}" + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nEnrollment failed with error: {ex.Message}" }; } } diff --git a/cscglobal-caplugin/FlowLogger.cs b/cscglobal-caplugin/FlowLogger.cs index 5696fcd..cd6718f 100644 --- a/cscglobal-caplugin/FlowLogger.cs +++ b/cscglobal-caplugin/FlowLogger.cs @@ -219,6 +219,39 @@ private string RenderFlow() return sb.ToString(); } + /// + /// Concise step-by-step summary suitable for surfacing in a user-facing failure message + /// (unlike 's ASCII-art tree, which is meant for Trace logs only). + /// + public string GetSummary() + { + var hasFailures = _steps.Any(s => s.Status == FlowStepStatus.Failed) || + _steps.SelectMany(s => s.Children).Any(c => c.Status == FlowStepStatus.Failed); + var overallStatus = hasFailures ? "FAILED" : "OK"; + + var sb = new StringBuilder(); + sb.AppendLine($"Flow: {_flowName} [{overallStatus}] Total: {_totalTimer.ElapsedMilliseconds}ms"); + sb.AppendLine("----------------------------------------"); + + foreach (var step in _steps) + { + AppendSummaryLine(sb, step, 0); + foreach (var child in step.Children) + AppendSummaryLine(sb, child, 1); + } + + return sb.ToString(); + } + + private static void AppendSummaryLine(StringBuilder sb, FlowStep step, int indentLevel) + { + var indent = new string(' ', indentLevel * 2); + var icon = GetStatusIcon(step.Status); + var elapsed = step.ElapsedMs > 0 ? $" ({step.ElapsedMs}ms)" : ""; + var detail = !string.IsNullOrEmpty(step.Detail) ? $" - {step.Detail}" : ""; + sb.AppendLine($"{indent}{icon} {step.Name}{elapsed}{detail}"); + } + private static string GetStatusIcon(FlowStepStatus status) { return status switch From d6fdefa0ec38c238570840857ddee20a35b8a528 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Mon, 21 Sep 2026 21:18:14 -0400 Subject: [PATCH 40/42] Attach flow summary to EnrollmentContext on success, not just StatusMessage Command's enrollment UI doesn't surface StatusMessage on a successful/pending result at all - only EnrollmentContext is shown, so the flow summary added for failures was invisible on the success path. Add AttachFlowSummary (following the same fix already proven on feature/ev-ov-dv-multiname-certs) to also attach a "Flow Summary" entry to EnrollmentContext for success/pending results, alongside whatever DCV instructions came back, and use it for the FAILED results returned from GetEnrollmentResult/GetRenewResponse/ GetReIssueResult, which the earlier Status=30 fix didn't cover since they're already FAILED coming out of RequestManager. Called after TryPublishCnameDcvAsync in the New enrollment path so the "Flow Summary" entry is never present yet when DNS auto-publish walks EnrollmentContext looking for real CNAME records to publish. --- .../CSCGlobalCAPluginTests.cs | 50 +++++++++++++++++++ cscglobal-caplugin/CSCGlobalCAPlugin.cs | 28 +++++++++++ 2 files changed, 78 insertions(+) diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs index c3402e6..a062965 100644 --- a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs @@ -837,6 +837,56 @@ public async Task Enroll_New_Success_ReturnsExternalValidation() Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); Assert.Equal("uuid-new", result.CARequestID); + // Command's enrollment UI doesn't surface StatusMessage on a successful/pending result - + // only EnrollmentContext is - so the flow summary must be attached there instead. + Assert.NotNull(result.EnrollmentContext); + Assert.True(result.EnrollmentContext.ContainsKey("Flow Summary")); + Assert.Contains("Enroll-New", result.EnrollmentContext["Flow Summary"]); + } + + [Fact] + public async Task Enroll_New_SuccessWithDcvDetails_KeepsDcvEntriesAlongsideFlowSummary() + { + 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 = "dcv.example.com", + Status = new Status { Uuid = "uuid-dcv" }, + DcvDetails = new List + { + new DcvDetail { CName = new CName { Name = "_dnsauth.example.com", Value = "token" } } + } + } + }); + + var plugin = MakePlugin(mockClient); + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), ProductInfo(), + RequestFormat.PKCS10, EnrollmentType.New); + + Assert.Equal("token", result.EnrollmentContext["_dnsauth.example.com"]); + Assert.True(result.EnrollmentContext.ContainsKey("Flow Summary")); + } + + [Fact] + public async Task Enroll_New_RegistrationErrorFromCsc_PrependsFlowSummaryToStatusMessage() + { + 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 = "Open order in progress" } + }); + + 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.Contains("Enroll-New", result.StatusMessage); + Assert.Contains("Open order in progress", result.StatusMessage); } [Fact] diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index 457ef42..c84f909 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -711,10 +711,12 @@ await flow.StepAsync("PollForIssuance", async () => if (newPolled != null) { flow.Step("PollResult", "issued during poll window"); + AttachFlowSummary(newPolled, flow); Logger.MethodExit(LogLevel.Debug); return newPolled; } + AttachFlowSummary(enrollResult, flow); Logger.MethodExit(LogLevel.Debug); return enrollResult; @@ -864,6 +866,7 @@ await flow.StepAsync("PollForIssuance", async () => { renewPolled = await TryPollForIssuedCertAsync(renewResult?.CARequestID); }); + AttachFlowSummary(renewPolled ?? renewResult, flow); Logger.MethodExit(LogLevel.Debug); return renewPolled ?? renewResult; } @@ -943,6 +946,7 @@ await flow.StepAsync("PollForIssuance", async () => { reissuePolled = await TryPollForIssuedCertAsync(reissueResult?.CARequestID); }); + AttachFlowSummary(reissuePolled ?? reissueResult, flow); Logger.MethodExit(LogLevel.Debug); return reissuePolled ?? reissueResult; } @@ -987,6 +991,30 @@ await flow.StepAsync("PollForIssuance", async () => } } + // 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. Command's enrollment + // UI does not surface StatusMessage on a successful/pending result at all - only + // EnrollmentContext is - so attach the summary there instead, as its own entry alongside + // whatever DCV instructions came back. Must be called after TryPublishCnameDcvAsync, which + // treats every EnrollmentContext entry as a candidate DNS record to publish - calling this + // first would make it try to publish "Flow Summary" as a CNAME. + private static void AttachFlowSummary(EnrollmentResult? result, FlowLogger flow) + { + 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 public async Task Ping() { From 32a132c3a179bb25b948e8ce58537a1c6c737e21 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Mon, 21 Sep 2026 21:37:20 -0400 Subject: [PATCH 41/42] Render flow summary as one bullet per step instead of one blob Matches the same fix already proven on feature/ev-ov-dv-multiname-certs: add FlowLogger.GetSummaryEntries(), which returns one dictionary entry per step (plus a header entry) instead of a single multi-line block. AttachFlowSummary now merges these into EnrollmentContext directly so Command's bulleted rendering shows a readable line per step, rather than one run-on entry with embedded newlines that don't render as separate bullets. --- .../CSCGlobalCAPluginTests.cs | 10 ++-- cscglobal-caplugin.Tests/FlowLoggerTests.cs | 45 ++++++++++++++++++ cscglobal-caplugin/CSCGlobalCAPlugin.cs | 6 ++- cscglobal-caplugin/FlowLogger.cs | 47 +++++++++++++++++++ 4 files changed, 103 insertions(+), 5 deletions(-) diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs index a062965..6443a8d 100644 --- a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs @@ -838,10 +838,11 @@ public async Task Enroll_New_Success_ReturnsExternalValidation() Assert.Equal((int)EndEntityStatus.EXTERNALVALIDATION, result.Status); Assert.Equal("uuid-new", result.CARequestID); // Command's enrollment UI doesn't surface StatusMessage on a successful/pending result - - // only EnrollmentContext is - so the flow summary must be attached there instead. + // only EnrollmentContext is - so the flow summary must be attached there instead, one + // bullet per step so it renders readably rather than as a single run-on blob. Assert.NotNull(result.EnrollmentContext); - Assert.True(result.EnrollmentContext.ContainsKey("Flow Summary")); - Assert.Contains("Enroll-New", result.EnrollmentContext["Flow Summary"]); + Assert.True(result.EnrollmentContext.ContainsKey("Flow: Enroll-New")); + Assert.True(result.EnrollmentContext.Keys.Count(k => k.StartsWith("Flow Step ")) > 1); } [Fact] @@ -867,7 +868,8 @@ public async Task Enroll_New_SuccessWithDcvDetails_KeepsDcvEntriesAlongsideFlowS RequestFormat.PKCS10, EnrollmentType.New); Assert.Equal("token", result.EnrollmentContext["_dnsauth.example.com"]); - Assert.True(result.EnrollmentContext.ContainsKey("Flow Summary")); + Assert.True(result.EnrollmentContext.ContainsKey("Flow: Enroll-New")); + Assert.True(result.EnrollmentContext.Keys.Count(k => k.StartsWith("Flow Step ")) > 1); } [Fact] diff --git a/cscglobal-caplugin.Tests/FlowLoggerTests.cs b/cscglobal-caplugin.Tests/FlowLoggerTests.cs index 2dea519..a9834fd 100644 --- a/cscglobal-caplugin.Tests/FlowLoggerTests.cs +++ b/cscglobal-caplugin.Tests/FlowLoggerTests.cs @@ -117,6 +117,51 @@ public void EndBranch_WithoutBranch_DoesNotThrow() flow.EndBranch(); } + [Fact] + public void GetSummaryEntries_OneEntryPerStepPlusHeader() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "MyFlow"); + flow.Step("StepOne"); + flow.Fail("StepTwo", "boom"); + + var entries = flow.GetSummaryEntries(); + + Assert.True(entries.ContainsKey("Flow: MyFlow")); + Assert.Contains("FAILED", entries["Flow: MyFlow"]); + Assert.Equal(3, entries.Count); // header + 2 steps + Assert.Contains(entries, e => e.Key.Contains("StepOne") && e.Value.Contains("OK")); + Assert.Contains(entries, e => e.Key.Contains("StepTwo") && e.Value.Contains("boom")); + } + + [Fact] + public void GetSummaryEntries_AllStepsSucceed_HeaderReportsOk() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "MyFlow"); + flow.Step("StepOne"); + flow.Step("StepTwo"); + + var entries = flow.GetSummaryEntries(); + + Assert.Contains("[OK]", entries["Flow: MyFlow"]); + } + + [Fact] + public void GetSummaryEntries_BranchChildren_IncludedAsSeparateEntries() + { + using var flow = new FlowLogger(NewLoggerMock().Object, "MyFlow"); + flow.Branch("Inner"); + flow.Step("NestedStep"); + flow.Fail("NestedFail", "inner reason"); + flow.EndBranch(); + flow.Step("TopLevelStep"); + + var entries = flow.GetSummaryEntries(); + + Assert.Contains(entries, e => e.Key.Contains("NestedStep")); + Assert.Contains(entries, e => e.Key.Contains("NestedFail") && e.Value.Contains("inner reason")); + Assert.Contains(entries, e => e.Key.Contains("TopLevelStep")); + } + [Fact] public void Dispose_NoSteps_DoesNotThrow() { diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index c84f909..928e8d7 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -1011,8 +1011,12 @@ private static void AttachFlowSummary(EnrollmentResult? result, FlowLogger flow) return; } + // One EnrollmentContext entry per step (rather than one entry holding the whole + // multi-line summary) so Command's bulleted rendering shows a readable line per step + // instead of a single run-on blob. 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 cd6718f..4ce4ef4 100644 --- a/cscglobal-caplugin/FlowLogger.cs +++ b/cscglobal-caplugin/FlowLogger.cs @@ -252,6 +252,53 @@ private static void AppendSummaryLine(StringBuilder sb, FlowStep step, int inden sb.AppendLine($"{indent}{icon} {step.Name}{elapsed}{detail}"); } + /// + /// 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 allSteps = _steps.Concat(_steps.SelectMany(s => s.Children)).ToList(); + var hasFailures = allSteps.Any(s => s.Status == FlowStepStatus.Failed); + var overallStatus = hasFailures ? "FAILED" : "OK"; + var succeeded = allSteps.Count(s => s.Status == FlowStepStatus.Success); + var failed = allSteps.Count(s => s.Status == FlowStepStatus.Failed); + var skipped = allSteps.Count(s => s.Status == FlowStepStatus.Skipped); + + var entries = new Dictionary + { + [$"Flow: {_flowName}"] = + $"[{overallStatus}] {_totalTimer.ElapsedMilliseconds}ms total - " + + $"{allSteps.Count} steps ({succeeded} ok, {failed} failed, {skipped} skipped)" + }; + + var stepNumber = 0; + foreach (var step in _steps) + { + stepNumber++; + AddSummaryEntry(entries, step, stepNumber, false); + + foreach (var child in step.Children) + { + stepNumber++; + AddSummaryEntry(entries, child, stepNumber, true); + } + } + + return entries; + } + + private static void AddSummaryEntry(Dictionary entries, FlowStep step, int stepNumber, bool indent) + { + var icon = GetStatusIcon(step.Status); + var time = step.ElapsedMs > 0 ? $" ({step.ElapsedMs}ms)" : ""; + var detail = !string.IsNullOrEmpty(step.Detail) ? $" - {step.Detail}" : ""; + var prefix = indent ? " " : ""; + entries[$"Flow Step {stepNumber:00}: {prefix}{step.Name}"] = $"{icon}{time}{detail}"; + } + private static string GetStatusIcon(FlowStepStatus status) { return status switch From 44bc7ff4245bed4e6fffe4447bb98506057371f3 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Mon, 21 Sep 2026 22:00:42 -0400 Subject: [PATCH 42/42] Rename PublishCnameDcv step and explain why it's a no-op for non-CNAME methods The DcvAutoPublish step (formerly PublishCnameDcv) ran unconditionally on every New enrollment and, for EMAIL-validated certs, silently no-op'd while still showing a bare [OK] under a CNAME-sounding step name in the flow summary - confusing when read back in Command's UI. Add a FlowLogger.StepAsync overload that uses the action's own return value as the step detail (the existing overload's detail parameter is evaluated before the action runs, so it can't reflect what the action decided), and have TryPublishCnameDcvAsync return why it skipped or how many records it published. --- .../CSCGlobalCAPluginTests.cs | 6 +++- cscglobal-caplugin/CSCGlobalCAPlugin.cs | 18 +++++----- cscglobal-caplugin/FlowLogger.cs | 36 +++++++++++++++++++ 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs index 6443a8d..a763f2e 100644 --- a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs @@ -1048,10 +1048,14 @@ public async Task Enroll_New_WithDnsValidatorFactoryButEmailMethod_DoesNotAttemp [EnrollmentConfigConstants.DomainControlValidationMethod] = "EMAIL" }); - await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, + var result = await plugin.Enroll("csr", "CN=test", new Dictionary(), productInfo, RequestFormat.PKCS10, EnrollmentType.New); mockFactory.Verify(f => f.ResolveDomainValidator(It.IsAny(), It.IsAny()), Times.Never); + // The DcvAutoPublish step must explain *why* it was a no-op for a non-CNAME method, + // rather than showing a bare [OK] under a CNAME-sounding step name. + var publishStep = result.EnrollmentContext.Single(e => e.Key.Contains("DcvAutoPublish")); + Assert.Contains("not CNAME", publishStep.Value); } [Fact] diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index 928e8d7..82129a3 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -698,10 +698,7 @@ await flow.StepAsync("SubmitRegistrationToCSC", async () => var enrollResult = _requestManager.GetEnrollmentResult(enrollmentResponse); flow.Step("MapResult", $"Status={enrollResult?.Status}, ID={enrollResult?.CARequestID ?? "(null)"}"); - await flow.StepAsync("PublishCnameDcv", async () => - { - await TryPublishCnameDcvAsync(productInfo, enrollResult); - }); + await flow.StepAsync("DcvAutoPublish", () => TryPublishCnameDcvAsync(productInfo, enrollResult)); EnrollmentResult? newPolled = null; await flow.StepAsync("PollForIssuance", async () => @@ -1388,19 +1385,21 @@ record = null; /// resolves for its domain. No-op if the factory wasn't injected, the cert isn't using CNAME /// validation, or the response contains no CNAME details. Failures are logged but never thrown — /// manual publishing remains a fallback so the enrollment result is still returned to Keyfactor. + /// Returns a short description of what happened (published/skipped/why), surfaced as the + /// flow step's detail so a no-op for non-CNAME methods doesn't look unexplained. ///
- private async Task TryPublishCnameDcvAsync(EnrollmentProductInfo productInfo, EnrollmentResult? enrollResult) + private async Task TryPublishCnameDcvAsync(EnrollmentProductInfo productInfo, EnrollmentResult? enrollResult) { if (_validatorFactory == null) { Logger.LogTrace("TryPublishCnameDcvAsync: no IDomainValidatorFactory was injected, skipping auto-publish."); - return; + return "skipped - no DNS validator factory injected"; } if (enrollResult?.EnrollmentContext == null || enrollResult.EnrollmentContext.Count == 0) { Logger.LogTrace("TryPublishCnameDcvAsync: no CNAME entries in EnrollmentContext, skipping."); - return; + return "skipped - no DCV entries returned by CSC"; } var dcvMethod = productInfo?.ProductParameters != null @@ -1412,7 +1411,7 @@ private async Task TryPublishCnameDcvAsync(EnrollmentProductInfo productInfo, En !string.Equals(dcvMethod, "CNAME", StringComparison.OrdinalIgnoreCase)) { Logger.LogTrace("TryPublishCnameDcvAsync: DCV method '{Method}' is not CNAME, skipping auto-publish.", dcvMethod ?? "(null)"); - return; + return $"skipped - DCV method is '{dcvMethod ?? "(none)"}', not CNAME"; } Logger.LogInformation( @@ -1498,6 +1497,9 @@ private async Task TryPublishCnameDcvAsync(EnrollmentProductInfo productInfo, En Logger.LogInformation( "TryPublishCnameDcvAsync: complete. Published={Published}, Failed={Failed}, Unresolved={Unresolved}", successCount, failCount, unresolvedCount); + + return $"published {successCount}, failed {failCount}, unresolved {unresolvedCount} " + + $"of {enrollResult.EnrollmentContext.Count} CNAME record(s)"; } //Trying to fix leaf extraction diff --git a/cscglobal-caplugin/FlowLogger.cs b/cscglobal-caplugin/FlowLogger.cs index 4ce4ef4..fb90961 100644 --- a/cscglobal-caplugin/FlowLogger.cs +++ b/cscglobal-caplugin/FlowLogger.cs @@ -124,6 +124,42 @@ public async Task StepAsync(string name, Func action, string d return this; } + /// + /// Record an async step whose own return value becomes the step's detail - unlike the + /// parameter on the other overload (which is evaluated before + /// the action runs and so can't reflect anything the action decided), this reflects what + /// actually happened during execution (e.g. why a conditional step was a no-op). + /// + public async Task StepAsync(string name, Func> action) + { + var sw = Stopwatch.StartNew(); + var step = new FlowStep { Name = name }; + try + { + _logger.LogTrace(" [{FlowName}] {StepName} ...", _flowName, name); + var detail = await action(); + sw.Stop(); + step.Status = FlowStepStatus.Success; + step.ElapsedMs = sw.ElapsedMilliseconds; + step.Detail = detail; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... OK ({Elapsed}ms){Detail}", + _flowName, name, sw.ElapsedMilliseconds, detail != null ? $" {detail}" : ""); + } + catch (Exception ex) + { + sw.Stop(); + step.Status = FlowStepStatus.Failed; + step.ElapsedMs = sw.ElapsedMilliseconds; + step.Detail = ex.Message; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... FAILED ({Elapsed}ms): {Error}", + _flowName, name, sw.ElapsedMilliseconds, ex.Message); + throw; + } + return this; + } + /// Record a failed step without throwing. public FlowLogger Fail(string name, string reason = null) {