From f3a9a877f7c0b16bd23371b6b60ddfbb615d1edf Mon Sep 17 00:00:00 2001 From: David Galey Date: Sun, 25 Oct 2026 22:58:20 -0400 Subject: [PATCH 1/5] Change CARequestID to OrderId-CertId to make it easier to link externalvalidation certs when they are finally pickd up. --- nameshield-caplugin/Models/Certificate.cs | 17 +++++++++++ nameshield-caplugin/NameshieldCAPlugin.cs | 29 ++++++++++++------- .../nameshield-caplugin.csproj | 2 +- 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/nameshield-caplugin/Models/Certificate.cs b/nameshield-caplugin/Models/Certificate.cs index fe8b22b..7564225 100644 --- a/nameshield-caplugin/Models/Certificate.cs +++ b/nameshield-caplugin/Models/Certificate.cs @@ -44,6 +44,9 @@ public class Certificate [JsonPropertyName("attributes")] [JsonProperty("attributes")] public CertificateAttributes Attributes { get; set; } + [JsonPropertyName("relationships")] + [JsonProperty("relationships")] + public CertificateRelationships Relationships { get; set; } } public class CertificateAttributes @@ -60,4 +63,18 @@ public class CertificateAttributes [JsonProperty("pem")] public string Pem { get; set; } } + + public class CertificateRelationships + { + [JsonPropertyName("current-order")] + [JsonProperty("current-order")] + public CertOrderRelationship Order { get; set; } + } + + public class CertOrderRelationship + { + [JsonPropertyName("data")] + [JsonProperty("data")] + public RelationshipData Data { get; set; } + } } diff --git a/nameshield-caplugin/NameshieldCAPlugin.cs b/nameshield-caplugin/NameshieldCAPlugin.cs index cd7714b..642d8ec 100644 --- a/nameshield-caplugin/NameshieldCAPlugin.cs +++ b/nameshield-caplugin/NameshieldCAPlugin.cs @@ -135,11 +135,12 @@ public async Task Enroll(string csr, string subject, Dictionar } _logger.LogTrace($"Certificate enrolled successfully"); - var cert = GetSingleRecord(response.Order.Relationships.Certificate.Data.id).Result; - + var reqId = $"{response.Order.Id}-{response.Order.Relationships.Certificate.Data.id}"; + var cert = GetSingleRecord(reqId).Result; + return new EnrollmentResult { - CARequestID = cert.CARequestID, + CARequestID = reqId, Certificate = cert.Certificate, Status = cert.Status, StatusMessage = $"Successfully enrolled for certificate {subject}" @@ -191,11 +192,17 @@ public List GetProductIds() public async Task GetSingleRecord(string caRequestID) { NameshieldClient client = NameshieldClient.InitializeClient(_config); + var idParts = caRequestID.Split('-').ToList(); + if (idParts.Count == 1) + { + _logger.LogWarning($"Order {caRequestID} is still awaiting delivery."); + throw new Exception($"Unable to retrieve certificate for order {caRequestID} as it is still awaiting delivery. The certificate will be picked up by a future sync once it is issued."); + } + string certId = idParts[1]; + var cert = Task.Run(async () => await client.GetCertificate(certId)).Result; + var certProduct = Task.Run(async () => await client.GetCertificateProduct(certId)).Result; - var cert = Task.Run(async () => await client.GetCertificate(caRequestID)).Result; - var certProduct = Task.Run(async () => await client.GetCertificateProduct(caRequestID)).Result; - - int status = ConvertToKeyfactorStatus(cert.Certificate.Attributes.Status, caRequestID); + int status = ConvertToKeyfactorStatus(cert.Certificate.Attributes.Status, certId); return new AnyCAPluginCertificate() { @@ -271,11 +278,13 @@ public async Task Synchronize(BlockingCollection blockin foreach (var cert in certs.Certificates) { + var certObj = Task.Run(async () => await client.GetCertificate(cert.Id)).Result; var certProduct = Task.Run(async () => await client.GetCertificateProduct(cert.Id)).Result; int status = ConvertToKeyfactorStatus(cert.Attributes.Status, cert.Id); var newCert = new AnyCAPluginCertificate() { - CARequestID = cert.Id, + CARequestID = $"{certObj.Certificate.Relationships.Order.Data.id}-{cert.Id}", + OldCARequestID = certObj.Certificate.Relationships.Order.Data.id, ProductID = certProduct.CertificateProduct.Attributes.Name, Status = status, Certificate = cert.Attributes.Pem, @@ -336,7 +345,7 @@ private static string ValidateConfigurationKey(Dictionary connec return string.Empty; } - private static int ConvertToKeyfactorStatus(string status, string sslId) + private static int ConvertToKeyfactorStatus(string status, string certId) { switch (status.ToUpper()) { @@ -353,7 +362,7 @@ private static int ConvertToKeyfactorStatus(string status, string sslId) return (int)EndEntityStatus.REVOKED; default: - throw new Exception($"Request ID {sslId} has unknown status {status}"); + throw new Exception($"Request ID {certId} has unknown status {status}"); } } diff --git a/nameshield-caplugin/nameshield-caplugin.csproj b/nameshield-caplugin/nameshield-caplugin.csproj index 054f6a7..6ef77c8 100644 --- a/nameshield-caplugin/nameshield-caplugin.csproj +++ b/nameshield-caplugin/nameshield-caplugin.csproj @@ -17,7 +17,7 @@ - + From 2257763a2ff56822502b6f33be47c08ac1c946e2 Mon Sep 17 00:00:00 2001 From: David Galey Date: Sun, 25 Oct 2026 23:17:36 -0400 Subject: [PATCH 2/5] Allow for configuration of duration --- nameshield-caplugin/Constants.cs | 1 + nameshield-caplugin/NameshieldCAPlugin.cs | 36 ++++++++++++++++++++--- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/nameshield-caplugin/Constants.cs b/nameshield-caplugin/Constants.cs index 0df6a51..3d8db08 100644 --- a/nameshield-caplugin/Constants.cs +++ b/nameshield-caplugin/Constants.cs @@ -20,6 +20,7 @@ public class Template { public const string ORGANIZATION_ID = "OrganizationId"; public const string ORGANIZATION = "Organization"; + public const string LIFETIME = "Lifetime"; } } } diff --git a/nameshield-caplugin/NameshieldCAPlugin.cs b/nameshield-caplugin/NameshieldCAPlugin.cs index cd7714b..d16f524 100644 --- a/nameshield-caplugin/NameshieldCAPlugin.cs +++ b/nameshield-caplugin/NameshieldCAPlugin.cs @@ -1,6 +1,7 @@ using Keyfactor.AnyGateway.Extensions; using Keyfactor.Extensions.CAPlugin.Nameshield.API; using Keyfactor.Extensions.CAPlugin.Nameshield.Client; +using Keyfactor.Extensions.CAPlugin.Nameshield.Models; using Keyfactor.Logging; using Keyfactor.PKI.Enums.EJBCA; @@ -93,12 +94,13 @@ public async Task Enroll(string csr, string subject, Dictionar { throw new Exception($"No organization provided in attributes or subject that matches an organization in the Nameshield account"); } + var lifetime = productInfo.ProductParameters[Constants.Config.Template.LIFETIME]; CertificateRequest request = new CertificateRequest(); request.data.type = "order"; request.data.attributes.action = "create"; request.data.attributes.csr = csr; - request.data.attributes.duration = "1 year"; + request.data.attributes.duration = lifetime; request.data.relationships.product.data.type = "product"; request.data.relationships.product.data.id = productId; request.data.relationships.organization.data.type = "organization"; @@ -224,6 +226,13 @@ public Dictionary GetTemplateParameterAnnotations() Hidden = false, DefaultValue = "", Type = "String" + }, + [Constants.Config.Template.LIFETIME] = new PropertyConfigInfo() + { + Comments = "REQUIRED:The lifetime of the certificate to be issued. This should be a string in the format recognized by Nameshield (e.g. 1 year, etc.)", + Hidden = false, + DefaultValue = "1 year", + Type = "String" } }; } @@ -310,20 +319,39 @@ public async Task ValidateProductInfo(EnrollmentProductInfo productInfo, Diction var productList = Task.Run(async () => await localClient.ListProducts()).Result; - bool foundProduct = false; + Product foundProduct = null; foreach (var product in productList.Products) { if (string.Equals(product.Attributes.Name, productInfo.ProductID, StringComparison.OrdinalIgnoreCase)) { - foundProduct = true; + foundProduct = product; break; } } - if (!foundProduct) + if (foundProduct == null) { throw new Exception($"Product ID {productInfo.ProductID} was not found in the list of valid products."); } + string lifetime = productInfo.ProductParameters[Constants.Config.Template.LIFETIME]; + if (string.IsNullOrEmpty(lifetime)) + { + throw new Exception($"Product ID {productInfo.ProductID} is missing a required lifetime value"); + } + + bool allowedDuration = false; + foreach (var duration in foundProduct.Attributes.Durations) + { + if (string.Equals(duration, lifetime, StringComparison.OrdinalIgnoreCase)) + { + allowedDuration = true; + break; + } + } + if (!allowedDuration) + { + throw new Exception($"Product ID {productInfo.ProductID} has an invalid lifetime value {lifetime}. Valid values for this product are: {string.Join(',', foundProduct.Attributes.Durations)}"); + } } private static string ValidateConfigurationKey(Dictionary connectionInfo, string key) From 41ceb01cdc5fccb510abe6d99292f2d10745563a Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Mon, 26 Oct 2026 03:19:10 +0000 Subject: [PATCH 3/5] Update generated docs --- README.md | 1 + integration-manifest.json | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/README.md b/README.md index 0bfb158..2448b0a 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ TODO Requirements is a required section * **OrganizationId** - If you know your organization's ID in nameshield, you can supply that here and that will be used as the organization regardless of what values exist in the request or the Organization field * **Organization** - If OrganizationId is empty, and an organization name is provided here, the Nameshield gateway will use that name to do organization lookups when enrolling. If neither OrganizationId nor Organization is supplied, the gateway will use whatever is in the O= field of the request subject. + * **Lifetime** - REQUIRED:The lifetime of the certificate to be issued. This should be a string in the format recognized by Nameshield (e.g. 1 year, etc.) diff --git a/integration-manifest.json b/integration-manifest.json index 3511222..0c297bf 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -34,6 +34,10 @@ { "name": "Organization", "description": "If OrganizationId is empty, and an organization name is provided here, the Nameshield gateway will use that name to do organization lookups when enrolling. If neither OrganizationId nor Organization is supplied, the gateway will use whatever is in the O= field of the request subject." + }, + { + "name": "Lifetime", + "description": "REQUIRED:The lifetime of the certificate to be issued. This should be a string in the format recognized by Nameshield (e.g. 1 year, etc.)" } ] } From a94cadcb80ca18c0c3f3381b9f6359c2b3fcd6a9 Mon Sep 17 00:00:00 2001 From: David Galey Date: Mon, 26 Oct 2026 00:20:44 -0400 Subject: [PATCH 4/5] add renew/reissue support --- nameshield-caplugin/Models/Order.cs | 3 ++ nameshield-caplugin/NameshieldCAPlugin.cs | 35 +++++++++++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/nameshield-caplugin/Models/Order.cs b/nameshield-caplugin/Models/Order.cs index aff4d17..88d816d 100644 --- a/nameshield-caplugin/Models/Order.cs +++ b/nameshield-caplugin/Models/Order.cs @@ -36,6 +36,9 @@ public class OrderAttributes [JsonPropertyName("status")] [JsonProperty("status")] public string Status { get; set; } + [JsonPropertyName("can_renew_at")] + [JsonProperty("can_renew_at")] + public string CanRenewAt { get; set; } } public class OrderRelationships diff --git a/nameshield-caplugin/NameshieldCAPlugin.cs b/nameshield-caplugin/NameshieldCAPlugin.cs index d16f524..aab9d8e 100644 --- a/nameshield-caplugin/NameshieldCAPlugin.cs +++ b/nameshield-caplugin/NameshieldCAPlugin.cs @@ -37,7 +37,7 @@ public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDa _config = JsonConvert.DeserializeObject(rawConfig); } - public async Task Enroll(string csr, string subject, Dictionary san, EnrollmentProductInfo productInfo, RequestFormat requestFormat, EnrollmentType enrollmnentType) + public async Task Enroll(string csr, string subject, Dictionary san, EnrollmentProductInfo productInfo, RequestFormat requestFormat, EnrollmentType enrollmentType) { _logger.MethodEntry(LogLevel.Debug); NameshieldClient client = NameshieldClient.InitializeClient(_config); @@ -96,9 +96,38 @@ public async Task Enroll(string csr, string subject, Dictionar } var lifetime = productInfo.ProductParameters[Constants.Config.Template.LIFETIME]; + string orderAction = "create"; + + // Determine if we should do a renew or a reissue + if (enrollmentType == EnrollmentType.RenewOrReissue) + { + string priorCertSnString = productInfo.ProductParameters["PriorCertSN"]; + var priorCertReqID = await _certificateDataReader.GetRequestIDBySerialNumber(priorCertSnString); + if (string.IsNullOrEmpty(priorCertReqID)) + { + throw new Exception($"Cannot find prior certificate with serial number {priorCertSnString}."); + } + + string orderId = priorCertReqID.Split('-')[0]; + + var orderResponse = Task.Run(async () => await client.GetOrderDetails(orderId)).Result; + if (orderResponse.Order.Attributes.CanRenewAt != null) + { + DateTime canRenewDT = DateTime.Parse(orderResponse.Order.Attributes.CanRenewAt); + if (canRenewDT < DateTime.UtcNow) + { + orderAction = "renew"; + } + else + { + orderAction = "reissue"; + } + } + } + CertificateRequest request = new CertificateRequest(); request.data.type = "order"; - request.data.attributes.action = "create"; + request.data.attributes.action = orderAction; request.data.attributes.csr = csr; request.data.attributes.duration = lifetime; request.data.relationships.product.data.type = "product"; @@ -137,7 +166,7 @@ public async Task Enroll(string csr, string subject, Dictionar } _logger.LogTrace($"Certificate enrolled successfully"); - var cert = GetSingleRecord(response.Order.Relationships.Certificate.Data.id).Result; + var cert = GetSingleRecord($"{response.Order.Id}-{response.Order.Relationships.Certificate.Data.id}").Result; return new EnrollmentResult { From 67d3f38894dc812323ba0e774417b31bef2ed334 Mon Sep 17 00:00:00 2001 From: David Galey Date: Mon, 26 Oct 2026 00:27:16 -0400 Subject: [PATCH 5/5] changelog --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29..76301ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +1.0.0 +* Initial Release + +1.1.0 +* Added support for renew/reissue +* Made CARequestID more consistent for pending/external validation certs +* Allow for the specifying of custom lifetimes for certs, and comparing the provided value to the allowed values \ No newline at end of file