From 62a8b24228821a3b74c28ec59caeb47f9caaef7a Mon Sep 17 00:00:00 2001 From: David Galey Date: Tue, 23 Jun 2026 09:47:10 -0400 Subject: [PATCH 01/15] Initial commit --- globalsign-atlas-caplugin.sln | 25 + .../APIProxy/AtlasBaseCalls.cs | 27 + .../APIProxy/Certificate.cs | 40 ++ globalsign-atlas-caplugin/APIProxy/Enroll.cs | 156 +++++ globalsign-atlas-caplugin/APIProxy/Login.cs | 25 + .../APIProxy/ValidationPolicy.cs | 238 +++++++ globalsign-atlas-caplugin/AtlasCAPlugin.cs | 435 ++++++++++++ globalsign-atlas-caplugin/AtlasConfig.cs | 29 + .../Client/AtlasClient.cs | 620 ++++++++++++++++++ .../globalsign-atlas-caplugin.csproj | 17 + 10 files changed, 1612 insertions(+) create mode 100644 globalsign-atlas-caplugin.sln create mode 100644 globalsign-atlas-caplugin/APIProxy/AtlasBaseCalls.cs create mode 100644 globalsign-atlas-caplugin/APIProxy/Certificate.cs create mode 100644 globalsign-atlas-caplugin/APIProxy/Enroll.cs create mode 100644 globalsign-atlas-caplugin/APIProxy/Login.cs create mode 100644 globalsign-atlas-caplugin/APIProxy/ValidationPolicy.cs create mode 100644 globalsign-atlas-caplugin/AtlasCAPlugin.cs create mode 100644 globalsign-atlas-caplugin/AtlasConfig.cs create mode 100644 globalsign-atlas-caplugin/Client/AtlasClient.cs create mode 100644 globalsign-atlas-caplugin/globalsign-atlas-caplugin.csproj diff --git a/globalsign-atlas-caplugin.sln b/globalsign-atlas-caplugin.sln new file mode 100644 index 0000000..e86e735 --- /dev/null +++ b/globalsign-atlas-caplugin.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36705.20 d17.14 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "globalsign-atlas-caplugin", "globalsign-atlas-caplugin\globalsign-atlas-caplugin.csproj", "{292B7A34-CFEC-4ABB-8BE6-31F87BC1651B}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {292B7A34-CFEC-4ABB-8BE6-31F87BC1651B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {292B7A34-CFEC-4ABB-8BE6-31F87BC1651B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {292B7A34-CFEC-4ABB-8BE6-31F87BC1651B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {292B7A34-CFEC-4ABB-8BE6-31F87BC1651B}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {D966B726-B709-498F-97E5-D53F6F7495A6} + EndGlobalSection +EndGlobal diff --git a/globalsign-atlas-caplugin/APIProxy/AtlasBaseCalls.cs b/globalsign-atlas-caplugin/APIProxy/AtlasBaseCalls.cs new file mode 100644 index 0000000..90e305c --- /dev/null +++ b/globalsign-atlas-caplugin/APIProxy/AtlasBaseCalls.cs @@ -0,0 +1,27 @@ +using Newtonsoft.Json; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Keyfactor.Extensions.CAPlugin.GlobalSign.Atlas.APIProxy +{ + public abstract class AtlasBaseRequest + { + [JsonIgnore] + public string Resource { get; internal set; } + + [JsonIgnore] + public string Method { get; internal set; } + + [JsonIgnore] + public string TargetURI { get; set; } + + public string BuildParameters() + { + return ""; + } + } +} diff --git a/globalsign-atlas-caplugin/APIProxy/Certificate.cs b/globalsign-atlas-caplugin/APIProxy/Certificate.cs new file mode 100644 index 0000000..e5f5465 --- /dev/null +++ b/globalsign-atlas-caplugin/APIProxy/Certificate.cs @@ -0,0 +1,40 @@ +using Newtonsoft.Json; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Keyfactor.Extensions.CAPlugin.GlobalSign.Atlas.APIProxy +{ + public class CertificateResponse + { + [JsonProperty("certificate")] + public string Certificate { get; set; } + + [JsonProperty("status")] + public string Status { get; set; } + + [JsonProperty("description")] + public string Description { get; set; } + } + + public class CertificateStatusResponse + { + [JsonProperty("not_after")] + public long NotAfter { get; set; } + + [JsonProperty("not_before")] + public long NotBefore { get; set; } + + [JsonProperty("serial_number")] + public string SerialNumber { get; set; } + } + + public class CertificateDetailsResponse + { + public CertificateResponse Cert { get; set; } + public CertificateStatusResponse Status { get; set; } + } +} diff --git a/globalsign-atlas-caplugin/APIProxy/Enroll.cs b/globalsign-atlas-caplugin/APIProxy/Enroll.cs new file mode 100644 index 0000000..031c3c7 --- /dev/null +++ b/globalsign-atlas-caplugin/APIProxy/Enroll.cs @@ -0,0 +1,156 @@ +using Keyfactor.PKI; + +using Newtonsoft.Json; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Keyfactor.Extensions.CAPlugin.GlobalSign.Atlas.APIProxy +{ + public class EnrollRequest : AtlasBaseRequest + { + public EnrollRequest() + { + this.Resource = "certificates"; + this.Method = "POST"; + } + } + + public class EnrollResponse + { + public string SerialNumber { get; set; } + public string Cert { get; set; } + public PKI.Enums.EJBCA.EndEntityStatus Status { get; set; } + public string StatusMessage { get; set; } + } + + public class Enroll + { + [JsonProperty("validity")] + public ValidityDates Validity { get; set; } + + [JsonProperty("subject_dn")] + public Subject SubjectDN { get; set; } + + [JsonProperty("san")] + public AlternateNames SANs { get; set; } + + [JsonProperty("extended_key_usages")] + public string[] EKUs + { get { return EKUList.ToArray(); } } + + [JsonIgnore] + public List EKUList { get; set; } + + [JsonProperty("public_key")] + public string CSR { get; set; } + + [JsonProperty("signature")] + public Signature Sig { get; set; } + + public Enroll() + { + Validity = new ValidityDates(); + SubjectDN = new Subject(); + SANs = new AlternateNames(); + Sig = new Signature(); + EKUList = new List(); + } + } + + public class ValidityDates + { + [JsonProperty("not_before")] + public long NotBeforeInt + { + get + { + return ((DateTimeOffset)NotBefore).ToUnixTimeSeconds(); + } + } + + [JsonProperty("not_after")] + public long NotAfterInt + { + get + { + return ((DateTimeOffset)NotAfter).ToUnixTimeSeconds(); + } + } + + [JsonIgnore] + public DateTime NotBefore { get; set; } + + [JsonIgnore] + public DateTime NotAfter { get; set; } + } + + public class Subject + { + [JsonProperty("common_name")] + public string CommonName { get; set; } + + [JsonProperty("country")] + public string Country { get; set; } + + [JsonProperty("state")] + public string State { get; set; } + + [JsonProperty("locality")] + public string Locality { get; set; } + + [JsonProperty("organization")] + public string Organization { get; set; } + + [JsonProperty("email")] + public string Email { get; set; } + } + + public class AlternateNames + { + [JsonProperty("dns_names")] + public string[] DNS + { get { return DNSList.ToArray(); } } + + [JsonIgnore] + public List DNSList { get; set; } + + [JsonProperty("emails")] + public string[] Emails + { get { return EmailList.ToArray(); } } + + [JsonIgnore] + public List EmailList { get; set; } + + [JsonProperty("ip_addresses")] + public string[] IPs + { get { return IPList.ToArray(); } } + + [JsonIgnore] + public List IPList { get; set; } + + [JsonProperty("uris")] + public string[] URIs + { get { return URIList.ToArray(); } } + + [JsonIgnore] + public List URIList { get; set; } + + public AlternateNames() + { + DNSList = new List(); + EmailList = new List(); + IPList = new List(); + URIList = new List(); + } + } + + public class Signature + { + [JsonProperty("hash_algorithm")] + public string HashAlgorithm { get; set; } + } +} diff --git a/globalsign-atlas-caplugin/APIProxy/Login.cs b/globalsign-atlas-caplugin/APIProxy/Login.cs new file mode 100644 index 0000000..8b1bb6c --- /dev/null +++ b/globalsign-atlas-caplugin/APIProxy/Login.cs @@ -0,0 +1,25 @@ +using Newtonsoft.Json; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Keyfactor.Extensions.CAPlugin.GlobalSign.Atlas.APIProxy +{ + public class LoginRequest + { + [JsonProperty("api_key")] + public string Key { get; set; } + + [JsonProperty("api_secret")] + public string Secret { get; set; } + } + + public class LoginResponse + { + [JsonProperty("access_token")] + public string Token { get; set; } + } +} diff --git a/globalsign-atlas-caplugin/APIProxy/ValidationPolicy.cs b/globalsign-atlas-caplugin/APIProxy/ValidationPolicy.cs new file mode 100644 index 0000000..53e9bf5 --- /dev/null +++ b/globalsign-atlas-caplugin/APIProxy/ValidationPolicy.cs @@ -0,0 +1,238 @@ +using Newtonsoft.Json; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Keyfactor.Extensions.CAPlugin.GlobalSign.Atlas.APIProxy +{ + public class ValidationPolicyResponse + { + [JsonProperty("validity")] + public ValidityResponse Validity { get; set; } + + [JsonProperty("subject_dn")] + public SubjectDNResponse SubjectDN { get; set; } + + [JsonProperty("san")] + public SANResponse San { get; set; } + + [JsonProperty("key_usages")] + public KeyUsageResponse KeyUsages { get; set; } + + [JsonProperty("extended_key_usages")] + public EKUResponse EKUs { get; set; } + + [JsonProperty("signature")] + public SignatureResponse Signature { get; set; } + + [JsonProperty("public_key")] + public PublicKeyResponse PublicKey { get; set; } + + [JsonProperty("public_key_signature")] + public string PublicKeySignature { get; set; } + } + + public class ValidityResponse + { + [JsonProperty("secondsmin")] + public long SecondsMin { get; set; } + + [JsonProperty("secondsmax")] + public long SecondsMax { get; set; } + + [JsonProperty("not_before_negative_skew")] + public long NotBeforeNegativeSkew { get; set; } + + [JsonProperty("not_before_positive_skew")] + public long NotBeforePositiveSkew { get; set; } + } + + public class SubjectDNResponse + { + [JsonProperty("common_name")] + public SubjectPartResponse CommonName { get; set; } + + [JsonProperty("given_name")] + public SubjectPartResponse GivenName { get; set; } + + [JsonProperty("surname")] + public SubjectPartResponse Surname { get; set; } + + [JsonProperty("organization")] + public SubjectPartResponse Organization { get; set; } + + [JsonProperty("organization_identifier")] + public SubjectPartResponse OrganizationIdentifier { get; set; } + + [JsonProperty("organizational_unit")] + public OUResponse OrganizationalUnit { get; set; } + + [JsonProperty("country")] + public SubjectPartResponse Country { get; set; } + + [JsonProperty("state")] + public SubjectPartResponse State { get; set; } + + [JsonProperty("locality")] + public SubjectPartResponse Locality { get; set; } + + [JsonProperty("postal_code")] + public SubjectPartResponse PostalCode { get; set; } + + [JsonProperty("street_address")] + public SubjectPartResponse StreetAddress { get; set; } + + [JsonProperty("email")] + public SubjectPartResponse Email { get; set; } + + [JsonProperty("jurisdiction_of_incorporation_locality_name")] + public SubjectPartResponse IncorporationLocality { get; set; } + + [JsonProperty("jurisdiction_of_incorporation_state_or_province_name")] + public SubjectPartResponse IncorporationState { get; set; } + + [JsonProperty("jurisdiction_of_incorporation_country_name")] + public SubjectPartResponse IncorporationCountry { get; set; } + + [JsonProperty("business_category")] + public SubjectPartResponse BusinessCategory { get; set; } + + [JsonProperty("serial_number")] + public SubjectPartResponse SerialNumber { get; set; } + } + + public class SubjectPartResponse + { + [JsonProperty("presence")] + public string Presence { get; set; } + + [JsonProperty("format")] + public string Format { get; set; } + + [JsonProperty("ignore_empty")] + public bool IgnoreEmpty { get; set; } + } + + public class OUResponse + { + [JsonProperty("static")] + public bool Static { get; set; } + + [JsonProperty("list")] + public List List { get; set; } + + [JsonProperty("mincount")] + public int MinCount { get; set; } + + [JsonProperty("maxcount")] + public int MaxCount { get; set; } + + [JsonProperty("ignore_empty")] + public bool IgnoreEmpty { get; set; } + } + + public class SANResponse + { + [JsonProperty("critical")] + public bool Critical { get; set; } + + [JsonProperty("dns_names")] + public ListTypeResponse DNSNames { get; set; } + + [JsonProperty("emails")] + public ListTypeResponse Emails { get; set; } + + [JsonProperty("ip_addresses")] + public ListTypeResponse IPAddresses { get; set; } + + [JsonProperty("uris")] + public ListTypeResponse URIs { get; set; } + } + + public class ListTypeResponse + { + [JsonProperty("static")] + public bool Static { get; set; } + + [JsonProperty("list")] + public List List { get; set; } + + [JsonProperty("mincount")] + public int MinCount { get; set; } + + [JsonProperty("maxcount")] + public int MaxCount { get; set; } + } + + public class KeyUsageResponse + { + [JsonProperty("digital_signature")] + public string DigitalSignature { get; set; } + + [JsonProperty("content_commitment")] + public string ContentCommitment { get; set; } + + [JsonProperty("key_encipherment")] + public string KeyEncipherment { get; set; } + + [JsonProperty("data_encipherment")] + public string DataEncipherment { get; set; } + + [JsonProperty("key_agreement")] + public string KeyAgreement { get; set; } + + [JsonProperty("key_certificate_sign")] + public string KeyCertificateSign { get; set; } + + [JsonProperty("crl_sign")] + public string CRLSign { get; set; } + + [JsonProperty("encipher_only")] + public string EncipherOnly { get; set; } + + [JsonProperty("decipher_only")] + public string DecipherOnly { get; set; } + } + + public class EKUResponse + { + [JsonProperty("critical")] + public bool Critical { get; set; } + + [JsonProperty("ekus")] + public ListTypeResponse EKUs { get; set; } + } + + public class SignatureResponse + { + [JsonProperty("algorithm")] + public AlgorithmResponse Algorithm { get; set; } + + [JsonProperty("hash_algorithm")] + public AlgorithmResponse HashAlgorithm { get; set; } + } + + public class AlgorithmResponse + { + [JsonProperty("presence")] + public string Presence { get; set; } + + [JsonProperty("list")] + public List List { get; set; } + } + + public class PublicKeyResponse + { + [JsonProperty("key_type")] + public string KeyType { get; set; } + + [JsonProperty("allowed_lengths")] + public List AllowedLengths { get; set; } + + [JsonProperty("key_format")] + public string KeyFormat { get; set; } + } +} diff --git a/globalsign-atlas-caplugin/AtlasCAPlugin.cs b/globalsign-atlas-caplugin/AtlasCAPlugin.cs new file mode 100644 index 0000000..1c4217c --- /dev/null +++ b/globalsign-atlas-caplugin/AtlasCAPlugin.cs @@ -0,0 +1,435 @@ +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.GlobalSign.Atlas.APIProxy; +using Keyfactor.Extensions.CAPlugin.GlobalSign.Atlas.Client; +using Keyfactor.Logging; +using Keyfactor.PKI.Enums.EJBCA; +using Keyfactor.PKI.PEM; + +using Microsoft.Extensions.Logging; + +using Newtonsoft.Json; + +using Org.BouncyCastle.Asn1.Cmp; +using Org.BouncyCastle.Asn1.X509; + +using System.Collections.Concurrent; +using System.Net.Http.Headers; +using System.Security.Cryptography.X509Certificates; + +using static Org.BouncyCastle.Math.EC.ECCurve; + +namespace Keyfactor.Extensions.CAPlugin.GlobalSign.Atlas +{ + public class AtlasCAPlugin : IAnyCAPlugin + { + private AtlasConfig _config; + private readonly ILogger _logger; + private ICertificateDataReader _certDataReader; + private ICertificateResolver _certResolver; + + public AtlasCAPlugin(ICertificateResolver certificateResolver = null) + { + _logger = LogHandler.GetClassLogger(); + _certResolver = certificateResolver; + } + + public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDataReader certificateDataReader) + { + _certDataReader = certificateDataReader; + string rawConfig = JsonConvert.SerializeObject(configProvider.CAConnectionData); + _config = JsonConvert.DeserializeObject(rawConfig); + } + + public async Task Enroll(string csr, string subject, Dictionary san, EnrollmentProductInfo productInfo, RequestFormat requestFormat, EnrollmentType enrollmentType) + { + _logger.MethodEntry(LogLevel.Trace); + AtlasClient client = AtlasClient.InitializeClient(_config, _certResolver); + Enroll enrollData = new Enroll(); + csr = PemUtilities.DERToPEM(Convert.FromBase64String(csr), PemUtilities.PemObjectType.CertRequest); + enrollData.CSR = csr; + + var validation = client.GetValidationPolicy(); + long days = 0; + if (productInfo.ProductParameters.ContainsKey("Lifetime")) + { + days = int.Parse(productInfo.ProductParameters["Lifetime"]); + _logger.LogTrace($"Verifying provided validity period of {days} days"); + } + else + { + days = validation.Validity.SecondsMax / 60 / 60 / 24; + _logger.LogTrace($"No validity period provided. Using default maximum of {days} days"); + } + long validitySeconds = days * 24 * 60 * 60; + if (validitySeconds > validation.Validity.SecondsMax || validitySeconds < validation.Validity.SecondsMin) + { + int minDays = Convert.ToInt32(Math.Ceiling(validation.Validity.SecondsMin / 60.0 / 60.0 / 24.0)); + int maxDays = Convert.ToInt32(Math.Floor(validation.Validity.SecondsMax / 60.0 / 60.0 / 24.0)); + string errMsg = $"Invalid validity period. Valid period is between {minDays} and {maxDays} days."; + _logger.LogError(errMsg); + throw new Exception(errMsg); + } + enrollData.Validity.NotBefore = DateTime.UtcNow; + enrollData.Validity.NotAfter = enrollData.Validity.NotBefore.AddDays(days); + + X509Name subjectParsed = new X509Name(subject); + string subjectField, validationPresence; + subjectField = subjectParsed.GetValueList(X509Name.CN).Cast().LastOrDefault(); + validationPresence = validation.SubjectDN.CommonName.Presence; + if (validationPresence.Equals("required", StringComparison.OrdinalIgnoreCase) || validationPresence.Equals("optional", StringComparison.OrdinalIgnoreCase)) + { + if (validationPresence.Equals("required", StringComparison.OrdinalIgnoreCase) && subjectField == null) + { + _logger.LogError($"Common Name is required"); + throw new Exception("Common Name is required"); + } + enrollData.SubjectDN.CommonName = subjectField; + } + else if (subjectField != null) + { + _logger.LogWarning($"Validation Policy does not allow Common Name, skipping"); + } + subjectField = subjectParsed.GetValueList(X509Name.C).Cast().LastOrDefault(); + validationPresence = validation.SubjectDN.Country.Presence; + if (validationPresence.Equals("required", StringComparison.OrdinalIgnoreCase) || validationPresence.Equals("optional", StringComparison.OrdinalIgnoreCase)) + { + if (validationPresence.Equals("required", StringComparison.OrdinalIgnoreCase) && subjectField == null) + { + _logger.LogError($"Country is required"); + throw new Exception("Country is required"); + } + enrollData.SubjectDN.Country = subjectField; + } + else if (subjectField != null) + { + _logger.LogWarning($"Validation Policy does not allow Country, skipping"); + } + subjectField = subjectParsed.GetValueList(X509Name.E).Cast().LastOrDefault(); + validationPresence = validation.SubjectDN.Email.Presence; + if (validationPresence.Equals("required", StringComparison.OrdinalIgnoreCase) || validationPresence.Equals("optional", StringComparison.OrdinalIgnoreCase)) + { + if (validationPresence.Equals("required", StringComparison.OrdinalIgnoreCase) && subjectField == null) + { + _logger.LogError($"Email is required"); + throw new Exception("Email is required"); + } + enrollData.SubjectDN.Email = subjectField; + } + else if (subjectField != null) + { + _logger.LogWarning($"Validation Policy does not allow Email, skipping"); + } + subjectField = subjectParsed.GetValueList(X509Name.L).Cast().LastOrDefault(); + validationPresence = validation.SubjectDN.Locality.Presence; + if (validationPresence.Equals("required", StringComparison.OrdinalIgnoreCase) || validationPresence.Equals("optional", StringComparison.OrdinalIgnoreCase)) + { + if (validationPresence.Equals("required", StringComparison.OrdinalIgnoreCase) && subjectField == null) + { + _logger.LogError($"Locality is required"); + throw new Exception("Locality is required"); + } + enrollData.SubjectDN.Locality = subjectField; + } + else if (subjectField != null) + { + _logger.LogWarning($"Validation Policy does not allow Locality, skipping"); + } + subjectField = subjectParsed.GetValueList(X509Name.O).Cast().LastOrDefault(); + validationPresence = validation.SubjectDN.Organization.Presence; + if (validationPresence.Equals("required", StringComparison.OrdinalIgnoreCase) || validationPresence.Equals("optional", StringComparison.OrdinalIgnoreCase)) + { + if (validationPresence.Equals("required", StringComparison.OrdinalIgnoreCase) && subjectField == null) + { + _logger.LogError($"Organization is required"); + throw new Exception("Organization is required"); + } + enrollData.SubjectDN.Organization = subjectField; + } + else if (subjectField != null) + { + _logger.LogWarning($"Validation Policy does not allow Organization, skipping"); + } + subjectField = subjectParsed.GetValueList(X509Name.ST).Cast().LastOrDefault(); + validationPresence = validation.SubjectDN.State.Presence; + if (validationPresence.Equals("required", StringComparison.OrdinalIgnoreCase) || validationPresence.Equals("optional", StringComparison.OrdinalIgnoreCase)) + { + if (validationPresence.Equals("required", StringComparison.OrdinalIgnoreCase) && subjectField == null) + { + _logger.LogError($"State is required"); + throw new Exception("State is required"); + } + enrollData.SubjectDN.State = subjectField; + } + else if (subjectField != null) + { + _logger.LogWarning($"Validation Policy does not allow State, skipping"); + } + + var sanDict = new Dictionary(san, StringComparer.OrdinalIgnoreCase); + if (!validation.San.DNSNames.Static) + { + if (sanDict.ContainsKey("dns")) + foreach (var dnsSan in sanDict["dns"]) + enrollData.SANs.DNSList.Add(dnsSan); + } + else if (sanDict.ContainsKey("dns")) + { + _logger.LogWarning($"Validation Policy does not allow DNS SANs, skipping"); + } + + if (!validation.San.IPAddresses.Static) + { + if (sanDict.ContainsKey("ipaddress")) + foreach (var ipSan in sanDict["ipaddress"]) + enrollData.SANs.IPList.Add(ipSan); + } + else if (sanDict.ContainsKey("ipaddress")) + { + _logger.LogWarning($"Validation Policy does not allow IP address SANs, skipping"); + } + + if (!validation.San.Emails.Static) + { + if (sanDict.ContainsKey("email")) + foreach (var emailSan in sanDict["email"]) + enrollData.SANs.EmailList.Add(emailSan); + } + else if (sanDict.ContainsKey("email")) + { + _logger.LogWarning($"Validation Policy does not allow email SANs, skipping"); + } + + string keyUsage = ((productInfo.ProductParameters.ContainsKey("KeyUsage")) ? productInfo.ProductParameters["KeyUsage"] : "").ToLower(); + if (!validation.EKUs.EKUs.Static) + { + if (string.IsNullOrEmpty(keyUsage)) + { + keyUsage = "clientserver"; + } + if (keyUsage.Contains("server")) + { + enrollData.EKUList.Add("1.3.6.1.5.5.7.3.1"); + } + if (keyUsage.Contains("client")) + { + enrollData.EKUList.Add("1.3.6.1.5.5.7.3.2"); + } + } + else if (!string.IsNullOrEmpty(keyUsage)) + { + _logger.LogWarning($"Validation Policy does not allow EKUs, skipping"); + } + + if (validation.Signature.HashAlgorithm.Presence.Equals("required", StringComparison.OrdinalIgnoreCase) || validation.Signature.HashAlgorithm.Presence.Equals("optional", StringComparison.OrdinalIgnoreCase)) + { + enrollData.Sig.HashAlgorithm = "SHA-256"; + } + + var response = client.RequestNewCertificate(enrollData, 5, 5); + + if (response.Status != EndEntityStatus.GENERATED && response.Status != EndEntityStatus.INPROCESS) + { + throw new Exception($"Certificate was not retrieved. Status: {response.StatusMessage}"); + } + + EnrollmentResult result = new EnrollmentResult(); + result.CARequestID = response.SerialNumber; + if (response.Status == EndEntityStatus.GENERATED) + { + result.Status = (int)EndEntityStatus.GENERATED; + result.StatusMessage = string.Format(response.StatusMessage, subject); + result.Certificate = Convert.ToBase64String(PemUtilities.PEMToDER(response.Cert)); + } + else + { + result.Status = (int)EndEntityStatus.EXTERNALVALIDATION; + result.StatusMessage = $"Certificate request is pending or requires approval. Certificate will be picked up during synchronization once available."; ; + } + return result; + } + + public Dictionary GetCAConnectorAnnotations() + { + return new Dictionary() + { + ["ApiKey"] = new PropertyConfigInfo() + { + Comments = "The API key for the Atlas credentials the gateway will use.", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + ["ApiSecret"] = new PropertyConfigInfo() + { + Comments = "The corresponding API secret value that matches with the ApiKey.", + Hidden = true, + DefaultValue = "", + Type = "String" + }, + ["ClientCertificate"] = new PropertyConfigInfo() + { + Comments = "The client auth certificate to use with the Atlas API", + Hidden = false, + DefaultValue = "", + Type = "ClientCertificate" + }, + ["SyncStartDate"] = new PropertyConfigInfo() + { + Comments = "The earliest date to go back when doing a full sync.", + Hidden = false, + DefaultValue = "2024-01-01", + Type = "String" + } + }; + } + + public List GetProductIds() + { + // Atlas does not use product IDs. Gateway framework requires a value, so just use 'certificate' + return new List() { "certificate" }; + } + + public async Task GetSingleRecord(string caRequestID) + { + AtlasClient client = AtlasClient.InitializeClient(_config, _certResolver); + var certResponse = client.GetCertificate(caRequestID); + + return new AnyCAPluginCertificate + { + CARequestID = caRequestID, + Certificate = certResponse.Certificate, + Status = certResponse.Status.Equals("issued", StringComparison.OrdinalIgnoreCase) ? (int)EndEntityStatus.GENERATED : + certResponse.Status.Equals("revoked", StringComparison.OrdinalIgnoreCase) ? (int)EndEntityStatus.REVOKED : (int)EndEntityStatus.EXTERNALVALIDATION, + }; + } + + public Dictionary GetTemplateParameterAnnotations() + { + return new Dictionary() + { + ["Lifetime"] = new PropertyConfigInfo() + { + Comments = "The term length (in days) to use for enrollment.", + Hidden = false, + DefaultValue = 30, + Type = "Number" + }, + ["KeyUsage"] = new PropertyConfigInfo() + { + Comments = "The key usage to use for enrolled certs. Valid values are 'client', 'server', and 'clientserver'.", + Hidden = false, + DefaultValue = "server", + Type = "String" + } + }; + } + + public async Task Ping() + { + try + { + AtlasClient client = AtlasClient.InitializeClient(_config, _certResolver); + _ = client.GetValidationPolicy(); + } + catch (Exception ex) + { + _logger.LogError($"Error attempting to contact GlobalSign Atlas: {ex.Message}"); + throw new Exception($"Error attempting to contact GlobalSign Atlas: {ex.Message}", ex); + } + } + + public async Task Revoke(string caRequestID, string hexSerialNumber, uint revocationReason) + { + AtlasClient client = AtlasClient.InitializeClient(_config, _certResolver); + client.RevokeCertificate(caRequestID); + + var certResponse = client.GetCertificate(caRequestID); + return certResponse.Status.Equals("issued", StringComparison.OrdinalIgnoreCase) ? (int)EndEntityStatus.GENERATED : + certResponse.Status.Equals("revoked", StringComparison.OrdinalIgnoreCase) ? (int)EndEntityStatus.REVOKED : (int)EndEntityStatus.EXTERNALVALIDATION; + } + + public async Task Synchronize(BlockingCollection blockingBuffer, DateTime? lastSync, bool fullSync, CancellationToken cancelToken) + { + AtlasClient client = AtlasClient.InitializeClient(_config, _certResolver); + var certs = client.GetAllCertificates(lastSync, fullSync); + + foreach (var cert in certs) + { + var anyCACert = new AnyCAPluginCertificate + { + CARequestID = cert.Status.SerialNumber, + Certificate = cert.Cert.Certificate, + Status = cert.Cert.Status.Equals("issued", StringComparison.OrdinalIgnoreCase) ? (int)EndEntityStatus.GENERATED : + cert.Cert.Status.Equals("revoked", StringComparison.OrdinalIgnoreCase) ? (int)EndEntityStatus.REVOKED : (int)EndEntityStatus.EXTERNALVALIDATION, + }; + blockingBuffer.Add(anyCACert); + } + } + + public async Task ValidateCAConnectionInfo(Dictionary connectionInfo) + { + _logger.LogTrace($"Validating CA Connection info"); + List errors = new List(); + string rawConfig = JsonConvert.SerializeObject(connectionInfo); + AtlasConfig testConfig = JsonConvert.DeserializeObject(rawConfig); + + _logger.LogTrace($"Checking for API Key/Secret"); + if (string.IsNullOrWhiteSpace(testConfig.ApiKey)) + { + errors.Add($"The API Key is required"); + } + if (string.IsNullOrWhiteSpace(testConfig.ApiSecret)) + { + errors.Add($"The API Secret is required"); + } + + _logger.LogTrace($"Checking Sync Start Date"); + if (string.IsNullOrWhiteSpace(testConfig.SyncStartDate)) + { + errors.Add($"Sync Start Date is required"); + } + else + { + if (!DateTime.TryParse(testConfig.SyncStartDate, out _)) + { + errors.Add("The Sync Start Date could not be parsed"); + } + } + + _logger.LogTrace($"Checking Client Certificate data"); + try + { + X509Certificate2 authCert = null; + if (!string.IsNullOrEmpty(testConfig.Certificate.ImportedCertificate)) + { + authCert = new X509Certificate2(Convert.FromBase64String(testConfig.Certificate.ImportedCertificate), testConfig.Certificate.ImportedCertificatePassword); + } + else + { + authCert = _certResolver.ResolveCertificate(testConfig.Certificate); + } + if (authCert == null) + { + errors.Add($"Unable to load authentication certificate"); + } + + _logger.LogTrace($"Auth Certificate found. Cert Details: \nSerial Number: {authCert.GetSerialNumberString()}\nHas PK: {authCert.HasPrivateKey.ToString()}\nSubject: {authCert.Subject}"); + } + catch (Exception ex) + { + errors.Add($"Unable to load authentication certificate: {ex.Message}"); + } + + if (errors.Any()) + { + throw new Exception(string.Join("\n", errors)); + } + _logger.LogTrace($"CA Connection validation complete"); + } + + public async Task ValidateProductInfo(EnrollmentProductInfo productInfo, Dictionary connectionInfo) + { + // Do nothing + } + } +} diff --git a/globalsign-atlas-caplugin/AtlasConfig.cs b/globalsign-atlas-caplugin/AtlasConfig.cs new file mode 100644 index 0000000..6c3ff89 --- /dev/null +++ b/globalsign-atlas-caplugin/AtlasConfig.cs @@ -0,0 +1,29 @@ +using Keyfactor.AnyGateway.Extensions; + +using Newtonsoft.Json; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Keyfactor.Extensions.CAPlugin.GlobalSign.Atlas +{ + public class AtlasConfig + { + public AtlasConfig() { } + + [JsonProperty("ApiKey")] + public string ApiKey { get; set; } + + [JsonProperty("ApiSecret")] + public string ApiSecret { get; set; } + + [JsonProperty("ClientCertificate")] + public ClientCertificate Certificate { get; set; } + + [JsonProperty("SyncStartDate")] + public string SyncStartDate { get; set; } + } +} diff --git a/globalsign-atlas-caplugin/Client/AtlasClient.cs b/globalsign-atlas-caplugin/Client/AtlasClient.cs new file mode 100644 index 0000000..468a76d --- /dev/null +++ b/globalsign-atlas-caplugin/Client/AtlasClient.cs @@ -0,0 +1,620 @@ +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Common.Exceptions; +using Keyfactor.Extensions.CAPlugin.GlobalSign.Atlas.APIProxy; +using Keyfactor.Logging; +using Keyfactor.PKI.Enums.EJBCA; + +using Microsoft.Extensions.Logging; + +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +using Org.BouncyCastle.Crmf; + +using Org.BouncyCastle.Pqc.Crypto.Lms; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Numerics; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading.Tasks; + +using CertificateResponse = Keyfactor.Extensions.CAPlugin.GlobalSign.Atlas.APIProxy.CertificateResponse; + +namespace Keyfactor.Extensions.CAPlugin.GlobalSign.Atlas.Client +{ + public class AtlasClient + { + private static ILogger Logger => LogHandler.GetClassLogger(); + private string _apiKey; + private string _apiSecret; + private X509Certificate2 _authCert; + private string _baseUrl; + + private string _token; + private DateTime _tokenTime; + + private DateTime _syncStart; + + private class AtlasResponse + { + public bool Success { get; set; } + public string Response { get; set; } + + public AtlasResponse() + { + Success = true; + Response = ""; + } + } + + public AtlasClient(string apiKey, string apiSecret, X509Certificate2 authCert, DateTime syncStart) + :this(apiKey, apiSecret, authCert, syncStart, "https://emea.api.hvca.globalsign.com:8443/v2/") + { } + + public AtlasClient(string apiKey, string apiSecret, X509Certificate2 authCert, DateTime syncStart, string baseUrl) + { + _apiKey = apiKey; + _apiSecret = apiSecret; + _authCert = authCert; + _syncStart = syncStart; + _baseUrl = baseUrl; + if (!_baseUrl.EndsWith("/")) + _baseUrl += "/"; + } + + public static AtlasClient InitializeClient(AtlasConfig config, ICertificateResolver certResolver) + { + Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace($"Retrieving auth certificate"); + X509Certificate2 authCert = null; + if (!string.IsNullOrEmpty(config.Certificate.ImportedCertificate)) + { + authCert = new X509Certificate2(Convert.FromBase64String(config.Certificate.ImportedCertificate), config.Certificate.ImportedCertificatePassword); + } + else + { + authCert = certResolver.ResolveCertificate(config.Certificate); + } + if (authCert == null) + { + Logger.MethodExit(LogLevel.Debug); + throw new Exception("Unable to resolve auth certificate."); + } + + Logger.LogTrace($"Auth Certificate found. Cert Details: \nSerial Number: {authCert.GetSerialNumberString()}\nHas PK: {authCert.HasPrivateKey.ToString()}\nSubject: {authCert.Subject}"); + + return new AtlasClient(config.ApiKey, config.ApiSecret, authCert, DateTime.Parse(config.SyncStartDate)); + } + + private void RefreshApiToken() + { + Logger.MethodEntry(LogLevel.Debug); + try + { + string targetUri = _baseUrl + "login/"; + HttpWebRequest request = (HttpWebRequest)WebRequest.Create(targetUri); + request.Method = "POST"; + request.ContentType = "application/json;charset=utf-8"; + request.Headers["X-SSL-Client-Serial"] = _authCert.SerialNumber; + request.ClientCertificates.Add(_authCert); + var loginReq = new LoginRequest() + { + Key = _apiKey, + Secret = _apiSecret + }; + string postBody = JsonConvert.SerializeObject(loginReq, Formatting.None, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }); + byte[] postBytes = Encoding.UTF8.GetBytes(postBody); + + request.ContentLength = postBytes.Length; + Stream requestStream = request.GetRequestStream(); + requestStream.Write(postBytes, 0, postBytes.Length); + requestStream.Close(); + + LoginResponse apiResponse = new LoginResponse(); + _tokenTime = DateTime.UtcNow; + + Logger.LogTrace($"Atlas Request: POST {targetUri}"); + using (HttpWebResponse response = (HttpWebResponse)request.GetResponse()) + { + apiResponse = JsonConvert.DeserializeObject(new StreamReader(response.GetResponseStream()).ReadToEnd()); + } + _token = apiResponse.Token; + } + catch (Exception ex) + { + Logger.LogError($"Atlas response error: {ex.Message}"); + throw new Exception($"Unable to establish connection to Atlas web service: {ex.Message}", ex); + } + } + + public EnrollResponse RequestNewCertificate(Enroll request, int pickupDelay, int pickupRetries) + { + EnrollResponse enrollResponse = new EnrollResponse(); + string certUrl = null; + try + { + string targetUri = _baseUrl + "certificates/"; + string method = "POST"; + + Logger.LogTrace($"Requesting new certificate"); + HttpWebRequest apiRequest = (HttpWebRequest)WebRequest.Create(targetUri); + apiRequest.Method = method; + apiRequest.ContentType = "application/json;charset=utf-8"; + if (string.IsNullOrEmpty(_token) || _tokenTime.AddMinutes(10) < DateTime.UtcNow) + { + RefreshApiToken(); + } + apiRequest.ClientCertificates.Add(_authCert); + apiRequest.Headers.Add("Authorization", "Bearer " + _token); + string postJson = JsonConvert.SerializeObject(request); + byte[] postBytes = Encoding.UTF8.GetBytes(postJson); + apiRequest.ContentLength = postBytes.Length; + Stream requestStream = apiRequest.GetRequestStream(); + requestStream.Write(postBytes, 0, postBytes.Length); + requestStream.Close(); + + Logger.LogTrace($"Atlas Request: POST {targetUri}\n{postJson}"); + using (HttpWebResponse apiResponse = (HttpWebResponse)apiRequest.GetResponse()) + { + Logger.LogTrace($"Atlas API returned response {apiResponse.StatusCode}"); + if (apiResponse.StatusCode == HttpStatusCode.Created) + { + certUrl = apiResponse.Headers["Location"]; + enrollResponse.SerialNumber = certUrl.Substring(certUrl.LastIndexOf('/') + 1); + } + else + { + throw new Exception($"Unable to enroll for certificate, status code: {apiResponse.StatusCode}"); + } + } + + targetUri = _baseUrl + "certificates/" + enrollResponse.SerialNumber; + method = "GET"; + + Logger.LogTrace($"Enrollment successful, retrieving new certificate"); + bool success = true; + int attempt = 0; + do + { + try + { + System.Threading.Thread.Sleep(pickupDelay * 1000); + apiRequest = (HttpWebRequest)WebRequest.Create(targetUri); + apiRequest.Method = method; + apiRequest.ContentType = "application/json;charset=utf-8"; + if (string.IsNullOrEmpty(_token) || _tokenTime.AddMinutes(10) < DateTime.UtcNow) + { + RefreshApiToken(); + } + apiRequest.ClientCertificates.Add(_authCert); + apiRequest.Headers.Add("Authorization", "Bearer " + _token); + + Logger.LogTrace($"Atlas Request: GET {targetUri}"); + using (HttpWebResponse apiResponse = (HttpWebResponse)apiRequest.GetResponse()) + { + Logger.LogTrace($"Atlas API returned response {apiResponse.StatusCode}"); + if (apiResponse.StatusCode == HttpStatusCode.OK) + { + Logger.LogTrace($"Certificate retrieved"); + CertificateResponse certResponse = JsonConvert.DeserializeObject(new StreamReader(apiResponse.GetResponseStream()).ReadToEnd()); + enrollResponse.Status = EndEntityStatus.GENERATED; + enrollResponse.Cert = certResponse.Certificate; + enrollResponse.StatusMessage = "Successfully enrolled for certificate {0}"; + } + else if (apiResponse.StatusCode == HttpStatusCode.Accepted) + { + Logger.LogTrace($"Certificate request in process"); + CertificateResponse certResponse = JsonConvert.DeserializeObject(new StreamReader(apiResponse.GetResponseStream()).ReadToEnd()); + + enrollResponse.Status = EndEntityStatus.INPROCESS; + enrollResponse.StatusMessage = certResponse.Description; + success = false; + attempt++; + } + else + { + success = false; + attempt++; + } + } + } + catch (WebException wex) + { + success = false; + if (wex.Response != null) + { + using (var errorResponse = (HttpWebResponse)wex.Response) + { + if (errorResponse.StatusCode == (HttpStatusCode)429 /*Too Many Requests*/) + { + Logger.LogInformation("Request was rate-limited. Trying again in 5 seconds"); + System.Threading.Thread.Sleep(5000); + } + else + { + attempt++; + } + } + } + else + { + attempt++; + } + } + if (!success) + { + if (attempt <= pickupRetries) + { + Logger.LogWarning($"Unable to pickup certificate. Retrying... (Retry attempt {attempt} of {pickupRetries})"); + } + else + { + Logger.LogError($"Failed to pickup enrolled certificate. Current disposition status: {enrollResponse.Status.ToString()}"); + } + } + } while (success == false && attempt <= pickupRetries); + return enrollResponse; + } + catch (WebException wex) + { + if (wex.Response != null) + { + using (var errorResponse = (HttpWebResponse)wex.Response) + { + if (errorResponse.StatusCode == (HttpStatusCode)429 /*Too Many Requests*/) + { + Logger.LogInformation("Request was rate-limited. Trying again in 5 seconds"); + System.Threading.Thread.Sleep(5000); + return RequestNewCertificate(request, pickupDelay, pickupRetries); + } + else + { + using (var stream = wex.Response.GetResponseStream()) + using (var reader = new StreamReader(stream)) + { + string errorString = reader.ReadToEnd(); + Logger.LogError($"Atlas CA has returned an error from enrolling: '{((HttpWebResponse)wex.Response).StatusCode}: {errorString}"); + throw new Exception(errorString, wex); + } + } + } + } + else + { + Logger.LogError($"Error enrolling for cert: {wex.Message}"); + throw new Exception($"Error enrolling for cert", wex); + } + } + catch (Exception ex) + { + Logger.LogError($"Error enrolling for cert: {ex.Message}"); + throw new Exception($"Error enrolling for cert", ex); + } + } + + public CertificateResponse GetCertificate(string caRequestID) + { + try + { + string targetUri = _baseUrl + "certificates/" + caRequestID; + string method = "GET"; + + Logger.LogTrace($"Retrieving certificate"); + HttpWebRequest apiRequest = (HttpWebRequest)WebRequest.Create(targetUri); + apiRequest.Method = method; + apiRequest.ContentType = "application/json;charset=utf-8"; + if (string.IsNullOrEmpty(_token) || _tokenTime.AddMinutes(10) < DateTime.UtcNow) + { + RefreshApiToken(); + } + apiRequest.ClientCertificates.Add(_authCert); + apiRequest.Headers.Add("Authorization", "Bearer " + _token); + + Logger.LogTrace($"Atlas Request: GET {targetUri}"); + using (HttpWebResponse apiResponse = (HttpWebResponse)apiRequest.GetResponse()) + { + Logger.LogTrace($"Atlas API returned response {apiResponse.StatusCode}"); + if (apiResponse.StatusCode == HttpStatusCode.OK) + { + CertificateResponse certResponse = JsonConvert.DeserializeObject(new StreamReader(apiResponse.GetResponseStream()).ReadToEnd()); + return certResponse; + } + else if (apiResponse.StatusCode == HttpStatusCode.Accepted) + { + CertificateResponse certResponse = JsonConvert.DeserializeObject(new StreamReader(apiResponse.GetResponseStream()).ReadToEnd()); + + return certResponse; + } + else + { + throw new Exception($"Unable to retrieve certificate, status code: {apiResponse.StatusCode}"); + } + } + } + catch (WebException wex) + { + if (wex.Response != null) + { + using (var errorResponse = (HttpWebResponse)wex.Response) + { + if (errorResponse.StatusCode == (HttpStatusCode)429 /*Too Many Requests*/) + { + Logger.LogInformation($"Request was rate-limited. Trying again in 5 seconds"); + System.Threading.Thread.Sleep(5000); + return GetCertificate(caRequestID); + } + else + { + using (var stream = wex.Response.GetResponseStream()) + using (var reader = new StreamReader(stream)) + { + string errorString = reader.ReadToEnd(); + Logger.LogError($"Atlas CA has returned an error from retrieving certificate: '{((HttpWebResponse)wex.Response).StatusCode}: {errorString}"); + throw new Exception(errorString, wex); + } + } + } + } + else + { + Logger.LogError($"Error retrieving cert: {wex.Message}"); + throw new Exception($"Error retrieving cert", wex); + } + } + catch (Exception ex) + { + Logger.LogError($"Error retrieving cert: {ex.Message}"); + throw new Exception($"Error retrieving cert", ex); + } + } + + public void RevokeCertificate(string caRequestID) + { + try + { + string targetUri = _baseUrl + "certificates/" + caRequestID; + string method = "DELETE"; + + Logger.LogTrace($"Attempting to revoke certificate"); + HttpWebRequest apiRequest = (HttpWebRequest)WebRequest.Create(targetUri); + apiRequest.Method = method; + apiRequest.ContentType = "application/json;charset=utf-8"; + if (string.IsNullOrEmpty(_token) || _tokenTime.AddMinutes(10) < DateTime.UtcNow) + { + RefreshApiToken(); + } + apiRequest.ClientCertificates.Add(_authCert); + apiRequest.Headers.Add("Authorization", "Bearer " + _token); + + Logger.LogTrace($"Atlas Request: DELETE {targetUri}"); + using (HttpWebResponse apiResponse = (HttpWebResponse)apiRequest.GetResponse()) + { + Logger.LogTrace($"Atlas API returned response {apiResponse.StatusCode}"); + if (apiResponse.StatusCode == HttpStatusCode.NoContent) + { + Logger.LogTrace($"Certificate successfully revoked"); + } + else + { + throw new Exception($"Unable to revoke certificate, status code: {apiResponse.StatusCode}"); + } + } + } + catch (WebException wex) + { + if (wex.Response != null) + { + using (var errorResponse = (HttpWebResponse)wex.Response) + { + if (errorResponse.StatusCode == (HttpStatusCode)429 /*Too Many Requests*/) + { + Logger.LogInformation("Request was rate-limited. Trying again in 5 seconds."); + System.Threading.Thread.Sleep(5000); + RevokeCertificate(caRequestID); + } + else + { + using (var stream = wex.Response.GetResponseStream()) + using (var reader = new StreamReader(stream)) + { + string errorString = reader.ReadToEnd(); + Logger.LogError($"Atlas CA has returned an error from revoking certificate: '{((HttpWebResponse)wex.Response).StatusCode}: {errorString}"); + throw new Exception(errorString, wex); + } + } + } + } + else + { + Logger.LogError($"Error revoking cert: {wex.Message}"); + throw new Exception($"Error revoking cert", wex); + } + } + catch (Exception ex) + { + Logger.LogError($"Error revoking cert: {ex.Message}"); + throw new Exception($"Error revoking cert", ex); + } + } + + public ValidationPolicyResponse GetValidationPolicy() + { + try + { + string targetUri = _baseUrl + "validationpolicy/"; + string method = "GET"; + + Logger.LogTrace($"Retrieving validation policy"); + HttpWebRequest apiRequest = (HttpWebRequest)WebRequest.Create(targetUri); + apiRequest.Method = method; + apiRequest.ContentType = "application/json;charset=utf-8"; + if (string.IsNullOrEmpty(_token) || _tokenTime.AddMinutes(10) < DateTime.UtcNow) + { + RefreshApiToken(); + } + apiRequest.ClientCertificates.Add(_authCert); + apiRequest.Headers.Add("Authorization", "Bearer " + _token); + Logger.LogTrace($"Atlas Request: GET {targetUri}"); + using (HttpWebResponse apiResponse = (HttpWebResponse)apiRequest.GetResponse()) + { + var fullResponse = new StreamReader(apiResponse.GetResponseStream()).ReadToEnd(); + Logger.LogTrace($"Atlas API returned response {apiResponse.StatusCode}"); + if (apiResponse.StatusCode == HttpStatusCode.OK) + { + ValidationPolicyResponse validationResponse = JsonConvert.DeserializeObject(fullResponse); + return validationResponse; + } + else + { + throw new Exception("Error retrieving validation policy"); + } + } + } + catch (WebException wex) + { + if (wex.Response != null) + { + using (var errorResponse = (HttpWebResponse)wex.Response) + { + if (errorResponse.StatusCode == (HttpStatusCode)429 /*Too Many Requests*/) + { + Logger.LogInformation("Request was rate-limited. Trying again in 5 seconds."); + System.Threading.Thread.Sleep(5000); + return GetValidationPolicy(); + } + else + { + using (var stream = wex.Response.GetResponseStream()) + using (var reader = new StreamReader(stream)) + { + string errorString = reader.ReadToEnd(); + Logger.LogError($"Atlas CA has returned an error from retrieving validation policy: '{((HttpWebResponse)wex.Response).StatusCode}: {errorString}"); + throw new Exception(errorString, wex); + } + } + } + } + else + { + Logger.LogError($"Error retrieving validation policy: {wex.Message}"); + throw new Exception($"Error retrieving validation policy", wex); + } + } + catch (Exception ex) + { + Logger.LogError($"Error retrieving validation policy: {ex.Message}"); + throw new Exception($"Error retrieving validation policy", ex); + } + } + + public List GetAllCertificates(DateTime? lastIncrementalSync, bool doFullSync) + { + if (!lastIncrementalSync.HasValue) + { + lastIncrementalSync = _syncStart; + } + DateTime startTime = doFullSync ? _syncStart : (lastIncrementalSync.Value); + DateTime endTime = DateTime.UtcNow; + long startTimeTicks = ((DateTimeOffset)startTime).ToUnixTimeSeconds(); + long endTimeTicks = ((DateTimeOffset)endTime).ToUnixTimeSeconds(); + + List certs = new List(); + + for (long i = startTimeTicks; i <= endTimeTicks; i += 1728000) // Sync 20 days at a time + { + int pagenum = 1; + bool morePages = false; + long toTicks = i + 1728000; + if (toTicks > endTimeTicks) + { + toTicks = endTimeTicks; + } + do + { + morePages = false; + string targetUri = _baseUrl + "stats/issued?page=" + pagenum + "&from=" + i + "&to=" + toTicks; + string method = "GET"; + + Logger.LogTrace($"Retrieving certificate list"); + HttpWebRequest apiRequest = (HttpWebRequest)WebRequest.Create(targetUri); + apiRequest.Method = method; + apiRequest.ContentType = "application/json;charset=utf-8"; + if (string.IsNullOrEmpty(_token) || _tokenTime.AddMinutes(10) < DateTime.UtcNow) + { + RefreshApiToken(); + } + apiRequest.ClientCertificates.Add(_authCert); + apiRequest.Headers.Add("Authorization", "Bearer " + _token); + List certResponse; + try + { + Logger.LogTrace($"Atlas Request: GET {targetUri}"); + using (HttpWebResponse apiResponse = (HttpWebResponse)apiRequest.GetResponse()) + { + Logger.LogTrace($"Atlas API returned response {apiResponse.StatusCode}"); + if (apiResponse.StatusCode == HttpStatusCode.OK) + { + certResponse = JsonConvert.DeserializeObject>(new StreamReader(apiResponse.GetResponseStream()).ReadToEnd()); + + var header = apiResponse.Headers["Links"]; + if (header.Contains("next")) + { + morePages = true; + pagenum++; + } + } + else + { + throw new Exception($"Unable to retrieve certificate list, status code: {apiResponse.StatusCode}"); + } + } + } + catch (WebException wex) + { + if (wex.Response != null) + { + using (var errorResponse = (HttpWebResponse)wex.Response) + { + if (errorResponse.StatusCode == (HttpStatusCode)429 /*Too Many Requests*/) + { + Logger.LogInformation("Request was rate-limited. Trying again in 5 seconds."); + System.Threading.Thread.Sleep(5000); + continue; + } + } + using (var stream = wex.Response.GetResponseStream()) + using (var reader = new StreamReader(stream)) + { + string errorString = reader.ReadToEnd(); + Logger.LogError($"Atlas CA has returned an error from retrieving certificate: '{((HttpWebResponse)wex.Response).StatusCode}: {errorString}"); + throw new Exception(errorString, wex); + } + } + else + { + Logger.LogError($"Error retrieving cert: {wex.Message}"); + throw new Exception($"Error retrieving cert", wex); + } + } + catch (Exception ex) + { + Logger.LogError($"Error retrieving cert: {ex.Message}"); + throw new Exception($"Error retrieving cert", ex); + } + foreach (var certStatus in certResponse) + { + CertificateDetailsResponse details = new CertificateDetailsResponse(); + details.Status = certStatus; + details.Cert = GetCertificate(certStatus.SerialNumber); + certs.Add(details); + } + } while (morePages); + } + return certs; + } + } +} diff --git a/globalsign-atlas-caplugin/globalsign-atlas-caplugin.csproj b/globalsign-atlas-caplugin/globalsign-atlas-caplugin.csproj new file mode 100644 index 0000000..0a24675 --- /dev/null +++ b/globalsign-atlas-caplugin/globalsign-atlas-caplugin.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + Keyfactor.Extensions.CAPlugin.GlobalSign.Atlas + enable + disable + + + + + + + + + + From f1e7a708073ffc9885f82faf6db2a24cc3967aaf Mon Sep 17 00:00:00 2001 From: Dave Galey <89407235+dgaley@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:50:17 -0400 Subject: [PATCH 02/15] Create keyfactor-bootstrap-workflow-v3.yml --- .../keyfactor-bootstrap-workflow-v3.yml | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/keyfactor-bootstrap-workflow-v3.yml diff --git a/.github/workflows/keyfactor-bootstrap-workflow-v3.yml b/.github/workflows/keyfactor-bootstrap-workflow-v3.yml new file mode 100644 index 0000000..64919a4 --- /dev/null +++ b/.github/workflows/keyfactor-bootstrap-workflow-v3.yml @@ -0,0 +1,20 @@ +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 }} From 06e15afa555a5cb491ce544ac94d4173d854c3f7 Mon Sep 17 00:00:00 2001 From: David Galey Date: Tue, 23 Jun 2026 09:54:16 -0400 Subject: [PATCH 03/15] integration manifest and changelog --- CHANGELOG.md | 2 ++ globalsign-atlas-caplugin.sln | 8 +++++++- integration-manifest.json | 13 +++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md create mode 100644 integration-manifest.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..03d82d1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,2 @@ +### 1.0.0 +* Initial Release diff --git a/globalsign-atlas-caplugin.sln b/globalsign-atlas-caplugin.sln index e86e735..411dafc 100644 --- a/globalsign-atlas-caplugin.sln +++ b/globalsign-atlas-caplugin.sln @@ -1,10 +1,16 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.14.36705.20 d17.14 +VisualStudioVersion = 17.14.36705.20 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "globalsign-atlas-caplugin", "globalsign-atlas-caplugin\globalsign-atlas-caplugin.csproj", "{292B7A34-CFEC-4ABB-8BE6-31F87BC1651B}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}" + ProjectSection(SolutionItems) = preProject + CHANGELOG.md = CHANGELOG.md + integration-manifest.json = integration-manifest.json + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU diff --git a/integration-manifest.json b/integration-manifest.json new file mode 100644 index 0000000..607b443 --- /dev/null +++ b/integration-manifest.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://keyfactor.github.io/v2/integration-manifest-schema.json", + "integration_type": "anyca-plugin", + "name": "GlobalSign Atlas AnyCA REST Gateway Plugin", + "status": "production", + "support_level": "kf-supported", + "link_github": true, + "update_catalog": true, + "description": "GlobalSign Atlas plugin for the AnyCA REST Gateway framework", + "gateway_framework": "24.2.0", + "release_dir": "globalsign-atlas-caplugin/bin/Release", + "release_project": "globalsign-atlas-caplugin/globalsign-atlas-caplugin.csproj", +} \ No newline at end of file From 901c4c718716368c5797928adaec51cafd9b9d0d Mon Sep 17 00:00:00 2001 From: David Galey Date: Tue, 23 Jun 2026 11:50:03 -0400 Subject: [PATCH 04/15] update manifest --- integration-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration-manifest.json b/integration-manifest.json index 607b443..59955fd 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -9,5 +9,5 @@ "description": "GlobalSign Atlas plugin for the AnyCA REST Gateway framework", "gateway_framework": "24.2.0", "release_dir": "globalsign-atlas-caplugin/bin/Release", - "release_project": "globalsign-atlas-caplugin/globalsign-atlas-caplugin.csproj", + "release_project": "globalsign-atlas-caplugin/globalsign-atlas-caplugin.csproj" } \ No newline at end of file From db25ff284bb08af28d31c58dd9406459fb777488 Mon Sep 17 00:00:00 2001 From: David Galey Date: Tue, 23 Jun 2026 11:57:32 -0400 Subject: [PATCH 05/15] add manifest --- .../globalsign-atlas-caplugin.csproj | 1 + globalsign-atlas-caplugin/manifest.json | 10 ++++++++++ 2 files changed, 11 insertions(+) create mode 100644 globalsign-atlas-caplugin/manifest.json diff --git a/globalsign-atlas-caplugin/globalsign-atlas-caplugin.csproj b/globalsign-atlas-caplugin/globalsign-atlas-caplugin.csproj index 0a24675..2d244d6 100644 --- a/globalsign-atlas-caplugin/globalsign-atlas-caplugin.csproj +++ b/globalsign-atlas-caplugin/globalsign-atlas-caplugin.csproj @@ -5,6 +5,7 @@ Keyfactor.Extensions.CAPlugin.GlobalSign.Atlas enable disable + AtlasCAPlugin diff --git a/globalsign-atlas-caplugin/manifest.json b/globalsign-atlas-caplugin/manifest.json new file mode 100644 index 0000000..81357a6 --- /dev/null +++ b/globalsign-atlas-caplugin/manifest.json @@ -0,0 +1,10 @@ +{ + "extensions": { + "Keyfactor.AnyGateway.Extensions.IAnyCAPlugin": { + "AtlasCAPlugin": { + "assemblypath": "AtlasCAPlugin.dll", + "TypeFullName": "Keyfactor.Extensions.CAPlugin.GlobalSign.Atlas.AtlasCAPlugin" + } + } + } +} From 9f1d6a7e876b800294a5fb2b68236f37e6cecc96 Mon Sep 17 00:00:00 2001 From: David Galey Date: Tue, 23 Jun 2026 11:57:56 -0400 Subject: [PATCH 06/15] copy manifest to output --- globalsign-atlas-caplugin/globalsign-atlas-caplugin.csproj | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/globalsign-atlas-caplugin/globalsign-atlas-caplugin.csproj b/globalsign-atlas-caplugin/globalsign-atlas-caplugin.csproj index 2d244d6..62f74d2 100644 --- a/globalsign-atlas-caplugin/globalsign-atlas-caplugin.csproj +++ b/globalsign-atlas-caplugin/globalsign-atlas-caplugin.csproj @@ -15,4 +15,10 @@ + + + Always + + + From ce0df68fd25f9d0330cd02fa25c7d2e3906d6deb Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Tue, 23 Jun 2026 15:59:57 +0000 Subject: [PATCH 07/15] Update generated docs --- README.md | 105 ++++++++++++++++++++++++++++++++++++- docsource/configuration.md | 24 +++++++++ integration-manifest.json | 37 ++++++++++++- 3 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 docsource/configuration.md diff --git a/README.md b/README.md index 45f8331..2185c80 100644 --- a/README.md +++ b/README.md @@ -1 +1,104 @@ -# globalsign-atlas-caplugin \ No newline at end of file +

