diff --git a/.github/workflows/keyfactor-bootstrap-workflow-v3.yml b/.github/workflows/keyfactor-bootstrap-workflow-v3.yml new file mode 100644 index 0000000..45eec87 --- /dev/null +++ b/.github/workflows/keyfactor-bootstrap-workflow-v3.yml @@ -0,0 +1,19 @@ +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@v5 + secrets: + token: ${{ secrets.V2BUILDTOKEN}} + gpg_key: ${{ secrets.KF_GPG_PRIVATE_KEY }} + gpg_pass: ${{ secrets.KF_GPG_PASSPHRASE }} + scan_token: ${{ secrets.SAST_TOKEN }} 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/README.md b/README.md index 45f8331..9c1605a 100644 --- a/README.md +++ b/README.md @@ -1 +1,107 @@ -# 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 + +

+ +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 + +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 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 + +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 + +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 `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\net8.0\Extensions + Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net10.0\Extensions + ``` + + > 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. + +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** + + 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** + + 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. 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. 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 + +Apache License 2.0, see [LICENSE](LICENSE). + +## Related Integrations + +See all [Keyfactor Any CA Gateways (REST)](https://github.com/orgs/Keyfactor/repositories?q=anycagateway). diff --git a/docsource/configuration.md b/docsource/configuration.md new file mode 100644 index 0000000..be92150 --- /dev/null +++ b/docsource/configuration.md @@ -0,0 +1,18 @@ +## Overview + +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 + +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 + +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 + +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 new file mode 100644 index 0000000..3257d35 --- /dev/null +++ b/globalsign-atlas-caplugin.sln @@ -0,0 +1,32 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# 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 +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..c08c907 --- /dev/null +++ b/globalsign-atlas-caplugin/AtlasCAPlugin.cs @@ -0,0 +1,449 @@ +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(); + + 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" + }, + ["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" + } + }; + } + + 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() + { + 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); + _ = 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 + { + ProductID = "certificate", + 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..2283063 --- /dev/null +++ b/globalsign-atlas-caplugin/AtlasConfig.cs @@ -0,0 +1,32 @@ +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; } + + [JsonProperty("Enabled")] + public bool Enabled { get; set; } = true; + } +} diff --git a/globalsign-atlas-caplugin/Client/AtlasClient.cs b/globalsign-atlas-caplugin/Client/AtlasClient.cs new file mode 100644 index 0000000..a77ef94 --- /dev/null +++ b/globalsign-atlas-caplugin/Client/AtlasClient.cs @@ -0,0 +1,628 @@ +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 static Org.BouncyCastle.Math.EC.ECCurve; + +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); + 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)) + { + 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..65fcadc --- /dev/null +++ b/globalsign-atlas-caplugin/globalsign-atlas-caplugin.csproj @@ -0,0 +1,24 @@ + + + + net8.0;net10.0 + Keyfactor.Extensions.CAPlugin.GlobalSign.Atlas + enable + disable + AtlasCAPlugin + + + + + + + + + + + + Always + + + + 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" + } + } + } +} diff --git a/integration-manifest.json b/integration-manifest.json new file mode 100644 index 0000000..a51f121 --- /dev/null +++ b/integration-manifest.json @@ -0,0 +1,48 @@ +{ + "$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", + "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