From 0df7f5bcdcaa839c7dcb271bcfc611af84237e28 Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele Date: Tue, 11 Nov 2025 17:18:20 -0500 Subject: [PATCH 01/35] Implemented enrollment, added helper methods and API response handlers --- .../Constants.cs | 21 +++ .../NexusCertManagerCAPlugin.cs | 43 ++++- .../NexusCertManagerCAPlugin.csproj | 2 + .../NexusCertManagerCAPluginConfig.cs | 16 +- .../NexusCertManagerClient.cs | 110 ++++++++++- .../models/ApiModels.cs | 173 ++++++++++++++++++ .../models/Helpers.cs | 172 +++++++++++++++++ 7 files changed, 525 insertions(+), 12 deletions(-) create mode 100644 nexus-certificate-manager-caplugin/Constants.cs create mode 100644 nexus-certificate-manager-caplugin/models/Helpers.cs diff --git a/nexus-certificate-manager-caplugin/Constants.cs b/nexus-certificate-manager-caplugin/Constants.cs new file mode 100644 index 0000000..4980fca --- /dev/null +++ b/nexus-certificate-manager-caplugin/Constants.cs @@ -0,0 +1,21 @@ +namespace Keyfactor.Extensions.CAPlugin.NexusCertManager +{ + public static class Constants + { + public const string HOST = "Host"; + public const string AUTHCERTPATH = "AuthCertificatePath"; + public const string ENABLED = "Enabled"; + public const string APIPATH = "pgwy/api"; + public const string AUTHCERTPASSWORD = "AuthCertPassword"; + } + + public static class ApiEndpoints + { + public const string LISTCERTS = "/certificates"; //get + public static string DOWNLOADCERT(string certId) => $"/certificates/{certId}/download"; //get + + public const string REVOKE = "/certificates/revoke"; //post + + public const string ENROLL = "/certificates/pkcs10"; + } +} diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs index 38ae13c..867f710 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs @@ -7,10 +7,13 @@ // and limitations under the License. using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.NexusCertManager.models; using Keyfactor.Logging; using Microsoft.Extensions.Logging; +using Keyfactor.PKI.Enums.EJBCA; using Newtonsoft.Json; using System.Collections.Concurrent; +using System.Security.AccessControl; namespace Keyfactor.Extensions.CAPlugin.NexusCertManager { @@ -19,6 +22,7 @@ public class NexusCertManagerCAPlugin : IAnyCAPlugin private readonly ILogger _logger; private NexusCertManagerCAPluginConfig _config; private ICertificateDataReader _certificateDataReader; + private NexusCertManagerClient _client; public NexusCertManagerCAPlugin(ILogger logger) { @@ -30,6 +34,8 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa _certificateDataReader = certificateDataReader; string rawConfig = JsonConvert.SerializeObject(configProvider.CAConnectionData); _config = JsonConvert.DeserializeObject(rawConfig); + + _client = new NexusCertManagerClient(_config.Host, _config.AuthCertPath, _config.AuthCertPassword); // need to set the values } /// @@ -45,10 +51,21 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa public async Task Enroll(string csr, string subject, Dictionary san, EnrollmentProductInfo productInfo, RequestFormat requestFormat, EnrollmentType enrollmentType) { _logger.MethodEntry(); - var enrollmentResult = new EnrollmentResult(); + string sans = string.Join(";", san.Select(s => string.Format("{0}:{1}", s.Key, string.Join(",", s.Value)))); string paramsList = string.Join(";", productInfo.ProductParameters.Select(x => string.Format("{0}={1}", x.Key, x.Value))); + string commonName = Helpers.ParseSubject(subject, "CN="); + _logger.LogTrace($"Attempting to enroll for certificate with:\nSubject: {subject}\nSANs: {sans}\nParams: {paramsList}\nCSR: {csr}"); + var res = await _client.Enroll(csr); + + var enrollmentResult = new EnrollmentResult + { + CARequestID = res.CertId, + Certificate = res.Base64EncodedCertificateData, + Status = (int)EndEntityStatus.GENERATED, + StatusMessage = $"Successfully enrolled certificate {commonName}" + }; return enrollmentResult; } @@ -83,9 +100,31 @@ public Task Revoke(string caRequestID, string hexSerialNumber, uint revocat throw new NotImplementedException(); } + /// + /// Synchronize gets the list of certs from the CA and updates the status of each known cert to the latest; and adds missing cert info to the database /// + /// + /// the database reader, passed by framework + /// the time of last sync + /// whether or not to perform a full sync + /// the cancel token + /// public Task Synchronize(BlockingCollection blockingBuffer, DateTime? lastSync, bool fullSync, CancellationToken cancelToken) { - throw new NotImplementedException(); + _logger.MethodEntry(); + + try + { + throw new NotImplementedException(); + } + catch (Exception ex) + { + _logger.LogError($"an error occurred during the sync: {ex.Message}"); + throw; + } + finally + { + _logger.MethodExit(); + } } public Task ValidateCAConnectionInfo(Dictionary connectionInfo) diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.csproj b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.csproj index a1f5d25..eb22c8b 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.csproj +++ b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.csproj @@ -11,8 +11,10 @@ + + diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerCAPluginConfig.cs b/nexus-certificate-manager-caplugin/NexusCertManagerCAPluginConfig.cs index e852ebc..dc96593 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerCAPluginConfig.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerCAPluginConfig.cs @@ -6,16 +6,22 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions // and limitations under the License. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.Text.Json.Serialization; namespace Keyfactor.Extensions.CAPlugin.NexusCertManager { public class NexusCertManagerCAPluginConfig { + [JsonPropertyName(Constants.HOST)] + public string Host { get; set; } + [JsonPropertyName(Constants.AUTHCERTPATH)] + public string AuthCertPath { get; set; } + + [JsonPropertyName(Constants.AUTHCERTPASSWORD)] + public string AuthCertPassword { get; set; } + + [JsonPropertyName(Constants.ENABLED)] + public bool Enabled { get; set; } } } diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs b/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs index 8b609eb..37865bb 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs @@ -5,22 +5,122 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions // and limitations under the License. +using Keyfactor.Extensions.CAPlugin.NexusCertManager.models; +using Keyfactor.Logging; +using Microsoft.Extensions.Logging; +using RestSharp; +using System.Buffers.Text; +using System.Security.Cryptography.X509Certificates; + namespace Keyfactor.Extensions.CAPlugin.NexusCertManager { public class NexusCertManagerClient { + ILogger _logger; + private RestClient _restClient; + private string _host; + private string _authCertPath; + + + public NexusCertManagerClient(string hostAndPort, string authCertPath, string authCertPassword) + { + _logger = LogHandler.GetClassLogger(typeof(NexusCertManagerClient)); + _host = hostAndPort; + _authCertPath = authCertPath; + + var url = _host.EndsWith(Constants.APIPATH) ? _host : _host.TrimEnd('/') + "/" + Constants.APIPATH; + _logger.LogTrace($"full api path: {url}"); + + _logger.LogTrace($"importing the client auth certificate from path {authCertPath}"); + + X509Certificate2 clientCertificate; + + try + { + clientCertificate = new X509Certificate2(authCertPath, authCertPassword); + } + catch (Exception ex) { + _logger.LogError($"there was an error attempting to load the certificate from the path {authCertPath}. Make sure that it is stored in the PFX format and that you have provided the password."); + _logger.LogError($"error message: {ex.Message}"); + throw; + } + + var clientCerts = new X509CertificateCollection(); + clientCerts.Add(clientCertificate); + var options = new RestClientOptions(url) { ClientCertificates = clientCerts }; + _restClient = new RestClient(options); + } - public NexusCertManagerClient() { } + public async Task Enroll(string csr) + { + _logger.MethodEntry(); + var req = new RestRequest(ApiEndpoints.ENROLL, Method.Post); + req.AddHeader("Accept", "application/pkcs7-mime"); // Or your preferred format + req.AddParameter("pkcs10", csr); + _logger.LogTrace($"preparing the request for enrollment."); - public string Enroll(string csr) + try + { + _logger.LogTrace($"submitting request to the endpoint '{_restClient.BuildUri(req)}'"); + var response = await _restClient.ExecuteAsync(req); + _logger.LogTrace($"recieved a response, parsing the result"); + var result = RestSharpResponseHandler.HandleCertificateBinaryResponse(response); + return result; + } + catch (Exception ex) + { + _logger.LogError($"an error occurred when attempting to submit the CSR: {ex.Message}"); + throw; + } + finally { _logger.MethodExit(); } + } + + public CertificateDetailsResponse GetCertificateDetails(string certId) { - throw new NotImplementedException(); + _logger.MethodEntry(); + try + { + throw new NotImplementedException(); + } + catch (Exception ex) + { + _logger.LogError($"an error occurred when attempting to get the certificate details: {ex.Message}"); + throw; + } + finally { _logger.MethodExit(); } } - public string GetCertificate(string certId) + public Task RevokeCertificate(string certId) { - throw new NotImplementedException(); + _logger.MethodEntry(); + try + { + throw new NotImplementedException(); + } + catch (Exception ex) + { + _logger.LogError($"an error occurred when attempting to revoke the certificate: {ex.Message}"); + throw; + } + finally { _logger.MethodExit(); } } + + public Task GetCertificateList(ListCertificatesRequest req) + { + _logger.MethodEntry(); + try + { + throw new NotImplementedException(); + } + catch (Exception ex) + { + _logger.LogError($"an error occurred when attempting to get a list of certificates: {ex.Message}"); + throw; + } + finally { _logger.MethodExit(); } + + } + } } diff --git a/nexus-certificate-manager-caplugin/models/ApiModels.cs b/nexus-certificate-manager-caplugin/models/ApiModels.cs index 0d2a332..b563a56 100644 --- a/nexus-certificate-manager-caplugin/models/ApiModels.cs +++ b/nexus-certificate-manager-caplugin/models/ApiModels.cs @@ -19,6 +19,18 @@ public class ApiResponse [JsonPropertyName("msg")] public string Message { get; set; } + + /// + /// Indicates whether the API call was successful (Error == 0) + /// + [JsonIgnore] + public bool IsSuccess => Error == 0; + + /// + /// Indicates whether the API call failed (Error != 0) + /// + [JsonIgnore] + public bool IsError => Error != 0; } public class ApiErrorResponse : ApiResponse @@ -74,6 +86,131 @@ public class ImportError #region Certificate Models + /// + /// Query parameters for listing certificates + /// + public class ListCertificatesRequest + { + // Pagination parameters + public int? SearchLimit { get; set; } + public int? SearchOffset { get; set; } + public string OrderBy { get; set; } + public bool? OrderDescending { get; set; } + + // Certificate identification + public string CardSerialNumber { get; set; } + public string CertificateSerialNumber { get; set; } + + // Revocation filters + public DateTime? RevocationTimeFrom { get; set; } + public DateTime? RevocationTimeTo { get; set; } + public List RevocationReason { get; set; } + public bool? IsNotRevoked { get; set; } + + // Subject filters + public string SubjectCommonName { get; set; } + public string SubjectGivenName { get; set; } + public string SubjectSurName { get; set; } + public string SubjectOrganisationName { get; set; } + public string SubjectOrganisationUnit { get; set; } + public string SubjectSerialNumber { get; set; } + public string SubjectCountry { get; set; } + + // Publication filters + public bool? PublicationAllowed { get; set; } + public DateTime? PublicationTimeFrom { get; set; } + public DateTime? PublicationTimeTo { get; set; } + + // OCSP filters + public DateTime? OcspActivationTimeFrom { get; set; } + public DateTime? OcspActivationTimeTo { get; set; } + + // Validity filters + public DateTime? ValidFromTimeFrom { get; set; } + public DateTime? ValidFromTimeTo { get; set; } + public bool? IsNotYetValid { get; set; } + public DateTime? ValidToTimeFrom { get; set; } + public DateTime? ValidToTimeTo { get; set; } + public bool? IsExpired { get; set; } + + // Extended search fields + public string Field1 { get; set; } + public string Field2 { get; set; } + public string Field3 { get; set; } + public string Field4 { get; set; } + public string Field5 { get; set; } + public string Field6 { get; set; } + + // Key identifiers + public string AuthorityKeyIdentifier { get; set; } + public string SubjectKeyIdentifier { get; set; } + + // Subject type + public List SubjectType { get; set; } + + // Issuer (RFC1779 distinguished name string, URL encoded) + public string Issuer { get; set; } + + /// + /// Converts the request to a query string + /// + public string ToQueryString() + { + var parameters = new List(); + + if (SearchLimit.HasValue) parameters.Add($"searchLimit={SearchLimit.Value}"); + if (SearchOffset.HasValue) parameters.Add($"searchOffset={SearchOffset.Value}"); + if (!string.IsNullOrEmpty(OrderBy)) parameters.Add($"orderBy={Uri.EscapeDataString(OrderBy)}"); + if (OrderDescending.HasValue) parameters.Add($"orderDescending={OrderDescending.Value.ToString().ToLower()}"); + + if (!string.IsNullOrEmpty(CardSerialNumber)) parameters.Add($"cardSerialNumber={Uri.EscapeDataString(CardSerialNumber)}"); + if (!string.IsNullOrEmpty(CertificateSerialNumber)) parameters.Add($"certificateSerialNumber={Uri.EscapeDataString(CertificateSerialNumber)}"); + + if (RevocationTimeFrom.HasValue) parameters.Add($"revocationTimeFrom={Uri.EscapeDataString(RevocationTimeFrom.Value.ToString("o"))}"); + if (RevocationTimeTo.HasValue) parameters.Add($"revocationTimeTo={Uri.EscapeDataString(RevocationTimeTo.Value.ToString("o"))}"); + if (RevocationReason != null && RevocationReason.Any()) parameters.Add($"revocationReason={string.Join(",", RevocationReason)}"); + if (IsNotRevoked.HasValue) parameters.Add($"isNotRevoked={IsNotRevoked.Value.ToString().ToLower()}"); + + if (!string.IsNullOrEmpty(SubjectCommonName)) parameters.Add($"subjectCommonName={Uri.EscapeDataString(SubjectCommonName)}"); + if (!string.IsNullOrEmpty(SubjectGivenName)) parameters.Add($"subjectGivenName={Uri.EscapeDataString(SubjectGivenName)}"); + if (!string.IsNullOrEmpty(SubjectSurName)) parameters.Add($"subjectSurName={Uri.EscapeDataString(SubjectSurName)}"); + if (!string.IsNullOrEmpty(SubjectOrganisationName)) parameters.Add($"subjectOrganisationName={Uri.EscapeDataString(SubjectOrganisationName)}"); + if (!string.IsNullOrEmpty(SubjectOrganisationUnit)) parameters.Add($"subjectOrganisationUnit={Uri.EscapeDataString(SubjectOrganisationUnit)}"); + if (!string.IsNullOrEmpty(SubjectSerialNumber)) parameters.Add($"subjectSerialNumber={Uri.EscapeDataString(SubjectSerialNumber)}"); + if (!string.IsNullOrEmpty(SubjectCountry)) parameters.Add($"subjectCountry={Uri.EscapeDataString(SubjectCountry)}"); + + if (PublicationAllowed.HasValue) parameters.Add($"publicationAllowed={PublicationAllowed.Value.ToString().ToLower()}"); + if (PublicationTimeFrom.HasValue) parameters.Add($"publicationTimeFrom={Uri.EscapeDataString(PublicationTimeFrom.Value.ToString("o"))}"); + if (PublicationTimeTo.HasValue) parameters.Add($"publicationTimeTo={Uri.EscapeDataString(PublicationTimeTo.Value.ToString("o"))}"); + + if (OcspActivationTimeFrom.HasValue) parameters.Add($"ocspActivationTimeFrom={Uri.EscapeDataString(OcspActivationTimeFrom.Value.ToString("o"))}"); + if (OcspActivationTimeTo.HasValue) parameters.Add($"ocspActivationTimeTo={Uri.EscapeDataString(OcspActivationTimeTo.Value.ToString("o"))}"); + + if (ValidFromTimeFrom.HasValue) parameters.Add($"validFromTimeFrom={Uri.EscapeDataString(ValidFromTimeFrom.Value.ToString("o"))}"); + if (ValidFromTimeTo.HasValue) parameters.Add($"validFromTimeTo={Uri.EscapeDataString(ValidFromTimeTo.Value.ToString("o"))}"); + if (IsNotYetValid.HasValue) parameters.Add($"isNotYetValid={IsNotYetValid.Value.ToString().ToLower()}"); + if (ValidToTimeFrom.HasValue) parameters.Add($"validToTimeFrom={Uri.EscapeDataString(ValidToTimeFrom.Value.ToString("o"))}"); + if (ValidToTimeTo.HasValue) parameters.Add($"validToTimeTo={Uri.EscapeDataString(ValidToTimeTo.Value.ToString("o"))}"); + if (IsExpired.HasValue) parameters.Add($"isExpired={IsExpired.Value.ToString().ToLower()}"); + + if (!string.IsNullOrEmpty(Field1)) parameters.Add($"field1={Uri.EscapeDataString(Field1)}"); + if (!string.IsNullOrEmpty(Field2)) parameters.Add($"field2={Uri.EscapeDataString(Field2)}"); + if (!string.IsNullOrEmpty(Field3)) parameters.Add($"field3={Uri.EscapeDataString(Field3)}"); + if (!string.IsNullOrEmpty(Field4)) parameters.Add($"field4={Uri.EscapeDataString(Field4)}"); + if (!string.IsNullOrEmpty(Field5)) parameters.Add($"field5={Uri.EscapeDataString(Field5)}"); + if (!string.IsNullOrEmpty(Field6)) parameters.Add($"field6={Uri.EscapeDataString(Field6)}"); + + if (!string.IsNullOrEmpty(AuthorityKeyIdentifier)) parameters.Add($"authorityKeyIdentifier={Uri.EscapeDataString(AuthorityKeyIdentifier)}"); + if (!string.IsNullOrEmpty(SubjectKeyIdentifier)) parameters.Add($"subjectKeyIdentifier={Uri.EscapeDataString(SubjectKeyIdentifier)}"); + + if (SubjectType != null && SubjectType.Any()) parameters.Add($"subjectType={string.Join(",", SubjectType)}"); + + if (!string.IsNullOrEmpty(Issuer)) parameters.Add($"issuer={Uri.EscapeDataString(Issuer)}"); + + return parameters.Any() ? "?" + string.Join("&", parameters) : string.Empty; + } + } + public class CertificateListResponse : ApiResponse { [JsonPropertyName("searchHits")] @@ -137,6 +274,42 @@ public class JsonCertificate public string Validity { get; set; } } + public class IssueCertificateBinaryResponse + { + /// + /// The binary certificate data (DER, PEM, or PKCS#7 depending on Accept header) + /// + public byte[] CertificateData { get; set; } + + /// + /// The CertId of the issued certificate from the response header + /// + public string CertId { get; set; } + + /// + /// The content type of the response + /// + public string ContentType { get; set; } + + /// + /// Indicates if this is a PKCS#7 response + /// + public bool IsPkcs7 => ContentType?.Contains("pkcs7") == true; + + /// + /// Indicates if this is a PEM response + /// + public bool IsPem => ContentType?.Contains("pem") == true; + + /// + /// Indicates if this is a DER response + /// + public bool IsDer => ContentType?.Contains("pkix-cert") == true; + + public string Base64EncodedCertificateData => Convert.ToBase64String(CertificateData); + + } + public class ExtendedCertSearch { [JsonPropertyName("field1")] diff --git a/nexus-certificate-manager-caplugin/models/Helpers.cs b/nexus-certificate-manager-caplugin/models/Helpers.cs new file mode 100644 index 0000000..d331189 --- /dev/null +++ b/nexus-certificate-manager-caplugin/models/Helpers.cs @@ -0,0 +1,172 @@ +using RestSharp; +using System.Text.Json; + +namespace Keyfactor.Extensions.CAPlugin.NexusCertManager.models +{ + public static class Helpers + { + public static string ParseSubject(string subject, string rdn) + { + string escapedSubject = subject.Replace("\\,", "|"); + string rdnString = escapedSubject.Split(',').ToList().Where(x => x.Contains(rdn)).FirstOrDefault(); + + if (!string.IsNullOrEmpty(rdnString)) + { + return rdnString.Replace(rdn, "").Replace("|", ",").Trim(); + } + else + { + throw new Exception($"The request is missing a {rdn} value"); + } + } + } + + /// + /// Helper methods for handling CM REST API responses with RestSharp + /// + public static class RestSharpResponseHandler + { + /// + /// Pattern 1: Deserialize to specific type and check IsSuccess property + /// This is the recommended approach for most scenarios + /// + public static T HandleResponse(RestResponse response) where T : ApiResponse + { + // Check HTTP status + if (!response.IsSuccessful) + { + // Try to parse error from content + if (!string.IsNullOrEmpty(response.Content)) + { + try + { + var errorResponse = JsonSerializer.Deserialize( + response.Content, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true } + ); + + if (errorResponse != null && errorResponse.IsError) + { + throw new CmApiException(errorResponse.Error, errorResponse.Message); + } + } + catch (JsonException) + { + // Not JSON, use raw content + throw new CmApiException( + (int)response.StatusCode, + $"HTTP {response.StatusCode}: {response.ErrorMessage ?? response.Content}" + ); + } + } + + throw new CmApiException( + (int)response.StatusCode, + response.ErrorMessage ?? $"HTTP {response.StatusCode}" + ); + } + + if (response.Data == null) + { + throw new CmApiException(-1, "Failed to deserialize response"); + } + + // Check the error code in the response body + if (response.Data.IsError) + { + throw new CmApiException(response.Data.Error, response.Data.Message); + } + + return response.Data; + } + + /// + /// Handles binary certificate responses (PKCS#7, DER, PEM) + /// Use for POST /certificates/pkcs10 and similar endpoints + /// + public static IssueCertificateBinaryResponse HandleCertificateBinaryResponse(RestResponse response) + { + // Check if response is JSON (error response) + var contentType = response.ContentType; + if (contentType?.Contains("json") == true) + { + try + { + var errorResponse = JsonSerializer.Deserialize( + response.Content, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true } + ); + + if (errorResponse != null && errorResponse.IsError) + { + throw new CmApiException(errorResponse.Error, errorResponse.Message); + } + } + catch (JsonException) + { + // Not a valid error response, continue + } + } + + // Check HTTP status + if (!response.IsSuccessful) + { + throw new CmApiException( + (int)response.StatusCode, + response.ErrorMessage ?? $"HTTP {response.StatusCode}" + ); + } + + // Get binary data + var binaryData = response.RawBytes; + + // Get CertId from response header + var certIdHeader = response.Headers?.FirstOrDefault(h => + h.Name.Equals("certId", StringComparison.OrdinalIgnoreCase)); + string certId = certIdHeader?.Value?.ToString(); + + return new IssueCertificateBinaryResponse + { + CertificateData = binaryData, + CertId = certId, + ContentType = contentType + }; + } + } + + + /// + /// Custom exception for CM API errors + /// + public class CmApiException : Exception + { + public int ErrorCode { get; } + + public CmApiException(int errorCode, string message) : base(message) + { + ErrorCode = errorCode; + } + + /// + /// Returns a user-friendly description of the error code + /// + public string GetErrorDescription() + { + return ErrorCode switch + { + 0 => "Success", + -1 => "General error", + -7 => "Missing field", + -8 => "Encoding error", + -12 => "Not initialized", + -14 => "Bad field value", + -15 => "Privilege error", + -17 => "Bad signature", + -18 => "Connection error", + -19 => "Signature required", + -40 => "Too many requests", + _ => "Unknown error" + }; + } + } +} From a13df0d27978e7ab52ff011d98d4b7018dd36f83 Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele Date: Wed, 12 Nov 2025 16:04:40 -0500 Subject: [PATCH 02/35] implemented cert retrieval methods on client. --- .../Constants.cs | 16 +++- .../NexusCertManagerCAPlugin.cs | 70 +++++++++++++---- .../NexusCertManagerClient.cs | 77 ++++++++++++++++--- .../models/ApiModels.cs | 2 +- .../models/Helpers.cs | 70 ++++++++++++++++- 5 files changed, 202 insertions(+), 33 deletions(-) diff --git a/nexus-certificate-manager-caplugin/Constants.cs b/nexus-certificate-manager-caplugin/Constants.cs index 4980fca..c44cf72 100644 --- a/nexus-certificate-manager-caplugin/Constants.cs +++ b/nexus-certificate-manager-caplugin/Constants.cs @@ -2,20 +2,28 @@ { public static class Constants { + //names public const string HOST = "Host"; public const string AUTHCERTPATH = "AuthCertificatePath"; - public const string ENABLED = "Enabled"; - public const string APIPATH = "pgwy/api"; + public const string ENABLED = "Enabled"; public const string AUTHCERTPASSWORD = "AuthCertPassword"; + + + //values + public const string APIPATH = "pgwy/api"; + public const string PRODUCTID = "NexusCM"; } public static class ApiEndpoints { public const string LISTCERTS = "/certificates"; //get - public static string DOWNLOADCERT(string certId) => $"/certificates/{certId}/download"; //get + public static string DOWNLOADCERT(string certId) => $"/certificates/{certId}/download"; //get + public static string CERTDETAILS(string certId) => $"/certificates/{certId}/details"; //get public const string REVOKE = "/certificates/revoke"; //post - public const string ENROLL = "/certificates/pkcs10"; + public const string ENROLL = "/certificates/pkcs10"; //post + + public const string LISTPROCEDURES = "/procedures"; } } diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs index 867f710..4949a55 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs @@ -13,7 +13,6 @@ using Keyfactor.PKI.Enums.EJBCA; using Newtonsoft.Json; using System.Collections.Concurrent; -using System.Security.AccessControl; namespace Keyfactor.Extensions.CAPlugin.NexusCertManager { @@ -51,23 +50,33 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa public async Task Enroll(string csr, string subject, Dictionary san, EnrollmentProductInfo productInfo, RequestFormat requestFormat, EnrollmentType enrollmentType) { _logger.MethodEntry(); - string sans = string.Join(";", san.Select(s => string.Format("{0}:{1}", s.Key, string.Join(",", s.Value)))); string paramsList = string.Join(";", productInfo.ProductParameters.Select(x => string.Format("{0}={1}", x.Key, x.Value))); string commonName = Helpers.ParseSubject(subject, "CN="); _logger.LogTrace($"Attempting to enroll for certificate with:\nSubject: {subject}\nSANs: {sans}\nParams: {paramsList}\nCSR: {csr}"); - var res = await _client.Enroll(csr); - - var enrollmentResult = new EnrollmentResult + try { - CARequestID = res.CertId, - Certificate = res.Base64EncodedCertificateData, - Status = (int)EndEntityStatus.GENERATED, - StatusMessage = $"Successfully enrolled certificate {commonName}" - }; - - return enrollmentResult; + var res = await _client.Enroll(csr); + + var enrollmentResult = new EnrollmentResult + { + CARequestID = res.CertId, + Certificate = res.Base64EncodedCertificateData, + Status = (int)EndEntityStatus.GENERATED, + StatusMessage = $"Successfully enrolled certificate {commonName}" + }; + return enrollmentResult; + } + catch (Exception ex) + { + _logger.LogError($"there was an error enrolling the certificate: {LogHandler.FlattenException(ex)}"); + throw; + } + finally + { + _logger.MethodExit(); + } } public Dictionary GetCAConnectorAnnotations() @@ -75,14 +84,45 @@ public Dictionary GetCAConnectorAnnotations() throw new NotImplementedException(); } + /// + /// this CA is does not split it's certificates into discernable "product types" + /// consequently, we are using a single product type for all certificates. + /// + /// A list of strings containing one element: "NexusCA" public List GetProductIds() { - throw new NotImplementedException(); + _logger.MethodEntry(); + return new List { Constants.PRODUCTID }; } - public Task GetSingleRecord(string caRequestID) + public async Task GetSingleRecord(string caRequestID) { - throw new NotImplementedException(); + _logger.MethodEntry(); + try + { + var certDetails = await _client.GetCertificateDetails(caRequestID); + var certContent = await _client.DownloadCertificate(caRequestID); + + var cert = new AnyCAPluginCertificate() + { + CARequestID = caRequestID, + Certificate = certContent.Base64EncodedCertificateData, + ProductID = certDetails.Certificate.CertId, + Status = Helpers.GetStatusCodeFromNexusCADescription(certDetails.Certificate.Status), + }; + if (cert.Status == (int)EndEntityStatus.REVOKED) { + cert.RevocationDate = certDetails.Certificate.RevocationTime; + cert.RevocationReason = Helpers.GetRevocationReasonCodeFromNexusCADescription(certDetails.Certificate.Reason); + } + return cert; + + } + catch (Exception ex) + { + _logger.LogError($"there was an error getting the certificate: {LogHandler.FlattenException(ex)}"); + throw; + } + finally { _logger.MethodExit(); } } public Dictionary GetTemplateParameterAnnotations() diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs b/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs index 37865bb..69f62d7 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs @@ -9,7 +9,6 @@ using Keyfactor.Logging; using Microsoft.Extensions.Logging; using RestSharp; -using System.Buffers.Text; using System.Security.Cryptography.X509Certificates; namespace Keyfactor.Extensions.CAPlugin.NexusCertManager @@ -40,7 +39,8 @@ public NexusCertManagerClient(string hostAndPort, string authCertPath, string au { clientCertificate = new X509Certificate2(authCertPath, authCertPassword); } - catch (Exception ex) { + catch (Exception ex) + { _logger.LogError($"there was an error attempting to load the certificate from the path {authCertPath}. Make sure that it is stored in the PFX format and that you have provided the password."); _logger.LogError($"error message: {ex.Message}"); throw; @@ -48,15 +48,15 @@ public NexusCertManagerClient(string hostAndPort, string authCertPath, string au var clientCerts = new X509CertificateCollection(); clientCerts.Add(clientCertificate); - var options = new RestClientOptions(url) { ClientCertificates = clientCerts }; + var options = new RestClientOptions(url) { ClientCertificates = clientCerts }; _restClient = new RestClient(options); } - public async Task Enroll(string csr) + public async Task Enroll(string csr) { _logger.MethodEntry(); var req = new RestRequest(ApiEndpoints.ENROLL, Method.Post); - req.AddHeader("Accept", "application/pkcs7-mime"); // Or your preferred format + req.AddHeader("Accept", "application/pkcs7-mime"); req.AddParameter("pkcs10", csr); _logger.LogTrace($"preparing the request for enrollment."); @@ -76,12 +76,18 @@ public async Task Enroll(string csr) finally { _logger.MethodExit(); } } - public CertificateDetailsResponse GetCertificateDetails(string certId) + public async Task GetCertificateDetails(string certId) { _logger.MethodEntry(); try { - throw new NotImplementedException(); + var endpoint = ApiEndpoints.CERTDETAILS(certId); + _logger.LogTrace($"performing the GET request for endpoint {endpoint}"); + + var res = await _restClient.GetAsync(endpoint); + _logger.LogTrace($"received a response; message: {res.Message}, error: {res.Error}"); + if (res.IsError) throw new CmApiException(res.Error, res.Message); + return res; } catch (Exception ex) { @@ -91,12 +97,38 @@ public CertificateDetailsResponse GetCertificateDetails(string certId) finally { _logger.MethodExit(); } } + public async Task DownloadCertificate(string certId, string format = "application/pkcs7-mime") + { + _logger.MethodEntry(); + try + { + var endpoint = ApiEndpoints.DOWNLOADCERT(certId); + var req = new RestRequest(endpoint, Method.Get); + req.AddHeader("Accept", format); + + _logger.LogTrace($"performing the GET request for endpoint {endpoint}"); + var res = await _restClient.GetAsync(req); + _logger.LogTrace($"recieved a response. status code: {res.StatusCode}"); + var response = RestSharpResponseHandler.HandleCertificateBinaryResponse(res); + return response; + + } + catch (Exception ex) + { + _logger.LogError($"an error occurred when attempting to download the certificate: {ex.Message}"); + throw; + } + finally { _logger.MethodExit(); } + } + public Task RevokeCertificate(string certId) { _logger.MethodEntry(); try { - throw new NotImplementedException(); + var endpoint = ApiEndpoints.REVOKE; + var req = new RevokeCertificateRequest(); + throw new NotImplementedException(); /// } catch (Exception ex) { @@ -106,12 +138,16 @@ public Task RevokeCertificate(string certId) finally { _logger.MethodExit(); } } - public Task GetCertificateList(ListCertificatesRequest req) + public async Task GetCertificateList(ListCertificatesRequest req, CancellationToken ct) { _logger.MethodEntry(); try { - throw new NotImplementedException(); + var endpoint = ApiEndpoints.LISTCERTS; + _logger.LogTrace($"performing the GET request for endpoint {endpoint}"); + var res = await _restClient.GetAsync(endpoint, ct); + _logger.LogTrace($"received a response. Number of certs returned: {res.SearchHits}"); + return res; } catch (Exception ex) { @@ -121,6 +157,27 @@ public Task GetCertificateList(ListCertificatesRequest finally { _logger.MethodExit(); } } + /// + /// This method calls the "/procedures" endpoint of the API + /// The ping is successful if the server returns "200". + /// The content of the response is ignored + /// + /// A boolean indicating whether the server is reachable and responding. + public async Task PingServer() { + _logger.MethodEntry(); + try { + var endpoint = ApiEndpoints.LISTPROCEDURES; + _logger.LogTrace($"pinging the endpoint {endpoint} to verify server is accessible"); + var req = new RestRequest(endpoint); + var res = await _restClient.GetAsync(req); + if (res.IsSuccessStatusCode) return true; + } + catch (Exception ex) { + _logger.LogError($"the attempt to ping the server failed: {ex.Message}"); + } + finally { _logger.MethodExit(); } + return false; + } } } diff --git a/nexus-certificate-manager-caplugin/models/ApiModels.cs b/nexus-certificate-manager-caplugin/models/ApiModels.cs index b563a56..77398b2 100644 --- a/nexus-certificate-manager-caplugin/models/ApiModels.cs +++ b/nexus-certificate-manager-caplugin/models/ApiModels.cs @@ -274,7 +274,7 @@ public class JsonCertificate public string Validity { get; set; } } - public class IssueCertificateBinaryResponse + public class CertificateBinaryResponse { /// /// The binary certificate data (DER, PEM, or PKCS#7 depending on Accept header) diff --git a/nexus-certificate-manager-caplugin/models/Helpers.cs b/nexus-certificate-manager-caplugin/models/Helpers.cs index d331189..35ae9b0 100644 --- a/nexus-certificate-manager-caplugin/models/Helpers.cs +++ b/nexus-certificate-manager-caplugin/models/Helpers.cs @@ -1,4 +1,6 @@ -using RestSharp; +using Keyfactor.PKI.Enums.EJBCA; +using Org.BouncyCastle.Tls; +using RestSharp; using System.Text.Json; namespace Keyfactor.Extensions.CAPlugin.NexusCertManager.models @@ -19,6 +21,66 @@ public static string ParseSubject(string subject, string rdn) throw new Exception($"The request is missing a {rdn} value"); } } + + public static int GetStatusCodeFromNexusCADescription(string status) + { + switch (status) + { + case "issued": + case "approved": + case "expired": + case "active": + return (int)EndEntityStatus.GENERATED; + + case "processing": + case "reissue_pending": + case "pending": // Pending from DigiCert means it will be issued after validation + case "waiting_pickup": + case "needs_approval": + return (int)EndEntityStatus.EXTERNALVALIDATION; + + case "denied": + case "rejected": + case "canceled": + return (int)EndEntityStatus.FAILED; + + case "revoked": + return (int)EndEntityStatus.REVOKED; + default: + return (int)EndEntityStatus.NEW; // set the status to "NEW" for any unknown description; to be evaluated as neededs + } + } + + public static int? GetRevocationReasonCodeFromNexusCADescription(string reason) + { + //from the NexusCA API docs: + //* 0: Unspecified + //* 1: Key Compromise + //* 3: Affiliation Changed + //* 4: Superseded + //* 5: Cessation Of Operation + //* 6: Certificate Hold + //* 9: Privilege Withdrawn + + switch (reason.ToLower()) + { + case "key compromise": + return (int)RevocationReason.KeyCompromise; + case "affiliation changed": + return (int)RevocationReason.AffiliationChanged; + case "superseded": + return (int)RevocationReason.Superseded; + case "cessation of operation": + return (int)RevocationReason.CessationOfOperation; + case "certificate hold": + return (int)RevocationReason.CertificateHold; + case "privilege withdrawn": + return (int)RevocationReason.PrivilegeWithdrawn; + default: + return (int)RevocationReason.Unspecified; + + } + } } /// @@ -84,7 +146,7 @@ public static T HandleResponse(RestResponse response) where T : ApiRespons /// Handles binary certificate responses (PKCS#7, DER, PEM) /// Use for POST /certificates/pkcs10 and similar endpoints /// - public static IssueCertificateBinaryResponse HandleCertificateBinaryResponse(RestResponse response) + public static CertificateBinaryResponse HandleCertificateBinaryResponse(RestResponse response) { // Check if response is JSON (error response) var contentType = response.ContentType; @@ -125,7 +187,7 @@ public static IssueCertificateBinaryResponse HandleCertificateBinaryResponse(Res h.Name.Equals("certId", StringComparison.OrdinalIgnoreCase)); string certId = certIdHeader?.Value?.ToString(); - return new IssueCertificateBinaryResponse + return new CertificateBinaryResponse { CertificateData = binaryData, CertId = certId, @@ -169,4 +231,6 @@ public string GetErrorDescription() }; } } + + } From 3f4fe3cf954e2d6dd9ed011308e2ac81a5ac1210 Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele Date: Thu, 13 Nov 2025 13:53:20 -0500 Subject: [PATCH 03/35] added additional logging, implemented revoke on client --- .../NexusCertManagerCAPlugin.cs | 27 +++++++-- .../NexusCertManagerClient.cs | 57 +++++++++++++++---- 2 files changed, 68 insertions(+), 16 deletions(-) diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs index 4949a55..1bef646 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs @@ -30,11 +30,22 @@ public NexusCertManagerCAPlugin(ILogger logger) public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDataReader certificateDataReader) { - _certificateDataReader = certificateDataReader; + LogPluginVersion(); + string rawConfig = JsonConvert.SerializeObject(configProvider.CAConnectionData); + _logger.LogTrace($"serialized configuration values: \n{rawConfig}\n"); _config = JsonConvert.DeserializeObject(rawConfig); - _client = new NexusCertManagerClient(_config.Host, _config.AuthCertPath, _config.AuthCertPassword); // need to set the values + _certificateDataReader = certificateDataReader; + } + + private void LogPluginVersion() + { + var targetAssembly = typeof(NexusCertManagerCAPlugin).Assembly; + var assemblyName = targetAssembly?.GetName(); + var version = assemblyName?.Version; + _logger.LogTrace("Keyfactor CA Gateway Plugin for Nexus Certificate Manager"); + _logger.LogTrace($"{assemblyName?.Name ?? "unknown"} v{version}"); } /// @@ -100,22 +111,26 @@ public async Task GetSingleRecord(string caRequestID) _logger.MethodEntry(); try { + _logger.LogTrace($"getting certificate details for certId: {caRequestID}"); var certDetails = await _client.GetCertificateDetails(caRequestID); + + _logger.LogTrace($"download certificate with ID: {caRequestID}"); var certContent = await _client.DownloadCertificate(caRequestID); var cert = new AnyCAPluginCertificate() { CARequestID = caRequestID, Certificate = certContent.Base64EncodedCertificateData, - ProductID = certDetails.Certificate.CertId, - Status = Helpers.GetStatusCodeFromNexusCADescription(certDetails.Certificate.Status), + ProductID = certDetails.Certificate.CertId, + Status = Helpers.GetStatusCodeFromNexusCADescription(certDetails.Certificate.Status), }; - if (cert.Status == (int)EndEntityStatus.REVOKED) { + if (cert.Status == (int)EndEntityStatus.REVOKED) + { cert.RevocationDate = certDetails.Certificate.RevocationTime; cert.RevocationReason = Helpers.GetRevocationReasonCodeFromNexusCADescription(certDetails.Certificate.Reason); } return cert; - + } catch (Exception ex) { diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs b/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs index 69f62d7..1ea5746 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs @@ -52,7 +52,7 @@ public NexusCertManagerClient(string hostAndPort, string authCertPath, string au _restClient = new RestClient(options); } - public async Task Enroll(string csr) + public async Task Enroll(string csr, CancellationToken ct = new CancellationToken()) { _logger.MethodEntry(); var req = new RestRequest(ApiEndpoints.ENROLL, Method.Post); @@ -63,7 +63,7 @@ public async Task Enroll(string csr) try { _logger.LogTrace($"submitting request to the endpoint '{_restClient.BuildUri(req)}'"); - var response = await _restClient.ExecuteAsync(req); + var response = await _restClient.ExecuteAsync(req, ct); _logger.LogTrace($"recieved a response, parsing the result"); var result = RestSharpResponseHandler.HandleCertificateBinaryResponse(response); return result; @@ -76,7 +76,14 @@ public async Task Enroll(string csr) finally { _logger.MethodExit(); } } - public async Task GetCertificateDetails(string certId) + /// + /// Returns detailed information about a certificate + /// + /// The certificate ID on the Nexus Certificate Manager + /// + /// + /// + public async Task GetCertificateDetails(string certId, CancellationToken ct = new CancellationToken()) { _logger.MethodEntry(); try @@ -84,7 +91,7 @@ public async Task GetCertificateDetails(string certI var endpoint = ApiEndpoints.CERTDETAILS(certId); _logger.LogTrace($"performing the GET request for endpoint {endpoint}"); - var res = await _restClient.GetAsync(endpoint); + var res = await _restClient.GetAsync(endpoint, ct); _logger.LogTrace($"received a response; message: {res.Message}, error: {res.Error}"); if (res.IsError) throw new CmApiException(res.Error, res.Message); return res; @@ -97,7 +104,20 @@ public async Task GetCertificateDetails(string certI finally { _logger.MethodExit(); } } - public async Task DownloadCertificate(string certId, string format = "application/pkcs7-mime") + /// + /// Downloads the contents of a certificate + /// + /// The certificate ID on the Nexus Certificate Manager + /// if provided, should be one of: + /// "application/zip" + /// "application/pkix-cert" + /// "application/pkcs7-mime" (default) + /// "application/pem-certificate-chain" + /// "application/pem-certificate-chain;depth=" + /// + /// + /// + public async Task DownloadCertificate(string certId, string format = "application/pkcs7-mime", CancellationToken ct = new CancellationToken()) { _logger.MethodEntry(); try @@ -107,7 +127,7 @@ public async Task DownloadCertificate(string certId, req.AddHeader("Accept", format); _logger.LogTrace($"performing the GET request for endpoint {endpoint}"); - var res = await _restClient.GetAsync(req); + var res = await _restClient.GetAsync(req, ct); _logger.LogTrace($"recieved a response. status code: {res.StatusCode}"); var response = RestSharpResponseHandler.HandleCertificateBinaryResponse(res); return response; @@ -121,14 +141,26 @@ public async Task DownloadCertificate(string certId, finally { _logger.MethodExit(); } } - public Task RevokeCertificate(string certId) + /// + /// Sends a request to revoke a certificate + /// + /// The certificate ID on the Nexus Certificate Manager + /// The reason code + /// Cancellation Token + public async Task RevokeCertificate(string certId, int reason, CancellationToken ct = new CancellationToken()) { _logger.MethodEntry(); try { var endpoint = ApiEndpoints.REVOKE; - var req = new RevokeCertificateRequest(); - throw new NotImplementedException(); /// + + var body = new RevokeCertificateRequest() { CertId = new List { certId }, Reason = reason }; + var req = new RestRequest(endpoint, Method.Post); + req.AddJsonBody(body); + + _logger.LogTrace($"sending a request to {endpoint} to revoke certificate with ID {certId} and reason code {reason}"); + var res = await _restClient.PostAsync(req, ct); + _logger.LogTrace($"response: {res.Message}"); } catch (Exception ex) { @@ -137,7 +169,12 @@ public Task RevokeCertificate(string certId) } finally { _logger.MethodExit(); } } - + /// + /// Returns a list of certificates + /// + /// + /// + /// public async Task GetCertificateList(ListCertificatesRequest req, CancellationToken ct) { _logger.MethodEntry(); From d3edb3859769b2c356e6a2cae3a9a520bab71b83 Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele Date: Mon, 17 Nov 2025 16:18:25 -0500 Subject: [PATCH 04/35] implemented sync and revoke; completed initial functionality --- .../Constants.cs | 1 + .../NexusCertManagerCAPlugin.cs | 257 +++++++++++++++++- .../NexusCertManagerClient.cs | 4 +- 3 files changed, 249 insertions(+), 13 deletions(-) diff --git a/nexus-certificate-manager-caplugin/Constants.cs b/nexus-certificate-manager-caplugin/Constants.cs index c44cf72..7770d96 100644 --- a/nexus-certificate-manager-caplugin/Constants.cs +++ b/nexus-certificate-manager-caplugin/Constants.cs @@ -12,6 +12,7 @@ public static class Constants //values public const string APIPATH = "pgwy/api"; public const string PRODUCTID = "NexusCM"; + public const string PKCS7MIMETYPE = "application/pkcs7-mime"; } public static class ApiEndpoints diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs index 1bef646..294fa5a 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs @@ -13,6 +13,7 @@ using Keyfactor.PKI.Enums.EJBCA; using Newtonsoft.Json; using System.Collections.Concurrent; +using System.Security.Cryptography.X509Certificates; namespace Keyfactor.Extensions.CAPlugin.NexusCertManager { @@ -90,11 +91,48 @@ public async Task Enroll(string csr, string subject, Dictionar } } + /// + /// Get the annotations for the CA Connector-level configuration fields + /// + /// A dictionary of the details for each property public Dictionary GetCAConnectorAnnotations() { - throw new NotImplementedException(); + _logger.MethodEntry(); + + return new Dictionary + { + [Constants.HOST] = new PropertyConfigInfo + { + Comments = "The path to the Nexus CM server, including port", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + [Constants.AUTHCERTPATH] = new PropertyConfigInfo + { + Comments = "The path to the PFX certificate for authenticating into Nexus CM", + Hidden = false, + DefaultValue = "", + Type = "String" + }, + [Constants.AUTHCERTPASSWORD] = new PropertyConfigInfo + { + Comments = "The password for the authentication certificate", + Hidden = true, + DefaultValue = "", + Type = "String" + }, + [Constants.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" + } + }; } + /// /// this CA is does not split it's certificates into discernable "product types" /// consequently, we are using a single product type for all certificates. @@ -140,19 +178,57 @@ public async Task GetSingleRecord(string caRequestID) finally { _logger.MethodExit(); } } + public Dictionary GetTemplateParameterAnnotations() { - throw new NotImplementedException(); + return new Dictionary(); // there are no template specific parameters for this CA Plugin } - public Task Ping() + /// + /// Sends an arbitrary request to the Nexus CA server to ensure that it is reachable + /// + /// + public async Task Ping() { - throw new NotImplementedException(); + _logger.MethodEntry(); + + try + { + _logger.LogTrace($"attempting to ping the Nexus CM server at {_config.Host}"); + var res = await _client.PingServer(); + } + catch (Exception ex) + { + _logger.LogError($"the attempt to ping the server failed: {ex.Message}"); + throw; + } + finally { _logger.MethodExit(); } } - public Task Revoke(string caRequestID, string hexSerialNumber, uint revocationReason) + /// + /// Revokes a certificate + /// + /// + /// + /// + /// An integer representing the updated certificate status + public async Task Revoke(string caRequestID, string hexSerialNumber, uint revocationReason) { - throw new NotImplementedException(); + _logger.MethodEntry(); + try + { + _logger.LogTrace($"attempting to revoke certificate with id {caRequestID} and serial number {hexSerialNumber} with reason code {revocationReason}"); + await _client.RevokeCertificate(caRequestID, (int)revocationReason); + _logger.LogTrace("successfully revoked certificate"); + return (int)revocationReason; + } + catch (Exception ex) + { + _logger.LogError($"an error occurred when attempting to revoke certificate with id {caRequestID}"); + _logger.LogError(LogHandler.FlattenException(ex)); + throw; + } + finally { _logger.MethodExit(); } } /// @@ -163,33 +239,192 @@ public Task Revoke(string caRequestID, string hexSerialNumber, uint revocat /// whether or not to perform a full sync /// the cancel token /// - public Task Synchronize(BlockingCollection blockingBuffer, DateTime? lastSync, bool fullSync, CancellationToken cancelToken) + public async Task Synchronize(BlockingCollection blockingBuffer, DateTime? lastSync, bool fullSync, CancellationToken cancelToken) { _logger.MethodEntry(); + var updatedCerts = new List(); try { - throw new NotImplementedException(); + // retreive the list of certs from Nexus CM + _logger.LogTrace("attempting to retrieve the list of cert names from Nexus CM"); + var certList = await _client.GetCertificateList(null, cancelToken); + _logger.LogTrace($"successfully returned {certList.SearchHits} results."); + + certList.Certificates.ForEach(cert => + { + var updatedCert = new AnyCAPluginCertificate + { + CARequestID = cert.CertId, + ProductID = Constants.PRODUCTID, + Status = Helpers.GetStatusCodeFromNexusCADescription(cert.Status), + RevocationDate = cert.RevocationTime, + RevocationReason = Helpers.GetRevocationReasonCodeFromNexusCADescription(cert.Reason) + }; + updatedCerts.Add(updatedCert); + }); + + // now get the cert content for each.. + _logger.LogTrace($"getting certificate content for each.."); + + foreach (var cert in updatedCerts) + { + if (cancelToken.IsCancellationRequested) + { + _logger.LogInformation("Nexus CA sync cancelled."); + cancelToken.ThrowIfCancellationRequested(); + } + var certContent = await _client.DownloadCertificate(cert.CARequestID, Constants.PKCS7MIMETYPE, cancelToken); + cert.Certificate = certContent.Base64EncodedCertificateData; + } + + _logger.LogTrace($"got the content for {updatedCerts.Count} certs"); + _logger.LogTrace($"updating the database.."); + + foreach (var cert in updatedCerts) + { + blockingBuffer.Add(cert, cancelToken); + } + + _logger.LogTrace($"successfully synced {updatedCerts.Count}"); } catch (Exception ex) { _logger.LogError($"an error occurred during the sync: {ex.Message}"); + _logger.LogError($"{LogHandler.FlattenException(ex)}"); throw; } finally { + _logger.LogTrace("successfully completed CA sync for Nexus CM"); _logger.MethodExit(); } } - public Task ValidateCAConnectionInfo(Dictionary connectionInfo) + public async Task ValidateCAConnectionInfo(Dictionary connectionInfo) { - throw new NotImplementedException(); + _logger.MethodEntry(); + var errors = new List(); + + if (!(bool)connectionInfo[Constants.ENABLED]) + { + _logger.LogWarning($"The CA is currently in the Disabled state. It must be Enabled to perform operations. Skipping validation..."); + return; + } + + // cert path + _logger.LogTrace("validating auth cert path.."); + if (string.IsNullOrEmpty((string)connectionInfo[Constants.AUTHCERTPATH])) + { + errors.Add($"{Constants.AUTHCERTPATH} is missing or empty"); + } + else + { + try + { + // see if we can open the file for reading.. + using (var fs = new FileStream((string)connectionInfo[Constants.AUTHCERTPATH], FileMode.Open, FileAccess.Read)) + { + // if we get this far, we can + _logger.LogTrace($"successfully read the file at path {connectionInfo[Constants.AUTHCERTPATH]}"); + } + } + catch (FileNotFoundException) + { + errors.Add($"unable to find the certificate for authenticating at the path {connectionInfo[Constants.AUTHCERTPATH]}"); + } + catch (UnauthorizedAccessException) + { + errors.Add($"the file exists at {connectionInfo[Constants.AUTHCERTPATH]}, but it is inaccessible due to insufficient permissions."); + } + } + + // cert password + + // validate that it exists + if (string.IsNullOrEmpty((string)connectionInfo[Constants.AUTHCERTPASSWORD])) + { + errors.Add("no password was provided for the authentication certificate"); + } + else + { + try + { + var certPath = (string)connectionInfo[Constants.AUTHCERTPATH]; + var certPassword = (string)connectionInfo[Constants.AUTHCERTPASSWORD]; + + // validate that it works + var clientCertificate = new X509Certificate2(certPath, certPassword); + + var pub = clientCertificate.GetPublicKey(); + var pubString = Convert.ToBase64String(pub); + _logger.LogTrace($"was able to successfully read the cert with the provided password. public key: {pubString}"); + } + catch (Exception ex) + { + errors.Add("unable to open the certificate with the provided password"); + } + } + + // host + + // validate that there is a value + + if (string.IsNullOrEmpty((string)connectionInfo[Constants.HOST])) + { + errors.Add("the host url for the instance of the Nexus Certificate Manager is required."); + } + else + { + // validate that it is a valid url + var valid = Uri.TryCreate((string)connectionInfo[Constants.HOST], UriKind.Absolute, out var newUri); + if (!valid) + { + errors.Add($"the host URL {connectionInfo[Constants.HOST]} could not be parsed as a valid URL"); + } + } + + // perform the final validation of calling an authenticated endpoint, if the values are present and seem valid + if (!errors.Any()) + { + + var host = (string)connectionInfo[Constants.HOST]; + var certPath = (string)connectionInfo[Constants.AUTHCERTPATH]; + var certPassword = (string)connectionInfo[Constants.AUTHCERTPASSWORD]; + + try + { + _client = new NexusCertManagerClient(host, certPath, certPassword); + + await _client.PingServer(); + } + catch (Exception ex) + { + errors.Add($"unable to make an authenticated request with the provided information: {LogHandler.FlattenException(ex)}"); + } + } + + if (errors.Any()) + { + var validationMsg = $"Validation errors:\n{string.Join("\n", errors)}"; + throw new AnyCAValidationException(validationMsg); + } + else + { + _logger.LogTrace("CA Connector configuration passed all validation checks"); + } } + /// + /// Since we are using a single productId, there is nothing to validate + /// + /// + /// + /// Task.CompletedTask public Task ValidateProductInfo(EnrollmentProductInfo productInfo, Dictionary connectionInfo) { - throw new NotImplementedException(); + // we are using a single prodi + return Task.CompletedTask; } } } diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs b/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs index 1ea5746..eb96f08 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs @@ -56,7 +56,7 @@ public NexusCertManagerClient(string hostAndPort, string authCertPath, string au { _logger.MethodEntry(); var req = new RestRequest(ApiEndpoints.ENROLL, Method.Post); - req.AddHeader("Accept", "application/pkcs7-mime"); + req.AddHeader("Accept", Constants.PKCS7MIMETYPE); req.AddParameter("pkcs10", csr); _logger.LogTrace($"preparing the request for enrollment."); @@ -175,7 +175,7 @@ public NexusCertManagerClient(string hostAndPort, string authCertPath, string au /// /// /// - public async Task GetCertificateList(ListCertificatesRequest req, CancellationToken ct) + public async Task GetCertificateList(ListCertificatesRequest req = null, CancellationToken ct = new CancellationToken()) { _logger.MethodEntry(); try From 8513944a5d7e9010708497c6ec2123c8641cd0d3 Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele Date: Tue, 18 Nov 2025 09:42:09 -0500 Subject: [PATCH 05/35] added changelog and license headers --- CHANGELOG.md | 2 ++ NexusCertManagerCAPlugin.sln | 1 + nexus-certificate-manager-caplugin/Constants.cs | 10 +++++++++- .../NexusCertManagerCAPlugin.cs | 3 +-- .../NexusCertManagerClient.cs | 3 ++- nexus-certificate-manager-caplugin/models/Helpers.cs | 10 +++++++++- 6 files changed, 24 insertions(+), 5 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..698de99 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,2 @@ +### 1.0.0 +* initial release \ No newline at end of file diff --git a/NexusCertManagerCAPlugin.sln b/NexusCertManagerCAPlugin.sln index 27fd10e..9c8934e 100644 --- a/NexusCertManagerCAPlugin.sln +++ b/NexusCertManagerCAPlugin.sln @@ -9,6 +9,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docsource", "docsource", "{ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{4FA0BDF6-B41E-4E00-805F-AE79B894784A}" ProjectSection(SolutionItems) = preProject + CHANGELOG.md = CHANGELOG.md manifest.json = manifest.json EndProjectSection EndProject diff --git a/nexus-certificate-manager-caplugin/Constants.cs b/nexus-certificate-manager-caplugin/Constants.cs index 7770d96..6038af4 100644 --- a/nexus-certificate-manager-caplugin/Constants.cs +++ b/nexus-certificate-manager-caplugin/Constants.cs @@ -1,4 +1,12 @@ -namespace Keyfactor.Extensions.CAPlugin.NexusCertManager + +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +namespace Keyfactor.Extensions.CAPlugin.NexusCertManager { public static class Constants { diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs index 294fa5a..6a69cc1 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs @@ -422,8 +422,7 @@ public async Task ValidateCAConnectionInfo(Dictionary connection /// /// Task.CompletedTask public Task ValidateProductInfo(EnrollmentProductInfo productInfo, Dictionary connectionInfo) - { - // we are using a single prodi + { return Task.CompletedTask; } } diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs b/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs index eb96f08..e307729 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs @@ -1,4 +1,5 @@ -// Copyright 2025 Keyfactor + +// Copyright 2025 Keyfactor // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. // You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, diff --git a/nexus-certificate-manager-caplugin/models/Helpers.cs b/nexus-certificate-manager-caplugin/models/Helpers.cs index 35ae9b0..852cdcf 100644 --- a/nexus-certificate-manager-caplugin/models/Helpers.cs +++ b/nexus-certificate-manager-caplugin/models/Helpers.cs @@ -1,4 +1,12 @@ -using Keyfactor.PKI.Enums.EJBCA; + +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using Keyfactor.PKI.Enums.EJBCA; using Org.BouncyCastle.Tls; using RestSharp; using System.Text.Json; From 379b5003287fb137f6f346898d28ad3c9397b94d Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele Date: Thu, 20 Nov 2025 16:14:22 -0500 Subject: [PATCH 06/35] added manifest, disabled auth cert domain check for nexus auth cert --- .../NexusCertManagerCAPlugin.cs | 3 ++- .../NexusCertManagerCAPlugin.csproj | 6 ++++++ .../NexusCertManagerCAPluginConfig.cs | 2 +- .../NexusCertManagerClient.cs | 8 +++++--- nexus-certificate-manager-caplugin/manifest.json | 6 +++--- 5 files changed, 17 insertions(+), 8 deletions(-) diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs index 6a69cc1..25f4df0 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs @@ -36,7 +36,8 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa string rawConfig = JsonConvert.SerializeObject(configProvider.CAConnectionData); _logger.LogTrace($"serialized configuration values: \n{rawConfig}\n"); _config = JsonConvert.DeserializeObject(rawConfig); - _client = new NexusCertManagerClient(_config.Host, _config.AuthCertPath, _config.AuthCertPassword); // need to set the values + _logger.LogTrace($"deserialized the configuration:\nAuthCertPath: {_config.AuthCertificatePath}\nHost: {_config.Host}\nAuthCertPassword: {_config.AuthCertPassword}"); + _client = new NexusCertManagerClient(_config.Host, _config.AuthCertificatePath, _config.AuthCertPassword); // need to set the values _certificateDataReader = certificateDataReader; } diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.csproj b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.csproj index eb22c8b..d8a1cc0 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.csproj +++ b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.csproj @@ -17,4 +17,10 @@ + + + Always + + + diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerCAPluginConfig.cs b/nexus-certificate-manager-caplugin/NexusCertManagerCAPluginConfig.cs index dc96593..bee1d48 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerCAPluginConfig.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerCAPluginConfig.cs @@ -16,7 +16,7 @@ public class NexusCertManagerCAPluginConfig public string Host { get; set; } [JsonPropertyName(Constants.AUTHCERTPATH)] - public string AuthCertPath { get; set; } + public string AuthCertificatePath { get; set; } [JsonPropertyName(Constants.AUTHCERTPASSWORD)] public string AuthCertPassword { get; set; } diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs b/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs index e307729..7270187 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs @@ -27,7 +27,9 @@ public NexusCertManagerClient(string hostAndPort, string authCertPath, string au { _logger = LogHandler.GetClassLogger(typeof(NexusCertManagerClient)); _host = hostAndPort; - _authCertPath = authCertPath; + _logger.LogTrace($"about to clean up the file path: {authCertPath}"); + _authCertPath = authCertPath.Replace(@"\\", @"\"); // remove the double encoded slashes + _logger.LogTrace($"clean cert path: {_authCertPath}"); var url = _host.EndsWith(Constants.APIPATH) ? _host : _host.TrimEnd('/') + "/" + Constants.APIPATH; _logger.LogTrace($"full api path: {url}"); @@ -49,7 +51,7 @@ public NexusCertManagerClient(string hostAndPort, string authCertPath, string au var clientCerts = new X509CertificateCollection(); clientCerts.Add(clientCertificate); - var options = new RestClientOptions(url) { ClientCertificates = clientCerts }; + var options = new RestClientOptions(url) { ClientCertificates = clientCerts, RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => true }; _restClient = new RestClient(options); } @@ -211,7 +213,7 @@ public async Task PingServer() { if (res.IsSuccessStatusCode) return true; } catch (Exception ex) { - _logger.LogError($"the attempt to ping the server failed: {ex.Message}"); + _logger.LogError($"the attempt to ping the server failed: {LogHandler.FlattenException(ex)}"); } finally { _logger.MethodExit(); } return false; diff --git a/nexus-certificate-manager-caplugin/manifest.json b/nexus-certificate-manager-caplugin/manifest.json index ce43650..1867fec 100644 --- a/nexus-certificate-manager-caplugin/manifest.json +++ b/nexus-certificate-manager-caplugin/manifest.json @@ -1,9 +1,9 @@ { "extensions": { "Keyfactor.AnyGateway.Extensions.IAnyCAPlugin": { - "DigicertCAPlugin": { - "assemblypath": "DigicertCAPlugin.dll", - "TypeFullName": "Keyfactor.Extensions.CAPlugin.DigiCert.CertCentralCAPlugin" + "NexusCAPlugin": { + "assemblypath": "../NexusCertManagerCAPlugin.dll", + "TypeFullName": "Keyfactor.Extensions.CAPlugin.NexusCertManager.NexusCertManagerCAPlugin" } } } From a6a885980c534a03a7be3126fa31916ed4ebbaf7 Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele Date: Tue, 2 Dec 2025 15:16:12 -0500 Subject: [PATCH 07/35] Updated enrollment to include first available procname for enrollment (error when there is no default set). --- .../Constants.cs | 7 ++ .../NexusCertManagerCAPlugin.cs | 22 ++++- .../NexusCertManagerClient.cs | 78 ++++++++++++++--- .../models/ApiModels.cs | 23 ++++- .../models/Helpers.cs | 86 +++++++++++++++++-- 5 files changed, 190 insertions(+), 26 deletions(-) diff --git a/nexus-certificate-manager-caplugin/Constants.cs b/nexus-certificate-manager-caplugin/Constants.cs index 6038af4..79da296 100644 --- a/nexus-certificate-manager-caplugin/Constants.cs +++ b/nexus-certificate-manager-caplugin/Constants.cs @@ -21,6 +21,13 @@ public static class Constants public const string APIPATH = "pgwy/api"; public const string PRODUCTID = "NexusCM"; public const string PKCS7MIMETYPE = "application/pkcs7-mime"; + public const string PEMCHAIN = "application/pem-certificate-chain"; + + public const string MEDIATYPE_PKCS10 = "pkcs10"; + public const string MEDIATYPE_PKCS12 = "pkcs12"; + public const string MEDIATYPE_SMARTCARD = "smartcard"; + public const string MEDIATYPE_ATTRIBUTECERT = "attributecertificate"; + public const string MEDIATYPE_DATA = "data"; } public static class ApiEndpoints diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs index 25f4df0..662ae4b 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs @@ -254,14 +254,25 @@ public async Task Synchronize(BlockingCollection blockin certList.Certificates.ForEach(cert => { + _logger.LogTrace("- cert details - "); + _logger.LogTrace($"certId: {cert.CertId}"); + _logger.LogTrace($"status: {cert.Status}"); + _logger.LogTrace($"revocation time: {cert.RevocationTime}"); + _logger.LogTrace($"serial number: {cert.CertificateSerialNumber}"); + _logger.LogTrace($"reason: {cert.Reason}"); + var updatedCert = new AnyCAPluginCertificate { CARequestID = cert.CertId, ProductID = Constants.PRODUCTID, Status = Helpers.GetStatusCodeFromNexusCADescription(cert.Status), RevocationDate = cert.RevocationTime, - RevocationReason = Helpers.GetRevocationReasonCodeFromNexusCADescription(cert.Reason) + }; + if (!string.IsNullOrEmpty(cert.Reason)) { + updatedCert.RevocationReason = Helpers.GetRevocationReasonCodeFromNexusCADescription(cert.Reason); + } + updatedCerts.Add(updatedCert); }); @@ -275,15 +286,18 @@ public async Task Synchronize(BlockingCollection blockin _logger.LogInformation("Nexus CA sync cancelled."); cancelToken.ThrowIfCancellationRequested(); } - var certContent = await _client.DownloadCertificate(cert.CARequestID, Constants.PKCS7MIMETYPE, cancelToken); - cert.Certificate = certContent.Base64EncodedCertificateData; + var certContent = await _client.DownloadCertificate(cert.CARequestID, Constants.PEMCHAIN, cancelToken); + _logger.LogTrace("getting the leaf certificate"); + cert.Certificate = Helpers.GetEndEntityCertificate(certContent.Base64EncodedCertificateData, _logger); + _logger.LogTrace($"leaf cert: {cert.Certificate}"); } _logger.LogTrace($"got the content for {updatedCerts.Count} certs"); _logger.LogTrace($"updating the database.."); - + foreach (var cert in updatedCerts) { + _logger.LogTrace($"adding cert with id: {cert.CARequestID} and productID {cert.ProductID}"); blockingBuffer.Add(cert, cancelToken); } diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs b/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs index 7270187..f646d20 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs @@ -58,15 +58,34 @@ public NexusCertManagerClient(string hostAndPort, string authCertPath, string au public async Task Enroll(string csr, CancellationToken ct = new CancellationToken()) { _logger.MethodEntry(); + _logger.LogTrace($"preparing the request for enrollment."); + var req = new RestRequest(ApiEndpoints.ENROLL, Method.Post); - req.AddHeader("Accept", Constants.PKCS7MIMETYPE); + req.AddHeader("Accept", Constants.PEMCHAIN); req.AddParameter("pkcs10", csr); - _logger.LogTrace($"preparing the request for enrollment."); + var procname = string.Empty; + try + { + _logger.LogTrace("getting first available proc name for pkcs10 to submit with request.."); + var procs = await GetProceduresByMediaType(Constants.MEDIATYPE_PKCS10); + procname = procs?.First(); + if (!string.IsNullOrEmpty(procname)) + { + req.AddParameter("procname", procname); + } + } + catch (Exception ex) + { + _logger.LogError($"unable to find a procedure for the media type {Constants.MEDIATYPE_PKCS10}: {LogHandler.FlattenException(ex)}"); + _logger.LogTrace("we will attempt to perform enrollment without specifying procedure ID; relying on the default procedure to be configured"); + } try { _logger.LogTrace($"submitting request to the endpoint '{_restClient.BuildUri(req)}'"); var response = await _restClient.ExecuteAsync(req, ct); + _logger.LogTrace($"response status code: {response.StatusCode}"); + _logger.LogTrace($"response content: {response.Content}"); _logger.LogTrace($"recieved a response, parsing the result"); var result = RestSharpResponseHandler.HandleCertificateBinaryResponse(response); return result; @@ -120,23 +139,26 @@ public NexusCertManagerClient(string hostAndPort, string authCertPath, string au /// /// /// - public async Task DownloadCertificate(string certId, string format = "application/pkcs7-mime", CancellationToken ct = new CancellationToken()) + public async Task DownloadCertificate(string certId, string format = Constants.PEMCHAIN, CancellationToken ct = new CancellationToken()) { _logger.MethodEntry(); - try - { + try + { var endpoint = ApiEndpoints.DOWNLOADCERT(certId); var req = new RestRequest(endpoint, Method.Get); req.AddHeader("Accept", format); - + _logger.LogTrace($"accept header: {format}"); _logger.LogTrace($"performing the GET request for endpoint {endpoint}"); var res = await _restClient.GetAsync(req, ct); _logger.LogTrace($"recieved a response. status code: {res.StatusCode}"); + _logger.LogTrace($"content: {res.Content}"); + var response = RestSharpResponseHandler.HandleCertificateBinaryResponse(res); + _logger.LogTrace($"parsed content: {response.Base64EncodedCertificateData}"); return response; } - catch (Exception ex) + catch (Exception ex) { _logger.LogError($"an error occurred when attempting to download the certificate: {ex.Message}"); throw; @@ -160,7 +182,7 @@ public NexusCertManagerClient(string hostAndPort, string authCertPath, string au var body = new RevokeCertificateRequest() { CertId = new List { certId }, Reason = reason }; var req = new RestRequest(endpoint, Method.Post); req.AddJsonBody(body); - + _logger.LogTrace($"sending a request to {endpoint} to revoke certificate with ID {certId} and reason code {reason}"); var res = await _restClient.PostAsync(req, ct); _logger.LogTrace($"response: {res.Message}"); @@ -197,23 +219,55 @@ public NexusCertManagerClient(string hostAndPort, string authCertPath, string au finally { _logger.MethodExit(); } } + /// + /// retreives the procedures associated with the provided media type value + /// + /// + /// a list of the procedure IDs + public async Task> GetProceduresByMediaType(string mediaType = Constants.MEDIATYPE_PKCS10) + { + _logger.MethodEntry(); + _logger.LogTrace($"getting available procedures for media type: {mediaType}"); + + try + { + var endpoint = ApiEndpoints.LISTPROCEDURES; + var req = new RestRequest(endpoint); + req.AddQueryParameter("mediaType", Constants.MEDIATYPE_PKCS10); + var res = await _restClient.GetAsync(req); + + var procedures = res.Procedures.Select(p => p.ProcId).ToList(); + _logger.LogTrace($"successfully retrieved a list of {procedures.Count} procedures for mediaType {mediaType}"); + return procedures; + } + catch (Exception ex) + { + _logger.LogError($"there was an error retrieving the procedures for mediaType {mediaType}: {LogHandler.FlattenException(ex)}"); + throw; + } + } + + /// /// This method calls the "/procedures" endpoint of the API /// The ping is successful if the server returns "200". /// The content of the response is ignored /// /// A boolean indicating whether the server is reachable and responding. - public async Task PingServer() { + public async Task PingServer() + { _logger.MethodEntry(); - try { + try + { var endpoint = ApiEndpoints.LISTPROCEDURES; _logger.LogTrace($"pinging the endpoint {endpoint} to verify server is accessible"); var req = new RestRequest(endpoint); var res = await _restClient.GetAsync(req); if (res.IsSuccessStatusCode) return true; } - catch (Exception ex) { - _logger.LogError($"the attempt to ping the server failed: {LogHandler.FlattenException(ex)}"); + catch (Exception ex) + { + _logger.LogError($"the attempt to ping the server failed: {LogHandler.FlattenException(ex)}"); } finally { _logger.MethodExit(); } return false; diff --git a/nexus-certificate-manager-caplugin/models/ApiModels.cs b/nexus-certificate-manager-caplugin/models/ApiModels.cs index 77398b2..56d1388 100644 --- a/nexus-certificate-manager-caplugin/models/ApiModels.cs +++ b/nexus-certificate-manager-caplugin/models/ApiModels.cs @@ -276,11 +276,17 @@ public class JsonCertificate public class CertificateBinaryResponse { + /// /// The binary certificate data (DER, PEM, or PKCS#7 depending on Accept header) /// public byte[] CertificateData { get; set; } + /// + /// The PEM cert content, if retrieved in PEM format + /// + public string PEMString { get; set; } + /// /// The CertId of the issued certificate from the response header /// @@ -306,7 +312,7 @@ public class CertificateBinaryResponse /// public bool IsDer => ContentType?.Contains("pkix-cert") == true; - public string Base64EncodedCertificateData => Convert.ToBase64String(CertificateData); + public string Base64EncodedCertificateData => IsPem ? PEMString : Convert.ToBase64String(CertificateData); } @@ -453,6 +459,21 @@ public class SignatureResponse public string DataToSign { get; set; } } + public class ListProceduresResponse : ApiResponse + { + [JsonPropertyName("procedures")] + public List Procedures { get; set; } + } + + public class ProceduresResponse + { + [JsonPropertyName("procid")] + public string ProcId { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + } + #endregion #region Enums diff --git a/nexus-certificate-manager-caplugin/models/Helpers.cs b/nexus-certificate-manager-caplugin/models/Helpers.cs index 852cdcf..dd86cc4 100644 --- a/nexus-certificate-manager-caplugin/models/Helpers.cs +++ b/nexus-certificate-manager-caplugin/models/Helpers.cs @@ -7,8 +7,12 @@ // and limitations under the License. using Keyfactor.PKI.Enums.EJBCA; +using Keyfactor.PKI.X509; +using Microsoft.Extensions.Logging; using Org.BouncyCastle.Tls; using RestSharp; +using System.Security.Cryptography.X509Certificates; +using System.Text; using System.Text.Json; namespace Keyfactor.Extensions.CAPlugin.NexusCertManager.models @@ -70,7 +74,7 @@ public static int GetStatusCodeFromNexusCADescription(string status) //* 6: Certificate Hold //* 9: Privilege Withdrawn - switch (reason.ToLower()) + switch (reason?.ToLower()) { case "key compromise": return (int)RevocationReason.KeyCompromise; @@ -89,8 +93,67 @@ public static int GetStatusCodeFromNexusCADescription(string status) } } + + // Helper method to extract end entity certificate from PEM chain + public static string GetEndEntityCertificate(string certData, ILogger _logger) + { + var splitCerts = certData.Split( + new[] { "-----END CERTIFICATE-----", "-----BEGIN CERTIFICATE-----" }, + StringSplitOptions.RemoveEmptyEntries); + + X509Certificate2Collection col = new X509Certificate2Collection(); + + foreach (var cert in splitCerts) + { + _logger.LogTrace($"Split Cert Value: {cert}"); + try + { + // Clean the cert string and add PEM headers if needed + var cleanCert = cert.Trim(); + if (!cleanCert.StartsWith("-----BEGIN CERTIFICATE-----")) + { + cleanCert = $"-----BEGIN CERTIFICATE-----\n{cleanCert}\n-----END CERTIFICATE-----"; + } + col.Import(Encoding.UTF8.GetBytes(cleanCert)); + } + catch (Exception ex) + { + _logger.LogWarning($"Failed to import certificate segment: {ex.Message}"); + } + } + + _logger.LogTrace("Getting End Entity Certificate"); + var currentCert = X509Utilities.ExtractEndEntityCertificateContents(ExportCollectionToPem(col), ""); + + _logger.LogTrace("Converting to Byte Array"); + var byteArray = currentCert?.Export(X509ContentType.Cert); + + _logger.LogTrace("Initializing empty string"); + var certString = string.Empty; + if (byteArray != null) + { + certString = Convert.ToBase64String(byteArray); + } + + _logger.LogTrace($"Got certificate {certString}"); + return certString; + } + + // Helper method to export X509Certificate2Collection to PEM format + private static string ExportCollectionToPem(X509Certificate2Collection collection) + { + var sb = new StringBuilder(); + foreach (var cert in collection) + { + sb.AppendLine("-----BEGIN CERTIFICATE-----"); + sb.AppendLine(Convert.ToBase64String(cert.Export(X509ContentType.Cert), Base64FormattingOptions.InsertLineBreaks)); + sb.AppendLine("-----END CERTIFICATE-----"); + } + return sb.ToString(); + } } + /// /// Helper methods for handling CM REST API responses with RestSharp /// @@ -157,7 +220,9 @@ public static T HandleResponse(RestResponse response) where T : ApiRespons public static CertificateBinaryResponse HandleCertificateBinaryResponse(RestResponse response) { // Check if response is JSON (error response) - var contentType = response.ContentType; + var contentType = response.ContentType; + var res = new CertificateBinaryResponse(); + if (contentType?.Contains("json") == true) { try @@ -187,20 +252,23 @@ public static CertificateBinaryResponse HandleCertificateBinaryResponse(RestResp ); } + if (contentType?.Contains(Constants.PEMCHAIN) == true) { + //the response content is the PEM cert chain, + res.PEMString = response.Content; + } // Get binary data var binaryData = response.RawBytes; - + // Get CertId from response header var certIdHeader = response.Headers?.FirstOrDefault(h => h.Name.Equals("certId", StringComparison.OrdinalIgnoreCase)); string certId = certIdHeader?.Value?.ToString(); - return new CertificateBinaryResponse - { - CertificateData = binaryData, - CertId = certId, - ContentType = contentType - }; + res.CertificateData = binaryData; + res.CertId = certId; + res.ContentType = contentType; + + return res; } } From 976d0f68aefe8b3d1d53b57c0ad0a98aedd053d2 Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele Date: Tue, 2 Dec 2025 15:31:26 -0500 Subject: [PATCH 08/35] updated request format for revocation --- .../NexusCertManagerClient.cs | 8 +++++--- nexus-certificate-manager-caplugin/models/ApiModels.cs | 3 --- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs b/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs index f646d20..bfe0c87 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs @@ -10,6 +10,7 @@ using Keyfactor.Logging; using Microsoft.Extensions.Logging; using RestSharp; +using System.Net.Http.Headers; using System.Security.Cryptography.X509Certificates; namespace Keyfactor.Extensions.CAPlugin.NexusCertManager @@ -178,10 +179,11 @@ public NexusCertManagerClient(string hostAndPort, string authCertPath, string au try { var endpoint = ApiEndpoints.REVOKE; - - var body = new RevokeCertificateRequest() { CertId = new List { certId }, Reason = reason }; + var req = new RestRequest(endpoint, Method.Post); - req.AddJsonBody(body); + req.AddHeader("Content-Type", "application/x-www-form-urlencoded"); + req.AddParameter("certid", certId); + req.AddParameter("reason", reason); _logger.LogTrace($"sending a request to {endpoint} to revoke certificate with ID {certId} and reason code {reason}"); var res = await _restClient.PostAsync(req, ct); diff --git a/nexus-certificate-manager-caplugin/models/ApiModels.cs b/nexus-certificate-manager-caplugin/models/ApiModels.cs index 56d1388..3345741 100644 --- a/nexus-certificate-manager-caplugin/models/ApiModels.cs +++ b/nexus-certificate-manager-caplugin/models/ApiModels.cs @@ -366,9 +366,6 @@ public class RevokeCertificateRequest [JsonPropertyName("reason")] public int Reason { get; set; } - - [JsonPropertyName("signature")] - public string Signature { get; set; } } public class RemoveCertificatesRequest From 4fc46a3784975d7756f917a636340d7bb5caf883 Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele Date: Tue, 2 Dec 2025 15:32:05 -0500 Subject: [PATCH 09/35] cleanup --- nexus-certificate-manager-caplugin/NexusCertManagerClient.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs b/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs index bfe0c87..66dcc68 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs @@ -10,7 +10,6 @@ using Keyfactor.Logging; using Microsoft.Extensions.Logging; using RestSharp; -using System.Net.Http.Headers; using System.Security.Cryptography.X509Certificates; namespace Keyfactor.Extensions.CAPlugin.NexusCertManager From e78dd829cfaa2115474520068b947dc3f49ef7dc Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele Date: Wed, 3 Dec 2025 15:55:09 -0500 Subject: [PATCH 10/35] documentation updates --- NexusCertManagerCAPlugin.sln | 4 ++ configuration.md | 24 ++++++++++++ integration-manifest.json | 37 +++++++++++++++++++ .../NexusCertManagerCAPlugin.csproj | 3 +- 4 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 configuration.md create mode 100644 integration-manifest.json diff --git a/NexusCertManagerCAPlugin.sln b/NexusCertManagerCAPlugin.sln index 9c8934e..343fb5a 100644 --- a/NexusCertManagerCAPlugin.sln +++ b/NexusCertManagerCAPlugin.sln @@ -6,10 +6,14 @@ MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NexusCertManagerCAPlugin", "nexus-certificate-manager-caplugin\NexusCertManagerCAPlugin.csproj", "{5107B3B8-4F3A-4A1B-BE0E-AF6A1A0B2995}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docsource", "docsource", "{40A1F9A6-A56D-4A38-8CAE-2E23676AE243}" + ProjectSection(SolutionItems) = preProject + configuration.md = configuration.md + EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{4FA0BDF6-B41E-4E00-805F-AE79B894784A}" ProjectSection(SolutionItems) = preProject CHANGELOG.md = CHANGELOG.md + integration-manifest.json = integration-manifest.json manifest.json = manifest.json EndProjectSection EndProject diff --git a/configuration.md b/configuration.md new file mode 100644 index 0000000..608a942 --- /dev/null +++ b/configuration.md @@ -0,0 +1,24 @@ +## Overview + +The Nexus Certificate Manager AnyCA REST plugin extends the capabilities of the Nexus Certificate Manager product to Keyfactor Command via the Keyfactor AnyCA Gateway REST. The plugin represents a fully featured AnyCA REST Plugin with the following capabilies: +* Certificate Synchronization +* Certificate Enrollment +* Certificate Revocation + +## Requirements + +- The host URL for the instance of Nexus Certificate Manager +- A certificate in the pfx format to use for authentication into Nexus Certificate Manager, located on the Gateway Host +- The passphrase for the pfx certificate + +## Gateway Registration + +In order to enroll certificates the Keyfactor Command server must trust the CA chain. Once you identify your Root and/or Subordinate CA used by the Nexus Certificate Manager platform, make sure to download and import the certificate chain into the Command Server certificate store + +## CA Connection + +The certificate used by the gateway for authenticating into the Nexus Certificate Manager will need to be copied to a location on the Gateway Host that is accessble by the gateway service. The Certificate Path + +## Certificate Template Creation Step + +For this AnyCA Gateway, there is a single product type named "NexusCM". \ No newline at end of file diff --git a/integration-manifest.json b/integration-manifest.json new file mode 100644 index 0000000..2966022 --- /dev/null +++ b/integration-manifest.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://keyfactor.github.io/v2/integration-manifest-schema.json", + "integration_type": "anyca-plugin", + "name": "Nexus Certificate Maanager AnyCA REST Gateway Plugin", + "status": "prototype", + "support_level": "kf-community", + "link_github": false, + "update_catalog": false, + "description": "Nexus Certificate Manager plugin for the AnyCA REST Gateway framework", + "gateway_framework": "25.2.0", + "release_dir": "nexus-certificate-manager-caplugin/bin/Release", + "release_project": "nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.csproj", + "about": { + "carest": { + "product_ids": ["NexusCM"], + "ca_plugin_config": [ + { + "name": "Host", + "description": "The URI of the instance of the Nexus Certificate Manager API, including port. example: https://127.0.0.1:8444" + }, + { + "name": "AuthCertificatePath", + "description": "The path on the AnyCA Gateway host where the PFX certificate that will be used for authentication can be found. example: C:\\Program Files\\Keyfactor\\Keyfactor AnyCA Gateway\\AnyGatewayREST\\net8.0\\my_auth_cert.pfx", + }, + { + "name": "AuthCertPassword", + "description": "The password for the PFX certificate located on the AnyCA Gateway Host that will be used for authentication into Nexus Certificate Manager" + }, + { + "name": "Enabled", + "description": "Flag to Enable or Disable gateway functionality. Disabling is primarily used to allow creation of the CA prior to configuration information being available." + } + ], + "enrollment_config": [] + } + } +} \ No newline at end of file diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.csproj b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.csproj index d8a1cc0..214234b 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.csproj +++ b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.csproj @@ -3,8 +3,9 @@ net6.0;net8.0 Keyfactor.Extensions.CAPlugin.NexusCertManager - enable + disable disable + true NexusCertManagerCAPlugin From 23297b34fdf6ca117571f39faa7b44f59c50f261 Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele Date: Wed, 3 Dec 2025 15:57:39 -0500 Subject: [PATCH 11/35] updated project settings for github build --- .../NexusCertManagerCAPlugin.cs | 6 ++++++ .../NexusCertManagerClient.cs | 6 +++++- nexus-certificate-manager-caplugin/models/ApiModels.cs | 3 +++ nexus-certificate-manager-caplugin/models/Helpers.cs | 2 ++ 4 files changed, 16 insertions(+), 1 deletion(-) diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs index 662ae4b..a3c689b 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs @@ -14,6 +14,12 @@ using Newtonsoft.Json; using System.Collections.Concurrent; using System.Security.Cryptography.X509Certificates; +using System.Threading.Tasks; +using System.Collections.Generic; +using System.Linq; +using System; +using System.Threading; +using System.IO; namespace Keyfactor.Extensions.CAPlugin.NexusCertManager { diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs b/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs index 66dcc68..43d6a12 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs @@ -10,7 +10,12 @@ using Keyfactor.Logging; using Microsoft.Extensions.Logging; using RestSharp; +using System; +using System.Collections.Generic; +using System.Linq; using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; namespace Keyfactor.Extensions.CAPlugin.NexusCertManager { @@ -273,6 +278,5 @@ public async Task PingServer() finally { _logger.MethodExit(); } return false; } - } } diff --git a/nexus-certificate-manager-caplugin/models/ApiModels.cs b/nexus-certificate-manager-caplugin/models/ApiModels.cs index 3345741..1de87f4 100644 --- a/nexus-certificate-manager-caplugin/models/ApiModels.cs +++ b/nexus-certificate-manager-caplugin/models/ApiModels.cs @@ -6,6 +6,9 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions // and limitations under the License. +using System; +using System.Collections.Generic; +using System.Linq; using System.Text.Json.Serialization; namespace Keyfactor.Extensions.CAPlugin.NexusCertManager.models diff --git a/nexus-certificate-manager-caplugin/models/Helpers.cs b/nexus-certificate-manager-caplugin/models/Helpers.cs index dd86cc4..d877c4a 100644 --- a/nexus-certificate-manager-caplugin/models/Helpers.cs +++ b/nexus-certificate-manager-caplugin/models/Helpers.cs @@ -11,6 +11,8 @@ using Microsoft.Extensions.Logging; using Org.BouncyCastle.Tls; using RestSharp; +using System; +using System.Linq; using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.Json; From 14828d8691dcb5f5b73afea4caef65816eb55102 Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele <76071503+joevanwanzeeleKF@users.noreply.github.com> Date: Thu, 4 Dec 2025 10:33:04 -0500 Subject: [PATCH 12/35] added keyfactor-bootstrap-workflow.yml --- .../keyfactor-bootstrap-workflow.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .github/workflows/keyfactor-bootstrap-workflow.yml diff --git a/.github/workflows/keyfactor-bootstrap-workflow.yml b/.github/workflows/keyfactor-bootstrap-workflow.yml new file mode 100644 index 0000000..46f6fc9 --- /dev/null +++ b/.github/workflows/keyfactor-bootstrap-workflow.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@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 }} From f71e0aa68f873130aef442aab3208b7f47e988e4 Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele Date: Thu, 4 Dec 2025 10:45:09 -0500 Subject: [PATCH 13/35] updated manifest --- integration-manifest.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/integration-manifest.json b/integration-manifest.json index 2966022..aec0196 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -12,7 +12,7 @@ "release_project": "nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.csproj", "about": { "carest": { - "product_ids": ["NexusCM"], + "product_ids": [ "NexusCM" ], "ca_plugin_config": [ { "name": "Host", @@ -20,7 +20,7 @@ }, { "name": "AuthCertificatePath", - "description": "The path on the AnyCA Gateway host where the PFX certificate that will be used for authentication can be found. example: C:\\Program Files\\Keyfactor\\Keyfactor AnyCA Gateway\\AnyGatewayREST\\net8.0\\my_auth_cert.pfx", + "description": "The path on the AnyCA Gateway host where the PFX certificate that will be used for authentication can be found. example: 'C:\\Program Files\\Keyfactor\\Keyfactor AnyCA Gateway\\AnyGatewayREST\\net8.0\\my_auth_cert.pfx'" }, { "name": "AuthCertPassword", @@ -28,10 +28,10 @@ }, { "name": "Enabled", - "description": "Flag to Enable or Disable gateway functionality. Disabling is primarily used to allow creation of the CA prior to configuration information being available." + "description": "Flag to enable or disable gateway functionality. Disabling is primarily used to allow creation of the CA prior to configuration information being available." } ], - "enrollment_config": [] + "enrollment_config": [] } } -} \ No newline at end of file +} From da3557d6bd105bafdb8097a07ccf9bfe6dbdb125 Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele Date: Wed, 17 Dec 2025 10:01:56 -0500 Subject: [PATCH 14/35] added docsource folder --- NexusCertManagerCAPlugin.sln | 6 +----- configuration.md => docsource/configuration.md | 0 2 files changed, 1 insertion(+), 5 deletions(-) rename configuration.md => docsource/configuration.md (100%) diff --git a/NexusCertManagerCAPlugin.sln b/NexusCertManagerCAPlugin.sln index 343fb5a..fad3cbb 100644 --- a/NexusCertManagerCAPlugin.sln +++ b/NexusCertManagerCAPlugin.sln @@ -5,14 +5,10 @@ VisualStudioVersion = 17.11.35327.3 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NexusCertManagerCAPlugin", "nexus-certificate-manager-caplugin\NexusCertManagerCAPlugin.csproj", "{5107B3B8-4F3A-4A1B-BE0E-AF6A1A0B2995}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docsource", "docsource", "{40A1F9A6-A56D-4A38-8CAE-2E23676AE243}" - ProjectSection(SolutionItems) = preProject - configuration.md = configuration.md - EndProjectSection -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{4FA0BDF6-B41E-4E00-805F-AE79B894784A}" ProjectSection(SolutionItems) = preProject CHANGELOG.md = CHANGELOG.md + docsource\configuration.md = docsource\configuration.md integration-manifest.json = integration-manifest.json manifest.json = manifest.json EndProjectSection diff --git a/configuration.md b/docsource/configuration.md similarity index 100% rename from configuration.md rename to docsource/configuration.md From 072b7396f9c8972735343dc05f71a43d6e300f51 Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele Date: Fri, 19 Dec 2025 10:59:41 -0500 Subject: [PATCH 15/35] corrected the returned value on a revoke request --- nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs index a3c689b..c20ac08 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs @@ -227,7 +227,7 @@ public async Task Revoke(string caRequestID, string hexSerialNumber, uint r _logger.LogTrace($"attempting to revoke certificate with id {caRequestID} and serial number {hexSerialNumber} with reason code {revocationReason}"); await _client.RevokeCertificate(caRequestID, (int)revocationReason); _logger.LogTrace("successfully revoked certificate"); - return (int)revocationReason; + return (int)EndEntityStatus.REVOKED; } catch (Exception ex) { From 9457add988bd7720ef081532159f1539a91ca583 Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele <76071503+joevanwanzeeleKF@users.noreply.github.com> Date: Fri, 16 Jan 2026 09:03:50 -0500 Subject: [PATCH 16/35] Update nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs flattening exception to retain potential useful info Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs index c20ac08..ff8a9a4 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs @@ -383,7 +383,7 @@ public async Task ValidateCAConnectionInfo(Dictionary connection } catch (Exception ex) { - errors.Add("unable to open the certificate with the provided password"); + errors.Add($"unable to open the certificate with the provided password: {LogHandler.FlattenException(ex)}"); } } From 34f633acb09406ee8fad13182271e60b86a9d968 Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele <76071503+joevanwanzeeleKF@users.noreply.github.com> Date: Fri, 16 Jan 2026 09:05:42 -0500 Subject: [PATCH 17/35] Update docsource/configuration.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docsource/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docsource/configuration.md b/docsource/configuration.md index 608a942..41b03f6 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -17,7 +17,7 @@ In order to enroll certificates the Keyfactor Command server must trust the CA c ## CA Connection -The certificate used by the gateway for authenticating into the Nexus Certificate Manager will need to be copied to a location on the Gateway Host that is accessble by the gateway service. The Certificate Path +The certificate used by the gateway for authenticating into the Nexus Certificate Manager will need to be copied to a location on the Gateway Host that is accessible by the gateway service. The Certificate Path ## Certificate Template Creation Step From e3c043395e2fbe20a9e0a14dccee6a38473c7511 Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele <76071503+joevanwanzeeleKF@users.noreply.github.com> Date: Fri, 16 Jan 2026 09:05:57 -0500 Subject: [PATCH 18/35] Update nexus-certificate-manager-caplugin/NexusCertManagerClient.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- nexus-certificate-manager-caplugin/NexusCertManagerClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs b/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs index 43d6a12..9514737 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs @@ -91,7 +91,7 @@ public NexusCertManagerClient(string hostAndPort, string authCertPath, string au var response = await _restClient.ExecuteAsync(req, ct); _logger.LogTrace($"response status code: {response.StatusCode}"); _logger.LogTrace($"response content: {response.Content}"); - _logger.LogTrace($"recieved a response, parsing the result"); + _logger.LogTrace($"received a response, parsing the result"); var result = RestSharpResponseHandler.HandleCertificateBinaryResponse(response); return result; } From a950dd9f94ee8b1c81a818030927460186fcd728 Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele <76071503+joevanwanzeeleKF@users.noreply.github.com> Date: Fri, 16 Jan 2026 09:06:13 -0500 Subject: [PATCH 19/35] Update nexus-certificate-manager-caplugin/models/Helpers.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- nexus-certificate-manager-caplugin/models/Helpers.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nexus-certificate-manager-caplugin/models/Helpers.cs b/nexus-certificate-manager-caplugin/models/Helpers.cs index d877c4a..e0de08d 100644 --- a/nexus-certificate-manager-caplugin/models/Helpers.cs +++ b/nexus-certificate-manager-caplugin/models/Helpers.cs @@ -61,7 +61,7 @@ public static int GetStatusCodeFromNexusCADescription(string status) case "revoked": return (int)EndEntityStatus.REVOKED; default: - return (int)EndEntityStatus.NEW; // set the status to "NEW" for any unknown description; to be evaluated as neededs + return (int)EndEntityStatus.NEW; // set the status to "NEW" for any unknown description; to be evaluated as needed } } From f7d5ca5d76d98dc6d1623b3f16eed3822d2ab312 Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele <76071503+joevanwanzeeleKF@users.noreply.github.com> Date: Fri, 16 Jan 2026 09:06:36 -0500 Subject: [PATCH 20/35] Update nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs index ff8a9a4..174864d 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs @@ -344,7 +344,7 @@ public async Task ValidateCAConnectionInfo(Dictionary connection try { // see if we can open the file for reading.. - using (var fs = new FileStream((string)connectionInfo[Constants.AUTHCERTPATH], FileMode.Open, FileAccess.Read)) + using (var _ = new FileStream((string)connectionInfo[Constants.AUTHCERTPATH], FileMode.Open, FileAccess.Read)) { // if we get this far, we can _logger.LogTrace($"successfully read the file at path {connectionInfo[Constants.AUTHCERTPATH]}"); From 5c8db482944d1725fc1302a76d1c1783145dec3c Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele <76071503+joevanwanzeeleKF@users.noreply.github.com> Date: Fri, 16 Jan 2026 09:08:30 -0500 Subject: [PATCH 21/35] Update nexus-certificate-manager-caplugin/NexusCertManagerClient.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- nexus-certificate-manager-caplugin/NexusCertManagerClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs b/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs index 9514737..24501e1 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs @@ -22,7 +22,7 @@ namespace Keyfactor.Extensions.CAPlugin.NexusCertManager public class NexusCertManagerClient { - ILogger _logger; + private readonly ILogger _logger; private RestClient _restClient; private string _host; private string _authCertPath; From 0f5334e33aff027c94178dfe6dd7f59918999dff Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele <76071503+joevanwanzeeleKF@users.noreply.github.com> Date: Fri, 16 Jan 2026 09:08:59 -0500 Subject: [PATCH 22/35] Update nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs index 174864d..7def168 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs @@ -239,7 +239,7 @@ public async Task Revoke(string caRequestID, string hexSerialNumber, uint r } /// - /// Synchronize gets the list of certs from the CA and updates the status of each known cert to the latest; and adds missing cert info to the database /// + /// Synchronize gets the list of certs from the CA and updates the status of each known cert to the latest, and adds missing cert info to the database. /// /// the database reader, passed by framework /// the time of last sync From 162d092edc23cb64fd559b8a6df9fc5ce037cba5 Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele <76071503+joevanwanzeeleKF@users.noreply.github.com> Date: Fri, 16 Jan 2026 09:14:35 -0500 Subject: [PATCH 23/35] Update nexus-certificate-manager-caplugin/NexusCertManagerClient.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- nexus-certificate-manager-caplugin/NexusCertManagerClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs b/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs index 24501e1..73dc4b6 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs @@ -73,7 +73,7 @@ public NexusCertManagerClient(string hostAndPort, string authCertPath, string au { _logger.LogTrace("getting first available proc name for pkcs10 to submit with request.."); var procs = await GetProceduresByMediaType(Constants.MEDIATYPE_PKCS10); - procname = procs?.First(); + procname = procs?.FirstOrDefault(); if (!string.IsNullOrEmpty(procname)) { req.AddParameter("procname", procname); From 7a79a124454058ffda5faf0c00dad9e7e33e6651 Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele <76071503+joevanwanzeeleKF@users.noreply.github.com> Date: Fri, 16 Jan 2026 09:14:52 -0500 Subject: [PATCH 24/35] Update nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs index 7def168..e0537ec 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs @@ -253,7 +253,7 @@ public async Task Synchronize(BlockingCollection blockin try { - // retreive the list of certs from Nexus CM + // retrieve the list of certs from Nexus CM _logger.LogTrace("attempting to retrieve the list of cert names from Nexus CM"); var certList = await _client.GetCertificateList(null, cancelToken); _logger.LogTrace($"successfully returned {certList.SearchHits} results."); From abf0b2b1b6aa5ad3d2db24f4d9551d54f1879a65 Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele Date: Fri, 16 Jan 2026 09:36:21 -0500 Subject: [PATCH 25/35] added check for partial sync --- NexusCertManagerCAPlugin.sln | 1 - manifest.json | 0 .../NexusCertManagerCAPlugin.cs | 22 +++++++++++++++++-- 3 files changed, 20 insertions(+), 3 deletions(-) delete mode 100644 manifest.json diff --git a/NexusCertManagerCAPlugin.sln b/NexusCertManagerCAPlugin.sln index fad3cbb..415feee 100644 --- a/NexusCertManagerCAPlugin.sln +++ b/NexusCertManagerCAPlugin.sln @@ -10,7 +10,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution CHANGELOG.md = CHANGELOG.md docsource\configuration.md = docsource\configuration.md integration-manifest.json = integration-manifest.json - manifest.json = manifest.json EndProjectSection EndProject Global diff --git a/manifest.json b/manifest.json deleted file mode 100644 index e69de29..0000000 diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs index c20ac08..8941b69 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs @@ -20,6 +20,7 @@ using System; using System.Threading; using System.IO; +using System.Net.NetworkInformation; namespace Keyfactor.Extensions.CAPlugin.NexusCertManager { @@ -258,8 +259,10 @@ public async Task Synchronize(BlockingCollection blockin var certList = await _client.GetCertificateList(null, cancelToken); _logger.LogTrace($"successfully returned {certList.SearchHits} results."); - certList.Certificates.ForEach(cert => + certList.Certificates.ForEach(async cert => { + var dbStatus = -1; + _logger.LogTrace("- cert details - "); _logger.LogTrace($"certId: {cert.CertId}"); _logger.LogTrace($"status: {cert.Status}"); @@ -278,8 +281,22 @@ public async Task Synchronize(BlockingCollection blockin if (!string.IsNullOrEmpty(cert.Reason)) { updatedCert.RevocationReason = Helpers.GetRevocationReasonCodeFromNexusCADescription(cert.Reason); } + // check for an existing local entry + try + { + _logger.LogTrace($"attempting to retreive status of cert with tracking id {cert.CertId} from the database"); + dbStatus = await _certificateDataReader.GetStatusByRequestID(cert.CertId); + } + catch + { + _logger.LogTrace($"tracking id {cert.CertId} was not found in the database. it will be added."); + } - updatedCerts.Add(updatedCert); + if (dbStatus == -1 || fullSync || (updatedCert.Status != dbStatus)) + { + // if it is a new cert, if we are doing a full sync, or if the status changed; we add it to collection to be updated in the db + updatedCerts.Add(updatedCert); + } }); // now get the cert content for each.. @@ -383,6 +400,7 @@ public async Task ValidateCAConnectionInfo(Dictionary connection } catch (Exception ex) { + _logger.LogTrace($"unable to open the certificate with the provided password: {LogHandler.FlattenException(ex)}"); errors.Add("unable to open the certificate with the provided password"); } } From ad20419011834ffd9431cdfe8249a8edb204845f Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele <76071503+joevanwanzeeleKF@users.noreply.github.com> Date: Fri, 16 Jan 2026 13:13:08 -0500 Subject: [PATCH 26/35] Update nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../NexusCertManagerCAPlugin.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs index 0570893..f54366a 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs @@ -392,11 +392,12 @@ public async Task ValidateCAConnectionInfo(Dictionary connection var certPassword = (string)connectionInfo[Constants.AUTHCERTPASSWORD]; // validate that it works - var clientCertificate = new X509Certificate2(certPath, certPassword); - - var pub = clientCertificate.GetPublicKey(); - var pubString = Convert.ToBase64String(pub); - _logger.LogTrace($"was able to successfully read the cert with the provided password. public key: {pubString}"); + using (var clientCertificate = new X509Certificate2(certPath, certPassword)) + { + var pub = clientCertificate.GetPublicKey(); + var pubString = Convert.ToBase64String(pub); + _logger.LogTrace($"was able to successfully read the cert with the provided password. public key: {pubString}"); + } } catch (Exception ex) { From a6ad3c45421ab864d22ba518454d884cde53a4e4 Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele Date: Fri, 16 Jan 2026 13:22:39 -0500 Subject: [PATCH 27/35] updating manifest for doctool build --- nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs | 1 - nexus-certificate-manager-caplugin/NexusCertManagerClient.cs | 1 + nexus-certificate-manager-caplugin/manifest.json | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs index 0570893..cb7eb99 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs @@ -20,7 +20,6 @@ using System; using System.Threading; using System.IO; -using System.Net.NetworkInformation; namespace Keyfactor.Extensions.CAPlugin.NexusCertManager { diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs b/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs index 73dc4b6..495af84 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs @@ -57,6 +57,7 @@ public NexusCertManagerClient(string hostAndPort, string authCertPath, string au var clientCerts = new X509CertificateCollection(); clientCerts.Add(clientCertificate); var options = new RestClientOptions(url) { ClientCertificates = clientCerts, RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => true }; + //var options = new RestClientOptions(url) { ClientCertificates = clientCerts }; _restClient = new RestClient(options); } diff --git a/nexus-certificate-manager-caplugin/manifest.json b/nexus-certificate-manager-caplugin/manifest.json index 1867fec..4770bd8 100644 --- a/nexus-certificate-manager-caplugin/manifest.json +++ b/nexus-certificate-manager-caplugin/manifest.json @@ -2,7 +2,7 @@ "extensions": { "Keyfactor.AnyGateway.Extensions.IAnyCAPlugin": { "NexusCAPlugin": { - "assemblypath": "../NexusCertManagerCAPlugin.dll", + "assemblypath": "NexusCertManagerCAPlugin.dll", "TypeFullName": "Keyfactor.Extensions.CAPlugin.NexusCertManager.NexusCertManagerCAPlugin" } } From 1927740d2958c5622cea016ed98e3144299798a6 Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele Date: Wed, 28 Jan 2026 10:48:34 -0500 Subject: [PATCH 28/35] added configuration.md to solution --- NexusCertManagerCAPlugin.sln | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/NexusCertManagerCAPlugin.sln b/NexusCertManagerCAPlugin.sln index 415feee..70cd12e 100644 --- a/NexusCertManagerCAPlugin.sln +++ b/NexusCertManagerCAPlugin.sln @@ -7,9 +7,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NexusCertManagerCAPlugin", EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{4FA0BDF6-B41E-4E00-805F-AE79B894784A}" ProjectSection(SolutionItems) = preProject - CHANGELOG.md = CHANGELOG.md docsource\configuration.md = docsource\configuration.md - integration-manifest.json = integration-manifest.json + manifest.json = manifest.json EndProjectSection EndProject Global From c55e9953da9e6d2058330882b46c84fc0051770a Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele Date: Thu, 14 May 2026 13:39:25 -0400 Subject: [PATCH 29/35] updated gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 37e9b31..c47078a 100644 --- a/.gitignore +++ b/.gitignore @@ -347,3 +347,4 @@ healthchecksdb /cert.pem /cert.csr .config/dotnet-tools.json +/.claude/agents From 1f515096902f4b925f8f554ca570593282ee46bb Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele Date: Thu, 14 May 2026 13:39:34 -0400 Subject: [PATCH 30/35] Now returning Nexus CM processes as product ID's; implemented conditional sync functionality; cleanup; unit tests --- CHANGELOG.md | 11 +- .../HelpersAndModelsTests.cs | 229 ++++++++++ .../NexusCertManagerCAPlugin.Tests.csproj | 36 ++ NexusCertManagerCAPlugin.Tests/PluginTests.cs | 392 ++++++++++++++++++ .../SynchronizeTests.cs | 370 +++++++++++++++++ .../TestFixtures.cs | 140 +++++++ NexusCertManagerCAPlugin.sln | 7 + docsource/configuration.md | 73 +++- integration-manifest.json | 14 +- .../Constants.cs | 13 +- .../INexusCertManagerClient.cs | 30 ++ .../NexusCertManagerCAPlugin.cs | 293 +++++++++---- .../NexusCertManagerCAPlugin.csproj | 8 +- .../NexusCertManagerCAPluginConfig.cs | 9 + .../NexusCertManagerClient.cs | 102 +++-- .../models/Helpers.cs | 11 +- 16 files changed, 1582 insertions(+), 156 deletions(-) create mode 100644 NexusCertManagerCAPlugin.Tests/HelpersAndModelsTests.cs create mode 100644 NexusCertManagerCAPlugin.Tests/NexusCertManagerCAPlugin.Tests.csproj create mode 100644 NexusCertManagerCAPlugin.Tests/PluginTests.cs create mode 100644 NexusCertManagerCAPlugin.Tests/SynchronizeTests.cs create mode 100644 NexusCertManagerCAPlugin.Tests/TestFixtures.cs create mode 100644 nexus-certificate-manager-caplugin/INexusCertManagerClient.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 698de99..5924ce3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,2 +1,11 @@ +### 1.1.0 +* **Procedures as ProductIDs** — `GetProductIds()` now returns Nexus CA token procedure names dynamically from the `/procedures` endpoint rather than a single hardcoded value. Each certificate template in Command should be configured with a procedure name as its ProductID. +* **Pagination** — `Synchronize` and `GetCertificateList` now page through results in batches of 500, resolving failures that occurred when the max returned records limit (that defaults to 500) was reached. +* **Conditional synchronization** — `Synchronize` throws `NotSupportedException` with a clear explanation when `SyncProcedureField` is not configured. When configured, sync reads the specified `ExtendedCertSearch` field from each certificate to resolve its ProductID. See documentation for CA-side requirements. +* **`GetSingleRecord` fix** — `ProductID` is now resolved from the configured `ExtendedCertSearch` field instead of incorrectly using `CertId`. +* **`ValidateProductInfo`** — now validates that `ProductID` is non-empty. +* **Fixed deadlock risk** — replaced `.Result` with `.GetAwaiter().GetResult()` in `GetProductIds()`. +* **General Cleanup** — corrected "retreived" / "retreive" in log messages throughout. + ### 1.0.0 -* initial release \ No newline at end of file +* Initial release diff --git a/NexusCertManagerCAPlugin.Tests/HelpersAndModelsTests.cs b/NexusCertManagerCAPlugin.Tests/HelpersAndModelsTests.cs new file mode 100644 index 0000000..87c0e37 --- /dev/null +++ b/NexusCertManagerCAPlugin.Tests/HelpersAndModelsTests.cs @@ -0,0 +1,229 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using System.Collections.Generic; +using FluentAssertions; +using Keyfactor.Extensions.CAPlugin.NexusCertManager.models; +using Keyfactor.PKI.Enums.EJBCA; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.NexusCertManager.Tests +{ + /// + /// Tests for . + /// + /// Covers all known Nexus status string mappings and the unknown-status fallback. + /// + public class StatusMappingTests + { + [Theory] + [InlineData("issued")] + [InlineData("approved")] + [InlineData("expired")] + [InlineData("active")] + public void GetStatusCode_ActiveStatuses_ReturnGenerated(string status) + { + Helpers.GetStatusCodeFromNexusCADescription(status) + .Should().Be((int)EndEntityStatus.GENERATED); + } + + [Theory] + [InlineData("processing")] + [InlineData("reissue_pending")] + [InlineData("pending")] + [InlineData("waiting_pickup")] + [InlineData("needs_approval")] + public void GetStatusCode_PendingStatuses_ReturnExternalValidation(string status) + { + Helpers.GetStatusCodeFromNexusCADescription(status) + .Should().Be((int)EndEntityStatus.EXTERNALVALIDATION); + } + + [Theory] + [InlineData("denied")] + [InlineData("rejected")] + [InlineData("canceled")] + public void GetStatusCode_FailureStatuses_ReturnFailed(string status) + { + Helpers.GetStatusCodeFromNexusCADescription(status) + .Should().Be((int)EndEntityStatus.FAILED); + } + + [Fact] + public void GetStatusCode_Revoked_ReturnRevoked() + { + Helpers.GetStatusCodeFromNexusCADescription("revoked") + .Should().Be((int)EndEntityStatus.REVOKED); + } + + [Theory] + [InlineData("unknown_value")] + [InlineData("")] + [InlineData(null)] + public void GetStatusCode_UnknownOrEmptyStatus_ReturnNew(string status) + { + Helpers.GetStatusCodeFromNexusCADescription(status) + .Should().Be((int)EndEntityStatus.NEW, + because: "unknown statuses should default to NEW for manual triage"); + } + } + + /// + /// Tests for . + /// + /// Covers all known Nexus revocation reason strings and the unspecified fallback. + /// + public class RevocationReasonMappingTests + { + [Theory] + [InlineData("key compromise", RevocationReason.KeyCompromise)] + [InlineData("affiliation changed", RevocationReason.AffiliationChanged)] + [InlineData("superseded", RevocationReason.Superseded)] + [InlineData("cessation of operation", RevocationReason.CessationOfOperation)] + [InlineData("certificate hold", RevocationReason.CertificateHold)] + [InlineData("privilege withdrawn", RevocationReason.PrivilegeWithdrawn)] + public void GetRevocationReason_KnownReasons_ReturnCorrectCode(string reason, int expected) + { + Helpers.GetRevocationReasonCodeFromNexusCADescription(reason) + .Should().Be(expected); + } + + [Theory] + [InlineData("KEY COMPROMISE")] // case insensitivity + [InlineData("Key Compromise")] + public void GetRevocationReason_CaseInsensitive(string reason) + { + Helpers.GetRevocationReasonCodeFromNexusCADescription(reason) + .Should().Be((int)RevocationReason.KeyCompromise); + } + + [Theory] + [InlineData("unknown reason")] + [InlineData("")] + [InlineData(null)] + public void GetRevocationReason_UnknownOrEmpty_ReturnUnspecified(string reason) + { + Helpers.GetRevocationReasonCodeFromNexusCADescription(reason) + .Should().Be(RevocationReason.Unspecified); + } + } + + /// + /// Tests for . + /// + /// Covers CN extraction from various subject formats. + /// + public class ParseSubjectTests + { + [Fact] + public void ParseSubject_SimpleCN_ExtractedCorrectly() + { + Helpers.ParseSubject("CN=test.example.com, O=Acme", "CN=") + .Should().Be("test.example.com"); + } + + [Fact] + public void ParseSubject_CNWithEscapedComma_PreservesComma() + { + // Escaped commas in the CN value should survive round-trip + Helpers.ParseSubject(@"CN=Last\, First, O=Acme", "CN=") + .Should().Be("Last, First"); + } + + [Fact] + public void ParseSubject_MissingRdn_ThrowsException() + { + var act = () => Helpers.ParseSubject("O=Acme, C=US", "CN="); + act.Should().Throw().WithMessage("*CN=*"); + } + + [Fact] + public void ParseSubject_ExtractsOrgUnit() + { + Helpers.ParseSubject("CN=test, OU=Engineering, O=Acme", "OU=") + .Should().Be("Engineering"); + } + } + + /// + /// Tests for . + /// + /// Covers error code descriptions and exception message plumbing. + /// + public class CmApiExceptionTests + { + [Theory] + [InlineData(0, "Success")] + [InlineData(-1, "General error")] + [InlineData(-7, "Missing field")] + [InlineData(-8, "Encoding error")] + [InlineData(-12, "Not initialized")] + [InlineData(-14, "Bad field value")] + [InlineData(-15, "Privilege error")] + [InlineData(-17, "Bad signature")] + [InlineData(-18, "Connection error")] + [InlineData(-19, "Signature required")] + [InlineData(-40, "Too many requests")] + [InlineData(-999, "Unknown error")] + public void GetErrorDescription_KnownCodes_ReturnExpectedDescription(int code, string expected) + { + var ex = new CmApiException(code, "test"); + ex.GetErrorDescription().Should().Be(expected); + } + + [Fact] + public void CmApiException_MessageIsPreserved() + { + var ex = new CmApiException(-1, "Something went wrong"); + ex.Message.Should().Be("Something went wrong"); + ex.ErrorCode.Should().Be(-1); + } + } + + /// + /// Tests for — the query parameter model + /// used to paginate and filter certificate list requests. + /// + /// Covers that key pagination fields are set correctly when constructing requests. + /// + public class ListCertificatesRequestTests + { + [Fact] + public void ListCertificatesRequest_PaginationFields_SetCorrectly() + { + var req = new ListCertificatesRequest + { + SearchLimit = 500, + SearchOffset = 1000 + }; + + req.SearchLimit.Should().Be(500); + req.SearchOffset.Should().Be(1000); + } + + [Fact] + public void ListCertificatesRequest_ExtendedSearchFields_SetCorrectly() + { + var req = new ListCertificatesRequest { Field1 = "ProcA", Field3 = "Something" }; + + req.Field1.Should().Be("ProcA"); + req.Field2.Should().BeNull(); + req.Field3.Should().Be("Something"); + } + + [Fact] + public void ListCertificatesRequest_AllFieldsNullByDefault() + { + var req = new ListCertificatesRequest(); + + req.SearchLimit.Should().BeNull(); + req.SearchOffset.Should().BeNull(); + req.Field1.Should().BeNull(); + req.OrderBy.Should().BeNull(); + } + } +} diff --git a/NexusCertManagerCAPlugin.Tests/NexusCertManagerCAPlugin.Tests.csproj b/NexusCertManagerCAPlugin.Tests/NexusCertManagerCAPlugin.Tests.csproj new file mode 100644 index 0000000..72625cc --- /dev/null +++ b/NexusCertManagerCAPlugin.Tests/NexusCertManagerCAPlugin.Tests.csproj @@ -0,0 +1,36 @@ + + + + net8.0 + Keyfactor.Extensions.CAPlugin.NexusCertManager.Tests + NexusCertManagerCAPlugin.Tests + disable + disable + false + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + + diff --git a/NexusCertManagerCAPlugin.Tests/PluginTests.cs b/NexusCertManagerCAPlugin.Tests/PluginTests.cs new file mode 100644 index 0000000..bf84983 --- /dev/null +++ b/NexusCertManagerCAPlugin.Tests/PluginTests.cs @@ -0,0 +1,392 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.NexusCertManager.models; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.NexusCertManager.Tests +{ + /// + /// Tests for . + /// + /// Covers: + /// - Happy path: enroll returns a populated EnrollmentResult + /// - ProductID is passed as the procedure name to the client + /// - Client exception is propagated + /// + public class EnrollTests + { + [Fact] + public async Task Enroll_HappyPath_ReturnsGeneratedStatus() + { + var client = new Mock(); + client.Setup(c => c.Enroll(It.IsAny(), "MyProcedure", It.IsAny())) + .ReturnsAsync(TestFixtures.MakeBinaryResponse("cert-001")); + + var plugin = TestFixtures.BuildPlugin(client.Object, + new Mock().Object, + TestFixtures.ConfigWithoutSync()); + + var result = await plugin.Enroll( + csr: "-----BEGIN CERTIFICATE REQUEST-----\nFAKE\n-----END CERTIFICATE REQUEST-----", + subject: "CN=test.example.com, O=Acme", + san: new Dictionary(), + productInfo: TestFixtures.MakeProductInfo("MyProcedure"), + requestFormat: RequestFormat.PKCS10, + enrollmentType: EnrollmentType.New); + + result.Should().NotBeNull(); + result.CARequestID.Should().Be("cert-001"); + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + result.StatusMessage.Should().Contain("test.example.com"); + } + + [Fact] + public async Task Enroll_PassesProcedureNameAsProductId() + { + string capturedProcName = null; + + var client = new Mock(); + client.Setup(c => c.Enroll(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, procName, __) => capturedProcName = procName) + .ReturnsAsync(TestFixtures.MakeBinaryResponse()); + + var plugin = TestFixtures.BuildPlugin(client.Object, + new Mock().Object, + TestFixtures.ConfigWithoutSync()); + + await plugin.Enroll("csr", "CN=test", new Dictionary(), + TestFixtures.MakeProductInfo("SpecificProcedureName"), + RequestFormat.PKCS10, EnrollmentType.New); + + capturedProcName.Should().Be("SpecificProcedureName", + because: "the ProductID must be forwarded directly as the Nexus CA procedure name"); + } + + [Fact] + public async Task Enroll_ClientThrows_ExceptionPropagates() + { + var client = new Mock(); + client.Setup(c => c.Enroll(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new CmApiException(-1, "General error")); + + var plugin = TestFixtures.BuildPlugin(client.Object, + new Mock().Object, + TestFixtures.ConfigWithoutSync()); + + await plugin.Invoking(p => p.Enroll("csr", "CN=test", new Dictionary(), + TestFixtures.MakeProductInfo("ProcA"), + RequestFormat.PKCS10, EnrollmentType.New)) + .Should().ThrowAsync(); + } + } + + /// + /// Tests for . + /// + /// Covers: + /// - Happy path: returns REVOKED status + /// - Reason code is forwarded to client + /// - Client exception propagates + /// + public class RevokeTests + { + [Fact] + public async Task Revoke_HappyPath_ReturnsRevokedStatus() + { + var client = new Mock(); + client.Setup(c => c.RevokeCertificate(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + var plugin = TestFixtures.BuildPlugin(client.Object, + new Mock().Object, + TestFixtures.ConfigWithoutSync()); + + var result = await plugin.Revoke("cert-001", "AABBCC", (uint)RevocationReason.KeyCompromise); + + result.Should().Be((int)EndEntityStatus.REVOKED); + } + + [Fact] + public async Task Revoke_ForwardsReasonCodeToClient() + { + int capturedReason = -1; + + var client = new Mock(); + client.Setup(c => c.RevokeCertificate(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, reason, __) => capturedReason = reason) + .Returns(Task.CompletedTask); + + var plugin = TestFixtures.BuildPlugin(client.Object, + new Mock().Object, + TestFixtures.ConfigWithoutSync()); + + await plugin.Revoke("cert-001", "AABBCC", (uint)RevocationReason.Superseded); + + capturedReason.Should().Be((int)RevocationReason.Superseded); + } + + [Fact] + public async Task Revoke_ClientThrows_ExceptionPropagates() + { + var client = new Mock(); + client.Setup(c => c.RevokeCertificate(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new CmApiException(-15, "Privilege error")); + + var plugin = TestFixtures.BuildPlugin(client.Object, + new Mock().Object, + TestFixtures.ConfigWithoutSync()); + + await plugin.Invoking(p => p.Revoke("cert-001", "AABBCC", 0)) + .Should().ThrowAsync(); + } + } + + /// + /// Tests for . + /// + /// Covers: + /// - Active cert: ProductID resolved from configured field + /// - Active cert: ProductID is null when SyncProcedureField is not configured + /// - Revoked cert: RevocationReason and RevocationDate are populated + /// - Client exception propagates + /// + public class GetSingleRecordTests + { + [Fact] + public async Task GetSingleRecord_ActiveCert_WithSyncFieldConfigured_ProductIdResolved() + { + var certJson = TestFixtures.MakeCert("cert-001", status: "active", + extendedCertSearch: TestFixtures.MakeExtendedSearch(field2: "ProcFromField2")); + + var client = new Mock(); + client.Setup(c => c.GetCertificateDetails("cert-001", It.IsAny())) + .ReturnsAsync(TestFixtures.MakeCertDetailsResponse(certJson)); + client.Setup(c => c.DownloadCertificate("cert-001", It.IsAny(), It.IsAny())) + .ReturnsAsync(TestFixtures.MakeBinaryResponse("cert-001")); + + var plugin = TestFixtures.BuildPlugin(client.Object, + new Mock().Object, + TestFixtures.ConfigWithSync("field2")); + + var result = await plugin.GetSingleRecord("cert-001"); + + result.ProductID.Should().Be("ProcFromField2"); + result.Status.Should().Be((int)EndEntityStatus.GENERATED); + } + + [Fact] + public async Task GetSingleRecord_ActiveCert_WithoutSyncFieldConfigured_ProductIdIsNull() + { + var certJson = TestFixtures.MakeCert("cert-001", status: "active", + extendedCertSearch: TestFixtures.MakeExtendedSearch(field1: "ProcA")); + + var client = new Mock(); + client.Setup(c => c.GetCertificateDetails("cert-001", It.IsAny())) + .ReturnsAsync(TestFixtures.MakeCertDetailsResponse(certJson)); + client.Setup(c => c.DownloadCertificate("cert-001", It.IsAny(), It.IsAny())) + .ReturnsAsync(TestFixtures.MakeBinaryResponse("cert-001")); + + var plugin = TestFixtures.BuildPlugin(client.Object, + new Mock().Object, + TestFixtures.ConfigWithoutSync()); + + var result = await plugin.GetSingleRecord("cert-001"); + + result.ProductID.Should().BeNull( + because: "without SyncProcedureField configured the ProductID cannot be resolved"); + } + + [Fact] + public async Task GetSingleRecord_RevokedCert_RevocationFieldsArePopulated() + { + var revocationTime = new DateTime(2025, 6, 1, 12, 0, 0, DateTimeKind.Utc); + var certJson = TestFixtures.MakeCert("cert-001", status: "revoked", reason: "key compromise", + extendedCertSearch: TestFixtures.MakeExtendedSearch(field1: "ProcA")); + certJson.RevocationTime = revocationTime; + + var client = new Mock(); + client.Setup(c => c.GetCertificateDetails("cert-001", It.IsAny())) + .ReturnsAsync(TestFixtures.MakeCertDetailsResponse(certJson)); + client.Setup(c => c.DownloadCertificate("cert-001", It.IsAny(), It.IsAny())) + .ReturnsAsync(TestFixtures.MakeBinaryResponse("cert-001")); + + var plugin = TestFixtures.BuildPlugin(client.Object, + new Mock().Object, + TestFixtures.ConfigWithSync("field1")); + + var result = await plugin.GetSingleRecord("cert-001"); + + result.Status.Should().Be((int)EndEntityStatus.REVOKED); + result.RevocationDate.Should().Be(revocationTime); + result.RevocationReason.Should().Be((int)RevocationReason.KeyCompromise); + } + + [Fact] + public async Task GetSingleRecord_ClientThrows_ExceptionPropagates() + { + var client = new Mock(); + client.Setup(c => c.GetCertificateDetails(It.IsAny(), It.IsAny())) + .ThrowsAsync(new CmApiException(-1, "General error")); + + var plugin = TestFixtures.BuildPlugin(client.Object, + new Mock().Object, + TestFixtures.ConfigWithSync("field1")); + + await plugin.Invoking(p => p.GetSingleRecord("cert-001")) + .Should().ThrowAsync(); + } + } + + /// + /// Tests for . + /// + /// Covers: + /// - Returns list of procedure names from client + /// - Returns empty list (not throw) when client fails + /// + public class GetProductIdsTests + { + [Fact] + public void GetProductIds_ReturnsProcedureNamesFromClient() + { + var procedures = new List { "ProcA", "ProcB", "ProcC" }; + + var client = new Mock(); + client.Setup(c => c.GetProceduresByMediaType(It.IsAny())) + .ReturnsAsync(procedures); + + var plugin = TestFixtures.BuildPlugin(client.Object, + new Mock().Object, + TestFixtures.ConfigWithoutSync()); + + var result = plugin.GetProductIds(); + + result.Should().BeEquivalentTo(procedures); + } + + [Fact] + public void GetProductIds_WhenClientThrows_ReturnsEmptyListWithoutThrowing() + { + var client = new Mock(); + client.Setup(c => c.GetProceduresByMediaType(It.IsAny())) + .ThrowsAsync(new Exception("connection failed")); + + var plugin = TestFixtures.BuildPlugin(client.Object, + new Mock().Object, + TestFixtures.ConfigWithoutSync()); + + var result = plugin.GetProductIds(); + + result.Should().BeEmpty(because: "failures during GetProductIds should degrade gracefully"); + } + } + + /// + /// Tests for . + /// + /// Covers: + /// - Valid ProductID completes without throwing + /// - Null/empty/whitespace ProductID throws AnyCAValidationException + /// + public class ValidateProductInfoTests + { + [Fact] + public async Task ValidateProductInfo_WithValidProductId_DoesNotThrow() + { + var plugin = TestFixtures.BuildPlugin( + new Mock().Object, + new Mock().Object, + TestFixtures.ConfigWithoutSync()); + + await plugin.Invoking(p => p.ValidateProductInfo( + TestFixtures.MakeProductInfo("ValidProcedure"), + new Dictionary())) + .Should().NotThrowAsync(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task ValidateProductInfo_WithEmptyProductId_ThrowsAnyCAValidationException(string productId) + { + var plugin = TestFixtures.BuildPlugin( + new Mock().Object, + new Mock().Object, + TestFixtures.ConfigWithoutSync()); + + await plugin.Invoking(p => p.ValidateProductInfo( + TestFixtures.MakeProductInfo(productId), + new Dictionary())) + .Should().ThrowAsync() + .WithMessage("*ProductID*"); + } + } + + /// + /// Tests for . + /// + /// Covers: + /// - All expected keys are present + /// - SyncProcedureField is not marked Hidden + /// - AuthCertPassword is marked Hidden + /// + public class GetCAConnectorAnnotationsTests + { + [Fact] + public void GetCAConnectorAnnotations_ContainsAllExpectedKeys() + { + var plugin = TestFixtures.BuildPlugin( + new Mock().Object, + new Mock().Object, + TestFixtures.ConfigWithoutSync()); + + var annotations = plugin.GetCAConnectorAnnotations(); + + annotations.Should().ContainKey(Constants.HOST); + annotations.Should().ContainKey(Constants.AUTHCERTPATH); + annotations.Should().ContainKey(Constants.AUTHCERTPASSWORD); + annotations.Should().ContainKey(Constants.ENABLED); + annotations.Should().ContainKey(Constants.SYNC_PROCEDURE_FIELD); + } + + [Fact] + public void GetCAConnectorAnnotations_AuthCertPasswordIsHidden() + { + var plugin = TestFixtures.BuildPlugin( + new Mock().Object, + new Mock().Object, + TestFixtures.ConfigWithoutSync()); + + var annotations = plugin.GetCAConnectorAnnotations(); + + annotations[Constants.AUTHCERTPASSWORD].Hidden.Should().BeTrue(); + } + + [Fact] + public void GetCAConnectorAnnotations_SyncProcedureFieldIsNotHidden() + { + var plugin = TestFixtures.BuildPlugin( + new Mock().Object, + new Mock().Object, + TestFixtures.ConfigWithoutSync()); + + var annotations = plugin.GetCAConnectorAnnotations(); + + annotations[Constants.SYNC_PROCEDURE_FIELD].Hidden.Should().BeFalse(); + } + } +} diff --git a/NexusCertManagerCAPlugin.Tests/SynchronizeTests.cs b/NexusCertManagerCAPlugin.Tests/SynchronizeTests.cs new file mode 100644 index 0000000..9ce0e78 --- /dev/null +++ b/NexusCertManagerCAPlugin.Tests/SynchronizeTests.cs @@ -0,0 +1,370 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.NexusCertManager.models; +using Keyfactor.PKI.Enums.EJBCA; +using Moq; +using Xunit; + +namespace Keyfactor.Extensions.CAPlugin.NexusCertManager.Tests +{ + /// + /// Tests for . + /// + /// Covers: + /// - Guard clause: throws NotSupportedException when SyncProcedureField is not configured + /// - Happy path: single page, all certs have procedure field populated + /// - Pagination: multiple pages are fetched until SearchHits is exhausted + /// - Skipping: certs with empty ExtendedCertSearch field are skipped + /// - Field routing: each of field1-field6 is read correctly + /// - Invalid field name: unrecognised SyncProcedureField skips all certs + /// - Full sync: certs already in DB are included when fullSync=true + /// - Status-change sync: cert is included when its status differs from DB + /// - Cancellation: OperationCanceledException propagates cleanly + /// + public class SynchronizeTests + { + // ── Guard clause ────────────────────────────────────────────────────── + + [Fact] + public async Task Synchronize_WhenSyncProcedureFieldNotConfigured_ThrowsNotSupportedException() + { + var client = new Mock(); + var reader = new Mock(); + var plugin = TestFixtures.BuildPlugin(client.Object, reader.Object, TestFixtures.ConfigWithoutSync()); + + var buffer = new BlockingCollection(100); + + await plugin.Invoking(p => p.Synchronize(buffer, null, false, CancellationToken.None)) + .Should().ThrowAsync() + .WithMessage("*SyncProcedureField*"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Synchronize_WhenSyncProcedureFieldIsNullOrWhitespace_ThrowsNotSupportedException(string fieldValue) + { + var config = TestFixtures.ConfigWithSync(fieldValue); + var plugin = TestFixtures.BuildPlugin( + new Mock().Object, + new Mock().Object, + config); + + var buffer = new BlockingCollection(100); + + await plugin.Invoking(p => p.Synchronize(buffer, null, false, CancellationToken.None)) + .Should().ThrowAsync(); + } + + // ── Happy path (single page) ────────────────────────────────────────── + + [Fact] + public async Task Synchronize_SinglePage_AllCertsBuffered() + { + var certs = new List + { + TestFixtures.MakeCert("cert-001", extendedCertSearch: TestFixtures.MakeExtendedSearch(field1: "ProcA")), + TestFixtures.MakeCert("cert-002", extendedCertSearch: TestFixtures.MakeExtendedSearch(field1: "ProcB")), + }; + + var client = new Mock(); + client.Setup(c => c.GetCertificateList(It.IsAny(), It.IsAny())) + .ReturnsAsync(TestFixtures.MakeCertListPage(2, certs)); + client.Setup(c => c.DownloadCertificate(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(TestFixtures.MakeBinaryResponse()); + + var reader = new Mock(); + reader.Setup(r => r.GetStatusByRequestID(It.IsAny())) + .ThrowsAsync(new Exception("not found")); // simulates cert not in DB yet + + var plugin = TestFixtures.BuildPlugin(client.Object, reader.Object, TestFixtures.ConfigWithSync("field1")); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, false, CancellationToken.None); + + buffer.Count.Should().Be(2, because: "both certs had a populated field1 value"); + buffer.Should().Contain(c => c.CARequestID == "cert-001" && c.ProductID == "ProcA"); + buffer.Should().Contain(c => c.CARequestID == "cert-002" && c.ProductID == "ProcB"); + } + + // ── Pagination ──────────────────────────────────────────────────────── + + [Fact] + public async Task Synchronize_MultiplePagesRequired_AllPagesAreFetched() + { + // 3 certs total, page size 500 but we simulate 2-cert pages by + // returning searchHits=3 on first call, then 1 cert on the second page. + var page1Certs = new List + { + TestFixtures.MakeCert("cert-001", extendedCertSearch: TestFixtures.MakeExtendedSearch(field1: "ProcA")), + TestFixtures.MakeCert("cert-002", extendedCertSearch: TestFixtures.MakeExtendedSearch(field1: "ProcA")), + }; + var page2Certs = new List + { + TestFixtures.MakeCert("cert-003", extendedCertSearch: TestFixtures.MakeExtendedSearch(field1: "ProcB")), + }; + + int callCount = 0; + var client = new Mock(); + client.Setup(c => c.GetCertificateList(It.IsAny(), It.IsAny())) + .ReturnsAsync(() => + { + callCount++; + return callCount == 1 + ? TestFixtures.MakeCertListPage(3, page1Certs) + : TestFixtures.MakeCertListPage(3, page2Certs); + }); + client.Setup(c => c.DownloadCertificate(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(TestFixtures.MakeBinaryResponse()); + + var reader = new Mock(); + reader.Setup(r => r.GetStatusByRequestID(It.IsAny())) + .ThrowsAsync(new Exception("not found")); + + var plugin = TestFixtures.BuildPlugin(client.Object, reader.Object, TestFixtures.ConfigWithSync("field1")); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, false, CancellationToken.None); + + client.Verify(c => c.GetCertificateList(It.IsAny(), It.IsAny()), + Times.Exactly(2), "3 total certs required 2 page fetches"); + buffer.Count.Should().Be(3); + } + + [Fact] + public async Task Synchronize_PaginationPassesCorrectOffsets() + { + var capturedRequests = new List(); + + var page1 = TestFixtures.MakeCertListPage(600, + new List { TestFixtures.MakeCert("cert-001", extendedCertSearch: TestFixtures.MakeExtendedSearch(field1: "ProcA")) }); + // Fill the rest of page 1 to simulate 500 returned on first call + for (int i = 2; i <= 500; i++) + page1.Certificates.Add(TestFixtures.MakeCert($"cert-{i:D3}", extendedCertSearch: TestFixtures.MakeExtendedSearch(field1: "ProcA"))); + + var page2 = TestFixtures.MakeCertListPage(600, + new List()); + for (int i = 501; i <= 600; i++) + page2.Certificates.Add(TestFixtures.MakeCert($"cert-{i:D3}", extendedCertSearch: TestFixtures.MakeExtendedSearch(field1: "ProcA"))); + + int call = 0; + var client = new Mock(); + client.Setup(c => c.GetCertificateList(It.IsAny(), It.IsAny())) + .ReturnsAsync((ListCertificatesRequest req, CancellationToken _) => + { + capturedRequests.Add(req); + return ++call == 1 ? page1 : page2; + }); + client.Setup(c => c.DownloadCertificate(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(TestFixtures.MakeBinaryResponse()); + + var reader = new Mock(); + reader.Setup(r => r.GetStatusByRequestID(It.IsAny())) + .ThrowsAsync(new Exception("not found")); + + var plugin = TestFixtures.BuildPlugin(client.Object, reader.Object, TestFixtures.ConfigWithSync("field1")); + var buffer = new BlockingCollection(10000); + + await plugin.Synchronize(buffer, null, false, CancellationToken.None); + + capturedRequests[0].SearchOffset.Should().Be(0, because: "first page starts at offset 0"); + capturedRequests[0].SearchLimit.Should().Be(Constants.SYNC_PAGE_SIZE); + capturedRequests[1].SearchOffset.Should().Be(500, because: "second page starts at offset equal to first page count"); + } + + // ── Skipping certs with empty field ─────────────────────────────────── + + [Fact] + public async Task Synchronize_CertWithEmptyProcedureField_IsSkipped() + { + var certs = new List + { + TestFixtures.MakeCert("cert-001", extendedCertSearch: TestFixtures.MakeExtendedSearch(field1: "ProcA")), + TestFixtures.MakeCert("cert-002", extendedCertSearch: TestFixtures.MakeExtendedSearch(field1: null)), // empty + TestFixtures.MakeCert("cert-003", extendedCertSearch: null), // no ExtendedCertSearch at all + }; + + var client = new Mock(); + client.Setup(c => c.GetCertificateList(It.IsAny(), It.IsAny())) + .ReturnsAsync(TestFixtures.MakeCertListPage(3, certs)); + client.Setup(c => c.DownloadCertificate(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(TestFixtures.MakeBinaryResponse()); + + var reader = new Mock(); + reader.Setup(r => r.GetStatusByRequestID(It.IsAny())) + .ThrowsAsync(new Exception("not found")); + + var plugin = TestFixtures.BuildPlugin(client.Object, reader.Object, TestFixtures.ConfigWithSync("field1")); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, false, CancellationToken.None); + + buffer.Count.Should().Be(1, because: "only cert-001 had a populated field1"); + buffer.Should().Contain(c => c.CARequestID == "cert-001"); + } + + // ── Field routing (field1 through field6) ───────────────────────────── + + [Theory] + [InlineData("field1", "ProcFromField1", null, null, null, null, null)] + [InlineData("field2", null, "ProcFromField2", null, null, null, null)] + [InlineData("field3", null, null, "ProcFromField3", null, null, null)] + [InlineData("field4", null, null, null, "ProcFromField4", null, null)] + [InlineData("field5", null, null, null, null, "ProcFromField5", null)] + [InlineData("field6", null, null, null, null, null, "ProcFromField6")] + public async Task Synchronize_ReadsProductIdFromCorrectField( + string configuredField, + string f1, string f2, string f3, string f4, string f5, string f6) + { + var expectedProcId = f1 ?? f2 ?? f3 ?? f4 ?? f5 ?? f6; + var cert = TestFixtures.MakeCert("cert-001", + extendedCertSearch: TestFixtures.MakeExtendedSearch(f1, f2, f3, f4, f5, f6)); + + var client = new Mock(); + client.Setup(c => c.GetCertificateList(It.IsAny(), It.IsAny())) + .ReturnsAsync(TestFixtures.MakeCertListPage(1, new List { cert })); + client.Setup(c => c.DownloadCertificate(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(TestFixtures.MakeBinaryResponse()); + + var reader = new Mock(); + reader.Setup(r => r.GetStatusByRequestID(It.IsAny())) + .ThrowsAsync(new Exception("not found")); + + var plugin = TestFixtures.BuildPlugin(client.Object, reader.Object, TestFixtures.ConfigWithSync(configuredField)); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, false, CancellationToken.None); + + buffer.Count.Should().Be(1); + buffer.Should().Contain(c => c.ProductID == expectedProcId, + because: $"ProductID should come from {configuredField}"); + } + + [Fact] + public async Task Synchronize_UnrecognisedFieldName_AllCertsSkipped() + { + var certs = new List + { + TestFixtures.MakeCert("cert-001", extendedCertSearch: TestFixtures.MakeExtendedSearch(field1: "ProcA")), + }; + + var client = new Mock(); + client.Setup(c => c.GetCertificateList(It.IsAny(), It.IsAny())) + .ReturnsAsync(TestFixtures.MakeCertListPage(1, certs)); + + var reader = new Mock(); + var plugin = TestFixtures.BuildPlugin(client.Object, reader.Object, TestFixtures.ConfigWithSync("field99")); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, false, CancellationToken.None); + + buffer.Count.Should().Be(0, because: "field99 is not a recognised ExtendedCertSearch field name"); + } + + // ── Incremental vs full sync ────────────────────────────────────────── + + [Fact] + public async Task Synchronize_CertAlreadyInDbWithSameStatus_NotBufferedOnIncrementalSync() + { + var cert = TestFixtures.MakeCert("cert-001", status: "active", + extendedCertSearch: TestFixtures.MakeExtendedSearch(field1: "ProcA")); + + var client = new Mock(); + client.Setup(c => c.GetCertificateList(It.IsAny(), It.IsAny())) + .ReturnsAsync(TestFixtures.MakeCertListPage(1, new List { cert })); + client.Setup(c => c.DownloadCertificate(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(TestFixtures.MakeBinaryResponse()); + + var reader = new Mock(); + reader.Setup(r => r.GetStatusByRequestID("cert-001")) + .ReturnsAsync((int)EndEntityStatus.GENERATED); // cert is already known with same status + + var plugin = TestFixtures.BuildPlugin(client.Object, reader.Object, TestFixtures.ConfigWithSync("field1")); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, fullSync: false, CancellationToken.None); + + buffer.Count.Should().Be(0, because: "cert status hasn't changed and fullSync is false"); + } + + [Fact] + public async Task Synchronize_CertAlreadyInDbWithSameStatus_IsBufferedOnFullSync() + { + var cert = TestFixtures.MakeCert("cert-001", status: "active", + extendedCertSearch: TestFixtures.MakeExtendedSearch(field1: "ProcA")); + + var client = new Mock(); + client.Setup(c => c.GetCertificateList(It.IsAny(), It.IsAny())) + .ReturnsAsync(TestFixtures.MakeCertListPage(1, new List { cert })); + client.Setup(c => c.DownloadCertificate(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(TestFixtures.MakeBinaryResponse()); + + var reader = new Mock(); + reader.Setup(r => r.GetStatusByRequestID("cert-001")) + .ReturnsAsync((int)EndEntityStatus.GENERATED); + + var plugin = TestFixtures.BuildPlugin(client.Object, reader.Object, TestFixtures.ConfigWithSync("field1")); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, fullSync: true, CancellationToken.None); + + buffer.Count.Should().Be(1, because: "fullSync=true forces inclusion regardless of status match"); + } + + [Fact] + public async Task Synchronize_CertStatusChangedInDb_IsBufferedOnIncrementalSync() + { + var cert = TestFixtures.MakeCert("cert-001", status: "revoked", + reason: "key compromise", + extendedCertSearch: TestFixtures.MakeExtendedSearch(field1: "ProcA")); + + var client = new Mock(); + client.Setup(c => c.GetCertificateList(It.IsAny(), It.IsAny())) + .ReturnsAsync(TestFixtures.MakeCertListPage(1, new List { cert })); + client.Setup(c => c.DownloadCertificate(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(TestFixtures.MakeBinaryResponse()); + + var reader = new Mock(); + reader.Setup(r => r.GetStatusByRequestID("cert-001")) + .ReturnsAsync((int)EndEntityStatus.GENERATED); // DB says active, CA says revoked + + var plugin = TestFixtures.BuildPlugin(client.Object, reader.Object, TestFixtures.ConfigWithSync("field1")); + var buffer = new BlockingCollection(100); + + await plugin.Synchronize(buffer, null, fullSync: false, CancellationToken.None); + + buffer.Count.Should().Be(1, because: "the cert's status changed from active to revoked"); + buffer.Should().Contain(c => c.Status == (int)EndEntityStatus.REVOKED && c.RevocationReason.HasValue); + } + + // ── Cancellation ────────────────────────────────────────────────────── + + [Fact] + public async Task Synchronize_CancellationRequested_ThrowsOperationCanceledException() + { + var cts = new CancellationTokenSource(); + cts.Cancel(); + + var client = new Mock(); + var reader = new Mock(); + var plugin = TestFixtures.BuildPlugin(client.Object, reader.Object, TestFixtures.ConfigWithSync("field1")); + var buffer = new BlockingCollection(100); + + await plugin.Invoking(p => p.Synchronize(buffer, null, false, cts.Token)) + .Should().ThrowAsync(); + } + } +} diff --git a/NexusCertManagerCAPlugin.Tests/TestFixtures.cs b/NexusCertManagerCAPlugin.Tests/TestFixtures.cs new file mode 100644 index 0000000..f6d926a --- /dev/null +++ b/NexusCertManagerCAPlugin.Tests/TestFixtures.cs @@ -0,0 +1,140 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using System.Collections.Generic; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.NexusCertManager.models; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace Keyfactor.Extensions.CAPlugin.NexusCertManager.Tests +{ + /// + /// Shared factory helpers used across all test classes. + /// + internal static class TestFixtures + { + // ── Config factories ────────────────────────────────────────────────── + + /// Returns a config with SyncProcedureField unset (sync disabled). + public static NexusCertManagerCAPluginConfig ConfigWithoutSync() => + new NexusCertManagerCAPluginConfig + { + Host = "https://nexus.example.com:8444", + AuthCertificatePath = @"C:\certs\auth.pfx", + AuthCertPassword = "password", + Enabled = true, + SyncProcedureField = null + }; + + /// Returns a config with SyncProcedureField set to the given field name. + public static NexusCertManagerCAPluginConfig ConfigWithSync(string fieldName = "field1") => + new NexusCertManagerCAPluginConfig + { + Host = "https://nexus.example.com:8444", + AuthCertificatePath = @"C:\certs\auth.pfx", + AuthCertPassword = "password", + Enabled = true, + SyncProcedureField = fieldName + }; + + // ── Plugin factory ──────────────────────────────────────────────────── + + /// + /// Builds a plugin instance with all dependencies injected. + /// Avoids any file I/O or real HTTP calls. + /// + public static NexusCertManagerCAPlugin BuildPlugin( + INexusCertManagerClient client, + ICertificateDataReader dataReader, + NexusCertManagerCAPluginConfig config) + { + var logger = NullLogger.Instance; + return new NexusCertManagerCAPlugin(logger, client, dataReader, config); + } + + // ── Model factories ─────────────────────────────────────────────────── + + public static JsonCertificate MakeCert( + string certId, + string status = "active", + string reason = null, + ExtendedCertSearch extendedCertSearch = null) => + new JsonCertificate + { + CertId = certId, + Status = status, + Reason = reason, + ExtendedCertSearch = extendedCertSearch + }; + + public static ExtendedCertSearch MakeExtendedSearch( + string field1 = null, string field2 = null, string field3 = null, + string field4 = null, string field5 = null, string field6 = null) => + new ExtendedCertSearch + { + Field1 = field1, + Field2 = field2, + Field3 = field3, + Field4 = field4, + Field5 = field5, + Field6 = field6, + }; + + public static CertificateListResponse MakeCertListPage( + int totalHits, List certs) => + new CertificateListResponse + { + SearchHits = totalHits, + Certificates = certs + }; + + public static CertificateDetailsResponse MakeCertDetailsResponse(JsonCertificate cert) => + new CertificateDetailsResponse { Certificate = cert }; + + // A real self-signed certificate used as stub data in tests that exercise the + // certificate download/parse path. Generated once with openssl; never expires in + // any timeframe relevant to this codebase (expiry 2036). + private const string _stubPemCert = + "-----BEGIN CERTIFICATE-----\n" + + "MIIC/zCCAeegAwIBAgIUIs543UBfs01GyhSpIf0rU9RBT1swDQYJKoZIhvcNAQEL\n" + + "BQAwDzENMAsGA1UEAwwEdGVzdDAeFw0yNjA1MTQxNzA2MTlaFw0zNjA1MTExNzA2\n" + + "MTlaMA8xDTALBgNVBAMMBHRlc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK\n" + + "AoIBAQC0scDeekblipzkK5EEUfT5Ozh8KqDJMvr7kgT4LjuV6M1N73F9fLGzEq7Y\n" + + "4EqfgJ3k/+mEdxZbPDr8pZhQu8oeeM35Mjmf2fpH/APqLcszG2Ms4SOW3bsvcM7u\n" + + "WUmig405gvQNgNQJyXJf/bZKakCWI00LA86GC1hN2Vj6TqEQNIqXRttJVtvqbfET\n" + + "P2QTDCwI04o0IUpdRkom0HpWqtmPb5+Q3Vz2CwebsSVc3wOUEzeo91J0qCQmSZmX\n" + + "Fxiy8FbWsAWRoMuQgCiBoSmcUBH5gHhm+S5AHClt3y1Lqzb/FBXIvAv94djjLZHI\n" + + "q4T59m/Q7AqZ3jrZizgVv6MnND7TAgMBAAGjUzBRMB0GA1UdDgQWBBS8tnCUL7Bc\n" + + "IDEP1r0BoxTnk2PM7DAfBgNVHSMEGDAWgBS8tnCUL7BcIDEP1r0BoxTnk2PM7DAP\n" + + "BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBqMKqxc//BqpGhSVWV\n" + + "HjusamahkkKA+sRuWctuWjNcX5Y5dJ/DEgnymXcLf19BOZToFy9MQUquAC8Rv2cX\n" + + "AdktYFIASabwLEnbXqiEmUqTND8xpe5tnlsK6q+cHoJTv9lW02j9pKb0KndhU6bw\n" + + "AFjLYOwOyLjMFHLuM2VbOkLOli1gqyZGrzDSmqFeGs0JCSzTNsKpZYe8ihlBhKpG\n" + + "ult87ygYu16KbXLyFifLAwqxkPicfS+04MVRzdj5BgkgtyCutKk+cIu44iIK9S3R\n" + + "lnCR6RG9fNloWZ/yCLChe3BiP7pbPbCasQ59/Xkyv9RIzjRpYn2wNHyhyXt+VQcb\n" + + "bv6i\n" + + "-----END CERTIFICATE-----\n"; + + public static CertificateBinaryResponse MakeBinaryResponse( + string certId = "cert-001", + string pemContent = null) => + new CertificateBinaryResponse + { + CertId = certId, + PEMString = pemContent ?? _stubPemCert, + ContentType = Constants.PEMCHAIN + }; + + public static EnrollmentProductInfo MakeProductInfo(string productId, Dictionary parameters = null) => + new EnrollmentProductInfo + { + ProductID = productId, + ProductParameters = parameters ?? new Dictionary() + }; + } +} diff --git a/NexusCertManagerCAPlugin.sln b/NexusCertManagerCAPlugin.sln index 70cd12e..9b734cf 100644 --- a/NexusCertManagerCAPlugin.sln +++ b/NexusCertManagerCAPlugin.sln @@ -7,10 +7,13 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NexusCertManagerCAPlugin", EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{4FA0BDF6-B41E-4E00-805F-AE79B894784A}" ProjectSection(SolutionItems) = preProject + CHANGELOG.md = CHANGELOG.md docsource\configuration.md = docsource\configuration.md manifest.json = manifest.json EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NexusCertManagerCAPlugin.Tests", "NexusCertManagerCAPlugin.Tests\NexusCertManagerCAPlugin.Tests.csproj", "{57AF39E7-1A53-4D57-8D35-4F07C990A092}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -21,6 +24,10 @@ Global {5107B3B8-4F3A-4A1B-BE0E-AF6A1A0B2995}.Debug|Any CPU.Build.0 = Debug|Any CPU {5107B3B8-4F3A-4A1B-BE0E-AF6A1A0B2995}.Release|Any CPU.ActiveCfg = Release|Any CPU {5107B3B8-4F3A-4A1B-BE0E-AF6A1A0B2995}.Release|Any CPU.Build.0 = Release|Any CPU + {57AF39E7-1A53-4D57-8D35-4F07C990A092}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {57AF39E7-1A53-4D57-8D35-4F07C990A092}.Debug|Any CPU.Build.0 = Debug|Any CPU + {57AF39E7-1A53-4D57-8D35-4F07C990A092}.Release|Any CPU.ActiveCfg = Release|Any CPU + {57AF39E7-1A53-4D57-8D35-4F07C990A092}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/docsource/configuration.md b/docsource/configuration.md index 41b03f6..cb3c585 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -1,24 +1,73 @@ ## Overview -The Nexus Certificate Manager AnyCA REST plugin extends the capabilities of the Nexus Certificate Manager product to Keyfactor Command via the Keyfactor AnyCA Gateway REST. The plugin represents a fully featured AnyCA REST Plugin with the following capabilies: -* Certificate Synchronization -* Certificate Enrollment -* Certificate Revocation +The Nexus Certificate Manager AnyCA Gateway REST Plugin integrates Keyfactor Command with Nexus Smart ID Certificate Manager via the Keyfactor AnyCA Gateway REST framework. It supports the following operations: + +- **Certificate Enrollment** — issues certificates against a named Nexus CA token procedure (ProductID) +- **Certificate Revocation** — revokes certificates by CA request ID +- **Certificate Synchronization** — optional; see [Synchronization](#synchronization) below ## Requirements -- The host URL for the instance of Nexus Certificate Manager -- A certificate in the pfx format to use for authentication into Nexus Certificate Manager, located on the Gateway Host -- The passphrase for the pfx certificate +- The host URL for the Nexus Certificate Manager instance (including port), e.g. `https://192.168.1.10:8444` +- A PFX certificate for authenticating into Nexus Certificate Manager, accessible on the Gateway host +- The passphrase for the PFX certificate +- Server-side signing (VRO mode) must be enabled in the Nexus Protocol Gateway `api.properties` ## Gateway Registration -In order to enroll certificates the Keyfactor Command server must trust the CA chain. Once you identify your Root and/or Subordinate CA used by the Nexus Certificate Manager platform, make sure to download and import the certificate chain into the Command Server certificate store +The Keyfactor Command server must trust the CA chain used by the Nexus Certificate Manager. Identify the Root and/or Subordinate CA, download the certificate chain, and import it into the Command server certificate store before configuring the gateway. + +## CA Connection Parameters + +| Parameter | Required | Description | +|---|---|---| +| `Host` | Yes | The full URI of the Nexus Certificate Manager API, including port. Example: `https://192.168.1.10:8444` | +| `AuthCertificatePath` | Yes | The full path on the AnyCA Gateway host to the PFX certificate used for mutual TLS authentication. Example: `C:\certs\nexus-officer.pfx` | +| `AuthCertPassword` | Yes | The password for the PFX authentication certificate. | +| `Enabled` | Yes | Enables or disables gateway functionality. Set to `false` to defer configuration. | +| `SyncProcedureField` | No | Enables certificate synchronization. See [Synchronization](#synchronization) for full details. | + +## Certificate Template (Product ID) Configuration + +Each certificate template in Command must be associated with a **ProductID** that corresponds to a Nexus CA token procedure name. The plugin calls the `/procedures` endpoint at startup to populate the list of available procedures. + +When enrolling, the procedure name from the selected template's ProductID is passed directly as the `procname` parameter in the enrollment request. + +## Synchronization + +By default, certificate synchronization is **disabled**. This is an intentional design decision: the Nexus CA REST API does not return the issuing procedure name in certificate list or detail responses, making it impossible to reconstruct the correct ProductID (procedure name) for each certificate during a sync. Without a valid ProductID, synchronized certificates cannot be associated with a certificate template in Command and will not appear in the UI. + +### Enabling Synchronization via `SyncProcedureField` + +Synchronization can be enabled by setting the optional `SyncProcedureField` CA connection parameter to the name of a Nexus CA `ExtendedCertSearch` field (e.g. `field1`) that your CA administrator has configured to store the issuing procedure name at enrollment time. + +When `SyncProcedureField` is set: + +- The sync operation pages through all certificates on the CA (500 per page) +- For each certificate, it reads the value of the specified `ExtendedCertSearch` field as the ProductID +- Certificates where the field is empty or missing are skipped and logged as warnings +- Only certificates with a resolvable ProductID are written to Command + +### CA-Side Configuration Requirements + +> ⚠️ **This configuration is entirely outside the scope of Keyfactor support.** The steps below describe what is required on the Nexus CA side. Keyfactor takes no responsibility for the correctness, stability, or ongoing maintenance of this configuration. + +To populate an `ExtendedCertSearch` field with the procedure name at enrollment time, a Nexus CA administrator must: + +1. **Develop a custom Java InputView** — InputViews are Java programs that run inside the Nexus Registration Authority (RA) and populate certificate attributes at issuance time. Custom InputViews must be compiled into a `.jar`, deployed to the CM server's `lib` directory, and registered in `cm.conf`. This requires working knowledge of the Nexus InputView API (documented in the CM Developers Guide). + +2. **Configure the InputView to write the procedure name into an `ExtendedCertSearch` field** — the `ExtendedCertSearch` database table supports six generic fields (`field1`–`field6`). The InputView must be written to set the desired field to the issuing procedure name during enrollment. + +3. **Associate the InputView with token procedures in the AWB** — using the Administrator's Workbench, a CA administrator must select the new InputView for each token procedure. This change must be signed by two administration officers in accordance with Nexus CM's four-eyes policy. + +4. **Set `SyncProcedureField`** on the Keyfactor CA connection to the field name used (e.g. `field1`). -## CA Connection +### Important Limitations -The certificate used by the gateway for authenticating into the Nexus Certificate Manager will need to be copied to a location on the Gateway Host that is accessible by the gateway service. The Certificate Path +- Certificates issued **before** the CA-side configuration change was made will not have the field populated and will be skipped during sync. +- If the CA admin changes or repurposes the configured `ExtendedCertSearch` field, sync will silently produce incorrect results. The plugin will log warnings for affected certificates. +- The `SyncProcedureField` value must exactly match the field name (case-insensitive): `field1`, `field2`, `field3`, `field4`, `field5`, or `field6`. Any other value will cause all certificates to be skipped during sync. -## Certificate Template Creation Step +## CHANGELOG -For this AnyCA Gateway, there is a single product type named "NexusCM". \ No newline at end of file +See [CHANGELOG.md](../CHANGELOG.md). diff --git a/integration-manifest.json b/integration-manifest.json index aec0196..a53b85d 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -1,7 +1,7 @@ { "$schema": "https://keyfactor.github.io/v2/integration-manifest-schema.json", "integration_type": "anyca-plugin", - "name": "Nexus Certificate Maanager AnyCA REST Gateway Plugin", + "name": "Nexus Certificate Manager AnyCA REST Gateway Plugin", "status": "prototype", "support_level": "kf-community", "link_github": false, @@ -12,23 +12,27 @@ "release_project": "nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.csproj", "about": { "carest": { - "product_ids": [ "NexusCM" ], + "product_ids": [], "ca_plugin_config": [ { "name": "Host", - "description": "The URI of the instance of the Nexus Certificate Manager API, including port. example: https://127.0.0.1:8444" + "description": "The URI of the Nexus Certificate Manager API, including port. Example: https://192.168.1.10:8444" }, { "name": "AuthCertificatePath", - "description": "The path on the AnyCA Gateway host where the PFX certificate that will be used for authentication can be found. example: 'C:\\Program Files\\Keyfactor\\Keyfactor AnyCA Gateway\\AnyGatewayREST\\net8.0\\my_auth_cert.pfx'" + "description": "The full path on the AnyCA Gateway host to the PFX certificate used for mutual TLS authentication. Example: C:\\certs\\nexus-officer.pfx" }, { "name": "AuthCertPassword", - "description": "The password for the PFX certificate located on the AnyCA Gateway Host that will be used for authentication into Nexus Certificate Manager" + "description": "The password for the PFX authentication certificate." }, { "name": "Enabled", "description": "Flag to enable or disable gateway functionality. Disabling is primarily used to allow creation of the CA prior to configuration information being available." + }, + { + "name": "SyncProcedureField", + "description": "Optional. Enables certificate synchronization. Set to the name of the Nexus CA ExtendedCertSearch field (e.g. 'field1') that the CA administrator has configured to store the issuing procedure name. When omitted, synchronization is disabled. See documentation for CA-side configuration requirements." } ], "enrollment_config": [] diff --git a/nexus-certificate-manager-caplugin/Constants.cs b/nexus-certificate-manager-caplugin/Constants.cs index 79da296..f299816 100644 --- a/nexus-certificate-manager-caplugin/Constants.cs +++ b/nexus-certificate-manager-caplugin/Constants.cs @@ -10,24 +10,27 @@ namespace Keyfactor.Extensions.CAPlugin.NexusCertManager { public static class Constants { - //names + // config property names public const string HOST = "Host"; public const string AUTHCERTPATH = "AuthCertificatePath"; - public const string ENABLED = "Enabled"; + public const string ENABLED = "Enabled"; public const string AUTHCERTPASSWORD = "AuthCertPassword"; + public const string SYNC_PROCEDURE_FIELD = "SyncProcedureField"; - - //values + // API / HTTP values public const string APIPATH = "pgwy/api"; - public const string PRODUCTID = "NexusCM"; public const string PKCS7MIMETYPE = "application/pkcs7-mime"; public const string PEMCHAIN = "application/pem-certificate-chain"; + // procedure media types public const string MEDIATYPE_PKCS10 = "pkcs10"; public const string MEDIATYPE_PKCS12 = "pkcs12"; public const string MEDIATYPE_SMARTCARD = "smartcard"; public const string MEDIATYPE_ATTRIBUTECERT = "attributecertificate"; public const string MEDIATYPE_DATA = "data"; + + // pagination + public const int SYNC_PAGE_SIZE = 500; } public static class ApiEndpoints diff --git a/nexus-certificate-manager-caplugin/INexusCertManagerClient.cs b/nexus-certificate-manager-caplugin/INexusCertManagerClient.cs new file mode 100644 index 0000000..f552a06 --- /dev/null +++ b/nexus-certificate-manager-caplugin/INexusCertManagerClient.cs @@ -0,0 +1,30 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Keyfactor.Extensions.CAPlugin.NexusCertManager.models; + +namespace Keyfactor.Extensions.CAPlugin.NexusCertManager +{ + /// + /// Abstraction over the Nexus Certificate Manager REST API client. + /// Extracted primarily to enable unit testing of + /// without requiring a live Nexus CA instance or a real PFX certificate on disk. + /// + public interface INexusCertManagerClient + { + Task Enroll(string csr, string procName, CancellationToken ct = default); + Task GetCertificateDetails(string certId, CancellationToken ct = default); + Task DownloadCertificate(string certId, string format = Constants.PEMCHAIN, CancellationToken ct = default); + Task RevokeCertificate(string certId, int reason, CancellationToken ct = default); + Task GetCertificateList(ListCertificatesRequest req = null, CancellationToken ct = default); + Task> GetProceduresByMediaType(string mediaType = Constants.MEDIATYPE_PKCS10); + Task PingServer(); + } +} diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs index a41437c..f7a541f 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.cs @@ -6,42 +6,60 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions // and limitations under the License. -using Keyfactor.AnyGateway.Extensions; -using Keyfactor.Extensions.CAPlugin.NexusCertManager.models; -using Keyfactor.Logging; -using Microsoft.Extensions.Logging; -using Keyfactor.PKI.Enums.EJBCA; -using Newtonsoft.Json; +using System; using System.Collections.Concurrent; -using System.Security.Cryptography.X509Certificates; -using System.Threading.Tasks; using System.Collections.Generic; +using System.IO; using System.Linq; -using System; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; using System.Threading; -using System.IO; +using System.Threading.Tasks; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.NexusCertManager.models; +using Keyfactor.Logging; +using Keyfactor.PKI.Enums.EJBCA; +using Microsoft.Extensions.Logging; namespace Keyfactor.Extensions.CAPlugin.NexusCertManager { public class NexusCertManagerCAPlugin : IAnyCAPlugin { + private static readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true }; + private readonly ILogger _logger; private NexusCertManagerCAPluginConfig _config; private ICertificateDataReader _certificateDataReader; - private NexusCertManagerClient _client; + private INexusCertManagerClient _client; public NexusCertManagerCAPlugin(ILogger logger) { _logger = logger; } + /// + /// Internal constructor used by unit tests to inject mock dependencies + /// without requiring a live Nexus CA or a PFX certificate on disk. + /// + internal NexusCertManagerCAPlugin( + ILogger logger, + INexusCertManagerClient client, + ICertificateDataReader certificateDataReader, + NexusCertManagerCAPluginConfig config) + { + _logger = logger; + _client = client; + _certificateDataReader = certificateDataReader; + _config = config; + } + public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDataReader certificateDataReader) { LogPluginVersion(); - string rawConfig = JsonConvert.SerializeObject(configProvider.CAConnectionData); + string rawConfig = JsonSerializer.Serialize(configProvider.CAConnectionData); _logger.LogTrace($"serialized configuration values: \n{rawConfig}\n"); - _config = JsonConvert.DeserializeObject(rawConfig); + _config = JsonSerializer.Deserialize(rawConfig, _jsonOptions); _logger.LogTrace($"deserialized the configuration:\nAuthCertPath: {_config.AuthCertificatePath}\nHost: {_config.Host}\nAuthCertPassword: {_config.AuthCertPassword}"); _client = new NexusCertManagerClient(_config.Host, _config.AuthCertificatePath, _config.AuthCertPassword); // need to set the values _certificateDataReader = certificateDataReader; @@ -76,7 +94,7 @@ public async Task Enroll(string csr, string subject, Dictionar _logger.LogTrace($"Attempting to enroll for certificate with:\nSubject: {subject}\nSANs: {sans}\nParams: {paramsList}\nCSR: {csr}"); try { - var res = await _client.Enroll(csr); + var res = await _client.Enroll(csr, productInfo.ProductID); var enrollmentResult = new EnrollmentResult { @@ -110,21 +128,21 @@ public Dictionary GetCAConnectorAnnotations() { [Constants.HOST] = new PropertyConfigInfo { - Comments = "The path to the Nexus CM server, including port", + Comments = "The URI of the Nexus Certificate Manager API, including port. Example: https://192.168.1.10:8444", Hidden = false, DefaultValue = "", Type = "String" }, [Constants.AUTHCERTPATH] = new PropertyConfigInfo { - Comments = "The path to the PFX certificate for authenticating into Nexus CM", + Comments = "The full path on the AnyCA Gateway host to the PFX certificate used for authenticating into Nexus Certificate Manager.", Hidden = false, DefaultValue = "", Type = "String" }, [Constants.AUTHCERTPASSWORD] = new PropertyConfigInfo { - Comments = "The password for the authentication certificate", + Comments = "The password for the PFX authentication certificate.", Hidden = true, DefaultValue = "", Type = "String" @@ -135,20 +153,45 @@ public Dictionary GetCAConnectorAnnotations() Hidden = false, DefaultValue = true, Type = "Boolean" + }, + [Constants.SYNC_PROCEDURE_FIELD] = new PropertyConfigInfo + { + Comments = "Optional. Enables certificate synchronization. Set this to the name of the Nexus CA ExtendedCertSearch field (e.g. \"field1\") " + + "that your CA administrator has configured to store the issuing procedure name at enrollment time. " + + "When provided, Synchronize will read that field from each certificate to reconstruct its ProductID (procedure name). " + + "When omitted, Synchronize is disabled because the Nexus CA API does not natively return the issuing procedure with certificate records. " + + "NOTE: Configuring the Nexus CA to populate this field requires custom Java InputView development and AWB policy changes by a CA administrator. " + + "This configuration is outside the scope of Keyfactor support.", + Hidden = false, + DefaultValue = "", + Type = "String" } }; } /// - /// this CA is does not split it's certificates into discernable "product types" - /// consequently, we are using a single product type for all certificates. + /// Product ID's correspond to 'procedures' in the Nexus Certificate Manager /// - /// A list of strings containing one element: "NexusCA" + /// A list of procedure identifiers to use as the product ID's public List GetProductIds() { _logger.MethodEntry(); - return new List { Constants.PRODUCTID }; + var productIds = new List(); + try + { + productIds = _client.GetProceduresByMediaType().GetAwaiter().GetResult(); + _logger.LogTrace($"successfully retrieved {productIds.Count} procedure names"); + } + catch (Exception ex) + { + _logger.LogError($"An error occurred when attempting to retrieve the procedure names: {LogHandler.FlattenException(ex)}"); + } + finally + { + _logger.MethodExit(); + } + return productIds; } public async Task GetSingleRecord(string caRequestID) @@ -159,14 +202,20 @@ public async Task GetSingleRecord(string caRequestID) _logger.LogTrace($"getting certificate details for certId: {caRequestID}"); var certDetails = await _client.GetCertificateDetails(caRequestID); - _logger.LogTrace($"download certificate with ID: {caRequestID}"); + _logger.LogTrace($"downloading certificate with ID: {caRequestID}"); var certContent = await _client.DownloadCertificate(caRequestID); + // Resolve ProductID from the configured ExtendedCertSearch field if available; + // otherwise leave null so the Gateway framework handles the unresolvable case. + string productId = ResolveProductIdFromExtendedSearch(certDetails.Certificate.ExtendedCertSearch); + if (productId == null) + _logger.LogWarning($"Unable to resolve ProductID for cert {caRequestID}: SyncProcedureField is not configured or the field was empty."); + var cert = new AnyCAPluginCertificate() { CARequestID = caRequestID, Certificate = certContent.Base64EncodedCertificateData, - ProductID = certDetails.Certificate.CertId, + ProductID = productId, Status = Helpers.GetStatusCodeFromNexusCADescription(certDetails.Certificate.Status), }; if (cert.Status == (int)EndEntityStatus.REVOKED) @@ -175,7 +224,6 @@ public async Task GetSingleRecord(string caRequestID) cert.RevocationReason = Helpers.GetRevocationReasonCodeFromNexusCADescription(certDetails.Certificate.Reason); } return cert; - } catch (Exception ex) { @@ -239,105 +287,170 @@ public async Task Revoke(string caRequestID, string hexSerialNumber, uint r } /// - /// Synchronize gets the list of certs from the CA and updates the status of each known cert to the latest, and adds missing cert info to the database. + /// Synchronizes certificates from the Nexus CA into Command. + /// + /// Synchronization requires the SyncProcedureField CA connection parameter to be configured. + /// When not configured, this method throws with an explanation. + /// See the plugin documentation for guidance on enabling sync. + /// /// - /// the database reader, passed by framework - /// the time of last sync - /// whether or not to perform a full sync - /// the cancel token - /// + /// Certificate buffer provided by the Gateway framework. + /// The time of the last sync operation. + /// Whether to perform a full sync regardless of last-sync time. + /// Cancellation token. public async Task Synchronize(BlockingCollection blockingBuffer, DateTime? lastSync, bool fullSync, CancellationToken cancelToken) { _logger.MethodEntry(); + + if (string.IsNullOrWhiteSpace(_config.SyncProcedureField)) + { + throw new NotSupportedException( + "Certificate synchronization is not supported by the Nexus Certificate Manager CA Plugin unless the " + + "'SyncProcedureField' CA connection parameter is configured. " + + "The Nexus CA REST API does not return the issuing procedure name in certificate list or detail responses, " + + "making it impossible to associate synced certificates with their originating ProductID (procedure). " + + "To enable sync, a Nexus CA administrator must configure a token procedure to populate one of the " + + "ExtendedCertSearch fields (field1-field6) with the procedure name at enrollment time, then set " + + "'SyncProcedureField' on this CA connection to the name of that field (e.g. 'field1'). " + + "See the plugin documentation for full details. Note: this CA-side configuration requires custom " + + "Java InputView development and is outside the scope of Keyfactor support."); + } + + _logger.LogTrace($"Sync is enabled. Resolving ProductID from ExtendedCertSearch field: '{_config.SyncProcedureField}'"); + var updatedCerts = new List(); + int offset = 0; + int totalHits = 0; + int pageCount = 0; try { - // retrieve the list of certs from Nexus CM - _logger.LogTrace("attempting to retrieve the list of cert names from Nexus CM"); - var certList = await _client.GetCertificateList(null, cancelToken); - _logger.LogTrace($"successfully returned {certList.SearchHits} results."); - - certList.Certificates.ForEach(async cert => + // paginated fetch loop + do { - var dbStatus = -1; - - _logger.LogTrace("- cert details - "); - _logger.LogTrace($"certId: {cert.CertId}"); - _logger.LogTrace($"status: {cert.Status}"); - _logger.LogTrace($"revocation time: {cert.RevocationTime}"); - _logger.LogTrace($"serial number: {cert.CertificateSerialNumber}"); - _logger.LogTrace($"reason: {cert.Reason}"); + cancelToken.ThrowIfCancellationRequested(); - var updatedCert = new AnyCAPluginCertificate - { - CARequestID = cert.CertId, - ProductID = Constants.PRODUCTID, - Status = Helpers.GetStatusCodeFromNexusCADescription(cert.Status), - RevocationDate = cert.RevocationTime, - - }; - if (!string.IsNullOrEmpty(cert.Reason)) { - updatedCert.RevocationReason = Helpers.GetRevocationReasonCodeFromNexusCADescription(cert.Reason); - } - // check for an existing local entry - try + _logger.LogTrace($"Fetching certificate page: offset={offset}, pageSize={Constants.SYNC_PAGE_SIZE}"); + var page = await _client.GetCertificateList(new ListCertificatesRequest { - _logger.LogTrace($"attempting to retreive status of cert with tracking id {cert.CertId} from the database"); - dbStatus = await _certificateDataReader.GetStatusByRequestID(cert.CertId); - } - catch + SearchLimit = Constants.SYNC_PAGE_SIZE, + SearchOffset = offset + }, cancelToken); + + if (pageCount == 0) { - _logger.LogTrace($"tracking id {cert.CertId} was not found in the database. it will be added."); + totalHits = page.SearchHits; + _logger.LogTrace($"Total certificates reported by Nexus CA: {totalHits}"); } - if (dbStatus == -1 || fullSync || (updatedCert.Status != dbStatus)) + var certs = page.Certificates ?? new List(); + _logger.LogTrace($"Page returned {certs.Count} certificates"); + + foreach (var cert in certs) { - // if it is a new cert, if we are doing a full sync, or if the status changed; we add it to collection to be updated in the db - updatedCerts.Add(updatedCert); + var productId = ResolveProductIdFromExtendedSearch(cert.ExtendedCertSearch); + if (productId == null) + { + _logger.LogWarning($"Cert {cert.CertId}: ExtendedCertSearch field '{_config.SyncProcedureField}' was empty or missing. " + + $"This certificate will be skipped during sync."); + continue; + } + + var updatedCert = new AnyCAPluginCertificate + { + CARequestID = cert.CertId, + ProductID = productId, + Status = Helpers.GetStatusCodeFromNexusCADescription(cert.Status), + RevocationDate = cert.RevocationTime, + }; + + if (!string.IsNullOrEmpty(cert.Reason)) + updatedCert.RevocationReason = Helpers.GetRevocationReasonCodeFromNexusCADescription(cert.Reason); + + // check for an existing local entry + var dbStatus = -1; + try + { + dbStatus = await _certificateDataReader.GetStatusByRequestID(cert.CertId); + } + catch + { + _logger.LogTrace($"Cert {cert.CertId} not found in local database — will be added."); + } + + if (dbStatus == -1 || fullSync || updatedCert.Status != dbStatus) + updatedCerts.Add(updatedCert); } - }); - // now get the cert content for each.. - _logger.LogTrace($"getting certificate content for each.."); + offset += certs.Count; + pageCount++; + + } while (offset < totalHits); + _logger.LogTrace($"Pagination complete. {pageCount} page(s) fetched. {updatedCerts.Count} certificates queued for update."); + + // download certificate content for each cert that needs updating foreach (var cert in updatedCerts) { - if (cancelToken.IsCancellationRequested) - { - _logger.LogInformation("Nexus CA sync cancelled."); - cancelToken.ThrowIfCancellationRequested(); - } + cancelToken.ThrowIfCancellationRequested(); + + _logger.LogTrace($"Downloading certificate content for certId: {cert.CARequestID}"); var certContent = await _client.DownloadCertificate(cert.CARequestID, Constants.PEMCHAIN, cancelToken); - _logger.LogTrace("getting the leaf certificate"); cert.Certificate = Helpers.GetEndEntityCertificate(certContent.Base64EncodedCertificateData, _logger); - _logger.LogTrace($"leaf cert: {cert.Certificate}"); } - _logger.LogTrace($"got the content for {updatedCerts.Count} certs"); - _logger.LogTrace($"updating the database.."); - + _logger.LogTrace($"Writing {updatedCerts.Count} certificates to the buffer."); foreach (var cert in updatedCerts) { - _logger.LogTrace($"adding cert with id: {cert.CARequestID} and productID {cert.ProductID}"); + _logger.LogTrace($"Buffering cert id={cert.CARequestID}, productId={cert.ProductID}"); blockingBuffer.Add(cert, cancelToken); } - _logger.LogTrace($"successfully synced {updatedCerts.Count}"); + _logger.LogInformation($"Nexus CA sync complete. {updatedCerts.Count} certificate(s) synchronized."); + } + catch (OperationCanceledException) + { + _logger.LogInformation("Nexus CA sync was cancelled."); + throw; } catch (Exception ex) { - _logger.LogError($"an error occurred during the sync: {ex.Message}"); - _logger.LogError($"{LogHandler.FlattenException(ex)}"); + _logger.LogError($"An error occurred during sync: {LogHandler.FlattenException(ex)}"); throw; } finally { - _logger.LogTrace("successfully completed CA sync for Nexus CM"); _logger.MethodExit(); } } + /// + /// Reads the ProductID (procedure name) from the configured ExtendedCertSearch field on a certificate. + /// Returns null if SyncProcedureField is not configured or the field value is empty. + /// + private string ResolveProductIdFromExtendedSearch(ExtendedCertSearch extendedCertSearch) + { + if (string.IsNullOrWhiteSpace(_config.SyncProcedureField) || extendedCertSearch == null) + return null; + + var fieldName = _config.SyncProcedureField.Trim().ToLowerInvariant(); + var value = fieldName switch + { + "field1" => extendedCertSearch.Field1, + "field2" => extendedCertSearch.Field2, + "field3" => extendedCertSearch.Field3, + "field4" => extendedCertSearch.Field4, + "field5" => extendedCertSearch.Field5, + "field6" => extendedCertSearch.Field6, + _ => null + }; + + if (value == null) + _logger.LogWarning($"SyncProcedureField '{_config.SyncProcedureField}' is not a recognised ExtendedCertSearch field name. Valid values are: field1, field2, field3, field4, field5, field6."); + + return string.IsNullOrWhiteSpace(value) ? null : value; + } + public async Task ValidateCAConnectionInfo(Dictionary connectionInfo) { _logger.MethodEntry(); @@ -416,7 +529,7 @@ public async Task ValidateCAConnectionInfo(Dictionary connection else { // validate that it is a valid url - var valid = Uri.TryCreate((string)connectionInfo[Constants.HOST], UriKind.Absolute, out var newUri); + var valid = Uri.TryCreate((string)connectionInfo[Constants.HOST], UriKind.Absolute, out _); if (!valid) { errors.Add($"the host URL {connectionInfo[Constants.HOST]} could not be parsed as a valid URL"); @@ -455,13 +568,15 @@ public async Task ValidateCAConnectionInfo(Dictionary connection } /// - /// Since we are using a single productId, there is nothing to validate + /// Validates that the ProductID on the enrollment request is a non-empty string. + /// ProductIDs correspond to Nexus CA procedure names; an empty value would cause + /// enrollment to fall back to the server-side default procedure, which is rarely intended. /// - /// - /// - /// Task.CompletedTask public Task ValidateProductInfo(EnrollmentProductInfo productInfo, Dictionary connectionInfo) - { + { + if (string.IsNullOrWhiteSpace(productInfo?.ProductID)) + throw new AnyCAValidationException("ProductID (procedure name) must not be empty. Ensure the certificate template is configured with a valid Nexus CA procedure name."); + return Task.CompletedTask; } } diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.csproj b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.csproj index 214234b..e9f41af 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.csproj +++ b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.csproj @@ -1,7 +1,7 @@  - net6.0;net8.0 + net8.0 Keyfactor.Extensions.CAPlugin.NexusCertManager disable disable @@ -9,6 +9,12 @@ NexusCertManagerCAPlugin + + + <_Parameter1>NexusCertManagerCAPlugin.Tests + + + diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerCAPluginConfig.cs b/nexus-certificate-manager-caplugin/NexusCertManagerCAPluginConfig.cs index bee1d48..a6b43b7 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerCAPluginConfig.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerCAPluginConfig.cs @@ -23,5 +23,14 @@ public class NexusCertManagerCAPluginConfig [JsonPropertyName(Constants.ENABLED)] public bool Enabled { get; set; } + + /// + /// Optional. The name of the ExtendedCertSearch field (e.g. "field1") that the Nexus CA + /// has been configured to populate with the issuing procedure name at certificate issuance + /// time. When set, Synchronize will use this field to resolve each certificate's ProductID. + /// When absent, Synchronize is disabled — see documentation for details. + /// + [JsonPropertyName(Constants.SYNC_PROCEDURE_FIELD)] + public string SyncProcedureField { get; set; } } } diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs b/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs index 495af84..6604d43 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs +++ b/nexus-certificate-manager-caplugin/NexusCertManagerClient.cs @@ -6,28 +6,26 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions // and limitations under the License. -using Keyfactor.Extensions.CAPlugin.NexusCertManager.models; -using Keyfactor.Logging; -using Microsoft.Extensions.Logging; -using RestSharp; using System; using System.Collections.Generic; using System.Linq; using System.Security.Cryptography.X509Certificates; using System.Threading; using System.Threading.Tasks; +using Keyfactor.Extensions.CAPlugin.NexusCertManager.models; +using Keyfactor.Logging; +using Microsoft.Extensions.Logging; +using RestSharp; namespace Keyfactor.Extensions.CAPlugin.NexusCertManager { - - public class NexusCertManagerClient + public class NexusCertManagerClient : INexusCertManagerClient { private readonly ILogger _logger; private RestClient _restClient; private string _host; private string _authCertPath; - public NexusCertManagerClient(string hostAndPort, string authCertPath, string authCertPassword) { _logger = LogHandler.GetClassLogger(typeof(NexusCertManagerClient)); @@ -56,12 +54,13 @@ public NexusCertManagerClient(string hostAndPort, string authCertPath, string au var clientCerts = new X509CertificateCollection(); clientCerts.Add(clientCertificate); - var options = new RestClientOptions(url) { ClientCertificates = clientCerts, RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => true }; - //var options = new RestClientOptions(url) { ClientCertificates = clientCerts }; + + var options = new RestClientOptions(url) { ClientCertificates = clientCerts, RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => true }; _restClient = new RestClient(options); } - public async Task Enroll(string csr, CancellationToken ct = new CancellationToken()) + + public async Task Enroll(string csr, string procName, CancellationToken ct = new CancellationToken()) { _logger.MethodEntry(); _logger.LogTrace($"preparing the request for enrollment."); @@ -69,22 +68,9 @@ public NexusCertManagerClient(string hostAndPort, string authCertPath, string au var req = new RestRequest(ApiEndpoints.ENROLL, Method.Post); req.AddHeader("Accept", Constants.PEMCHAIN); req.AddParameter("pkcs10", csr); - var procname = string.Empty; - try - { - _logger.LogTrace("getting first available proc name for pkcs10 to submit with request.."); - var procs = await GetProceduresByMediaType(Constants.MEDIATYPE_PKCS10); - procname = procs?.FirstOrDefault(); - if (!string.IsNullOrEmpty(procname)) - { - req.AddParameter("procname", procname); - } - } - catch (Exception ex) - { - _logger.LogError($"unable to find a procedure for the media type {Constants.MEDIATYPE_PKCS10}: {LogHandler.FlattenException(ex)}"); - _logger.LogTrace("we will attempt to perform enrollment without specifying procedure ID; relying on the default procedure to be configured"); - } + req.AddParameter("procname", procName); + + _logger.LogTrace($"using the provided procedure name (product ID) of {procName}"); try { @@ -104,6 +90,7 @@ public NexusCertManagerClient(string hostAndPort, string authCertPath, string au finally { _logger.MethodExit(); } } + /// /// Returns detailed information about a certificate /// @@ -132,6 +119,7 @@ public NexusCertManagerClient(string hostAndPort, string authCertPath, string au finally { _logger.MethodExit(); } } + /// /// Downloads the contents of a certificate /// @@ -172,6 +160,7 @@ public NexusCertManagerClient(string hostAndPort, string authCertPath, string au finally { _logger.MethodExit(); } } + /// /// Sends a request to revoke a certificate /// @@ -187,7 +176,7 @@ public NexusCertManagerClient(string hostAndPort, string authCertPath, string au var req = new RestRequest(endpoint, Method.Post); req.AddHeader("Content-Type", "application/x-www-form-urlencoded"); - req.AddParameter("certid", certId); + req.AddParameter("certId", certId); req.AddParameter("reason", reason); _logger.LogTrace($"sending a request to {endpoint} to revoke certificate with ID {certId} and reason code {reason}"); @@ -201,10 +190,15 @@ public NexusCertManagerClient(string hostAndPort, string authCertPath, string au } finally { _logger.MethodExit(); } } + + /// - /// Returns a list of certificates + /// Returns one page of certificates matching the provided search parameters. + /// Use and + /// for pagination. + /// The total result count is available on the returned . /// - /// + /// Optional search / pagination parameters. /// /// public async Task GetCertificateList(ListCertificatesRequest req = null, CancellationToken ct = new CancellationToken()) @@ -212,10 +206,43 @@ public NexusCertManagerClient(string hostAndPort, string authCertPath, string au _logger.MethodEntry(); try { - var endpoint = ApiEndpoints.LISTCERTS; - _logger.LogTrace($"performing the GET request for endpoint {endpoint}"); - var res = await _restClient.GetAsync(endpoint, ct); - _logger.LogTrace($"received a response. Number of certs returned: {res.SearchHits}"); + var restReq = new RestRequest(ApiEndpoints.LISTCERTS, Method.Get); + + if (req != null) + { + if (req.SearchLimit.HasValue) restReq.AddQueryParameter("searchLimit", req.SearchLimit.Value.ToString()); + if (req.SearchOffset.HasValue) restReq.AddQueryParameter("searchOffset", req.SearchOffset.Value.ToString()); + if (!string.IsNullOrEmpty(req.OrderBy)) restReq.AddQueryParameter("orderBy", req.OrderBy); + if (req.OrderDescending.HasValue) restReq.AddQueryParameter("orderDescending", req.OrderDescending.Value.ToString().ToLower()); + if (req.IsNotRevoked.HasValue) restReq.AddQueryParameter("isNotRevoked", req.IsNotRevoked.Value.ToString().ToLower()); + if (req.IsExpired.HasValue) restReq.AddQueryParameter("isExpired", req.IsExpired.Value.ToString().ToLower()); + if (req.IsNotYetValid.HasValue) restReq.AddQueryParameter("isNotYetValid", req.IsNotYetValid.Value.ToString().ToLower()); + if (!string.IsNullOrEmpty(req.Field1)) restReq.AddQueryParameter("field1", req.Field1); + if (!string.IsNullOrEmpty(req.Field2)) restReq.AddQueryParameter("field2", req.Field2); + if (!string.IsNullOrEmpty(req.Field3)) restReq.AddQueryParameter("field3", req.Field3); + if (!string.IsNullOrEmpty(req.Field4)) restReq.AddQueryParameter("field4", req.Field4); + if (!string.IsNullOrEmpty(req.Field5)) restReq.AddQueryParameter("field5", req.Field5); + if (!string.IsNullOrEmpty(req.Field6)) restReq.AddQueryParameter("field6", req.Field6); + if (!string.IsNullOrEmpty(req.SubjectCommonName)) restReq.AddQueryParameter("subjectCommonName", req.SubjectCommonName); + if (!string.IsNullOrEmpty(req.CertificateSerialNumber)) restReq.AddQueryParameter("certificateSerialNumber", req.CertificateSerialNumber); + if (!string.IsNullOrEmpty(req.Issuer)) restReq.AddQueryParameter("issuer", req.Issuer); + if (!string.IsNullOrEmpty(req.AuthorityKeyIdentifier)) restReq.AddQueryParameter("authorityKeyIdentifier", req.AuthorityKeyIdentifier); + if (!string.IsNullOrEmpty(req.SubjectKeyIdentifier)) restReq.AddQueryParameter("subjectKeyIdentifier", req.SubjectKeyIdentifier); + if (req.SubjectType != null && req.SubjectType.Count > 0) + restReq.AddQueryParameter("subjectType", string.Join(",", req.SubjectType)); + if (req.RevocationReason != null && req.RevocationReason.Count > 0) + restReq.AddQueryParameter("revocationReason", string.Join(",", req.RevocationReason)); + if (req.RevocationTimeFrom.HasValue) restReq.AddQueryParameter("revocationTimeFrom", req.RevocationTimeFrom.Value.ToString("o")); + if (req.RevocationTimeTo.HasValue) restReq.AddQueryParameter("revocationTimeTo", req.RevocationTimeTo.Value.ToString("o")); + if (req.ValidFromTimeFrom.HasValue) restReq.AddQueryParameter("validFromTimeFrom", req.ValidFromTimeFrom.Value.ToString("o")); + if (req.ValidFromTimeTo.HasValue) restReq.AddQueryParameter("validFromTimeTo", req.ValidFromTimeTo.Value.ToString("o")); + if (req.ValidToTimeFrom.HasValue) restReq.AddQueryParameter("validToTimeFrom", req.ValidToTimeFrom.Value.ToString("o")); + if (req.ValidToTimeTo.HasValue) restReq.AddQueryParameter("validToTimeTo", req.ValidToTimeTo.Value.ToString("o")); + } + + _logger.LogTrace($"performing the GET request for endpoint {_restClient.BuildUri(restReq)}"); + var res = await _restClient.GetAsync(restReq, ct); + _logger.LogTrace($"received a response. searchHits: {res.SearchHits}, certificates in page: {res.Certificates?.Count}"); return res; } catch (Exception ex) @@ -224,8 +251,9 @@ public NexusCertManagerClient(string hostAndPort, string authCertPath, string au throw; } finally { _logger.MethodExit(); } - } + + /// /// retreives the procedures associated with the provided media type value /// @@ -240,10 +268,10 @@ public async Task> GetProceduresByMediaType(string mediaType = Cons { var endpoint = ApiEndpoints.LISTPROCEDURES; var req = new RestRequest(endpoint); - req.AddQueryParameter("mediaType", Constants.MEDIATYPE_PKCS10); + req.AddQueryParameter("mediaType", mediaType); var res = await _restClient.GetAsync(req); - var procedures = res.Procedures.Select(p => p.ProcId).ToList(); + var procedures = res.Procedures.Select(p => p.Name).ToList(); _logger.LogTrace($"successfully retrieved a list of {procedures.Count} procedures for mediaType {mediaType}"); return procedures; } @@ -261,7 +289,7 @@ public async Task> GetProceduresByMediaType(string mediaType = Cons /// The content of the response is ignored /// /// A boolean indicating whether the server is reachable and responding. - public async Task PingServer() + public async Task PingServer() { _logger.MethodEntry(); try diff --git a/nexus-certificate-manager-caplugin/models/Helpers.cs b/nexus-certificate-manager-caplugin/models/Helpers.cs index e0de08d..3efa3fb 100644 --- a/nexus-certificate-manager-caplugin/models/Helpers.cs +++ b/nexus-certificate-manager-caplugin/models/Helpers.cs @@ -6,16 +6,15 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions // and limitations under the License. -using Keyfactor.PKI.Enums.EJBCA; -using Keyfactor.PKI.X509; -using Microsoft.Extensions.Logging; -using Org.BouncyCastle.Tls; -using RestSharp; using System; using System.Linq; using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.Json; +using Keyfactor.PKI.Enums.EJBCA; +using Keyfactor.PKI.X509; +using Microsoft.Extensions.Logging; +using RestSharp; namespace Keyfactor.Extensions.CAPlugin.NexusCertManager.models { @@ -24,7 +23,7 @@ public static class Helpers public static string ParseSubject(string subject, string rdn) { string escapedSubject = subject.Replace("\\,", "|"); - string rdnString = escapedSubject.Split(',').ToList().Where(x => x.Contains(rdn)).FirstOrDefault(); + string rdnString = escapedSubject.Split(',').ToList().FirstOrDefault(x => x.Contains(rdn)); if (!string.IsNullOrEmpty(rdnString)) { From f2f77682e1f16c885c6e45bc8b09965cae1a5f43 Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Thu, 14 May 2026 17:41:27 +0000 Subject: [PATCH 31/35] Update generated docs --- README.md | 165 +++++++++++++++++++++++++++++++++++++ docsource/configuration.md | 5 ++ integration-manifest.json | 76 ++++++++--------- 3 files changed, 208 insertions(+), 38 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..195cb0b --- /dev/null +++ b/README.md @@ -0,0 +1,165 @@ +

+ Nexus Certificate Manager AnyCA Gateway REST Plugin +

+ +

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

+ +

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

+ + +The Nexus Certificate Manager AnyCA Gateway REST Plugin integrates Keyfactor Command with Nexus Smart ID Certificate Manager via the Keyfactor AnyCA Gateway REST framework. It supports the following operations: + +- **Certificate Enrollment** — issues certificates against a named Nexus CA token procedure (ProductID) +- **Certificate Revocation** — revokes certificates by CA request ID +- **Certificate Synchronization** — optional; see [Synchronization](#synchronization) below + +## Compatibility + +The Nexus Certificate Manager AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 25.2.0 and later. + +## Support +The Nexus Certificate Manager AnyCA Gateway REST plugin is open source and there is **no SLA**. Keyfactor will address issues as resources become available. Keyfactor customers may request escalation by opening up a support ticket through their Keyfactor representative. + +> 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 + +- The host URL for the Nexus Certificate Manager instance (including port), e.g. `https://192.168.1.10:8444` +- A PFX certificate for authenticating into Nexus Certificate Manager, accessible on the Gateway host +- The passphrase for the PFX certificate +- Server-side signing (VRO mode) must be enabled in the Nexus Protocol Gateway `api.properties` + +## 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 [Nexus Certificate Manager AnyCA Gateway REST plugin](https://github.com/Keyfactor/nexus-certificate-manager-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 Nexus Certificate Manager 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 Nexus Certificate Manager 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** + + The Keyfactor Command server must trust the CA chain used by the Nexus Certificate Manager. Identify the Root and/or Subordinate CA, download the certificate chain, and import it into the Command server certificate store before configuring the gateway. + + * **CA Connection** + + Populate using the configuration fields collected in the [requirements](#requirements) section. + + * **Host** - The URI of the Nexus Certificate Manager API, including port. Example: https://192.168.1.10:8444 + * **AuthCertificatePath** - The full path on the AnyCA Gateway host to the PFX certificate used for authenticating into Nexus Certificate Manager. + * **AuthCertPassword** - The password for the PFX authentication certificate. + * **Enabled** - Flag to Enable or Disable gateway functionality. Disabling is primarily used to allow creation of the CA prior to configuration information being available. + * **SyncProcedureField** - Optional. Enables certificate synchronization. Set this to the name of the Nexus CA ExtendedCertSearch field (e.g. "field1") that your CA administrator has configured to store the issuing procedure name at enrollment time. When provided, Synchronize will read that field from each certificate to reconstruct its ProductID (procedure name). When omitted, Synchronize is disabled because the Nexus CA API does not natively return the issuing procedure with certificate records. NOTE: Configuring the Nexus CA to populate this field requires custom Java InputView development and AWB policy changes by a CA administrator. This configuration is outside the scope of Keyfactor support. + +2. TODO Certificate Template Creation Step is a required section + +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. + + +## CA Connection Parameters + +| Parameter | Required | Description | +|---|---|---| +| `Host` | Yes | The full URI of the Nexus Certificate Manager API, including port. Example: `https://192.168.1.10:8444` | +| `AuthCertificatePath` | Yes | The full path on the AnyCA Gateway host to the PFX certificate used for mutual TLS authentication. Example: `C:\certs\nexus-officer.pfx` | +| `AuthCertPassword` | Yes | The password for the PFX authentication certificate. | +| `Enabled` | Yes | Enables or disables gateway functionality. Set to `false` to defer configuration. | +| `SyncProcedureField` | No | Enables certificate synchronization. See [Synchronization](#synchronization) for full details. | + +## Certificate Template (Product ID) Configuration + +Each certificate template in Command must be associated with a **ProductID** that corresponds to a Nexus CA token procedure name. The plugin calls the `/procedures` endpoint at startup to populate the list of available procedures. + +When enrolling, the procedure name from the selected template's ProductID is passed directly as the `procname` parameter in the enrollment request. + +## Synchronization + +By default, certificate synchronization is **disabled**. This is an intentional design decision: the Nexus CA REST API does not return the issuing procedure name in certificate list or detail responses, making it impossible to reconstruct the correct ProductID (procedure name) for each certificate during a sync. Without a valid ProductID, synchronized certificates cannot be associated with a certificate template in Command and will not appear in the UI. + +### Enabling Synchronization via `SyncProcedureField` + +Synchronization can be enabled by setting the optional `SyncProcedureField` CA connection parameter to the name of a Nexus CA `ExtendedCertSearch` field (e.g. `field1`) that your CA administrator has configured to store the issuing procedure name at enrollment time. + +When `SyncProcedureField` is set: + +- The sync operation pages through all certificates on the CA (500 per page) +- For each certificate, it reads the value of the specified `ExtendedCertSearch` field as the ProductID +- Certificates where the field is empty or missing are skipped and logged as warnings +- Only certificates with a resolvable ProductID are written to Command + +### CA-Side Configuration Requirements + +> ⚠️ **This configuration is entirely outside the scope of Keyfactor support.** The steps below describe what is required on the Nexus CA side. Keyfactor takes no responsibility for the correctness, stability, or ongoing maintenance of this configuration. + +To populate an `ExtendedCertSearch` field with the procedure name at enrollment time, a Nexus CA administrator must: + +1. **Develop a custom Java InputView** — InputViews are Java programs that run inside the Nexus Registration Authority (RA) and populate certificate attributes at issuance time. Custom InputViews must be compiled into a `.jar`, deployed to the CM server's `lib` directory, and registered in `cm.conf`. This requires working knowledge of the Nexus InputView API (documented in the CM Developers Guide). + +2. **Configure the InputView to write the procedure name into an `ExtendedCertSearch` field** — the `ExtendedCertSearch` database table supports six generic fields (`field1`–`field6`). The InputView must be written to set the desired field to the issuing procedure name during enrollment. + +3. **Associate the InputView with token procedures in the AWB** — using the Administrator's Workbench, a CA administrator must select the new InputView for each token procedure. This change must be signed by two administration officers in accordance with Nexus CM's four-eyes policy. + +4. **Set `SyncProcedureField`** on the Keyfactor CA connection to the field name used (e.g. `field1`). + +### Important Limitations + +- Certificates issued **before** the CA-side configuration change was made will not have the field populated and will be skipped during sync. +- If the CA admin changes or repurposes the configured `ExtendedCertSearch` field, sync will silently produce incorrect results. The plugin will log warnings for affected certificates. +- The `SyncProcedureField` value must exactly match the field name (case-insensitive): `field1`, `field2`, `field3`, `field4`, `field5`, or `field6`. Any other value will cause all certificates to be skipped during sync. + +## CHANGELOG + +See [CHANGELOG.md](../CHANGELOG.md). + + +## 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 index cb3c585..18bf7cf 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -71,3 +71,8 @@ To populate an `ExtendedCertSearch` field with the procedure name at enrollment ## CHANGELOG See [CHANGELOG.md](../CHANGELOG.md). + +## Certificate Template Creation Step + +TODO Certificate Template Creation Step is a required section + diff --git a/integration-manifest.json b/integration-manifest.json index a53b85d..4d7b9a5 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -1,41 +1,41 @@ { - "$schema": "https://keyfactor.github.io/v2/integration-manifest-schema.json", - "integration_type": "anyca-plugin", - "name": "Nexus Certificate Manager AnyCA REST Gateway Plugin", - "status": "prototype", - "support_level": "kf-community", - "link_github": false, - "update_catalog": false, - "description": "Nexus Certificate Manager plugin for the AnyCA REST Gateway framework", - "gateway_framework": "25.2.0", - "release_dir": "nexus-certificate-manager-caplugin/bin/Release", - "release_project": "nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.csproj", - "about": { - "carest": { - "product_ids": [], - "ca_plugin_config": [ - { - "name": "Host", - "description": "The URI of the Nexus Certificate Manager API, including port. Example: https://192.168.1.10:8444" - }, - { - "name": "AuthCertificatePath", - "description": "The full path on the AnyCA Gateway host to the PFX certificate used for mutual TLS authentication. Example: C:\\certs\\nexus-officer.pfx" - }, - { - "name": "AuthCertPassword", - "description": "The password for the PFX authentication certificate." - }, - { - "name": "Enabled", - "description": "Flag to enable or disable gateway functionality. Disabling is primarily used to allow creation of the CA prior to configuration information being available." - }, - { - "name": "SyncProcedureField", - "description": "Optional. Enables certificate synchronization. Set to the name of the Nexus CA ExtendedCertSearch field (e.g. 'field1') that the CA administrator has configured to store the issuing procedure name. When omitted, synchronization is disabled. See documentation for CA-side configuration requirements." + "$schema": "https://keyfactor.github.io/v2/integration-manifest-schema.json", + "integration_type": "anyca-plugin", + "name": "Nexus Certificate Manager AnyCA REST Gateway Plugin", + "status": "prototype", + "support_level": "kf-community", + "link_github": false, + "update_catalog": false, + "description": "Nexus Certificate Manager plugin for the AnyCA REST Gateway framework", + "gateway_framework": "25.2.0", + "release_dir": "nexus-certificate-manager-caplugin/bin/Release", + "release_project": "nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.csproj", + "about": { + "carest": { + "product_ids": [], + "ca_plugin_config": [ + { + "name": "Host", + "description": "The URI of the Nexus Certificate Manager API, including port. Example: https://192.168.1.10:8444" + }, + { + "name": "AuthCertificatePath", + "description": "The full path on the AnyCA Gateway host to the PFX certificate used for authenticating into Nexus Certificate Manager." + }, + { + "name": "AuthCertPassword", + "description": "The password for the PFX authentication certificate." + }, + { + "name": "Enabled", + "description": "Flag to Enable or Disable gateway functionality. Disabling is primarily used to allow creation of the CA prior to configuration information being available." + }, + { + "name": "SyncProcedureField", + "description": "Optional. Enables certificate synchronization. Set this to the name of the Nexus CA ExtendedCertSearch field (e.g. \"field1\") that your CA administrator has configured to store the issuing procedure name at enrollment time. When provided, Synchronize will read that field from each certificate to reconstruct its ProductID (procedure name). When omitted, Synchronize is disabled because the Nexus CA API does not natively return the issuing procedure with certificate records. NOTE: Configuring the Nexus CA to populate this field requires custom Java InputView development and AWB policy changes by a CA administrator. This configuration is outside the scope of Keyfactor support." + } + ], + "enrollment_config": [] } - ], - "enrollment_config": [] } - } -} +} \ No newline at end of file From 91bc7cdf8b427963f978212d3c04edf757cc835e Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele <76071503+joevanwanzeeleKF@users.noreply.github.com> Date: Fri, 15 May 2026 11:36:18 -0400 Subject: [PATCH 32/35] Change starter workflow version and update secrets Updated workflow to use version 3 of the starter workflow and modified secrets. --- .../workflows/keyfactor-bootstrap-workflow.yml | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/.github/workflows/keyfactor-bootstrap-workflow.yml b/.github/workflows/keyfactor-bootstrap-workflow.yml index bc94853..46f6fc9 100644 --- a/.github/workflows/keyfactor-bootstrap-workflow.yml +++ b/.github/workflows/keyfactor-bootstrap-workflow.yml @@ -11,19 +11,9 @@ on: jobs: call-starter-workflow: - uses: keyfactor/actions/.github/workflows/starter.yml@v4 - permissions: - contents: write - with: - command_token_url: ${{ vars.COMMAND_TOKEN_URL }} - command_hostname: ${{ vars.COMMAND_HOSTNAME }} - command_base_api_path: ${{ vars.COMMAND_API_PATH }} + uses: keyfactor/actions/.github/workflows/starter.yml@v3 secrets: - token: ${{ github.token }} + token: ${{ secrets.V2BUILDTOKEN}} + APPROVE_README_PUSH: ${{ secrets.APPROVE_README_PUSH}} gpg_key: ${{ secrets.KF_GPG_PRIVATE_KEY }} gpg_pass: ${{ secrets.KF_GPG_PASSPHRASE }} - scan_token: ${{ secrets.SAST_TOKEN }} - entra_username: ${{ secrets.DOCTOOL_ENTRA_USERNAME }} - entra_password: ${{ secrets.DOCTOOL_ENTRA_PASSWD }} - command_client_id: ${{ secrets.COMMAND_CLIENT_ID }} - command_client_secret: ${{ secrets.COMMAND_CLIENT_SECRET }} From 9a5bbf8f08ccfd5554b512067ef5fce9e4e127f9 Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele Date: Fri, 15 May 2026 11:40:55 -0400 Subject: [PATCH 33/35] added .net6.0 build --- .../NexusCertManagerCAPlugin.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.csproj b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.csproj index e9f41af..870bcca 100644 --- a/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.csproj +++ b/nexus-certificate-manager-caplugin/NexusCertManagerCAPlugin.csproj @@ -1,7 +1,7 @@  - net8.0 + net6.0;net8.0 Keyfactor.Extensions.CAPlugin.NexusCertManager disable disable From da1650a702b12ba4d2227164e6610c514c9956e6 Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele Date: Mon, 18 May 2026 10:12:28 -0400 Subject: [PATCH 34/35] removed leftover comment from configuration.md --- docsource/configuration.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docsource/configuration.md b/docsource/configuration.md index 18bf7cf..8b3c46e 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -72,7 +72,6 @@ To populate an `ExtendedCertSearch` field with the procedure name at enrollment See [CHANGELOG.md](../CHANGELOG.md). -## Certificate Template Creation Step -TODO Certificate Template Creation Step is a required section + From 3413c6f40c583675bca03246a5729859f98a6fac Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Mon, 18 May 2026 14:14:16 +0000 Subject: [PATCH 35/35] Update generated docs --- docsource/configuration.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docsource/configuration.md b/docsource/configuration.md index 8b3c46e..18bf7cf 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -72,6 +72,7 @@ To populate an `ExtendedCertSearch` field with the procedure name at enrollment See [CHANGELOG.md](../CHANGELOG.md). +## Certificate Template Creation Step - +TODO Certificate Template Creation Step is a required section