+ GlobalSign Atlas AnyCA Gateway REST Plugin +

+ +

+ +Integration Status: production +Release +Issues +GitHub Downloads (all assets, all releases) +

+ +

+ + + Support + + · + + Requirements + + · + + Installation + + · + + License + + · + + Related Integrations + +

+ + +TODO Overview is a required section + +## Compatibility + +The GlobalSign Atlas AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 24.2.0 and later. + +## Support +The GlobalSign Atlas 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. + +## Requirements + +TODO Requirements is a required section + +## Installation + +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 Atlas AnyCA Gateway REST plugin](https://github.com/Keyfactor/globalsign-atlas-caplugin/releases/latest) from GitHub. + +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\net6.0\Extensions + Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net8.0\Extensions + ``` + + > The directory containing the GlobalSign Atlas 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 Atlas plugin by hovering over the ⓘ symbol to the right of the Gateway on the top left of the portal. + +## Configuration + +1. Follow the [official AnyCA Gateway REST documentation](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCA-Gateway.htm) to define a new Certificate Authority, and use the notes below to configure the **Gateway Registration** and **CA Connection** tabs: + + * **Gateway Registration** + + TODO Gateway Registration is a required section + + * **CA Connection** + + Populate using the configuration fields collected in the [requirements](#requirements) section. + + * **ApiKey** - The API key for the Atlas credentials the gateway will use. + * **ApiSecret** - The corresponding API secret value that matches with the ApiKey. + * **ClientCertificate** - The client auth certificate to use with the Atlas API + * **SyncStartDate** - The earliest date to go back when doing a full sync. + +2. TODO Certificate Template Creation Step is an optional section. If this section doesn't seem necessary on initial glance, please delete it. Refer to the docs on [Confluence](https://keyfactor.atlassian.net/wiki/x/SAAyHg) for more info + +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. TODO Custom Enrollment Parameter Creation Step is an optional section. If this section doesn't seem necessary on initial glance, please delete it. Refer to the docs on [Confluence](https://keyfactor.atlassian.net/wiki/x/SAAyHg) for more info + + + +## 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 diff --git a/docsource/configuration.md b/docsource/configuration.md new file mode 100644 index 0000000..71fb0e2 --- /dev/null +++ b/docsource/configuration.md @@ -0,0 +1,24 @@ +## Overview + +TODO Overview is a required section + +## Requirements + +TODO Requirements is a required section + +## Gateway Registration + +TODO Gateway Registration is a required section + +## Certificate Template Creation Step + +TODO Certificate Template Creation Step is an optional section. If this section doesn't seem necessary on initial glance, please delete it. Refer to the docs on [Confluence](https://keyfactor.atlassian.net/wiki/x/SAAyHg) for more info + +## Custom Enrollment Parameter Creation Step + +TODO Custom Enrollment Parameter Creation Step is an optional section. If this section doesn't seem necessary on initial glance, please delete it. Refer to the docs on [Confluence](https://keyfactor.atlassian.net/wiki/x/SAAyHg) for more info + +## Mechanics + +TODO Mechanics is an optional section. If this section doesn't seem necessary on initial glance, please delete it. Refer to the docs on [Confluence](https://keyfactor.atlassian.net/wiki/x/SAAyHg) for more info + diff --git a/integration-manifest.json b/integration-manifest.json index 59955fd..a51f121 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -9,5 +9,40 @@ "description": "GlobalSign Atlas plugin for the AnyCA REST Gateway framework", "gateway_framework": "24.2.0", "release_dir": "globalsign-atlas-caplugin/bin/Release", - "release_project": "globalsign-atlas-caplugin/globalsign-atlas-caplugin.csproj" + "release_project": "globalsign-atlas-caplugin/globalsign-atlas-caplugin.csproj", + "about": { + "carest": { + "product_ids": [ + "certificate" + ], + "ca_plugin_config": [ + { + "name": "ApiKey", + "description": "The API key for the Atlas credentials the gateway will use." + }, + { + "name": "ApiSecret", + "description": "The corresponding API secret value that matches with the ApiKey." + }, + { + "name": "ClientCertificate", + "description": "The client auth certificate to use with the Atlas API" + }, + { + "name": "SyncStartDate", + "description": "The earliest date to go back when doing a full sync." + } + ], + "enrollment_config": [ + { + "name": "Lifetime", + "description": "The term length (in days) to use for enrollment." + }, + { + "name": "KeyUsage", + "description": "The key usage to use for enrolled certs. Valid values are 'client', 'server', and 'clientserver'." + } + ] + } + } } \ No newline at end of file From 88c451c86f0c699d9b06ab7b9483f2750749f34b Mon Sep 17 00:00:00 2001 From: David Galey Date: Tue, 23 Jun 2026 12:59:01 -0400 Subject: [PATCH 08/15] net10 --- globalsign-atlas-caplugin/globalsign-atlas-caplugin.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/globalsign-atlas-caplugin/globalsign-atlas-caplugin.csproj b/globalsign-atlas-caplugin/globalsign-atlas-caplugin.csproj index 62f74d2..65fcadc 100644 --- a/globalsign-atlas-caplugin/globalsign-atlas-caplugin.csproj +++ b/globalsign-atlas-caplugin/globalsign-atlas-caplugin.csproj @@ -1,7 +1,7 @@  - net8.0 + net8.0;net10.0 Keyfactor.Extensions.CAPlugin.GlobalSign.Atlas enable disable From 3b39b4aca84db31dc83c84ad937f7d57d943667d Mon Sep 17 00:00:00 2001 From: David Galey Date: Thu, 25 Jun 2026 15:07:53 -0400 Subject: [PATCH 09/15] fix for csr encoding --- globalsign-atlas-caplugin/AtlasCAPlugin.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/globalsign-atlas-caplugin/AtlasCAPlugin.cs b/globalsign-atlas-caplugin/AtlasCAPlugin.cs index 1c4217c..e16a7ae 100644 --- a/globalsign-atlas-caplugin/AtlasCAPlugin.cs +++ b/globalsign-atlas-caplugin/AtlasCAPlugin.cs @@ -45,7 +45,7 @@ public async Task Enroll(string csr, string subject, Dictionar _logger.MethodEntry(LogLevel.Trace); AtlasClient client = AtlasClient.InitializeClient(_config, _certResolver); Enroll enrollData = new Enroll(); - csr = PemUtilities.DERToPEM(Convert.FromBase64String(csr), PemUtilities.PemObjectType.CertRequest); + enrollData.CSR = csr; var validation = client.GetValidationPolicy(); From c93220692672577cd80438eb214e6bc649a22c3f Mon Sep 17 00:00:00 2001 From: Dave Galey <89407235+dgaley@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:25:33 -0400 Subject: [PATCH 10/15] Update keyfactor-bootstrap-workflow-v3.yml --- .github/workflows/keyfactor-bootstrap-workflow-v3.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/keyfactor-bootstrap-workflow-v3.yml b/.github/workflows/keyfactor-bootstrap-workflow-v3.yml index 64919a4..b72a703 100644 --- a/.github/workflows/keyfactor-bootstrap-workflow-v3.yml +++ b/.github/workflows/keyfactor-bootstrap-workflow-v3.yml @@ -11,7 +11,7 @@ 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}} From 581f9f57a3c23c582c65ebaa5dfce84d0d8b55e9 Mon Sep 17 00:00:00 2001 From: Dave Galey <89407235+dgaley@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:26:08 -0400 Subject: [PATCH 11/15] Update keyfactor-bootstrap-workflow-v3.yml --- .github/workflows/keyfactor-bootstrap-workflow-v3.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/keyfactor-bootstrap-workflow-v3.yml b/.github/workflows/keyfactor-bootstrap-workflow-v3.yml index b72a703..45eec87 100644 --- a/.github/workflows/keyfactor-bootstrap-workflow-v3.yml +++ b/.github/workflows/keyfactor-bootstrap-workflow-v3.yml @@ -14,7 +14,6 @@ jobs: 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 c7d4a8955d09c9c0c059123866c34c393bff809d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 25 Jun 2026 19:26:35 +0000 Subject: [PATCH 12/15] docs: auto-generate README and documentation [skip ci] --- README.md | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 2185c80..12405fb 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Support - + · Requirements @@ -33,7 +33,6 @@

