From 65b0b9f89ecb93d9fa060f77fe16261a92945112 Mon Sep 17 00:00:00 2001 From: Dave Galey <89407235+dgaley@users.noreply.github.com> Date: Thu, 19 Mar 2026 13:28:41 -0400 Subject: [PATCH 1/9] Release: 1.0.1 Signed-off-by: Morgan Gangwere Co-authored-by: Mark Kachkaev Co-authored-by: Keyfactor --- .../keyfactor-bootstrap-workflow-v3.yml | 20 --- .../keyfactor-bootstrap-workflow.yml | 29 +++ README.md | 18 +- .../Api/GlobalSignEnrollRequest.cs | 27 ++- .../Api/GlobalSignRenewRequest.cs | 25 ++- .../Client/GlobalSignApiClient.cs | 29 +-- globalsign-mssl-caplugin/Constants.cs | 4 + .../GlobalSignCAConfig.cs | 2 + .../GlobalSignCAPlugin.cs | 167 +++++++++++++++--- integration-manifest.json | 16 ++ 10 files changed, 273 insertions(+), 64 deletions(-) delete mode 100644 .github/workflows/keyfactor-bootstrap-workflow-v3.yml create mode 100644 .github/workflows/keyfactor-bootstrap-workflow.yml diff --git a/.github/workflows/keyfactor-bootstrap-workflow-v3.yml b/.github/workflows/keyfactor-bootstrap-workflow-v3.yml deleted file mode 100644 index 64919a4..0000000 --- a/.github/workflows/keyfactor-bootstrap-workflow-v3.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: Keyfactor Bootstrap Workflow - -on: - workflow_dispatch: - pull_request: - types: [opened, closed, synchronize, edited, reopened] - push: - create: - branches: - - 'release-*.*' - -jobs: - call-starter-workflow: - uses: keyfactor/actions/.github/workflows/starter.yml@v3 - 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 }} diff --git a/.github/workflows/keyfactor-bootstrap-workflow.yml b/.github/workflows/keyfactor-bootstrap-workflow.yml new file mode 100644 index 0000000..56756c6 --- /dev/null +++ b/.github/workflows/keyfactor-bootstrap-workflow.yml @@ -0,0 +1,29 @@ +name: Keyfactor Bootstrap Workflow + +on: + workflow_dispatch: + pull_request: + types: [opened, closed, synchronize, edited, reopened] + push: + create: + branches: + - 'release-*.*' + +jobs: + call-starter-workflow: + uses: keyfactor/actions/.github/workflows/starter.yml@v4 + permissions: + contents: write # Explicitly grant write permission + 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}} + 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 }} \ No newline at end of file diff --git a/README.md b/README.md index 35a37ad..d58ec3f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- GlobalSign MSSL Gateway AnyCA Gateway REST Plugin + GlobalSign MSSL AnyCA Gateway REST Plugin