- TODO Overview is a required section ## Compatibility @@ -41,7 +40,7 @@ TODO Overview is a required section The GlobalSign Atlas AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 24.2.0 and later. ## Support -The GlobalSign Atlas 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 Atlas 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,16 @@ TODO Requirements is a required section 2. On the server hosting the AnyCA Gateway REST, download and unzip the latest [GlobalSign Atlas AnyCA Gateway REST plugin](https://github.com/Keyfactor/globalsign-atlas-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 Atlas 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 Atlas 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. @@ -82,10 +81,10 @@ TODO Requirements is a required section Populate using the configuration fields collected in the [requirements](#requirements) section. - * **ApiKey** - The API key for the Atlas credentials the gateway will use. - * **ApiSecret** - The corresponding API secret value that matches with the ApiKey. - * **ClientCertificate** - The client auth certificate to use with the Atlas API - * **SyncStartDate** - The earliest date to go back when doing a full sync. + * **ApiKey** - The API key for the Atlas credentials the gateway will use. + * **ApiSecret** - The corresponding API secret value that matches with the ApiKey. + * **ClientCertificate** - The client auth certificate to use with the Atlas API + * **SyncStartDate** - The earliest date to go back when doing a full sync. 2. TODO Certificate Template Creation Step is an optional section. If this section doesn't seem necessary on initial glance, please delete it. Refer to the docs on [Confluence](https://keyfactor.atlassian.net/wiki/x/SAAyHg) for more info @@ -93,12 +92,10 @@ TODO Requirements is a required section 4. TODO Custom Enrollment Parameter Creation Step is an optional section. If this section doesn't seem necessary on initial glance, please delete it. Refer to the docs on [Confluence](https://keyfactor.atlassian.net/wiki/x/SAAyHg) for more info - - ## 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 8fbeb474858acea0d8823b94ffbac8b8a636d597 Mon Sep 17 00:00:00 2001 From: David Galey Date: Mon, 29 Jun 2026 14:12:34 -0400 Subject: [PATCH 13/15] docs --- docsource/configuration.md | 20 +++++++------------- globalsign-atlas-caplugin.sln | 5 +++-- globalsign-atlas-caplugin/AtlasCAPlugin.cs | 1 + 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/docsource/configuration.md b/docsource/configuration.md index 71fb0e2..be92150 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -1,24 +1,18 @@ ## Overview -TODO Overview is a required section +The GlobalSign Atlas AnyCA Gateway REST plugin extends the capabilities of GlobalSign Atlas to Keyfactor Command via the Keyfactor AnyCA Gateway REST. The plugin has the following capabilities: +* SSl Certificate Synchronization +* SSL Certificate Enrollment +* SSL Certificate Revocation ## Requirements -TODO Requirements is a required section +To use the GlobalSign Atlas AnyCA Gateway, you must generate a set of API Credentials (key/secret) within the Atlas portal, as well as an mTLS certificate linked to those credentials. ## Gateway Registration -TODO Gateway Registration is a required section +In order to enroll for certificates the Keyfactor Command server must trust the trust chain. Once you configure your Root and/or Subordinate CA in your Atlas account, make sure to download and import the certificate chain into the Command Server certificate store ## Certificate Template Creation Step -TODO Certificate Template Creation Step is an optional section. If this section doesn't seem necessary on initial glance, please delete it. Refer to the docs on [Confluence](https://keyfactor.atlassian.net/wiki/x/SAAyHg) for more info - -## Custom Enrollment Parameter Creation Step - -TODO Custom Enrollment Parameter Creation Step is an optional section. If this section doesn't seem necessary on initial glance, please delete it. Refer to the docs on [Confluence](https://keyfactor.atlassian.net/wiki/x/SAAyHg) for more info - -## Mechanics - -TODO Mechanics is an optional section. If this section doesn't seem necessary on initial glance, please delete it. Refer to the docs on [Confluence](https://keyfactor.atlassian.net/wiki/x/SAAyHg) for more info - +When defining templates, the product ID should just be defined as "certificate". \ No newline at end of file diff --git a/globalsign-atlas-caplugin.sln b/globalsign-atlas-caplugin.sln index 411dafc..3257d35 100644 --- a/globalsign-atlas-caplugin.sln +++ b/globalsign-atlas-caplugin.sln @@ -1,13 +1,14 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.14.36705.20 +# Visual Studio Version 18 +VisualStudioVersion = 18.7.11919.86 stable MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "globalsign-atlas-caplugin", "globalsign-atlas-caplugin\globalsign-atlas-caplugin.csproj", "{292B7A34-CFEC-4ABB-8BE6-31F87BC1651B}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}" ProjectSection(SolutionItems) = preProject CHANGELOG.md = CHANGELOG.md + docsource\configuration.md = docsource\configuration.md integration-manifest.json = integration-manifest.json EndProjectSection EndProject diff --git a/globalsign-atlas-caplugin/AtlasCAPlugin.cs b/globalsign-atlas-caplugin/AtlasCAPlugin.cs index e16a7ae..ce04e1a 100644 --- a/globalsign-atlas-caplugin/AtlasCAPlugin.cs +++ b/globalsign-atlas-caplugin/AtlasCAPlugin.cs @@ -357,6 +357,7 @@ public async Task Synchronize(BlockingCollection blockin { var anyCACert = new AnyCAPluginCertificate { + ProductID = "certificate", CARequestID = cert.Status.SerialNumber, Certificate = cert.Cert.Certificate, Status = cert.Cert.Status.Equals("issued", StringComparison.OrdinalIgnoreCase) ? (int)EndEntityStatus.GENERATED : From 9d11affd4e6d8e2fa6b28cbcbd535a8ba7572b76 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 29 Jun 2026 18:13:06 +0000 Subject: [PATCH 14/15] docs: auto-generate README and documentation [skip ci] --- README.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 12405fb..9c1605a 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,10 @@