@@ -38,10 +38,10 @@ The GlobalSign CAPlugin enables the Synchronization, Enrollment, and Revocation ## Compatibility -The GlobalSign MSSL Gateway AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 25.2.0 and later. +The GlobalSign MSSL AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 25.2.0 and later. ## Support -The GlobalSign MSSL 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 GlobalSign MSSL 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. @@ -60,7 +60,7 @@ This extension uses the contact information of the GCC Domain point of contact f 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 [GlobalSign MSSL Gateway AnyCA Gateway REST plugin](https://github.com/Keyfactor/globalsign-mssl-caplugin/releases/latest) from GitHub. +2. On the server hosting the AnyCA Gateway REST, download and unzip the latest [GlobalSign MSSL AnyCA Gateway REST plugin](https://github.com/Keyfactor/globalsign-mssl-caplugin/releases/latest) from GitHub. 3. Copy the unzipped directory (usually called `net6.0` or `net8.0`) to the Extensions directory: @@ -71,11 +71,11 @@ This extension uses the contact information of the GCC Domain point of contact f Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net8.0\Extensions ``` - > The directory containing the GlobalSign MSSL 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 GlobalSign MSSL 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 GlobalSign MSSL 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 GlobalSign MSSL plugin by hovering over the ⓘ symbol to the right of the Gateway on the top left of the portal. ## Configuration @@ -103,8 +103,10 @@ This extension uses the contact information of the GCC Domain point of contact f * **RetryCount** - This is the number of times the AnyGateway will attempt to pickup an new certificate before reporting an error. Default is 5. * **SyncIntervalDays** - OPTIONAL: Required if SyncStartDate is used. Specifies how to page the certificate sync. Should be a value such that no interval of that length contains > 500 certificate enrollments. * **SyncStartDate** - If provided, full syncs will start at the specified date. + * **SyncProducts** - OPTIONAL: If provided as a comma-separated list of product IDs, will limit the certificate sync to only certificates of those products. If blank or not provided, will sync all certs. + * **Enabled** - Flag to Enable or Disable gateway functionality. Disabling is primarily used to allow creation of the CA prior to configuration information being available. -2. Define [Certificate Profiles](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCP-Gateway.htm) and [Certificate Templates](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCA-Gateway.htm) for the Certificate Authority as required. One Certificate Profile must be defined per Certificate Template. It's recommended that each Certificate Profile be named after the Product ID. The GlobalSign MSSL Gateway plugin supports the following product IDs: +2. Define [Certificate Profiles](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCP-Gateway.htm) and [Certificate Templates](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCA-Gateway.htm) for the Certificate Authority as required. One Certificate Profile must be defined per Certificate Template. It's recommended that each Certificate Profile be named after the Product ID. The GlobalSign MSSL plugin supports the following product IDs: * **PEV_SHA2** * **PEV** @@ -123,6 +125,8 @@ This extension uses the contact information of the GCC Domain point of contact f * **CertificateValidityInYears** - Number of years the certificate will be valid for * **SlotSize** - Maximum number of SANs that a certificate may have - valid values are [FIVE, TEN, FIFTEEN, TWENTY, THIRTY, FOURTY, FIFTY, ONE_HUNDRED] * **RootCAType** - The certificate's root CA - Depending on certificate expiration date, SHA_1 not be allowed. Will default to SHA_2 if expiration date exceeds sha1 allowed date. Options are GlobalSign R certs. + * **MSSLProfileId** - OPTIONAL: If specified, enrollments will use that profile ID for domain lookups. If not provided, domain lookup will be done based on the Common Name or first DNS SAN. Useful if your GlobalSign account has multiple domain objects with the same domain string, or subdomains (e.g. sub.test.com vs test.com). + * **ContactName** - The name of the contact to use for enrollments. Can be specified here or via an Enrollment Field in Command. Enrollment Fields will override any value supplied here. ## Valid GlobalSign SAN Usage diff --git a/globalsign-mssl-caplugin/Api/GlobalSignEnrollRequest.cs b/globalsign-mssl-caplugin/Api/GlobalSignEnrollRequest.cs index 9d4b16e..df91f0c 100644 --- a/globalsign-mssl-caplugin/Api/GlobalSignEnrollRequest.cs +++ b/globalsign-mssl-caplugin/Api/GlobalSignEnrollRequest.cs @@ -107,7 +107,32 @@ public BmV2PvOrderRequest Request continue; } - var entry = new SANEntry(); + string trimCN = CommonName, trimItem = item; + + if (CommonName.StartsWith("*.")) + { + trimCN = CommonName.Substring(2).ToLower(); + trimItem = item.ToLower(); + List equivs = new List { $"*.{trimCN}", $"www.{trimCN}", $"{trimCN}" }; + if (equivs.Contains(trimItem)) + { + Logger.LogInformation($"SAN Entry {item} is equivalent to CN ignoring wildcards or www prefix, removing from request"); + continue; + } + } + else if (CommonName.StartsWith("www.")) + { + trimCN = CommonName.Substring(4).ToLower(); + trimItem = item.ToLower(); + List equivs = new List { $"www.{trimCN}", $"{trimCN}" }; + if (equivs.Contains(trimItem)) + { + Logger.LogInformation($"SAN Entry {item} is equivalent to CN ignoring wildcards or www prefix, removing from request"); + continue; + } + } + + var entry = new SANEntry(); entry.SubjectAltName = item; var sb = new StringBuilder(); sb.Append("Adding SAN entry of type "); diff --git a/globalsign-mssl-caplugin/Api/GlobalSignRenewRequest.cs b/globalsign-mssl-caplugin/Api/GlobalSignRenewRequest.cs index 8ab88d7..117576c 100644 --- a/globalsign-mssl-caplugin/Api/GlobalSignRenewRequest.cs +++ b/globalsign-mssl-caplugin/Api/GlobalSignRenewRequest.cs @@ -53,8 +53,31 @@ public GlobalSignRenewRequest(GlobalSignCAConfig config, bool privateDomain, boo Logger.LogInformation($"SAN Entry {item} matches CN, removing from request"); continue; } + string trimCN = CommonName, trimItem = item; - var entry = new SANEntry(); + if (CommonName.StartsWith("*.")) + { + trimCN = CommonName.Substring(2).ToLower(); + trimItem = item.ToLower(); + List equivs = new List { $"*.{trimCN}", $"www.{trimCN}", $"{trimCN}" }; + if (equivs.Contains(trimItem)) + { + Logger.LogInformation($"SAN Entry {item} is equivalent to CN ignoring wildcards or www prefix, removing from request"); + continue; + } + } + else if (CommonName.StartsWith("www.")) + { + trimCN = CommonName.Substring(4).ToLower(); + trimItem = item.ToLower(); + List equivs = new List { $"www.{trimCN}", $"{trimCN}" }; + if (equivs.Contains(trimItem)) + { + Logger.LogInformation($"SAN Entry {item} is equivalent to CN ignoring wildcards or www prefix, removing from request"); + continue; + } + } + var entry = new SANEntry(); entry.SubjectAltName = item; var sb = new StringBuilder(); sb.Append("Adding SAN entry of type "); diff --git a/globalsign-mssl-caplugin/Client/GlobalSignApiClient.cs b/globalsign-mssl-caplugin/Client/GlobalSignApiClient.cs index 0c54492..ef4c468 100644 --- a/globalsign-mssl-caplugin/Client/GlobalSignApiClient.cs +++ b/globalsign-mssl-caplugin/Client/GlobalSignApiClient.cs @@ -26,6 +26,13 @@ public GlobalSignApiClient(GlobalSignCAConfig config, ILogger logger) Logger = logger; Config = config; // Logger = LogHandler.GetClassLogger(this.GetType()); + var enabled =config.Enabled; + if (!enabled) + { + Logger.LogWarning($"The CA is currently in the Disabled state. It must be Enabled to perform operations. Skipping config validation and MSSL Client creation..."); + Logger.MethodExit(); + return; + } QueryService = new GASV1Client { Endpoint = { Address = new EndpointAddress(config.GetUrl(GlobalSignServiceType.QUERY)), Name = "QUERY" } @@ -47,10 +54,8 @@ public async Task> GetCertificatesForSyncAsync( var results = new List(); if (fullSync) { - // If startDate is before year 2000, treat it as “since the dawn of time” - var from = startDate > new DateTime(2000, 1, 1) - ? startDate - : DateTime.MinValue; + + var from = startDate; var finalStop = DateTime.UtcNow; // first window @@ -72,8 +77,12 @@ public async Task> GetCertificatesForSyncAsync( } else { - // incremental sync since lastSync + // incremental sync since lastSync, unless lastSync is earlier than startDate, then use that var from = lastSync; + if (from < startDate) + { + from = startDate; + } var to = DateTime.UtcNow; results.AddRange( @@ -272,11 +281,11 @@ public async Task Enroll(GlobalSignEnrollRequest enrollRequest Logger.MethodEntry(); var rawRequest = enrollRequest.Request; Logger.LogTrace("Request details:"); - Logger.LogTrace($"Profile ID: {enrollRequest.MsslProfileId}"); - Logger.LogTrace($"Domain ID: {enrollRequest.MsslDomainId}"); + Logger.LogTrace($"Profile ID: {rawRequest.MSSLProfileID}"); + Logger.LogTrace($"Domain ID: {rawRequest.MSSLDomainID}"); Logger.LogTrace( - $"Contact Info: {enrollRequest.FirstName}, {enrollRequest.LastName}, {enrollRequest.Email}, {enrollRequest.Phone}"); - Logger.LogTrace($"SAN Count: {enrollRequest.SANs.Count()}"); + $"Contact Info: {rawRequest.ContactInfo.FirstName}, {rawRequest.ContactInfo.LastName}, {rawRequest.ContactInfo.Email}, {rawRequest.ContactInfo.Phone}"); + Logger.LogTrace($"SAN Count: {rawRequest.SANEntries.Count()}"); if (rawRequest.SANEntries.Count() > 0) Logger.LogTrace($"SANs: {string.Join(",", rawRequest.SANEntries.Select(s => s.SubjectAltName))}"); Logger.LogTrace($"Product Code: {rawRequest.OrderRequestParameter.ProductCode}"); @@ -284,7 +293,7 @@ public async Task Enroll(GlobalSignEnrollRequest enrollRequest if (!string.IsNullOrEmpty(rawRequest.OrderRequestParameter.BaseOption)) Logger.LogTrace($"Order Base Option: {rawRequest.OrderRequestParameter.BaseOption}"); - var requestwrapper = new PVOrder(enrollRequest.Request); + var requestwrapper = new PVOrder(rawRequest); var responsewrapper = await OrderService.PVOrderAsync(requestwrapper); ; var response = responsewrapper.Response; diff --git a/globalsign-mssl-caplugin/Constants.cs b/globalsign-mssl-caplugin/Constants.cs index 108da49..dbbcb43 100644 --- a/globalsign-mssl-caplugin/Constants.cs +++ b/globalsign-mssl-caplugin/Constants.cs @@ -21,6 +21,8 @@ internal class Constants public static string PICKUPDELAY = "DelayTime"; public static string SYNCSTARTDATE = "SyncStartDate"; public static string SYNCINTERNVALDAYS = "SyncIntervalDays"; + public static string SYNCPRODUCTS = "SyncProducts"; + public static string Enabled = "Enabled"; } public static class EnrollmentConfigConstants @@ -28,4 +30,6 @@ public static class EnrollmentConfigConstants public const string RootCAType = "RootCAType"; public const string SlotSize = "SlotSize"; public const string CertificateValidityInYears = "CertificateValidityInYears"; + public const string MSSLProfileId = "MSSLProfileId"; + public const string ContactName = "ContactName"; } \ No newline at end of file diff --git a/globalsign-mssl-caplugin/GlobalSignCAConfig.cs b/globalsign-mssl-caplugin/GlobalSignCAConfig.cs index 0340b09..ba52afe 100644 --- a/globalsign-mssl-caplugin/GlobalSignCAConfig.cs +++ b/globalsign-mssl-caplugin/GlobalSignCAConfig.cs @@ -32,7 +32,9 @@ public class GlobalSignCAConfig public string SyncStartDate { get; set; } = ""; public int SyncIntervalDays { get; set; } = 0; + public string SyncProducts { get; set; } = ""; + public bool Enabled { get; set; } = true; public string GetUrl(GlobalSignServiceType queryType) { diff --git a/globalsign-mssl-caplugin/GlobalSignCAPlugin.cs b/globalsign-mssl-caplugin/GlobalSignCAPlugin.cs index e4cfde3..d6ec046 100644 --- a/globalsign-mssl-caplugin/GlobalSignCAPlugin.cs +++ b/globalsign-mssl-caplugin/GlobalSignCAPlugin.cs @@ -23,16 +23,25 @@ public class GlobalSignCAPlugin : IAnyCAPlugin private ICertificateDataReader? _certificateDataReader; private ILogger Logger; - private GlobalSignCAConfig Config { get; set; } = new(); - + private GlobalSignCAConfig Config { get; set; } = new(); + private bool _enabled = false; public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDataReader certificateDataReader) { Logger = LogHandler.GetClassLogger(GetType()); Logger.MethodEntry(); + var enabledValue = configProvider.CAConnectionData["Enabled"]; + bool isEnabled = enabledValue is bool ? (bool)enabledValue : bool.Parse((string)enabledValue); + if (!isEnabled) + { + Logger.LogWarning($"The CA is currently in the Disabled state. It must be Enabled to perform operations. Skipping config validation and MSSL Client creation..."); + Logger.MethodExit(); + return; + } Config = new GlobalSignCAConfig { IsTest = bool.Parse((string)configProvider.CAConnectionData["TestAPI"]), + Enabled = isEnabled, Password = (string)configProvider.CAConnectionData["GlobalSignPassword"], Username = (string)configProvider.CAConnectionData["GlobalSignUsername"], PickupDelay = int.Parse((string)configProvider.CAConnectionData["DelayTime"]), @@ -83,15 +92,37 @@ public async Task Synchronize(BlockingCollection blockin if (Config == null) throw new InvalidOperationException("Config is not initialized."); var apiClient = new GlobalSignApiClient(Config, Logger); - var fullSyncFrom = new DateTime(2000, 01, 01); + var fullSyncFrom = DateTime.UtcNow.AddYears(-1); if (!string.IsNullOrEmpty(Config.SyncStartDate)) fullSyncFrom = DateTime.Parse(Config.SyncStartDate); var syncFrom = lastSync; var certs = await apiClient.GetCertificatesForSyncAsync(fullSync, syncFrom, fullSyncFrom, Config.SyncIntervalDays); + bool productFilter = false; + List products = null; + if (!string.IsNullOrEmpty(Config.SyncProducts)) + { + products = Config.SyncProducts.Split(',').ToList(); + products.ForEach(p => p.ToUpper()); + productFilter = true; + } + foreach (var c in certs) { + if (productFilter) + { + bool prodMatch = false; + if (c.OrderInfo?.ProductCode != null && products.Contains(c.OrderInfo.ProductCode.ToUpper())) + { + prodMatch = true; + } + if (!prodMatch) + { + Logger.LogInformation($"Found certificate with product code {c.OrderInfo?.ProductCode}, which does not match the filter criteria. Skipping."); + continue; + } + } var orderStatus = (GlobalSignOrderStatus)Enum.Parse(typeof(GlobalSignOrderStatus), c.CertificateInfo?.CertificateStatus ?? string.Empty); DateTime? subDate = DateTime.TryParse(c.OrderInfo?.OrderDate, out var orderDate) ? orderDate : null; @@ -237,6 +268,8 @@ public async Task Enroll(string csr, string subject, Dictionar if (sanDict.TryGetValue("ipaddress", out var ipSans)) Logger.LogTrace($"IP SAN Count: {ipSans.Length}"); + List matchedDomains = new List(); + // only try to resolve a domain if we don't already have a commonName if (string.IsNullOrWhiteSpace(commonName)) { @@ -247,16 +280,16 @@ public async Task Enroll(string csr, string subject, Dictionar if (string.IsNullOrWhiteSpace(ipSan)) continue; - var tempDomain = validDomains? - .FirstOrDefault(d => + var tempDomains = validDomains + .Where(d => !string.IsNullOrEmpty(d?.DomainName) && ipSan.EndsWith($".{d.DomainName}", StringComparison.OrdinalIgnoreCase) - ); + ).ToList(); - if (tempDomain != null) + if (tempDomains != null && tempDomains.Count > 0) { Logger.LogDebug($"ipSAN Domain match found for ipSAN: {ipSan}"); - domain = tempDomain; + matchedDomains = tempDomains; commonName = ipSan; break; } @@ -269,23 +302,23 @@ public async Task Enroll(string csr, string subject, Dictionar if (string.IsNullOrWhiteSpace(dnsSan)) continue; - var tempDomain = validDomains? - .FirstOrDefault(d => + var tempDomains = validDomains + .Where(d => !string.IsNullOrEmpty(d?.DomainName) && dnsSan.EndsWith(d.DomainName, StringComparison.OrdinalIgnoreCase) - ); + ).ToList(); - if (tempDomain != null) + if (tempDomains != null && tempDomains.Count > 0) { Logger.LogDebug($"SAN Domain match found for SAN: {dnsSan}"); - domain = tempDomain; + matchedDomains = tempDomains; commonName = dnsSan; break; } } } // If private domain skip domain resolution. - if (privateDomain) + else if (privateDomain) { var profiles = await apiClient.GetProfiles(); var fillProfile = profiles.FirstOrDefault(); @@ -302,18 +335,41 @@ public async Task Enroll(string csr, string subject, Dictionar } }; domain.MSSLProfileID = fillProfile.MSSLProfileId; + matchedDomains = new List { domain }; } // 3) Fallback: if we did obtain a commonName (or it was already set), try matching it - if (domain == null && !string.IsNullOrWhiteSpace(commonName)) - domain = validDomains? - .FirstOrDefault(d => + else if (domain == null && !string.IsNullOrWhiteSpace(commonName)) + matchedDomains = validDomains + .Where(d => !string.IsNullOrEmpty(d?.DomainName) && commonName.EndsWith(d.DomainName, StringComparison.OrdinalIgnoreCase) - ); - - - if (domain == null) throw new Exception("Unable to determine GlobalSign domain"); + ).ToList(); + + if (matchedDomains.Count == 1) + { + domain = matchedDomains[0]; + } + else + { + var profId = productInfo.ProductParameters["MSSLProfileID"]; + if (!string.IsNullOrEmpty(profId) ) + { + var tempDomain = matchedDomains.Where(d => + d.MSSLProfileID.Equals(profId, StringComparison.OrdinalIgnoreCase)).FirstOrDefault(); + if (tempDomain != null) + { + domain = tempDomain; + } + else + { + throw new Exception($"No domain matching common name {commonName} has provided MSSLProfileID of {profId}. Check configuration."); + } + } + } + + + if (domain == null) throw new Exception("Unable to determine GlobalSign domain"); @@ -426,6 +482,12 @@ public async Task Enroll(string csr, string subject, Dictionar public async Task Ping() { Logger.MethodEntry(); + if (!_enabled) + { + Logger.LogWarning($"The CA is currently in the Disabled state. It must be Enabled to perform operations. Skipping config validation and MSSL Client creation..."); + Logger.MethodExit(); + return; + } try { Logger.LogInformation("Ping reqeuest recieved"); @@ -443,6 +505,18 @@ public async Task ValidateCAConnectionInfo(Dictionary connection { Logger = LogHandler.GetClassLogger(GetType()); Logger.MethodEntry(); + + // Handle Enabled flag - could be bool or string + var enabledValue = connectionInfo["Enabled"]; + bool isEnabled = enabledValue is bool ? (bool)enabledValue : bool.Parse((string)enabledValue); + + if (!isEnabled) + { + Logger.LogWarning($"The CA is currently in the Disabled state. It must be Enabled to perform operations. Skipping validation..."); + Logger.MethodExit(LogLevel.Trace); + return; + } + Config = new GlobalSignCAConfig { IsTest = bool.Parse((string)connectionInfo["TestAPI"]), @@ -455,6 +529,7 @@ public async Task ValidateCAConnectionInfo(Dictionary connection ORDER_TEST_URL = (string)connectionInfo["OrderAPITestURL"], QUERY_TEST_URL = (string)connectionInfo["QueryAPITestURL"], QUERY_PROD_URL = (string)connectionInfo["QueryAPIProdURL"], + Enabled = isEnabled, SyncStartDate = connectionInfo.TryGetValue("SyncStartDate", out object? value) ? (string)value : string.Empty, SyncIntervalDays = connectionInfo.TryGetValue("SyncIntervalDays", out var val) @@ -470,6 +545,17 @@ public async Task ValidateCAConnectionInfo(Dictionary connection public Task ValidateProductInfo(EnrollmentProductInfo productInfo, Dictionary connectionInfo) { + // Handle Enabled flag - could be bool or string + var enabledValue = connectionInfo["Enabled"]; + bool isEnabled = enabledValue is bool ? (bool)enabledValue : bool.Parse((string)enabledValue); + + if (!isEnabled) + { + Logger.LogWarning($"The CA is currently in the Disabled state. It must be Enabled to perform operations. Skipping validation..."); + Logger.MethodExit(LogLevel.Trace); + return Task.CompletedTask; + } + Config = new GlobalSignCAConfig { IsTest = bool.Parse((string)connectionInfo["TestAPI"]), @@ -482,6 +568,7 @@ public Task ValidateProductInfo(EnrollmentProductInfo productInfo, Dictionary GetCAConnectorAnnotations() Comments = "If provided, full syncs will start at the specified date.", Hidden = false, - DefaultValue = "2000-01-01", - Type = "Integer" + DefaultValue = "", + Type = "String" + }, + [Constants.SYNCPRODUCTS] = new() + { + Comments = "OPTIONAL: If provided as a comma-separated list of product IDs, will limit the certificate sync to only certificates of those products. If blank or not provided, will sync all certs.", + Hidden = false, + DefaultValue = null, + Type = "String" + }, + [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" } }; } @@ -605,7 +706,7 @@ public Dictionary GetTemplateParameterAnnotations() { Comments = "Number of years the certificate will be valid for", Hidden = false, - DefaultValue = "1", + DefaultValue = 1, Type = "Number" }, [EnrollmentConfigConstants.SlotSize] = new() @@ -623,7 +724,23 @@ public Dictionary GetTemplateParameterAnnotations() Hidden = false, DefaultValue = "GLOBALSIGN_ROOT_R3", Type = "String" - } + }, + [EnrollmentConfigConstants.MSSLProfileId] = new() + { + Comments = + "OPTIONAL: If specified, enrollments will use that profile ID for domain lookups. If not provided, domain lookup will be done based on the Common Name or first DNS SAN. Useful if your GlobalSign account has multiple domain objects with the same domain string, or subdomains (e.g. sub.test.com vs test.com).", + Hidden = false, + DefaultValue = null, + Type = "String" + }, + [EnrollmentConfigConstants.ContactName] = new() + { + Comments = + "The name of the contact to use for enrollments. Can be specified here or via an Enrollment Field in Command. Enrollment Fields will override any value supplied here.", + Hidden = false, + DefaultValue = "", + Type = "String" + } }; } diff --git a/integration-manifest.json b/integration-manifest.json index 49a628a..412b9e5 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -60,6 +60,14 @@ { "name": "SyncStartDate", "description": "If provided, full syncs will start at the specified date." + }, + { + "name": "SyncProducts", + "description": "OPTIONAL: If provided as a comma-separated list of product IDs, will limit the certificate sync to only certificates of those products. If blank or not provided, will sync all certs." + }, + { + "name": "Enabled", + "description": "Flag to Enable or Disable gateway functionality. Disabling is primarily used to allow creation of the CA prior to configuration information being available." } ], "enrollment_config": [ @@ -74,6 +82,14 @@ { "name": "RootCAType", "description": "The certificate's root CA - Depending on certificate expiration date, SHA_1 not be allowed. Will default to SHA_2 if expiration date exceeds sha1 allowed date. Options are GlobalSign R certs." + }, + { + "name": "MSSLProfileId", + "description": "OPTIONAL: If specified, enrollments will use that profile ID for domain lookups. If not provided, domain lookup will be done based on the Common Name or first DNS SAN. Useful if your GlobalSign account has multiple domain objects with the same domain string, or subdomains (e.g. sub.test.com vs test.com)." + }, + { + "name": "ContactName", + "description": "The name of the contact to use for enrollments. Can be specified here or via an Enrollment Field in Command. Enrollment Fields will override any value supplied here." } ], "product_ids": [ From b987fc64ad67530767841d4fe7b6155248e43125 Mon Sep 17 00:00:00 2001 From: Dave Galey <89407235+dgaley@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:17:05 -0400 Subject: [PATCH 2/9] Dev 1.1 (#6) * Update to provide "Enabled" flag support for SAAS deployment. * Update generated docs * Fixing build. * Fixes for enabled. * Update generated docs * More fixes. * remove retry logic for reissue pickup * changelog * Update keyfactor-bootstrap-workflow.yml * Update keyfactor-bootstrap-workflow.yml --------- Co-authored-by: Mark Kachkaev Co-authored-by: Keyfactor --- .../keyfactor-bootstrap-workflow.yml | 13 +-- CHANGELOG.md | 5 +- .../Client/GlobalSignApiClient.cs | 100 ++++++++++-------- 3 files changed, 60 insertions(+), 58 deletions(-) diff --git a/.github/workflows/keyfactor-bootstrap-workflow.yml b/.github/workflows/keyfactor-bootstrap-workflow.yml index 56756c6..64919a4 100644 --- a/.github/workflows/keyfactor-bootstrap-workflow.yml +++ b/.github/workflows/keyfactor-bootstrap-workflow.yml @@ -11,19 +11,10 @@ on: jobs: call-starter-workflow: - uses: keyfactor/actions/.github/workflows/starter.yml@v4 - permissions: - contents: write # Explicitly grant write permission - 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 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 }} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index faffcc3..95948b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,2 +1,5 @@ v1.0 --Initial Release. \ No newline at end of file +-Initial Release. + +1.1 +Remove retry logic around reissue pickups \ No newline at end of file diff --git a/globalsign-mssl-caplugin/Client/GlobalSignApiClient.cs b/globalsign-mssl-caplugin/Client/GlobalSignApiClient.cs index ef4c468..8e3e6ad 100644 --- a/globalsign-mssl-caplugin/Client/GlobalSignApiClient.cs +++ b/globalsign-mssl-caplugin/Client/GlobalSignApiClient.cs @@ -157,64 +157,63 @@ public async Task PickupCertificateById(string caRequest } }; - var retryCounter = 0; - while (retryCounter <= Config.PickupRetries) + var wrapper = new GetOrderByOrderID(request); + var responseWrapper = await QueryService.GetOrderByOrderIDAsync(wrapper); + var response = responseWrapper.Response; + + if (response.OrderResponseHeader.SuccessCode == 0) { - var wrapper = new GetOrderByOrderID(request); - var responseWrapper = await QueryService.GetOrderByOrderIDAsync(wrapper); - var response = responseWrapper.Response; + Logger.LogDebug($"Order with order ID {caRequestId} successfully picked up"); + var orderStatus = (GlobalSignOrderStatus)Enum.Parse( + typeof(GlobalSignOrderStatus), + response.OrderDetail.CertificateInfo.CertificateStatus); - if (response.OrderResponseHeader.SuccessCode == 0) + if (orderStatus == GlobalSignOrderStatus.Issued) { - Logger.LogDebug($"Order with order ID {caRequestId} successfully picked up"); - var orderStatus = (GlobalSignOrderStatus)Enum.Parse( - typeof(GlobalSignOrderStatus), - response.OrderDetail.CertificateInfo.CertificateStatus); - - if (orderStatus == GlobalSignOrderStatus.Issued) + var orderDate = DateTime.TryParse( + response.OrderDetail.OrderInfo.OrderDate, + out var od) + ? od + : (DateTime?)null; + var completeDate = DateTime.TryParse( + response.OrderDetail.OrderInfo.OrderCompleteDate, + out var cd) + ? cd + : (DateTime?)null; + var deactivateDate = DateTime.TryParse( + response.OrderDetail.OrderInfo.OrderDeactivatedDate, + out var de) + ? de + : (DateTime?)null; + + Logger.MethodExit(); + return new AnyCAPluginCertificate { - var orderDate = DateTime.TryParse( - response.OrderDetail.OrderInfo.OrderDate, - out var od) - ? od - : (DateTime?)null; - var completeDate = DateTime.TryParse( - response.OrderDetail.OrderInfo.OrderCompleteDate, - out var cd) - ? cd - : (DateTime?)null; - var deactivateDate = DateTime.TryParse( - response.OrderDetail.OrderInfo.OrderDeactivatedDate, - out var de) - ? de - : (DateTime?)null; - - Logger.MethodExit(); - return new AnyCAPluginCertificate - { - CARequestID = caRequestId, - ProductID = response.OrderDetail.OrderInfo.ProductCode, - Status = OrderStatus.ConvertToKeyfactorStatus(orderStatus), - CSR = response.OrderDetail.Fulfillment.OriginalCSR, - Certificate = response.OrderDetail.Fulfillment.ServerCertificate.X509Cert, - RevocationReason = 0, - RevocationDate = orderStatus == GlobalSignOrderStatus.Revoked ? deactivateDate : null - }; - } + CARequestID = caRequestId, + ProductID = response.OrderDetail.OrderInfo.ProductCode, + Status = OrderStatus.ConvertToKeyfactorStatus(orderStatus), + CSR = response.OrderDetail.Fulfillment.OriginalCSR, + Certificate = response.OrderDetail.Fulfillment.ServerCertificate.X509Cert, + RevocationReason = 0, + RevocationDate = orderStatus == GlobalSignOrderStatus.Revoked ? deactivateDate : null + }; } - - retryCounter++; - Logger.LogDebug( - $"Pickup certificate failed for order ID {caRequestId}. Attempt {retryCounter} of {Config.PickupRetries}.{(retryCounter < Config.PickupRetries ? " Retrying..." : string.Empty)}"); - await Task.Delay(TimeSpan.FromSeconds(Config.PickupDelay)); } + Logger.LogInformation( + $"Certificate for order ID {caRequestId} was not immediately available. Once issued, it should be picked up by the next gateway sync."); + + var gsError = GlobalSignErrorIndex.GetGlobalSignError(-9916); var errorMsg = "Unable to pickup certificate during configured pickup window. Check for required approvals in GlobalSign portal. This can also be caused by a delay with GlobalSign, in which case the certificate will get picked up by a future sync"; Logger.LogError(errorMsg); Logger.LogError(gsError.DetailedMessage); - throw new Exception(errorMsg); + return new AnyCAPluginCertificate() + { + CARequestID = caRequestId, + Status = (int)EndEntityStatus.INPROCESS + }; } public async Task> GetDomains() @@ -418,6 +417,15 @@ public async Task Reissue(GlobalSignReissueRequest reissueRequ // Pick up the certificate after reissue var pickupResponse = await PickupCertificateById(response.OrderID); + + if (pickupResponse.Status == (int)EndEntityStatus.INPROCESS) + { + return new EnrollmentResult + { + CARequestID = response.OrderID, + Status = (int)EndEntityStatus.INPROCESS + }; + } var cert = CertificateConverterFactory.FromPEM(pickupResponse.Certificate).ToX509Certificate2(); // If newly generated or serial differs, return success From 761d687d3e7debfa48a0d83e52c05480e70c42ad Mon Sep 17 00:00:00 2001 From: Dave Galey <89407235+dgaley@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:37:22 -0400 Subject: [PATCH 3/9] Dev 1.1 (#10) * Update to provide "Enabled" flag support for SAAS deployment. * Update generated docs * Fixing build. * Fixes for enabled. * Update generated docs * More fixes. * remove retry logic for reissue pickup * changelog * Update keyfactor-bootstrap-workflow.yml * Update keyfactor-bootstrap-workflow.yml * Change incremental sync to use GetModifiedOrders API call --------- Co-authored-by: Mark Kachkaev Co-authored-by: Keyfactor --- CHANGELOG.md | 5 +- .../Client/GlobalSignApiClient.cs | 56 ++++++++++++++++++- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95948b2..47b52c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,4 +2,7 @@ v1.0 -Initial Release. 1.1 -Remove retry logic around reissue pickups \ No newline at end of file +Remove retry logic around reissue pickups + +1.1.1 +Change incremental sync to use GetModifiedOrders API call \ No newline at end of file diff --git a/globalsign-mssl-caplugin/Client/GlobalSignApiClient.cs b/globalsign-mssl-caplugin/Client/GlobalSignApiClient.cs index 8e3e6ad..f3e5036 100644 --- a/globalsign-mssl-caplugin/Client/GlobalSignApiClient.cs +++ b/globalsign-mssl-caplugin/Client/GlobalSignApiClient.cs @@ -1,5 +1,7 @@ using System.Globalization; using System.ServiceModel; +using System.Text; + using Keyfactor.AnyGateway.Extensions; using Keyfactor.Extensions.CAPlugin.GlobalSign.Api; using Keyfactor.Logging; @@ -86,7 +88,7 @@ public async Task> GetCertificatesForSyncAsync( var to = DateTime.UtcNow; results.AddRange( - await GetCertificatesByDateRange(from, to) + await GetModifiedOrdersByDateRange(from, to) ); } @@ -126,17 +128,65 @@ private async Task> GetCertificatesByDateRange(DateTime? fromD else { int errCode = int.Parse(allOrdersResponse.Response.QueryResponseHeader.Errors[0].ErrorCode); - Logger.LogError($"Unable to retrieve certificates:"); + StringBuilder sb = new StringBuilder(); + sb.Append($"Unable to retrieve certificates:"); foreach (var e in allOrdersResponse.Response.QueryResponseHeader.Errors) { - Logger.LogError($"{e.ErrorCode} | {e.ErrorField} | {e.ErrorMessage}"); + sb.Append($"\n{e.ErrorCode} | {e.ErrorField} | {e.ErrorMessage}"); } + Logger.LogError(sb.ToString()); var gsError = GlobalSignErrorIndex.GetGlobalSignError(errCode); Logger.LogError(gsError.DetailedMessage); throw new Exception(gsError.Message); } } + private async Task> GetModifiedOrdersByDateRange(DateTime? fromDate, DateTime? toDate) + { + var tmpFromDate = fromDate ?? DateTime.MinValue; + var tmpToDate = toDate ?? DateTime.UtcNow; + + QbV1GetModifiedOrdersRequest req = new QbV1GetModifiedOrdersRequest + { + QueryRequestHeader = new QueryRequestHeader + { + AuthToken = Config.GetQueryAuthToken() + }, + FromDate = tmpFromDate.ToString(Config.DateFormatString, DateTimeFormatInfo.InvariantInfo), + ToDate = tmpToDate.ToString(Config.DateFormatString, DateTimeFormatInfo.InvariantInfo), + OrderQueryOption = new OrderQueryOption + { + ReturnOrderOption = "true", + ReturnCertificateInfo = "true", + ReturnFulfillment = "true", + ReturnOriginalCSR = "true" + } + }; + Logger.LogDebug($"Retrieving all modified orders between {tmpFromDate} and {tmpToDate}"); + var modOrdersResponse = await QueryService.GetModifiedOrdersAsync(new GetModifiedOrders(req)); + + if (modOrdersResponse.Response.QueryResponseHeader.SuccessCode == 0) + { + var retVal = modOrdersResponse.Response.OrderDetails?.ToList() ?? new List(); + Logger.LogDebug($"Retrieved {retVal.Count} modified orders from GlobalSign"); + return retVal; + } + else + { + int errCode = int.Parse(modOrdersResponse.Response.QueryResponseHeader.Errors[0].ErrorCode); + StringBuilder sb = new StringBuilder(); + sb.Append("Unable to retrieve certificates:"); + foreach (var e in modOrdersResponse.Response.QueryResponseHeader.Errors) + { + sb.Append($"\n{e.ErrorCode} | {e.ErrorField} | {e.ErrorMessage}"); + } + Logger.LogError(sb.ToString()); + var gsError = GlobalSignErrorIndex.GetGlobalSignError(errCode); + Logger.LogError(gsError.DetailedMessage); + throw new Exception(gsError.Message); + } + } + public async Task PickupCertificateById(string caRequestId) { Logger.MethodEntry(); From a624006c14f1b9135ad3a4a23507bfc6059c5146 Mon Sep 17 00:00:00 2001 From: Dave Galey <89407235+dgaley@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:52:25 -0400 Subject: [PATCH 4/9] Release 1.1 * Update to provide "Enabled" flag support for SAAS deployment. * Update generated docs * Fixing build. * Fixes for enabled. * Update generated docs * More fixes. * remove retry logic for reissue pickup * changelog * Update keyfactor-bootstrap-workflow.yml * Update keyfactor-bootstrap-workflow.yml * Change incremental sync to use GetModifiedOrders API call * Merge 1.1.1 to main (#11) * Release: 1.0.1 Signed-off-by: Morgan Gangwere Co-authored-by: Mark Kachkaev Co-authored-by: Keyfactor * Dev 1.1 (#6) * Update to provide "Enabled" flag support for SAAS deployment. * Update generated docs * Fixing build. * Fixes for enabled. * Update generated docs * More fixes. * remove retry logic for reissue pickup * changelog * Update keyfactor-bootstrap-workflow.yml * Update keyfactor-bootstrap-workflow.yml --------- Co-authored-by: Mark Kachkaev Co-authored-by: Keyfactor * Dev 1.1 (#10) * Update to provide "Enabled" flag support for SAAS deployment. * Update generated docs * Fixing build. * Fixes for enabled. * Update generated docs * More fixes. * remove retry logic for reissue pickup * changelog * Update keyfactor-bootstrap-workflow.yml * Update keyfactor-bootstrap-workflow.yml * Change incremental sync to use GetModifiedOrders API call --------- Co-authored-by: Mark Kachkaev Co-authored-by: Keyfactor --------- Signed-off-by: Morgan Gangwere Co-authored-by: Dave Galey <89407235+dgaley@users.noreply.github.com> Co-authored-by: Mark Kachkaev Co-authored-by: Keyfactor * Add 30 day window from expiration for doing a renewal instead of a reissue * changelog * documentation * Update generated docs --------- Signed-off-by: Morgan Gangwere Co-authored-by: Mark Kachkaev Co-authored-by: Keyfactor Co-authored-by: Morgan Gangwere <470584+indrora@users.noreply.github.com> --- CHANGELOG.md | 6 ++++-- README.md | 5 ++++- docsource/configuration.md | 5 ++++- globalsign-mssl-caplugin/GlobalSignCAPlugin.cs | 2 +- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db145fe..6d68ee8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,5 +5,7 @@ v1.0 Remove retry logic around reissue pickups 1.1.1 -Change incremental sync to use GetModifiedOrders API call -Remove retry logic around reissue pickups +Change incremental sync to use GetModifiedOrders API call + +1.1.2 +Add 30 day window from expiration for doing renewals instead of reissues diff --git a/README.md b/README.md index d58ec3f..d0ce3a5 100644 --- a/README.md +++ b/README.md @@ -133,12 +133,15 @@ This extension uses the contact information of the GCC Domain point of contact f GlobalSign supports specific combinations of SAN types with certain GlobalSign products. For example, a Private IP can only be used as a SAN with a `PV_INTRA` Certificate. Please refer to the GlobalSign documentation for more information on SAN usage: [GlobalSign MSSL API User Guide (Section 2.2.5)](https://www.globalsign.com/en/repository/globalsign-mssl-api-user-guide.pdf) -## Enrollment Fields +## Enrollment ### Required Enrollment Fields The following fields are required for enrollment on all certificate templates: - **ContactName**: Set Data Type to 'string' when creating the field. The name of the contact person for the certificate. This is required by the GlobalSign API. +### Renewal vs Reissue +Currently, the gateway determines whether to do a renew or reissue based on the expiration date of the certificate in question. If it is within 30 days of expiration, the gateway will do a renew; otherwise, it will do a reissue. + ### PV_INTRA Specific Enrollment Fields The following fields are available for use in the enrollment of `PV_INTRA` Certificates: - **PrivateDomain**: Set Data Type to 'string' when creating the field. Set to `true` if enrolling a certificate for a private domain (e.g., `.local`, `.lab`, etc.). diff --git a/docsource/configuration.md b/docsource/configuration.md index b6afe0d..ea208a3 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -24,12 +24,15 @@ GlobalSign supports specific combinations of SAN types with certain GlobalSign p Please refer to the GlobalSign documentation for more information on SAN usage: [GlobalSign MSSL API User Guide (Section 2.2.5)](https://www.globalsign.com/en/repository/globalsign-mssl-api-user-guide.pdf) -## Enrollment Fields +## Enrollment ### Required Enrollment Fields The following fields are required for enrollment on all certificate templates: - **ContactName**: Set Data Type to 'string' when creating the field. The name of the contact person for the certificate. This is required by the GlobalSign API. +### Renewal vs Reissue +Currently, the gateway determines whether to do a renew or reissue based on the expiration date of the certificate in question. If it is within 30 days of expiration, the gateway will do a renew; otherwise, it will do a reissue. + ### PV_INTRA Specific Enrollment Fields The following fields are available for use in the enrollment of `PV_INTRA` Certificates: - **PrivateDomain**: Set Data Type to 'string' when creating the field. Set to `true` if enrolling a certificate for a private domain (e.g., `.local`, `.lab`, etc.). diff --git a/globalsign-mssl-caplugin/GlobalSignCAPlugin.cs b/globalsign-mssl-caplugin/GlobalSignCAPlugin.cs index d6ec046..1aa848d 100644 --- a/globalsign-mssl-caplugin/GlobalSignCAPlugin.cs +++ b/globalsign-mssl-caplugin/GlobalSignCAPlugin.cs @@ -427,7 +427,7 @@ public async Task Enroll(string csr, string subject, Dictionar expirationDate = localcert.RevocationDate; } - if (expirationDate < DateTime.Now) renewal = true; + if (expirationDate.Value.AddDays(-30) < DateTime.Now) renewal = true; // 30 day renewal window if (renewal) { Logger.LogDebug( From 4f9417b1f8ee4d4da618cba976382f3077f5f984 Mon Sep 17 00:00:00 2001 From: Dave Galey <89407235+dgaley@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:19:47 -0400 Subject: [PATCH 5/9] Dev 1.1 (#14) * Update to provide "Enabled" flag support for SAAS deployment. * Update generated docs * Fixing build. * Fixes for enabled. * Update generated docs * More fixes. * remove retry logic for reissue pickup * changelog * Update keyfactor-bootstrap-workflow.yml * Update keyfactor-bootstrap-workflow.yml * Change incremental sync to use GetModifiedOrders API call * Add 30 day window from expiration for doing a renewal instead of a reissue * changelog * documentation * Update generated docs * domain matching logging --------- Co-authored-by: Mark Kachkaev Co-authored-by: Keyfactor Co-authored-by: Morgan Gangwere <470584+indrora@users.noreply.github.com> --- .../GlobalSignCAPlugin.cs | 170 ++++++++++-------- 1 file changed, 98 insertions(+), 72 deletions(-) diff --git a/globalsign-mssl-caplugin/GlobalSignCAPlugin.cs b/globalsign-mssl-caplugin/GlobalSignCAPlugin.cs index 1aa848d..ba38f6e 100644 --- a/globalsign-mssl-caplugin/GlobalSignCAPlugin.cs +++ b/globalsign-mssl-caplugin/GlobalSignCAPlugin.cs @@ -270,91 +270,112 @@ public async Task Enroll(string csr, string subject, Dictionar List matchedDomains = new List(); - // only try to resolve a domain if we don't already have a commonName - if (string.IsNullOrWhiteSpace(commonName)) - { - // 1) Try IP SANs first - if (ipSans != null) - foreach (var ipSan in ipSans) - { - if (string.IsNullOrWhiteSpace(ipSan)) - continue; - - var tempDomains = validDomains - .Where(d => - !string.IsNullOrEmpty(d?.DomainName) && - ipSan.EndsWith($".{d.DomainName}", StringComparison.OrdinalIgnoreCase) - ).ToList(); - - if (tempDomains != null && tempDomains.Count > 0) - { - Logger.LogDebug($"ipSAN Domain match found for ipSAN: {ipSan}"); - matchedDomains = tempDomains; - commonName = ipSan; - break; - } - } - - // 2) If still not found, try DNS SANs - if (domain == null && dnsSans != null) - foreach (var dnsSan in dnsSans) - { - if (string.IsNullOrWhiteSpace(dnsSan)) - continue; - - var tempDomains = validDomains - .Where(d => - !string.IsNullOrEmpty(d?.DomainName) && - dnsSan.EndsWith(d.DomainName, StringComparison.OrdinalIgnoreCase) - ).ToList(); + // only try to resolve a domain if we don't already have a commonName + if (string.IsNullOrWhiteSpace(commonName)) + { + // 1) Try IP SANs first + if (ipSans != null) + foreach (var ipSan in ipSans) + { + if (string.IsNullOrWhiteSpace(ipSan)) + continue; + + var tempDomains = validDomains + .Where(d => + !string.IsNullOrEmpty(d?.DomainName) && + ipSan.EndsWith($".{d.DomainName}", StringComparison.OrdinalIgnoreCase) + ).ToList(); + + if (tempDomains != null && tempDomains.Count > 0) + { + Logger.LogDebug($"ipSAN Domain match found for ipSAN: {ipSan}"); + matchedDomains = tempDomains; + commonName = ipSan; + break; + } + } - if (tempDomains != null && tempDomains.Count > 0) - { - Logger.LogDebug($"SAN Domain match found for SAN: {dnsSan}"); - matchedDomains = tempDomains; - commonName = dnsSan; - break; - } - } - } - // If private domain skip domain resolution. - else if (privateDomain) - { - var profiles = await apiClient.GetProfiles(); - var fillProfile = profiles.FirstOrDefault(); - // If PrivateDomain is true, we don't need to fully resolve a domain - domain = new GetDomainsDomainDetail() - { - ContactInfo = new ContactInfoDomain() - { - Email = requesterEmail, - Phone = requesterTel, - FirstName = requestor, - LastName = requestor + // 2) If still not found, try DNS SANs + if (domain == null && dnsSans != null) + foreach (var dnsSan in dnsSans) + { + if (string.IsNullOrWhiteSpace(dnsSan)) + continue; + + var tempDomains = validDomains + .Where(d => + !string.IsNullOrEmpty(d?.DomainName) && + dnsSan.EndsWith(d.DomainName, StringComparison.OrdinalIgnoreCase) + ).ToList(); + + if (tempDomains != null && tempDomains.Count > 0) + { + Logger.LogDebug($"SAN Domain match found for SAN: {dnsSan}"); + matchedDomains = tempDomains; + commonName = dnsSan; + break; + } + } + } + // If private domain skip domain resolution. + else if (privateDomain) + { + var profiles = await apiClient.GetProfiles(); + var fillProfile = profiles.FirstOrDefault(); + // If PrivateDomain is true, we don't need to fully resolve a domain + domain = new GetDomainsDomainDetail() + { + ContactInfo = new ContactInfoDomain() + { + Email = requesterEmail, + Phone = requesterTel, + FirstName = requestor, + LastName = requestor - } - }; - domain.MSSLProfileID = fillProfile.MSSLProfileId; + } + }; + domain.MSSLProfileID = fillProfile.MSSLProfileId; matchedDomains = new List { domain }; - } + } - // 3) Fallback: if we did obtain a commonName (or it was already set), try matching it - else if (domain == null && !string.IsNullOrWhiteSpace(commonName)) - matchedDomains = validDomains - .Where(d => - !string.IsNullOrEmpty(d?.DomainName) && - commonName.EndsWith(d.DomainName, StringComparison.OrdinalIgnoreCase) - ).ToList(); + // 3) Fallback: if we did obtain a commonName (or it was already set), try matching it + else if (domain == null && !string.IsNullOrWhiteSpace(commonName)) + { + matchedDomains = validDomains + .Where(d => + !string.IsNullOrEmpty(d?.DomainName) && + commonName.EndsWith(d.DomainName, StringComparison.OrdinalIgnoreCase) + ).ToList(); + } if (matchedDomains.Count == 1) { domain = matchedDomains[0]; } + else if (matchedDomains.Count == 0) + { + StringBuilder stringBuilder = new StringBuilder(); + stringBuilder.AppendLine($"No domains found matching common name {commonName}\nAll domains:"); + foreach (var dm in validDomains) + { + stringBuilder.AppendLine($"Domain: {dm.DomainName}\tProfileID: {dm.MSSLProfileID}"); + } + Logger.LogTrace(stringBuilder.ToString()); + } else { + StringBuilder sb = new StringBuilder(); + sb.AppendLine($"Domains found matching common name {commonName}:"); + foreach (var dom in matchedDomains) + { + sb.AppendLine($"Domain: {dom.DomainName}\tProfileID: {dom.MSSLProfileID}"); + } + //Logger.LogTrace($"Multiple domains found matching common name {commonName}:\n{string.Join(',', matchedDomains.Select(d => d.DomainName))}.\nChecking for Profile ID."); var profId = productInfo.ProductParameters["MSSLProfileID"]; - if (!string.IsNullOrEmpty(profId) ) + if (!string.IsNullOrEmpty(profId)) { + sb.AppendLine($"Comparing ProfileID to provided MSSLProfileID: {profId}"); + Logger.LogTrace(sb.ToString()); var tempDomain = matchedDomains.Where(d => d.MSSLProfileID.Equals(profId, StringComparison.OrdinalIgnoreCase)).FirstOrDefault(); if (tempDomain != null) @@ -366,6 +387,11 @@ public async Task Enroll(string csr, string subject, Dictionar throw new Exception($"No domain matching common name {commonName} has provided MSSLProfileID of {profId}. Check configuration."); } } + else + { + sb.AppendLine("No MSSLProfileID provided, unable to determine correct domain"); + Logger.LogTrace(sb.ToString()); + } } From 7c2d81ed449b17b10b9389558767e0bd2864360f Mon Sep 17 00:00:00 2001 From: David Galey Date: Wed, 14 Oct 2026 05:17:49 -0400 Subject: [PATCH 6/9] dns validation support --- CHANGELOG.md | 6 ++ docsource/configuration.md | 6 ++ globalsign-mssl-caplugin.sln | 7 +- .../Api/GlobalSignRenewDomainRequest.cs | 41 ++++++++ .../Api/GlobalSignVerifyDomainRequest.cs | 40 ++++++++ .../Client/GlobalSignApiClient.cs | 42 ++++++++ globalsign-mssl-caplugin/Constants.cs | 1 + .../GlobalSignCAConfig.cs | 3 + .../GlobalSignCAPlugin.cs | 96 ++++++++++++++++--- .../GlobalSignCAPlugin.csproj | 13 +-- 10 files changed, 230 insertions(+), 25 deletions(-) create mode 100644 globalsign-mssl-caplugin/Api/GlobalSignRenewDomainRequest.cs create mode 100644 globalsign-mssl-caplugin/Api/GlobalSignVerifyDomainRequest.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d68ee8..1e56436 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,3 +9,9 @@ Change incremental sync to use GetModifiedOrders API call 1.1.2 Add 30 day window from expiration for doing renewals instead of reissues + +1.1.3 +Return EXTERNALVALIDATION status for all pending requests. + +2.0.0 +Add support for automated domain validation via DNS gateway plugins. diff --git a/docsource/configuration.md b/docsource/configuration.md index ea208a3..7153655 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -23,6 +23,12 @@ Each additional Root will require a separate CA setup. GlobalSign supports specific combinations of SAN types with certain GlobalSign products. For example, a Private IP can only be used as a SAN with a `PV_INTRA` Certificate. Please refer to the GlobalSign documentation for more information on SAN usage: [GlobalSign MSSL API User Guide (Section 2.2.5)](https://www.globalsign.com/en/repository/globalsign-mssl-api-user-guide.pdf) +### Automated DNS Domain Validation +This plugin integrates with the AnyCA Gateway **DNS provider plugin framework** (`KeyfactorAnyGateway.IAnyCAPlugin` 3.3.0+). DNS provider plugins (Azure DNS, AWS Route53, Cloudflare, Google Cloud DNS, etc.) are deployed and configured **separately** on the gateway; this CA plugin does not bundle any DNS provider SDKs. The gateway injects an `IDomainValidatorFactory` that resolves the correct provider for each domain at enrollment time. + +GlobalSign supports **TXT** records for DNS validation. + +`DnsValidationEnabled` determines whether to use the automated validation. Make sure you have the necessary DNS plugins installed and configured before enabling. ## Enrollment diff --git a/globalsign-mssl-caplugin.sln b/globalsign-mssl-caplugin.sln index 5949674..2dcf0cc 100644 --- a/globalsign-mssl-caplugin.sln +++ b/globalsign-mssl-caplugin.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.11.35222.181 +# Visual Studio Version 18 +VisualStudioVersion = 18.9.12105.275 stable MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GlobalSignCAPlugin", "globalsign-mssl-caplugin\GlobalSignCAPlugin.csproj", "{E3B20C7A-1B3C-4FAE-A1C9-EA4CEC400D70}" EndProject @@ -21,7 +21,8 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {E3B20C7A-1B3C-4FAE-A1C9-EA4CEC400D70}.Debug|Any CPU.ActiveCfg = DebugAndPush|Any CPU + {E3B20C7A-1B3C-4FAE-A1C9-EA4CEC400D70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E3B20C7A-1B3C-4FAE-A1C9-EA4CEC400D70}.Debug|Any CPU.Build.0 = Debug|Any CPU {E3B20C7A-1B3C-4FAE-A1C9-EA4CEC400D70}.DebugAndPush|Any CPU.ActiveCfg = DebugAndPush|Any CPU {E3B20C7A-1B3C-4FAE-A1C9-EA4CEC400D70}.DebugAndPush|Any CPU.Build.0 = DebugAndPush|Any CPU {E3B20C7A-1B3C-4FAE-A1C9-EA4CEC400D70}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/globalsign-mssl-caplugin/Api/GlobalSignRenewDomainRequest.cs b/globalsign-mssl-caplugin/Api/GlobalSignRenewDomainRequest.cs new file mode 100644 index 0000000..f8ba891 --- /dev/null +++ b/globalsign-mssl-caplugin/Api/GlobalSignRenewDomainRequest.cs @@ -0,0 +1,41 @@ +// 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 Order; + +namespace Keyfactor.Extensions.CAPlugin.GlobalSign.Api; + +public class GlobalSignRenewDomainRequest +{ + private readonly GlobalSignCAConfig Config; + + public GlobalSignRenewDomainRequest(GlobalSignCAConfig config) + { + Config = config; + } + + public string MsslProfileId { get; set; } = string.Empty; + public string DomainId { get; set; } = string.Empty; + + public BmV1RenewalDomainRequest Request + { + get + { + var request = new BmV1RenewalDomainRequest(); + var header = new OrderRequestHeader + { + AuthToken = Config.GetOrderAuthToken() + }; + request.OrderRequestHeader = header; + request.MSSLProfileID = MsslProfileId; + request.DomainID = DomainId; + request.VettingLevel = "OV"; + request.VettingType = "DNS"; + return request; + } + } +} \ No newline at end of file diff --git a/globalsign-mssl-caplugin/Api/GlobalSignVerifyDomainRequest.cs b/globalsign-mssl-caplugin/Api/GlobalSignVerifyDomainRequest.cs new file mode 100644 index 0000000..8dea01e --- /dev/null +++ b/globalsign-mssl-caplugin/Api/GlobalSignVerifyDomainRequest.cs @@ -0,0 +1,40 @@ +// 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 Order; + +namespace Keyfactor.Extensions.CAPlugin.GlobalSign.Api; + +public class GlobalSignVerifyDomainRequest +{ + private readonly GlobalSignCAConfig Config; + + public GlobalSignVerifyDomainRequest(GlobalSignCAConfig config) + { + Config = config; + } + + public string DomainName { get; set; } = string.Empty; + public string DomainId { get; set; } = string.Empty; + + public BmV1VerifyMsslDomainRequest Request + { + get + { + var request = new BmV1VerifyMsslDomainRequest(); + var header = new OrderRequestHeader + { + AuthToken = Config.GetOrderAuthToken() + }; + request.OrderRequestHeader = header; + request.DomainID = DomainId; + request.TagLocation = DomainName; + request.VettingType = "DNS"; + return request; + } + } +} \ No newline at end of file diff --git a/globalsign-mssl-caplugin/Client/GlobalSignApiClient.cs b/globalsign-mssl-caplugin/Client/GlobalSignApiClient.cs index f3e5036..6190e9d 100644 --- a/globalsign-mssl-caplugin/Client/GlobalSignApiClient.cs +++ b/globalsign-mssl-caplugin/Client/GlobalSignApiClient.cs @@ -325,6 +325,48 @@ public async Task> GetProfiles() throw new Exception(gsError.Message); } + public async Task RenewDomain(GlobalSignRenewDomainRequest request) + { + Logger.MethodEntry(); + var rawRequest = request.Request; + Logger.LogTrace($"Renew Domain request details:"); + Logger.LogTrace($"Profile ID: {rawRequest.MSSLProfileID}"); + Logger.LogTrace($"Domain ID: {rawRequest.DomainID}"); + var responsewrapper = await OrderService.RenewalDomainAsync(new RenewalDomain(rawRequest)); + var response = responsewrapper.Response; + if (response.OrderResponseHeader.SuccessCode == 0) + { + Logger.LogDebug("Renewal domain request successfully submitted"); + Logger.MethodExit(); + return response.DnsTXT; + } + var errorCode = int.Parse(response.OrderResponseHeader.Errors[0].ErrorCode); + var err = GlobalSignErrorIndex.GetGlobalSignError(errorCode); + Logger.MethodExit(); + throw new Exception($"Error while attempting to renew domain: {err.DetailedMessage}"); + } + + public async Task VerifyDomain(GlobalSignVerifyDomainRequest request) + { + Logger.MethodEntry(); + var rawRequest = request.Request; + Logger.LogTrace($"Verify Domain request details:"); + Logger.LogTrace($"Domain Name: {rawRequest.TagLocation}"); + Logger.LogTrace($"Domain ID: {rawRequest.DomainID}"); + var responsewrapper = await OrderService.VerifyMsslDomainAsync(new VerifyMsslDomain(rawRequest)); + var response = responsewrapper.Response; + if (response.OrderResponseHeader.SuccessCode == 0) + { + Logger.LogDebug("Verify domain request successful"); + Logger.MethodExit(); + return true; + } + var errorCode = int.Parse(response.OrderResponseHeader.Errors[0].ErrorCode); + var err = GlobalSignErrorIndex.GetGlobalSignError(errorCode); + Logger.MethodExit(); + throw new Exception($"Error while attempting to verify domain: {err.DetailedMessage}"); + } + public async Task Enroll(GlobalSignEnrollRequest enrollRequest) { Logger.MethodEntry(); diff --git a/globalsign-mssl-caplugin/Constants.cs b/globalsign-mssl-caplugin/Constants.cs index dbbcb43..8f20021 100644 --- a/globalsign-mssl-caplugin/Constants.cs +++ b/globalsign-mssl-caplugin/Constants.cs @@ -23,6 +23,7 @@ internal class Constants public static string SYNCINTERNVALDAYS = "SyncIntervalDays"; public static string SYNCPRODUCTS = "SyncProducts"; public static string Enabled = "Enabled"; + public static string DNS_VALIDATION_ENABLED = "DnsValidationEnabled"; } public static class EnrollmentConfigConstants diff --git a/globalsign-mssl-caplugin/GlobalSignCAConfig.cs b/globalsign-mssl-caplugin/GlobalSignCAConfig.cs index ba52afe..467ceb0 100644 --- a/globalsign-mssl-caplugin/GlobalSignCAConfig.cs +++ b/globalsign-mssl-caplugin/GlobalSignCAConfig.cs @@ -36,6 +36,9 @@ public class GlobalSignCAConfig public bool Enabled { get; set; } = true; + public bool DnsValidationEnabled { get; set; } + + public string GetUrl(GlobalSignServiceType queryType) { switch (queryType) diff --git a/globalsign-mssl-caplugin/GlobalSignCAPlugin.cs b/globalsign-mssl-caplugin/GlobalSignCAPlugin.cs index ba38f6e..71f86d6 100644 --- a/globalsign-mssl-caplugin/GlobalSignCAPlugin.cs +++ b/globalsign-mssl-caplugin/GlobalSignCAPlugin.cs @@ -20,13 +20,20 @@ namespace Keyfactor.Extensions.CAPlugin.GlobalSign; public class GlobalSignCAPlugin : IAnyCAPlugin { - private ICertificateDataReader? _certificateDataReader; - private ILogger Logger; + private ICertificateDataReader? _certificateDataReader; + private ILogger Logger = LogHandler.GetClassLogger(); private GlobalSignCAConfig Config { get; set; } = new(); private bool _enabled = false; + private readonly IDomainValidatorFactory DomainValidatorFactory; - public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDataReader certificateDataReader) + public GlobalSignCAPlugin(IDomainValidatorFactory domainValidatorFactory) + { + DomainValidatorFactory = domainValidatorFactory; + } + + + public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDataReader certificateDataReader) { Logger = LogHandler.GetClassLogger(GetType()); Logger.MethodEntry(); @@ -57,8 +64,9 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa SyncIntervalDays = configProvider.CAConnectionData.TryGetValue("SyncIntervalDays", out var val) && int.TryParse(val as string, out var parsed) ? parsed - : 0 - }; + : 0, + DnsValidationEnabled = bool.Parse((string)configProvider.CAConnectionData["DnsValidationEnabled"]) + }; _certificateDataReader = certificateDataReader; Logger.MethodExit(); } @@ -238,9 +246,9 @@ public async Task Enroll(string csr, string subject, Dictionar GetDomainsDomainDetail? domain = null; var allDomains = await apiClient.GetDomains(); var validDomainStatus = new List { "3", "7", "9", "10" }; - var validDomains = allDomains.Where(d => validDomainStatus.Contains(d.DomainStatus)).ToList(); - if (validDomains.Count == 0 && !privateDomain) - throw new Exception("No domains found that are valid for certificate enrollment"); + //var validDomains = allDomains.Where(d => validDomainStatus.Contains(d.DomainStatus)).ToList(); + //if (validDomains.Count == 0 && !privateDomain) + // throw new Exception("No domains found that are valid for certificate enrollment"); try { commonName = ParseSubject(subject, "CN="); @@ -280,7 +288,7 @@ public async Task Enroll(string csr, string subject, Dictionar if (string.IsNullOrWhiteSpace(ipSan)) continue; - var tempDomains = validDomains + var tempDomains = allDomains .Where(d => !string.IsNullOrEmpty(d?.DomainName) && ipSan.EndsWith($".{d.DomainName}", StringComparison.OrdinalIgnoreCase) @@ -302,7 +310,7 @@ public async Task Enroll(string csr, string subject, Dictionar if (string.IsNullOrWhiteSpace(dnsSan)) continue; - var tempDomains = validDomains + var tempDomains = allDomains .Where(d => !string.IsNullOrEmpty(d?.DomainName) && dnsSan.EndsWith(d.DomainName, StringComparison.OrdinalIgnoreCase) @@ -341,7 +349,7 @@ public async Task Enroll(string csr, string subject, Dictionar // 3) Fallback: if we did obtain a commonName (or it was already set), try matching it else if (domain == null && !string.IsNullOrWhiteSpace(commonName)) { - matchedDomains = validDomains + matchedDomains = allDomains .Where(d => !string.IsNullOrEmpty(d?.DomainName) && commonName.EndsWith(d.DomainName, StringComparison.OrdinalIgnoreCase) @@ -356,7 +364,7 @@ public async Task Enroll(string csr, string subject, Dictionar { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.AppendLine($"No domains found matching common name {commonName}\nAll domains:"); - foreach (var dm in validDomains) + foreach (var dm in allDomains) { stringBuilder.AppendLine($"Domain: {dm.DomainName}\tProfileID: {dm.MSSLProfileID}"); } @@ -397,9 +405,59 @@ public async Task Enroll(string csr, string subject, Dictionar if (domain == null) throw new Exception("Unable to determine GlobalSign domain"); - + if (!string.Equals(domain.DomainStatus, "3") && !privateDomain) + { + if (!Config.DnsValidationEnabled) + { + if (!validDomainStatus.Contains(domain.DomainStatus)) + { + throw new Exception($"Domain {domain.DomainName} is not valid for certificate enrollment, and DNS validation is disabled"); + } + else + { + // Domain is still valid for enrollment but is pending re-validation. Do nothing if DnsValidationEnabled is false. + } + } + else + { + var request = new GlobalSignRenewDomainRequest(Config) + { + MsslProfileId = domain.MSSLProfileID, + DomainId = domain.DomainID + }; + var dnsTxt = await apiClient.RenewDomain(request); - Logger.LogDebug( + IDomainValidator validator; + try + { + validator = DomainValidatorFactory.ResolveDomainValidator(domain.DomainName, "dns-01"); + } + catch (Exception ex) + { + Logger.LogError($"Failed to resolve domain validator for domain {domain.DomainName}: {ex.Message}"); + throw new Exception($"Failed to resolve domain validator for domain {domain.DomainName}: {ex.Message}", ex); + } + + DomainValidationResult domValResult = null; + domValResult = await validator.StageValidation(domain.DomainName, dnsTxt, CancellationToken.None); + + if (domValResult == null || !domValResult.Success) + { + var msg = domValResult?.ErrorMessage ?? "unknown error"; + Logger.LogError(msg); + throw new Exception(msg); + } + + var verifyRequest = new GlobalSignVerifyDomainRequest(Config) + { + DomainId = domain.DomainID, + DomainName = domain.DomainName + }; + var verifyResult = await apiClient.VerifyDomain(verifyRequest); + } + } + + Logger.LogDebug( $"Domain info:\nDomain Name: {domain?.DomainName}\nMsslDomainId: {domain?.DomainID}\nMsslProfileId: {domain?.MSSLProfileID}"); Logger.LogDebug($"Using common name: {commonName}"); var months = (int.Parse(productInfo.ProductParameters["CertificateValidityInYears"]) * 12).ToString(); @@ -713,6 +771,16 @@ public Dictionary GetCAConnectorAnnotations() DefaultValue = null, Type = "String" }, + [Constants.DNS_VALIDATION_ENABLED] = new() + { + Comments = "Enable automated DNS (TXT record) domain control validation. When enabled, the plugin " + + "requests TXT-based validation from GlobalSign and publishes the returned record via the " + + "DNS provider plugin resolved by the AnyCA Gateway. Requires a DNS provider plugin (e.g. Azure, " + + "Cloudflare, etc) to be deployed and configured on the gateway.", + Hidden = false, + DefaultValue = false, + Type = "Boolean" + }, [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.", diff --git a/globalsign-mssl-caplugin/GlobalSignCAPlugin.csproj b/globalsign-mssl-caplugin/GlobalSignCAPlugin.csproj index 8230d1c..03ae1cd 100644 --- a/globalsign-mssl-caplugin/GlobalSignCAPlugin.csproj +++ b/globalsign-mssl-caplugin/GlobalSignCAPlugin.csproj @@ -3,7 +3,7 @@ true - net6.0;net8.0 + net8.0;net10.0 Keyfactor.Extensions.CAPlugin.GlobalSign true enable @@ -14,15 +14,12 @@ - - - - + + + + - - - From 62c7c10abcbb666c6c39e6ddfbf64847bcfeac0c Mon Sep 17 00:00:00 2001 From: Dave Galey <89407235+dgaley@users.noreply.github.com> Date: Wed, 14 Oct 2026 05:19:19 -0400 Subject: [PATCH 7/9] Upgrade starter workflow to version 5 Updated the starter workflow version from v3 to v5. --- .github/workflows/keyfactor-bootstrap-workflow.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/keyfactor-bootstrap-workflow.yml b/.github/workflows/keyfactor-bootstrap-workflow.yml index 64919a4..45eec87 100644 --- a/.github/workflows/keyfactor-bootstrap-workflow.yml +++ b/.github/workflows/keyfactor-bootstrap-workflow.yml @@ -11,10 +11,9 @@ on: jobs: call-starter-workflow: - uses: keyfactor/actions/.github/workflows/starter.yml@v3 + uses: keyfactor/actions/.github/workflows/starter.yml@v5 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 }} From f62542428d9e79075a593559899be1567f2538db Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 14 Oct 2026 09:19:45 +0000 Subject: [PATCH 8/9] docs: auto-generate README and documentation [skip ci] --- README.md | 60 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index d0ce3a5..4a9f18a 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Support - + · Requirements @@ -33,7 +33,6 @@

- The GlobalSign CAPlugin enables the Synchronization, Enrollment, and Revocation of TLS Certificates from the GlobalSign Certificate Center. ## Compatibility @@ -41,7 +40,7 @@ The GlobalSign CAPlugin enables the Synchronization, Enrollment, and Revocation The GlobalSign MSSL AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 25.2.0 and later. ## Support -The GlobalSign MSSL 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 GlobalSign MSSL 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. @@ -62,16 +61,16 @@ This extension uses the contact information of the GCC Domain point of contact f 2. On the server hosting the AnyCA Gateway REST, download and unzip the latest [GlobalSign MSSL AnyCA Gateway REST plugin](https://github.com/Keyfactor/globalsign-mssl-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 `net8.0` or `net10.0`) to the Extensions directory: ```shell Depending on your AnyCA Gateway REST version, copy the unzipped directory to one of the following locations: - Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net6.0\Extensions Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net8.0\Extensions + Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net10.0\Extensions ``` - > The directory containing the GlobalSign MSSL 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 GlobalSign MSSL AnyCA Gateway REST plugin DLLs (`net8.0` or `net10.0`) can be named anything, as long as it is unique within the `Extensions` directory. 4. Restart the AnyCA Gateway REST service. @@ -91,20 +90,20 @@ This extension uses the contact information of the GCC Domain point of contact f Populate using the configuration fields collected in the [requirements](#requirements) section. - * **GlobalSignUsername** - GlobalSign MSSL API Username - * **GlobalSignPassword** - GlobalSign MSSL API Password - * **DateFormatString** - Date format string. Default is yyyy-MM-ddTHH:mm:ss.fffZ - * **OrderAPIProdURL** - MSSL Order Prod API URL. Default is https://system.globalsign.com/kb/ws/v2/ManagedSSLService - * **OrderAPITestURL** - MSSL Order Test API URL. Default is https://test-gcc.globalsign.com/kb/ws/v2/ManagedSSLService - * **QueryAPIProdURL** - MSSL Query Prod API URL. Default is https://system.globalsign.com/kb/ws/v1/GASService - * **QueryAPITestURL** - MSSL Query Test API URL. Default is https://test-gcc.globalsign.com/kb/ws/v1/GASService - * **TestAPI** - Enable the use of the test GlobalSign API endpoints. Default is false. - * **DelayTime** - This is the number of seconds between retries when attempting to download a certificate. Default is 150. - * **RetryCount** - This is the number of times the AnyGateway will attempt to pickup an new certificate before reporting an error. Default is 5. - * **SyncIntervalDays** - OPTIONAL: Required if SyncStartDate is used. Specifies how to page the certificate sync. Should be a value such that no interval of that length contains > 500 certificate enrollments. - * **SyncStartDate** - If provided, full syncs will start at the specified date. - * **SyncProducts** - OPTIONAL: If provided as a comma-separated list of product IDs, will limit the certificate sync to only certificates of those products. If blank or not provided, will sync all certs. - * **Enabled** - Flag to Enable or Disable gateway functionality. Disabling is primarily used to allow creation of the CA prior to configuration information being available. + * **GlobalSignUsername** - GlobalSign MSSL API Username + * **GlobalSignPassword** - GlobalSign MSSL API Password + * **DateFormatString** - Date format string. Default is yyyy-MM-ddTHH:mm:ss.fffZ + * **OrderAPIProdURL** - MSSL Order Prod API URL. Default is https://system.globalsign.com/kb/ws/v2/ManagedSSLService + * **OrderAPITestURL** - MSSL Order Test API URL. Default is https://test-gcc.globalsign.com/kb/ws/v2/ManagedSSLService + * **QueryAPIProdURL** - MSSL Query Prod API URL. Default is https://system.globalsign.com/kb/ws/v1/GASService + * **QueryAPITestURL** - MSSL Query Test API URL. Default is https://test-gcc.globalsign.com/kb/ws/v1/GASService + * **TestAPI** - Enable the use of the test GlobalSign API endpoints. Default is false. + * **DelayTime** - This is the number of seconds between retries when attempting to download a certificate. Default is 150. + * **RetryCount** - This is the number of times the AnyGateway will attempt to pickup an new certificate before reporting an error. Default is 5. + * **SyncIntervalDays** - OPTIONAL: Required if SyncStartDate is used. Specifies how to page the certificate sync. Should be a value such that no interval of that length contains > 500 certificate enrollments. + * **SyncStartDate** - If provided, full syncs will start at the specified date. + * **SyncProducts** - OPTIONAL: If provided as a comma-separated list of product IDs, will limit the certificate sync to only certificates of those products. If blank or not provided, will sync all certs. + * **Enabled** - Flag to Enable or Disable gateway functionality. Disabling is primarily used to allow creation of the CA prior to configuration information being available. 2. Define [Certificate Profiles](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCP-Gateway.htm) and [Certificate Templates](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCA-Gateway.htm) for the Certificate Authority as required. One Certificate Profile must be defined per Certificate Template. It's recommended that each Certificate Profile be named after the Product ID. The GlobalSign MSSL plugin supports the following product IDs: @@ -122,17 +121,23 @@ This extension uses the contact information of the GCC Domain point of contact f 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: - * **CertificateValidityInYears** - Number of years the certificate will be valid for - * **SlotSize** - Maximum number of SANs that a certificate may have - valid values are [FIVE, TEN, FIFTEEN, TWENTY, THIRTY, FOURTY, FIFTY, ONE_HUNDRED] - * **RootCAType** - The certificate's root CA - Depending on certificate expiration date, SHA_1 not be allowed. Will default to SHA_2 if expiration date exceeds sha1 allowed date. Options are GlobalSign R certs. - * **MSSLProfileId** - OPTIONAL: If specified, enrollments will use that profile ID for domain lookups. If not provided, domain lookup will be done based on the Common Name or first DNS SAN. Useful if your GlobalSign account has multiple domain objects with the same domain string, or subdomains (e.g. sub.test.com vs test.com). - * **ContactName** - The name of the contact to use for enrollments. Can be specified here or via an Enrollment Field in Command. Enrollment Fields will override any value supplied here. - + * **CertificateValidityInYears** - Number of years the certificate will be valid for + * **SlotSize** - Maximum number of SANs that a certificate may have - valid values are [FIVE, TEN, FIFTEEN, TWENTY, THIRTY, FOURTY, FIFTY, ONE_HUNDRED] + * **RootCAType** - The certificate's root CA - Depending on certificate expiration date, SHA_1 not be allowed. Will default to SHA_2 if expiration date exceeds sha1 allowed date. Options are GlobalSign R certs. + * **MSSLProfileId** - OPTIONAL: If specified, enrollments will use that profile ID for domain lookups. If not provided, domain lookup will be done based on the Common Name or first DNS SAN. Useful if your GlobalSign account has multiple domain objects with the same domain string, or subdomains (e.g. sub.test.com vs test.com). + * **ContactName** - The name of the contact to use for enrollments. Can be specified here or via an Enrollment Field in Command. Enrollment Fields will override any value supplied here. ## Valid GlobalSign SAN Usage GlobalSign supports specific combinations of SAN types with certain GlobalSign products. For example, a Private IP can only be used as a SAN with a `PV_INTRA` Certificate. Please refer to the GlobalSign documentation for more information on SAN usage: [GlobalSign MSSL API User Guide (Section 2.2.5)](https://www.globalsign.com/en/repository/globalsign-mssl-api-user-guide.pdf) +### Automated DNS Domain Validation +This plugin integrates with the AnyCA Gateway **DNS provider plugin framework** (`KeyfactorAnyGateway.IAnyCAPlugin` 3.3.0+). DNS provider plugins (Azure DNS, AWS Route53, Cloudflare, Google Cloud DNS, etc.) are deployed and configured **separately** on the gateway; this CA plugin does not bundle any DNS provider SDKs. The gateway injects an `IDomainValidatorFactory` that resolves the correct provider for each domain at enrollment time. + +GlobalSign supports **TXT** records for DNS validation. + +`DnsValidationEnabled` determines whether to use the automated validation. Make sure you have the necessary DNS plugins installed and configured before enabling. + ## Enrollment ### Required Enrollment Fields @@ -150,11 +155,10 @@ The following fields are available for use in the enrollment of `PV_INTRA` Certi - **RequesterTel**: Set Data Type to 'string' when creating the field. The contact telephone number for the enrollment. Required by the GlobalSign API. - **InternalIP**: Set Data Type to 'string' when creating the field. Set to `true` if an IP SAN attached during a `PV_INTRA` certificate enrollment is a private IP address (e.g., `10.x.x.x`, `192.168.x.x`, etc.). - ## 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 445005b368694196a70df42c24aeb307b2933209 Mon Sep 17 00:00:00 2001 From: David Galey Date: Thu, 15 Oct 2026 09:22:27 -0400 Subject: [PATCH 9/9] domain check fix --- .../GlobalSignCAPlugin.cs | 49 +++++++++++-------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/globalsign-mssl-caplugin/GlobalSignCAPlugin.cs b/globalsign-mssl-caplugin/GlobalSignCAPlugin.cs index 71f86d6..6a6d3d6 100644 --- a/globalsign-mssl-caplugin/GlobalSignCAPlugin.cs +++ b/globalsign-mssl-caplugin/GlobalSignCAPlugin.cs @@ -246,7 +246,7 @@ public async Task Enroll(string csr, string subject, Dictionar GetDomainsDomainDetail? domain = null; var allDomains = await apiClient.GetDomains(); var validDomainStatus = new List { "3", "7", "9", "10" }; - //var validDomains = allDomains.Where(d => validDomainStatus.Contains(d.DomainStatus)).ToList(); + var validDomains = allDomains.Where(d => validDomainStatus.Contains(d.DomainStatus)).ToList(); //if (validDomains.Count == 0 && !privateDomain) // throw new Exception("No domains found that are valid for certificate enrollment"); try @@ -372,34 +372,43 @@ public async Task Enroll(string csr, string subject, Dictionar } else { - StringBuilder sb = new StringBuilder(); - sb.AppendLine($"Domains found matching common name {commonName}:"); - foreach (var dom in matchedDomains) + var validMatchedDomains = matchedDomains.FindAll(d => validDomains.Contains(d)).ToList(); + matchedDomains = validMatchedDomains; + if (matchedDomains.Count == 1) { - sb.AppendLine($"Domain: {dom.DomainName}\tProfileID: {dom.MSSLProfileID}"); + domain = matchedDomains[0]; } - //Logger.LogTrace($"Multiple domains found matching common name {commonName}:\n{string.Join(',', matchedDomains.Select(d => d.DomainName))}.\nChecking for Profile ID."); - var profId = productInfo.ProductParameters["MSSLProfileID"]; - if (!string.IsNullOrEmpty(profId)) + else { - sb.AppendLine($"Comparing ProfileID to provided MSSLProfileID: {profId}"); - Logger.LogTrace(sb.ToString()); - var tempDomain = matchedDomains.Where(d => - d.MSSLProfileID.Equals(profId, StringComparison.OrdinalIgnoreCase)).FirstOrDefault(); - if (tempDomain != null) + StringBuilder sb = new StringBuilder(); + sb.AppendLine($"Domains found matching common name {commonName}:"); + foreach (var dom in matchedDomains) + { + sb.AppendLine($"Domain: {dom.DomainName}\tProfileID: {dom.MSSLProfileID}"); + } + //Logger.LogTrace($"Multiple domains found matching common name {commonName}:\n{string.Join(',', matchedDomains.Select(d => d.DomainName))}.\nChecking for Profile ID."); + var profId = productInfo.ProductParameters["MSSLProfileID"]; + if (!string.IsNullOrEmpty(profId)) { - domain = tempDomain; + sb.AppendLine($"Comparing ProfileID to provided MSSLProfileID: {profId}"); + Logger.LogTrace(sb.ToString()); + var tempDomain = matchedDomains.Where(d => + d.MSSLProfileID.Equals(profId, StringComparison.OrdinalIgnoreCase)).FirstOrDefault(); + if (tempDomain != null) + { + domain = tempDomain; + } + else + { + throw new Exception($"No domain matching common name {commonName} has provided MSSLProfileID of {profId}. Check configuration."); + } } else { - throw new Exception($"No domain matching common name {commonName} has provided MSSLProfileID of {profId}. Check configuration."); + sb.AppendLine("No MSSLProfileID provided, unable to determine correct domain"); + Logger.LogTrace(sb.ToString()); } } - else - { - sb.AppendLine("No MSSLProfileID provided, unable to determine correct domain"); - Logger.LogTrace(sb.ToString()); - } }