-TODO Overview is a required section +The GlobalSign Atlas AnyCA Gateway REST plugin extends the capabilities of GlobalSign Atlas to Keyfactor Command via the Keyfactor AnyCA Gateway REST. The plugin has the following capabilities: +* SSl Certificate Synchronization +* SSL Certificate Enrollment +* SSL Certificate Revocation ## Compatibility @@ -46,7 +49,7 @@ The GlobalSign Atlas AnyCA Gateway REST plugin is supported by Keyfactor for Key ## Requirements -TODO Requirements is a required section +To use the GlobalSign Atlas AnyCA Gateway, you must generate a set of API Credentials (key/secret) within the Atlas portal, as well as an mTLS certificate linked to those credentials. ## Installation @@ -75,7 +78,7 @@ TODO Requirements is a required section * **Gateway Registration** - TODO Gateway Registration is a required section + In order to enroll for certificates the Keyfactor Command server must trust the trust chain. Once you configure your Root and/or Subordinate CA in your Atlas account, make sure to download and import the certificate chain into the Command Server certificate store * **CA Connection** @@ -86,11 +89,14 @@ TODO Requirements is a required section * **ClientCertificate** - The client auth certificate to use with the Atlas API * **SyncStartDate** - The earliest date to go back when doing a full sync. -2. TODO Certificate Template Creation Step is an optional section. If this section doesn't seem necessary on initial glance, please delete it. Refer to the docs on [Confluence](https://keyfactor.atlassian.net/wiki/x/SAAyHg) for more info +2. When defining templates, the product ID should just be defined as "certificate". 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. TODO Custom Enrollment Parameter Creation Step is an optional section. If this section doesn't seem necessary on initial glance, please delete it. Refer to the docs on [Confluence](https://keyfactor.atlassian.net/wiki/x/SAAyHg) for more info +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: + + * **Lifetime** - The term length (in days) to use for enrollment. + * **KeyUsage** - The key usage to use for enrolled certs. Valid values are 'client', 'server', and 'clientserver'. ## License From 86cdc1f9d726569ee95e4c620369d42c48ff28c4 Mon Sep 17 00:00:00 2001 From: David Galey Date: Wed, 8 Jul 2026 12:36:25 -0400 Subject: [PATCH 15/15] add Enable flag --- globalsign-atlas-caplugin/AtlasCAPlugin.cs | 13 +++++++++++++ globalsign-atlas-caplugin/AtlasConfig.cs | 3 +++ globalsign-atlas-caplugin/Client/AtlasClient.cs | 8 ++++++++ 3 files changed, 24 insertions(+) diff --git a/globalsign-atlas-caplugin/AtlasCAPlugin.cs b/globalsign-atlas-caplugin/AtlasCAPlugin.cs index ce04e1a..c08c907 100644 --- a/globalsign-atlas-caplugin/AtlasCAPlugin.cs +++ b/globalsign-atlas-caplugin/AtlasCAPlugin.cs @@ -279,6 +279,13 @@ public Dictionary GetCAConnectorAnnotations() Hidden = false, DefaultValue = "2024-01-01", Type = "String" + }, + ["Enabled"] = new PropertyConfigInfo() + { + 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" } }; } @@ -326,6 +333,12 @@ public Dictionary GetTemplateParameterAnnotations() public async Task Ping() { + if (!_config.Enabled) + { + _logger.LogWarning($"The CA is currently in the Disabled state. It must be Enabled to perform operations. Skipping connectivity test..."); + _logger.MethodExit(LogLevel.Trace); + return; + } try { AtlasClient client = AtlasClient.InitializeClient(_config, _certResolver); diff --git a/globalsign-atlas-caplugin/AtlasConfig.cs b/globalsign-atlas-caplugin/AtlasConfig.cs index 6c3ff89..2283063 100644 --- a/globalsign-atlas-caplugin/AtlasConfig.cs +++ b/globalsign-atlas-caplugin/AtlasConfig.cs @@ -25,5 +25,8 @@ public AtlasConfig() { } [JsonProperty("SyncStartDate")] public string SyncStartDate { get; set; } + + [JsonProperty("Enabled")] + public bool Enabled { get; set; } = true; } } diff --git a/globalsign-atlas-caplugin/Client/AtlasClient.cs b/globalsign-atlas-caplugin/Client/AtlasClient.cs index 468a76d..a77ef94 100644 --- a/globalsign-atlas-caplugin/Client/AtlasClient.cs +++ b/globalsign-atlas-caplugin/Client/AtlasClient.cs @@ -22,6 +22,8 @@ using System.Text; using System.Threading.Tasks; +using static Org.BouncyCastle.Math.EC.ECCurve; + using CertificateResponse = Keyfactor.Extensions.CAPlugin.GlobalSign.Atlas.APIProxy.CertificateResponse; namespace Keyfactor.Extensions.CAPlugin.GlobalSign.Atlas.Client @@ -69,6 +71,12 @@ public AtlasClient(string apiKey, string apiSecret, X509Certificate2 authCert, D public static AtlasClient InitializeClient(AtlasConfig config, ICertificateResolver certResolver) { Logger.MethodEntry(LogLevel.Debug); + if (!config.Enabled) + { + Logger.LogWarning($"The CA is currently in the Disabled state. It must be Enabled to perform operations. Skipping connectivity test..."); + Logger.MethodExit(LogLevel.Trace); + throw new Exception($"The CA is currently in the Disabled state. It must be Enabled to perform operations."); + } Logger.LogTrace($"Retrieving auth certificate"); X509Certificate2 authCert = null; if (!string.IsNullOrEmpty(config.Certificate.ImportedCertificate))