From 984ab17062ec9c37331e7237edcc5b1d4d36f8d4 Mon Sep 17 00:00:00 2001 From: Elena Fiocca Date: Thu, 16 Apr 2026 18:40:07 -0600 Subject: [PATCH 1/5] Save local changes --- AxisIPCamera/AxisIPCamera.csproj | 77 ++++++++++--------- AxisIPCamera/Client/AxisHttpClient.cs | 42 ++++++++--- AxisIPCamera/Helpers/DeviceCertValidator.cs | 16 +++- AxisIPCamera/Helpers/SANBuilder.cs | 64 ++++++++++++++++ AxisIPCamera/Reenrollment.cs | 84 +++++++++++++++------ docsource/axisipcamera.md | 14 +++- docsource/content.md | 8 +- 7 files changed, 229 insertions(+), 76 deletions(-) create mode 100644 AxisIPCamera/Helpers/SANBuilder.cs diff --git a/AxisIPCamera/AxisIPCamera.csproj b/AxisIPCamera/AxisIPCamera.csproj index 020f565..13b30a3 100644 --- a/AxisIPCamera/AxisIPCamera.csproj +++ b/AxisIPCamera/AxisIPCamera.csproj @@ -2,47 +2,54 @@ true - net6.0;net8.0 + net8.0 true disable Keyfactor.Extensions.Orchestrator.AxisIPCamera + 1.1.0 - - - - - Always - - - - - - - - Always - - - - Always - - - - Always - - - - Always - - - - Always - - - - Always - + + + + + + + + + + + + + + + Always + + + + Always + + + + Always + + + + Always + + + + Always + + + + Always + + + + Always + diff --git a/AxisIPCamera/Client/AxisHttpClient.cs b/AxisIPCamera/Client/AxisHttpClient.cs index 7f594dc..2bbe950 100644 --- a/AxisIPCamera/Client/AxisHttpClient.cs +++ b/AxisIPCamera/Client/AxisHttpClient.cs @@ -1,4 +1,4 @@ -// Copyright 2025 Keyfactor +// Copyright 2026 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, @@ -6,6 +6,7 @@ // and limitations under the License. using System; +using System.Collections.Generic; using System.Reflection; using System.IO; using System.Linq; @@ -285,8 +286,7 @@ public Constants.Keystore GetDefaultKeystore() /// Combination of key algorithm and key size /// Default keystore for the device /// Subject provided for the certificate - /// Subject Alternative Names - public void CreateSelfSignedCert(string alias, string keyType, string keystore, string subject, string[] sans) + public void CreateSelfSignedCert(string alias, string keyType, string keystore, string subject) { try { @@ -303,7 +303,7 @@ public void CreateSelfSignedCert(string alias, string keyType, string keystore, KeyType = keyType, Keystore = keystore, Subject = subject, - SANS = sans, + SANS = [], ValidFrom = 0, // Cert validity period will be determined by the template ValidTo = 0 // Cert validity period will be determined by the template } @@ -347,12 +347,15 @@ public void CreateSelfSignedCert(string alias, string keyType, string keystore, } /// - /// Obtains a CSR for the self-signed certificate with private key on the device. - /// Fields from the self-signed certificate will be copied into the CSR. + /// Obtains a CSR for the self-signed or existing certificate with private key on the device. + /// Fields from the certificate will be copied into the CSR. + /// SANs will be added to the CSR. /// /// Unique identifier for the cert to be generated from the CSR + /// Subject provided for the certificate + /// Subject Alternative Names /// CSR string - public string ObtainCSR(string alias) + public string ObtainCSR(string alias, string subject, List sans) { try { @@ -360,12 +363,29 @@ public string ObtainCSR(string alias) var postCSRResource = $"{Constants.RestApiEntryPoint}/certificates/{alias}/get_csr"; - // Compose the body --- This is required, but leaving the contents blank. - // All information obtained in the self-signed cert will be used to create the CSR. + // Compose the body --- This is required. + // All information obtained in the self-signed or existing cert will be used to create the CSR. // If there are attributes assigned by the CA, those will override the attributes that end up // in the certificate signed by the CA. - string jsonBody = @"{""data"":{}}"; - var httpResponse = ExecuteHttp(postCSRResource, Method.Post, Constants.ApiType.Rest, jsonBody); + // 1. If a field is filled out, that value will be used in the CSR + // 2. If a field is NOT filled out, the existing value from the existing certificate will be copied into the CSR + // 3. If a field is filled out with a blank value, that field is not copied from the existing certificate nor added to the CSR + var jsonBody = new StringBuilder(@"{""data"":{"); + + if (sans.Count == 0) + { + jsonBody.Append(@"""subject"":""").Append(subject).Append("}}"); + } + else + { + jsonBody.Append(@"""subject"":""").Append(subject).Append(@""",""subject_alt_names"": ["); + string result = string.Join(",", sans); + jsonBody.Append(result).Append("]}}"); + } + + Logger.LogDebug($"POST Request Body: {jsonBody}"); + + var httpResponse = ExecuteHttp(postCSRResource, Method.Post, Constants.ApiType.Rest, jsonBody.ToString()); // Decode the HTTP response if failed if (httpResponse is {IsSuccessful:false}) diff --git a/AxisIPCamera/Helpers/DeviceCertValidator.cs b/AxisIPCamera/Helpers/DeviceCertValidator.cs index f24a662..f705a04 100644 --- a/AxisIPCamera/Helpers/DeviceCertValidator.cs +++ b/AxisIPCamera/Helpers/DeviceCertValidator.cs @@ -73,6 +73,12 @@ public static class DeviceCertValidator // Add TLS cert as leaf certificate to the end of the custom chain customChain.Add(parser.ReadCertificate(cert.RawData)); + + if (!File.Exists(trustedIntCertPath)) + { + logger.LogError($"{trustedIntCertPath} does not exist."); + return false; + } logger.LogTrace($"Loading Trusted Intermediate Certs from {trustedIntCertPath}"); var trustedIntCerts = parser.ReadCertificates(File.ReadAllBytes(trustedIntCertPath)); @@ -92,6 +98,12 @@ public static class DeviceCertValidator logger.LogTrace($"{trustedIntCerts.Count} Trusted Intermediate Certs found"); + if (!File.Exists(trustedRootCertPath)) + { + logger.LogError($"{trustedRootCertPath} does not exist."); + return false; + } + logger.LogTrace($"Loading Trusted Root Cert from {trustedRootCertPath}"); var trustedRootCerts = parser.ReadCertificates(File.ReadAllBytes(trustedRootCertPath)); @@ -214,8 +226,8 @@ private static bool VerifyAkiSkiChain(List customChain, ILogger { logger.MethodEntry(); - logger.LogTrace("Custom chain being validated includes: (1) Leaf cert from TLS session, (2) n-Intermediate certs from custom trust, &" + - "n-Root certs from custom trust"); + logger.LogTrace("Custom chain being validated includes: (1) Leaf cert from TLS session, (2) n-Intermediate certs from custom trust, & " + + "(3) n-Root certs from custom trust"); for (int i = 0; i < customChain.Count - 1; i++) { diff --git a/AxisIPCamera/Helpers/SANBuilder.cs b/AxisIPCamera/Helpers/SANBuilder.cs new file mode 100644 index 0000000..e539e82 --- /dev/null +++ b/AxisIPCamera/Helpers/SANBuilder.cs @@ -0,0 +1,64 @@ +// Copyright 2026 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.Linq; +using Microsoft.Extensions.Logging; + +namespace Keyfactor.Extensions.Orchestrator.AxisIPCamera.Helpers +{ + public static class SANBuilder + { + public static List BuildSANString(Dictionary sans, ILogger logger) + { + var parts = new List(); + + if (sans == null || sans.Count == 0) + { + logger.LogTrace($"SANs is null or empty"); + return parts; + } + + foreach (var entry in sans) + { + string key = NormalizeSanKey(entry.Key); + + // The Axis API only supports the addition of 'dns' and 'ip' SAN type keys + if (key is not ("DNS" or "IP")) + continue; + + if (entry.Value == null || entry.Value.Length == 0) + continue; + + // NOTE: We are separating the key and value pairs with a colon because this is the format + // required to send SANs to the Axis API endpoint + parts.AddRange( + entry.Value + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Select(v => $@"""{key}:{v.Trim()}""") + ); + } + + return parts; + } + + /// + /// Normalize SAN type keys to RFC-compliant names. + /// **NOTE: The Axis API only supports the addition of 'dns' and 'ip' SAN types. + /// Courtesy of B.Pokorny. + /// + private static string NormalizeSanKey(string key) + { + return key.Trim().ToLower() switch + { + "dns" => "DNS", + "ip" or "ip4" or "ip6" => "IP", + _ => key.ToLower() // default + }; + } + } +} \ No newline at end of file diff --git a/AxisIPCamera/Reenrollment.cs b/AxisIPCamera/Reenrollment.cs index afd519b..716028b 100644 --- a/AxisIPCamera/Reenrollment.cs +++ b/AxisIPCamera/Reenrollment.cs @@ -1,4 +1,4 @@ -// Copyright 2025 Keyfactor +// Copyright 2026 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, @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; +using System.Net.Http; using System.Text; using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; @@ -14,10 +15,14 @@ using Keyfactor.Logging; using Keyfactor.Extensions.Orchestrator.AxisIPCamera.Client; +using Keyfactor.Extensions.Orchestrator.AxisIPCamera.Helpers; using Keyfactor.Extensions.Orchestrator.AxisIPCamera.Model; using Keyfactor.Orchestrators.Common.Enums; using Keyfactor.Orchestrators.Extensions; using Keyfactor.Orchestrators.Extensions.Interfaces; +using Keyfactor.PKI.Enums; +using Keyfactor.PKI.X509; +//using Org.BouncyCastle.X509; namespace Keyfactor.Extensions.Orchestrator.AxisIPCamera { @@ -25,7 +30,7 @@ public class Reenrollment : IReenrollmentJobExtension { private readonly ILogger _logger; - private readonly IPAMSecretResolver _resolver; + private readonly IPAMSecretResolver _resolver; public string ExtensionName => ""; public Reenrollment(IPAMSecretResolver resolver) @@ -53,6 +58,22 @@ public JobResult ProcessJob(ReenrollmentJobConfiguration config, SubmitReenrollm } _logger.LogDebug("--- End Job Properties"); + // Log each SAN, if provided + _logger.LogDebug("Begin SANs ---"); + var formattedSANs = SANBuilder.BuildSANString(config.SANs,_logger); + if (formattedSANs.Count == 0) + { + _logger.LogDebug($"No SAN values found."); + } + else + { + foreach (var san in formattedSANs) + { + _logger.LogDebug($"{san}"); + } + } + _logger.LogDebug("--- End SANs"); + // Get required reenrollment fields string certUsage = config.JobProperties[Constants.CertUsageParamName].ToString() ?? throw new Exception($"{Constants.CertUsageParamDisplay} returned null"); var certUsageEnum = Constants.GetCertUsageAsEnum(certUsage); @@ -75,6 +96,7 @@ public JobResult ProcessJob(ReenrollmentJobConfiguration config, SubmitReenrollm // Get current binding for reenrollment certificate usage provided _logger.LogTrace($"Check '{certUsage}' binding for same alias"); var boundAlias = client.GetCertUsageBinding(Constants.GetCertUsageAsEnum(certUsage)); + var replaceCert = false; if (!string.IsNullOrEmpty(boundAlias)) { _logger.LogDebug($"Alias currently bound to certificate usage type '{certUsage}': {boundAlias}"); @@ -82,14 +104,14 @@ public JobResult ProcessJob(ReenrollmentJobConfiguration config, SubmitReenrollm if (boundAlias == reenrollAlias) { _logger.LogDebug($"Alias '{reenrollAlias}' provided for reenrollment matches alias '{boundAlias}' currently bound " + - $"to certificate usage type {certUsage}"); - - throw new Exception( - $"Alias '{reenrollAlias}' already exists for certificate usage type {certUsage}. Reenroll using another alias."); + $"to certificate usage type {certUsage}. Proceeding with rekeying, CSR, and replacing cert for alias..."); + replaceCert = true; + } + else + { + _logger.LogTrace($"Alias '{reenrollAlias}' provided for reenrollment differs from alias '{boundAlias}' currently bound " + + $"to certificate usage type {certUsage}. Proceeding with new key, CSR, and adding cert for alias..."); } - - _logger.LogTrace($"Alias '{reenrollAlias}' provided for reenrollment differs from alias '{boundAlias}' currently bound " + - $"to certificate usage type {certUsage}. Proceeding..."); } else { @@ -112,10 +134,10 @@ public JobResult ProcessJob(ReenrollmentJobConfiguration config, SubmitReenrollm Constants.Keystore defaultKeystore = client.GetDefaultKeystore(); string defaultKeystoreString = defaultKeystore.ToString(); _logger.LogDebug($"Reenrollment - Default keystore: {defaultKeystoreString}"); - - _logger.LogTrace("Generating self-signed cert with private key on device"); - List sansList = new List(); - if (certUsageEnum == Constants.CertificateUsage.Https) + + // If no SANs are provided and the cert usage is 'HTTPS' and this is a new alias --- + // Add 1 for DNS and 1 for IP address to eliminate TLS errors + if(formattedSANs.Count == 0 && certUsageEnum == Constants.CertificateUsage.Https && !replaceCert) { _logger.LogTrace("Extracting CN and IP address to add as SANs to the certificate"); // Extract the CN from the Subject @@ -126,8 +148,9 @@ public JobResult ProcessJob(ReenrollmentJobConfiguration config, SubmitReenrollm throw new Exception( "No value provided in the Subject for 'CN'. This is required for HTTPS certificates."); } + _logger.LogTrace($"Extracted CN attribute from the Subject: {cnMatch.Groups[1].Value}"); - + // Extract the IP address from the Client Machine var ipMatch = Regex.Match(config.CertificateStoreDetails.ClientMachine, @"^(?(?:\d{1,3}\.){3}\d{1,3})", RegexOptions.IgnoreCase); @@ -137,15 +160,21 @@ public JobResult ProcessJob(ReenrollmentJobConfiguration config, SubmitReenrollm throw new Exception( "Value provided for the Client Machine does not match IPv4 format."); } - _logger.LogTrace($"Extracted IP Address from the Client Machine: { ipMatch.Groups["ip"].Value}"); - sansList.Add("DNS:" + cnMatch.Groups[1].Value); - sansList.Add("IP:" + ipMatch.Groups["ip"].Value); + _logger.LogTrace($"Extracted IP Address from the Client Machine: {ipMatch.Groups["ip"].Value}"); + + formattedSANs.Add($@"""DNS:{cnMatch.Groups[1].Value}"""); + formattedSANs.Add($@"""IP:{ipMatch.Groups["ip"].Value}"""); + } + + if (!replaceCert) + { + _logger.LogTrace("Generating self-signed cert with private key on device"); + client.CreateSelfSignedCert(reenrollAlias,keyType,defaultKeystoreString,subject); } - client.CreateSelfSignedCert(reenrollAlias,keyType,defaultKeystoreString,subject,sansList.ToArray()); - _logger.LogTrace("Obtaining CSR using self-signed certificate"); - var csr = client.ObtainCSR(reenrollAlias); + _logger.LogTrace("Obtaining CSR"); + var csr = client.ObtainCSR(reenrollAlias,subject,formattedSANs); _logger.LogDebug($"CSR: \n{csr}"); _logger.LogTrace("Validating CSR"); @@ -155,6 +184,19 @@ public JobResult ProcessJob(ReenrollmentJobConfiguration config, SubmitReenrollm // Submit CSR to be signed in Keyfactor _logger.LogTrace("Submitting CSR to be signed in Command"); var x509Cert = submitReenrollment.Invoke(csr); + + // TESTING build chain functionality + /*using var aiaClient = new HttpClient(); + var builder = new ChainBuilder(aiaClient); + var bcX509Cert = new X509CertificateParser().ReadCertificate(x509Cert.RawData); + var chain = builder.BuildChain(bcX509Cert, CertificateCollectionOrder.EndEntityFirst); + + int i = 0; + foreach (var cert in chain.Certificates) + { + i++; + _logger.LogTrace($"Cert {i}: {cert.SubjectDN.ToString()}"); + }*/ // Build PEM content // ** NOTE: The static newline (\n) characters are required in the API request @@ -166,7 +208,7 @@ public JobResult ProcessJob(ReenrollmentJobConfiguration config, SubmitReenrollm pemBuilder.Append(@"\n-----END CERTIFICATE-----"); var pemCert = pemBuilder.ToString(); - _logger.LogTrace($"Replacing self-signed cert '{reenrollAlias}' with the following cert: " + pemCert); + _logger.LogTrace($"Replacing cert '{reenrollAlias}' with the following cert: " + pemCert); client.ReplaceCertificate(reenrollAlias,pemCert); _logger.LogTrace($"Setting '{certUsage}' binding to alias '{reenrollAlias}'"); diff --git a/docsource/axisipcamera.md b/docsource/axisipcamera.md index af3aed2..1971ad6 100644 --- a/docsource/axisipcamera.md +++ b/docsource/axisipcamera.md @@ -41,11 +41,17 @@ There are five (5) possible options: > [!NOTE] > A Reenrollment (ODKG) job will not allow enrollment of certificates with **Trust** assigned as the \`Certificate Usage\`. > Trust CA certificates can be added to the camera via a Management - Add job. +> These CA certificates establish trust for TLS connections initiated by the camera. > [!NOTE] -> For a Reenrollment (ODKG) job, where the \`Certificate Usage\` assigned is **HTTPS**, IP and DNS are added as SANS -> to the enrolled certificate. +> As of Keyfactor Command v25.4, SANs can be provided for a Reenrollment (ODKG) job. +> You must also have installed, at minimum, the Keyfactor Universal Orchestator v25.1 +> in order for the SANs to be sent to the orchestrator. > -> IP = Client Machine configured for the certificate store (excluding any port) +> The Axis IP Camera API *only* supports the addition of DNS and IP SANs. If you add other SAN types to the ODKG job, these will be ignored and not added to the certificate. > -> DNS = CN set in the Subject DN \ No newline at end of file +> * If SANs are NOT provided and the \`Certificate Usage\` assigned is **HTTPS**, IP and DNS will be automatically added as SANs to an enrolled certificate associated with a NEW alias. +> +> * IP = Client Machine configured for the certificate store (excluding any port) +> +> * DNS = CN set in the Subject DN \ No newline at end of file diff --git a/docsource/content.md b/docsource/content.md index 24e31b2..338b04e 100644 --- a/docsource/content.md +++ b/docsource/content.md @@ -70,6 +70,8 @@ These values must match or the session will be denied. ## Caveats > [!NOTE] -> Reenrollment jobs will not replace or remove a client-server certificate with the same alias. They will also not remove -> the original certificate if a particular \`Certificate Usage\` had an associated cert. Since the camera has limited storage, -> it will be up to the user to remove any unused client-server certificates via the AXIS Network Camera GUI. +> v1.1.0 - A Reenrollment job will now replace the certificate contents for an existing alias on the camera, therefore, not requiring +> a new alias be supplied for every new certificate enrollment. +> +> If a new alias is supplied, a Reenrollment job will not remove the original certificate associated with the \`Certificate Usage\`. +> Since the camera has limited storage, it will be up to the user to remove any unused certificates via the AXIS Network Camera GUI. From 8c632ce30480fe7ef172d73501f86af66ad98c00 Mon Sep 17 00:00:00 2001 From: Elena Fiocca Date: Tue, 5 May 2026 19:10:44 -0600 Subject: [PATCH 2/5] Save local changes --- AxisIPCamera/Client/AxisHttpClient.cs | 111 +++++++++++++++++--------- AxisIPCamera/Helpers/SANBuilder.cs | 4 +- AxisIPCamera/Model/Constants.cs | 33 +++++++- AxisIPCamera/Reenrollment.cs | 105 ++++++++++++------------ 4 files changed, 160 insertions(+), 93 deletions(-) diff --git a/AxisIPCamera/Client/AxisHttpClient.cs b/AxisIPCamera/Client/AxisHttpClient.cs index 2bbe950..72f661d 100644 --- a/AxisIPCamera/Client/AxisHttpClient.cs +++ b/AxisIPCamera/Client/AxisHttpClient.cs @@ -6,7 +6,6 @@ // and limitations under the License. using System; -using System.Collections.Generic; using System.Reflection; using System.IO; using System.Linq; @@ -286,7 +285,8 @@ public Constants.Keystore GetDefaultKeystore() /// Combination of key algorithm and key size /// Default keystore for the device /// Subject provided for the certificate - public void CreateSelfSignedCert(string alias, string keyType, string keystore, string subject) + /// Subject Alternative Names + public void CreateSelfSignedCert(string alias, string keyType, string keystore, string subject, string[] sans) { try { @@ -303,7 +303,7 @@ public void CreateSelfSignedCert(string alias, string keyType, string keystore, KeyType = keyType, Keystore = keystore, Subject = subject, - SANS = [], + SANS = sans, ValidFrom = 0, // Cert validity period will be determined by the template ValidTo = 0 // Cert validity period will be determined by the template } @@ -347,15 +347,12 @@ public void CreateSelfSignedCert(string alias, string keyType, string keystore, } /// - /// Obtains a CSR for the self-signed or existing certificate with private key on the device. - /// Fields from the certificate will be copied into the CSR. - /// SANs will be added to the CSR. + /// Obtains a CSR for the self-signed certificate with private key on the device. + /// Fields from the self-signed certificate will be copied into the CSR. /// /// Unique identifier for the cert to be generated from the CSR - /// Subject provided for the certificate - /// Subject Alternative Names /// CSR string - public string ObtainCSR(string alias, string subject, List sans) + public string ObtainCSR(string alias) { try { @@ -363,29 +360,12 @@ public string ObtainCSR(string alias, string subject, List sans) var postCSRResource = $"{Constants.RestApiEntryPoint}/certificates/{alias}/get_csr"; - // Compose the body --- This is required. - // All information obtained in the self-signed or existing cert will be used to create the CSR. + // Compose the body --- This is required, but leaving the contents blank. + // All information obtained in the self-signed cert will be used to create the CSR. // If there are attributes assigned by the CA, those will override the attributes that end up // in the certificate signed by the CA. - // 1. If a field is filled out, that value will be used in the CSR - // 2. If a field is NOT filled out, the existing value from the existing certificate will be copied into the CSR - // 3. If a field is filled out with a blank value, that field is not copied from the existing certificate nor added to the CSR - var jsonBody = new StringBuilder(@"{""data"":{"); - - if (sans.Count == 0) - { - jsonBody.Append(@"""subject"":""").Append(subject).Append("}}"); - } - else - { - jsonBody.Append(@"""subject"":""").Append(subject).Append(@""",""subject_alt_names"": ["); - string result = string.Join(",", sans); - jsonBody.Append(result).Append("]}}"); - } - - Logger.LogDebug($"POST Request Body: {jsonBody}"); - - var httpResponse = ExecuteHttp(postCSRResource, Method.Post, Constants.ApiType.Rest, jsonBody.ToString()); + string jsonBody = @"{""data"":{}}"; + var httpResponse = ExecuteHttp(postCSRResource, Method.Post, Constants.ApiType.Rest, jsonBody); // Decode the HTTP response if failed if (httpResponse is {IsSuccessful:false}) @@ -544,22 +524,75 @@ public void RemoveCACertificate(string alias) Logger.LogError($"HTTP Request unsuccessful - HTTP Response: {DecodeHttpStatus(httpResponse)}"); throw new Exception($"HTTP Request unsuccessful."); } + // Decode the API response when HTTP response is successful + if (httpResponse != null && string.IsNullOrEmpty(httpResponse.Content)) + { + throw new Exception("No content returned from HTTP Response"); + } + + RestApiResponse apiResponse = JsonConvert.DeserializeObject(httpResponse.Content); + if (apiResponse.Status == Constants.Status.Success) + { + Logger.MethodExit(); + } else { - if (httpResponse != null && string.IsNullOrEmpty(httpResponse.Content)) - { - throw new Exception("No content returned from HTTP Response"); - } + ErrorData error = JsonConvert.DeserializeObject(httpResponse.Content); + throw new Exception( + $"API error encountered - {error.ErrorInfo.Message} - (Code: {error.ErrorInfo.Code})"); + } + } + catch (Exception e) + { + Logger.LogError("Error completing CA certificate remove: " + LogHandler.FlattenException(e)); + throw new Exception(e.Message); + } + } + + /// + /// Removes a certificate with private key from the device. + /// + /// Unique identifier of the CA certificate to be removed + public void RemoveCertificate(string alias) + { + try + { + Logger.MethodEntry(); - RestApiResponse apiResponse = JsonConvert.DeserializeObject(httpResponse.Content); - if (apiResponse.Status == Constants.Status.Success) + var deleteCertResource = $"{Constants.RestApiEntryPoint}/certificates/{alias}"; + var httpResponse = ExecuteHttp(deleteCertResource, Method.Delete); + + // Decode the HTTP response if failed + if (httpResponse is { IsSuccessful: false }) + { + Logger.LogError($"HTTP Request unsuccessful - HTTP Response: {DecodeHttpStatus(httpResponse)}"); + throw new Exception($"HTTP Request unsuccessful."); + } + + // Decode the API response when HTTP response is successful + if (httpResponse != null && string.IsNullOrEmpty(httpResponse.Content)) + { + throw new Exception("No content returned from HTTP Response"); + } + + RestApiResponse apiResponse = JsonConvert.DeserializeObject(httpResponse.Content); + if (apiResponse.Status == Constants.Status.Success) + { + Logger.MethodExit(); + } + else + { + ErrorData error = JsonConvert.DeserializeObject(httpResponse.Content); + + // Check for error code 5 - "Validation error: Certificate is in use" or "Validation error: Certificate is not deletable" --- + // This will capture all device ID certs, which we do not want to delete anyway + if (error.ErrorInfo is { Code: 5, Message: "Validation error: Certificate is not deletable" or "Validation error: Certificate is in use"}) { - Logger.MethodExit(); + Logger.LogWarning($"API warning encountered - {error.ErrorInfo.Message} - (Code: {error.ErrorInfo.Code})"); } else { - ErrorData error = JsonConvert.DeserializeObject(httpResponse.Content); throw new Exception( $"API error encountered - {error.ErrorInfo.Message} - (Code: {error.ErrorInfo.Code})"); } @@ -567,7 +600,7 @@ public void RemoveCACertificate(string alias) } catch (Exception e) { - Logger.LogError("Error completing CA certificate remove: " + LogHandler.FlattenException(e)); + Logger.LogError("Error completing certificate remove: " + LogHandler.FlattenException(e)); throw new Exception(e.Message); } } diff --git a/AxisIPCamera/Helpers/SANBuilder.cs b/AxisIPCamera/Helpers/SANBuilder.cs index e539e82..5b569d8 100644 --- a/AxisIPCamera/Helpers/SANBuilder.cs +++ b/AxisIPCamera/Helpers/SANBuilder.cs @@ -13,7 +13,7 @@ namespace Keyfactor.Extensions.Orchestrator.AxisIPCamera.Helpers { public static class SANBuilder { - public static List BuildSANString(Dictionary sans, ILogger logger) + public static List BuildSANList(Dictionary sans, ILogger logger) { var parts = new List(); @@ -39,7 +39,7 @@ public static List BuildSANString(Dictionary sans, ILo parts.AddRange( entry.Value .Where(v => !string.IsNullOrWhiteSpace(v)) - .Select(v => $@"""{key}:{v.Trim()}""") + .Select(v => $"{key}:{v.Trim()}") ); } diff --git a/AxisIPCamera/Model/Constants.cs b/AxisIPCamera/Model/Constants.cs index 2f182e0..9a0f9ad 100644 --- a/AxisIPCamera/Model/Constants.cs +++ b/AxisIPCamera/Model/Constants.cs @@ -1,4 +1,4 @@ -// Copyright 2025 Keyfactor +// Copyright 2026 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, @@ -6,7 +6,9 @@ // and limitations under the License. using System; +using System.Globalization; using System.IO; +using System.Text.RegularExpressions; using Newtonsoft.Json; using Org.BouncyCastle.OpenSsl; using Org.BouncyCastle.Pkcs; @@ -16,7 +18,7 @@ namespace Keyfactor.Extensions.Orchestrator.AxisIPCamera.Model public static class Constants { // This is the API entry point for the REST VAPIX Cert Management API - public static string RestApiEntryPoint = "/config/rest/cert/v1beta"; + public static string RestApiEntryPoint = "/config/rest/cert/v1"; // This is the API entry point for the SOAP Cert Management API public static string SoapApiEntryPoint = "/vapix/services"; @@ -248,4 +250,31 @@ public override void WriteJson( } } } + + public static class CertificateName + { + /// + /// Returns a UTC-based suffix, i.e. "2602171544" + /// + public static string GetUtcSuffix() => + DateTime.UtcNow.ToString("yyMMddHHmm", CultureInfo.InvariantCulture); + + /// + /// Creates a unique certificate name by appending ['_' + Utc DateTime suffix] to the end of the user-supplied certificate name. + /// Example: "_2602171544" + /// + public static string CreateUniqueCertName(string certName) + { + // check to see if the old cert name had a previously appended timestamp + // EDGE CASE: Scenario under which this could happen - Cert name bound to usage is known and used to schedule an ODKG job + Regex rgx = new Regex(@"_[0-9]{10}$",RegexOptions.CultureInvariant); + var m = Regex.Match(certName,@"_[0-9]{10}$"); + if (m.Success) + { + return certName.Remove(m.Index, m.Length) + "_" + GetUtcSuffix(); + } + + return certName + "_" + GetUtcSuffix(); + } + } } diff --git a/AxisIPCamera/Reenrollment.cs b/AxisIPCamera/Reenrollment.cs index 716028b..ed5d275 100644 --- a/AxisIPCamera/Reenrollment.cs +++ b/AxisIPCamera/Reenrollment.cs @@ -60,7 +60,7 @@ public JobResult ProcessJob(ReenrollmentJobConfiguration config, SubmitReenrollm // Log each SAN, if provided _logger.LogDebug("Begin SANs ---"); - var formattedSANs = SANBuilder.BuildSANString(config.SANs,_logger); + var formattedSANs = SANBuilder.BuildSANList(config.SANs,_logger); if (formattedSANs.Count == 0) { _logger.LogDebug($"No SAN values found."); @@ -80,8 +80,8 @@ public JobResult ProcessJob(ReenrollmentJobConfiguration config, SubmitReenrollm string keyAlgorithm = config.JobProperties["keyType"].ToString() ?? throw new Exception("Key Algorithm returned null"); string keySize = config.JobProperties["keySize"].ToString() ?? throw new Exception("Key Size returned null"); string subject = config.JobProperties["subjectText"].ToString() ?? throw new Exception("Subject returned null"); - string reenrollAlias = config.Alias ?? throw new Exception("Alias returned null"); - _logger.LogDebug($"Alias: {reenrollAlias}"); + string newAlias = config.Alias ?? throw new Exception("Alias returned null"); + _logger.LogDebug($"Alias: {newAlias}"); // Prevent reenrollment on Trust certificates if (certUsageEnum is Constants.CertificateUsage.Trust) @@ -93,29 +93,27 @@ public JobResult ProcessJob(ReenrollmentJobConfiguration config, SubmitReenrollm _logger.LogTrace("Create HTTPS client to connect to device"); var client = new AxisHttpClient(config, config.CertificateStoreDetails, _resolver); - // Get current binding for reenrollment certificate usage provided + // Get the existing alias name associated with the supplied cert usage _logger.LogTrace($"Check '{certUsage}' binding for same alias"); - var boundAlias = client.GetCertUsageBinding(Constants.GetCertUsageAsEnum(certUsage)); - var replaceCert = false; - if (!string.IsNullOrEmpty(boundAlias)) + var oldAlias = client.GetCertUsageBinding(Constants.GetCertUsageAsEnum(certUsage)); + var oldCertExists = false; + if (!string.IsNullOrEmpty(oldAlias)) { - _logger.LogDebug($"Alias currently bound to certificate usage type '{certUsage}': {boundAlias}"); + oldCertExists = true; + _logger.LogDebug($"Alias currently bound to certificate usage type '{certUsage}': {oldAlias}"); - if (boundAlias == reenrollAlias) - { - _logger.LogDebug($"Alias '{reenrollAlias}' provided for reenrollment matches alias '{boundAlias}' currently bound " + - $"to certificate usage type {certUsage}. Proceeding with rekeying, CSR, and replacing cert for alias..."); - replaceCert = true; - } - else - { - _logger.LogTrace($"Alias '{reenrollAlias}' provided for reenrollment differs from alias '{boundAlias}' currently bound " + - $"to certificate usage type {certUsage}. Proceeding with new key, CSR, and adding cert for alias..."); - } + // compare the old alias name with the new alias name --- + // 1) if the names are the same, append a reserved time-based suffix to the end of the name + // This new name [AliasA_Timestamp] will be used to create the new cert. + // OR + // 2) EDGE CASE: if the old alias name currently tied to the cert usage does NOT match the new alias name, + // also create a new name [CertB_Timestamp] for the new cert in case the user-supplied cert name is already + // associated with an existing certificate that is NOT bound to a cert usage + newAlias = CertificateName.CreateUniqueCertName(newAlias); } else { - _logger.LogDebug($"No alias currently bound to certificate usage type {certUsage}"); + _logger.LogDebug($"No alias currently bound to certificate usage type {certUsage}. Proceeding with new key, CSR, and adding cert for new alias..."); } // Map the key type and key size from the job properties to a corresponding key type available on the device @@ -135,9 +133,9 @@ public JobResult ProcessJob(ReenrollmentJobConfiguration config, SubmitReenrollm string defaultKeystoreString = defaultKeystore.ToString(); _logger.LogDebug($"Reenrollment - Default keystore: {defaultKeystoreString}"); - // If no SANs are provided and the cert usage is 'HTTPS' and this is a new alias --- + // If no SANs are provided and the cert usage is 'HTTPS' --- // Add 1 for DNS and 1 for IP address to eliminate TLS errors - if(formattedSANs.Count == 0 && certUsageEnum == Constants.CertificateUsage.Https && !replaceCert) + if(formattedSANs.Count == 0 && certUsageEnum == Constants.CertificateUsage.Https) { _logger.LogTrace("Extracting CN and IP address to add as SANs to the certificate"); // Extract the CN from the Subject @@ -163,28 +161,51 @@ public JobResult ProcessJob(ReenrollmentJobConfiguration config, SubmitReenrollm _logger.LogTrace($"Extracted IP Address from the Client Machine: {ipMatch.Groups["ip"].Value}"); - formattedSANs.Add($@"""DNS:{cnMatch.Groups[1].Value}"""); - formattedSANs.Add($@"""IP:{ipMatch.Groups["ip"].Value}"""); - } - - if (!replaceCert) - { - _logger.LogTrace("Generating self-signed cert with private key on device"); - client.CreateSelfSignedCert(reenrollAlias,keyType,defaultKeystoreString,subject); + formattedSANs.Add($"DNS:{cnMatch.Groups[1].Value}"); + formattedSANs.Add($"IP:{ipMatch.Groups["ip"].Value}"); } + _logger.LogTrace("Generating private key pair on device"); + client.CreateSelfSignedCert(newAlias,keyType,defaultKeystoreString,subject,formattedSANs.ToArray()); + _logger.LogTrace("Obtaining CSR"); - var csr = client.ObtainCSR(reenrollAlias,subject,formattedSANs); + var csr = client.ObtainCSR(newAlias); _logger.LogDebug($"CSR: \n{csr}"); - + _logger.LogTrace("Validating CSR"); Constants.ValidateCsr(csr); _logger.LogTrace("CSR is valid"); - // Submit CSR to be signed in Keyfactor - _logger.LogTrace("Submitting CSR to be signed in Command"); + // Submit CSR to be signed + _logger.LogTrace("Submitting CSR to Command to enroll for signed certificate"); var x509Cert = submitReenrollment.Invoke(csr); + // Build PEM content + // ** NOTE: The static newline (\n) characters are required in the API request + StringBuilder pemBuilder = new StringBuilder(); + pemBuilder.Append(@"-----BEGIN CERTIFICATE-----\n"); + string s = Convert.ToBase64String(x509Cert.RawData, Base64FormattingOptions.InsertLineBreaks); + var noLineBreaks = s.Replace(Environment.NewLine,@"\n"); + pemBuilder.Append(noLineBreaks); + pemBuilder.Append(@"\n-----END CERTIFICATE-----"); + var pemCert = pemBuilder.ToString(); + + _logger.LogTrace($"Replacing cert '{newAlias}' with the following cert: " + pemCert); + client.ReplaceCertificate(newAlias,pemCert); + + _logger.LogTrace($"Setting '{certUsage}' binding to alias '{newAlias}'"); + client.SetCertUsageBinding(newAlias,certUsageEnum); + + // Perform unused certificate cleanup --- + // 1) If a bound alias exists and there is a new alias to enroll, delete the bound alias + if (oldCertExists) + { + _logger.LogTrace($"Removing certificate and private key associated with alias '{oldAlias}'"); + client.RemoveCertificate(oldAlias); + } + + // TODO: Add check for device ID + // TESTING build chain functionality /*using var aiaClient = new HttpClient(); var builder = new ChainBuilder(aiaClient); @@ -197,22 +218,6 @@ public JobResult ProcessJob(ReenrollmentJobConfiguration config, SubmitReenrollm i++; _logger.LogTrace($"Cert {i}: {cert.SubjectDN.ToString()}"); }*/ - - // Build PEM content - // ** NOTE: The static newline (\n) characters are required in the API request - StringBuilder pemBuilder = new StringBuilder(); - pemBuilder.Append(@"-----BEGIN CERTIFICATE-----\n"); - string s = Convert.ToBase64String(x509Cert.RawData, Base64FormattingOptions.InsertLineBreaks); - var noLineBreaks = s.Replace(Environment.NewLine,@"\n"); - pemBuilder.Append(noLineBreaks); - pemBuilder.Append(@"\n-----END CERTIFICATE-----"); - var pemCert = pemBuilder.ToString(); - - _logger.LogTrace($"Replacing cert '{reenrollAlias}' with the following cert: " + pemCert); - client.ReplaceCertificate(reenrollAlias,pemCert); - - _logger.LogTrace($"Setting '{certUsage}' binding to alias '{reenrollAlias}'"); - client.SetCertUsageBinding(reenrollAlias, certUsageEnum); } catch (Exception ex) { From 0192565d3d59483cf0616e93d6cece5f12540c78 Mon Sep 17 00:00:00 2001 From: Elena Fiocca Date: Tue, 5 May 2026 19:15:05 -0600 Subject: [PATCH 3/5] Save local changes --- AxisIPCamera/Reenrollment.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/AxisIPCamera/Reenrollment.cs b/AxisIPCamera/Reenrollment.cs index ed5d275..77cf765 100644 --- a/AxisIPCamera/Reenrollment.cs +++ b/AxisIPCamera/Reenrollment.cs @@ -204,8 +204,6 @@ public JobResult ProcessJob(ReenrollmentJobConfiguration config, SubmitReenrollm client.RemoveCertificate(oldAlias); } - // TODO: Add check for device ID - // TESTING build chain functionality /*using var aiaClient = new HttpClient(); var builder = new ChainBuilder(aiaClient); From 1cb29d2f7fa2a98844975a70f6f11456d384e66e Mon Sep 17 00:00:00 2001 From: Elena Fiocca Date: Wed, 6 May 2026 13:19:50 -0600 Subject: [PATCH 4/5] Save local changes --- AxisIPCamera/Client/AxisHttpClient.cs | 42 ++++++++--------- AxisIPCamera/Inventory.cs | 9 ++-- AxisIPCamera/Model/HttpResponse.cs | 68 +++++++++++++++++++++++++++ AxisIPCamera/Reenrollment.cs | 13 ++++- 4 files changed, 103 insertions(+), 29 deletions(-) create mode 100644 AxisIPCamera/Model/HttpResponse.cs diff --git a/AxisIPCamera/Client/AxisHttpClient.cs b/AxisIPCamera/Client/AxisHttpClient.cs index 72f661d..103f3cd 100644 --- a/AxisIPCamera/Client/AxisHttpClient.cs +++ b/AxisIPCamera/Client/AxisHttpClient.cs @@ -549,54 +549,50 @@ public void RemoveCACertificate(string alias) throw new Exception(e.Message); } } - + /// /// Removes a certificate with private key from the device. /// /// Unique identifier of the CA certificate to be removed - public void RemoveCertificate(string alias) + public HttpResult RemoveCertificate(string alias) { try { Logger.MethodEntry(); + var context = new HttpContext(); + var deleteCertResource = $"{Constants.RestApiEntryPoint}/certificates/{alias}"; var httpResponse = ExecuteHttp(deleteCertResource, Method.Delete); // Decode the HTTP response if failed if (httpResponse is { IsSuccessful: false }) { - Logger.LogError($"HTTP Request unsuccessful - HTTP Response: {DecodeHttpStatus(httpResponse)}"); - throw new Exception($"HTTP Request unsuccessful."); + var decodedStatus = DecodeHttpStatus(httpResponse); + + Logger.LogWarning($"HTTP Request unsuccessful - HTTP Response: {decodedStatus}"); + context.AddWarning(decodedStatus); } - // Decode the API response when HTTP response is successful + // Decode the API response for more information if (httpResponse != null && string.IsNullOrEmpty(httpResponse.Content)) { - throw new Exception("No content returned from HTTP Response"); - } - - RestApiResponse apiResponse = JsonConvert.DeserializeObject(httpResponse.Content); - if (apiResponse.Status == Constants.Status.Success) - { - Logger.MethodExit(); + Logger.LogError("No content returned from HTTP Response"); + context.AddError($"No content returned from HTTP Response for {nameof(Method.Delete)} {deleteCertResource}"); } else { - ErrorData error = JsonConvert.DeserializeObject(httpResponse.Content); - - // Check for error code 5 - "Validation error: Certificate is in use" or "Validation error: Certificate is not deletable" --- - // This will capture all device ID certs, which we do not want to delete anyway - if (error.ErrorInfo is { Code: 5, Message: "Validation error: Certificate is not deletable" or "Validation error: Certificate is in use"}) - { - Logger.LogWarning($"API warning encountered - {error.ErrorInfo.Message} - (Code: {error.ErrorInfo.Code})"); - } - else + RestApiResponse apiResponse = JsonConvert.DeserializeObject(httpResponse.Content); + if (apiResponse.Status != Constants.Status.Success) { - throw new Exception( - $"API error encountered - {error.ErrorInfo.Message} - (Code: {error.ErrorInfo.Code})"); + ErrorData error = JsonConvert.DeserializeObject(httpResponse.Content); + Logger.LogWarning($"API error encountered - {error.ErrorInfo.Message} - (Code: {error.ErrorInfo.Code})"); + context.AddWarning($"HTTP Request {nameof(Method.Delete)} {deleteCertResource}: API error encountered - {error.ErrorInfo.Message} - (Code: {error.ErrorInfo.Code})"); } } + + Logger.MethodExit(); + return context.ToResult(); } catch (Exception e) { diff --git a/AxisIPCamera/Inventory.cs b/AxisIPCamera/Inventory.cs index ca579c9..278eb3c 100644 --- a/AxisIPCamera/Inventory.cs +++ b/AxisIPCamera/Inventory.cs @@ -62,8 +62,9 @@ public JobResult ProcessJob(InventoryJobConfiguration config, SubmitInventoryUpd _logger.LogTrace("Retrieve all client certificates"); CertificateData data2 = client.ListCertificates(); + // TODO: Remove this if not using // Get the default keystore - _logger.LogTrace("Retrieve the default keystore"); + /*_logger.LogTrace("Retrieve the default keystore"); Constants.Keystore defaultKeystore = client.GetDefaultKeystore(); string defaultKeystoreString = defaultKeystore.ToString(); _logger.LogDebug($"Inventory - Default keystore: {defaultKeystoreString}"); @@ -78,7 +79,7 @@ public JobResult ProcessJob(InventoryJobConfiguration config, SubmitInventoryUpd foreach (var cert in data2.Certs.Where(cert => cert.Keystore == defaultKeystore)) { data2DefKey.Certs.Add(cert); - } + }*/ _logger.LogTrace("Retrieve all certificate bindings for each possible certificate usage type"); // Lookup the certificate used for HTTPS, MQTT, IEEE802.X @@ -88,7 +89,7 @@ public JobResult ProcessJob(InventoryJobConfiguration config, SubmitInventoryUpd // Set the binding on the client certificates object if the aliases found for each certificate usage match _logger.LogTrace("Mark each client certificate with the appropriate certificate usage type"); - foreach (Certificate c in data2DefKey.Certs) + foreach (Certificate c in data2.Certs) { if (c.Alias.Equals(httpAlias)) { @@ -131,7 +132,7 @@ public JobResult ProcessJob(InventoryJobConfiguration config, SubmitInventoryUpd }).Where(item => item?.Certificates != null).ToList()); // Build the list of client certificates and add to the InventoryItems object that is sent back to Command - inventoryItems.AddRange(data2DefKey.Certs.Select( + inventoryItems.AddRange(data2.Certs.Select( c => { try diff --git a/AxisIPCamera/Model/HttpResponse.cs b/AxisIPCamera/Model/HttpResponse.cs new file mode 100644 index 0000000..d1a0441 --- /dev/null +++ b/AxisIPCamera/Model/HttpResponse.cs @@ -0,0 +1,68 @@ +// Copyright 2026 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.Linq; + +namespace Keyfactor.Extensions.Orchestrator.AxisIPCamera.Model +{ + public sealed class HttpContext + { + private readonly List _warnings = new(); + private readonly List _errors = new(); + + public IReadOnlyList Warnings => _warnings; + public IReadOnlyList Errors => _errors; + + public void AddWarning(string message) => + _warnings.Add(message); + + public void AddError(string message) => + _errors.Add(message); + + public HttpResult ToResult() + { + if (_errors.Any()) + return HttpResult.Error(FormatMessages(_errors)); + + if (_warnings.Any()) + return HttpResult.Warning(FormatMessages(_warnings)); + + return HttpResult.Success(); + } + + private static string FormatMessages(IEnumerable messages) + { + return string.Join( + Environment.NewLine, + messages.Select((message, index) => + $"({index + 1}) {message}") + ); + } + } + + public enum HttpStatus + { + Success, + Warning, + Error + } + + public sealed record HttpResult(HttpStatus Status, string Message = null) + { + public static HttpResult Success() + => new(HttpStatus.Success); + + public static HttpResult Warning(string message) + => new(HttpStatus.Warning, message); + + public static HttpResult Error(string message) + => new(HttpStatus.Error, message); + + } +} \ No newline at end of file diff --git a/AxisIPCamera/Reenrollment.cs b/AxisIPCamera/Reenrollment.cs index 77cf765..b411815 100644 --- a/AxisIPCamera/Reenrollment.cs +++ b/AxisIPCamera/Reenrollment.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; +using System.Net; using System.Net.Http; using System.Text; using System.Text.RegularExpressions; @@ -197,13 +198,21 @@ public JobResult ProcessJob(ReenrollmentJobConfiguration config, SubmitReenrollm client.SetCertUsageBinding(newAlias,certUsageEnum); // Perform unused certificate cleanup --- - // 1) If a bound alias exists and there is a new alias to enroll, delete the bound alias + // 1) If a bound alias exists, delete the bound alias + HttpResult result; if (oldCertExists) { _logger.LogTrace($"Removing certificate and private key associated with alias '{oldAlias}'"); - client.RemoveCertificate(oldAlias); + result = client.RemoveCertificate(oldAlias); + + if (result.Status == HttpStatus.Warning) + { + return new JobResult() { Result = OrchestratorJobStatusJobResult.Warning, JobHistoryId = config.JobHistoryId, + FailureMessage = $"Reenrollment Job Had Warnings - Refer to logs for more detailed information." }; + } } + // TESTING build chain functionality /*using var aiaClient = new HttpClient(); var builder = new ChainBuilder(aiaClient); From 494741ea8ab58a397062ef9b6b6538865d1bbfc6 Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Thu, 7 May 2026 18:25:41 +0000 Subject: [PATCH 5/5] Update generated docs --- README.md | 28 ++-- .../bash/curl_create_store_types.sh | 128 ++++++++++++++++++ .../bash/kfutil_create_store_types.sh | 28 ++++ .../powershell/kfutil_create_store_types.ps1 | 29 ++++ .../restmethod_create_store_types.ps1 | 122 +++++++++++++++++ 5 files changed, 324 insertions(+), 11 deletions(-) create mode 100755 scripts/store_types/bash/curl_create_store_types.sh create mode 100755 scripts/store_types/bash/kfutil_create_store_types.sh create mode 100644 scripts/store_types/powershell/kfutil_create_store_types.ps1 create mode 100644 scripts/store_types/powershell/restmethod_create_store_types.ps1 diff --git a/README.md b/README.md index 4e275e1..53c69fa 100644 --- a/README.md +++ b/README.md @@ -247,14 +247,12 @@ the Keyfactor Command Portal | Universal Orchestrator Version | Latest .NET version installed on the Universal Orchestrator server | `rollForward` condition in `Orchestrator.runtimeconfig.json` | `axis-ipcamera-orchestrator` .NET version to download | | --------- | ----------- | ----------- | ----------- | - | Older than `11.0.0` | | | `net6.0` | - | Between `11.0.0` and `11.5.1` (inclusive) | `net6.0` | | `net6.0` | - | Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `Disable` | `net6.0` || Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `LatestMajor` | `net8.0` | + | Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `LatestMajor` | `net8.0` | | `11.6` _and_ newer | `net8.0` | | `net8.0` | Unzip the archive containing extension assemblies to a known location. - > **Note** If you don't see an asset with a corresponding .NET version, you should always assume that it was compiled for `net6.0`. + > **Note** If you don't see an asset with a corresponding .NET version, you should always assume that it was compiled for `net8.0`. 2. **Locate the Universal Orchestrator extensions directory.** @@ -435,23 +433,31 @@ There are five (5) possible options: > [!NOTE] > A Reenrollment (ODKG) job will not allow enrollment of certificates with **Trust** assigned as the \`Certificate Usage\`. > Trust CA certificates can be added to the camera via a Management - Add job. +> These CA certificates establish trust for TLS connections initiated by the camera. > [!NOTE] -> For a Reenrollment (ODKG) job, where the \`Certificate Usage\` assigned is **HTTPS**, IP and DNS are added as SANS -> to the enrolled certificate. +> As of Keyfactor Command v25.4, SANs can be provided for a Reenrollment (ODKG) job. +> You must also have installed, at minimum, the Keyfactor Universal Orchestator v25.1 +> in order for the SANs to be sent to the orchestrator. > -> IP = Client Machine configured for the certificate store (excluding any port) +> The Axis IP Camera API *only* supports the addition of DNS and IP SANs. If you add other SAN types to the ODKG job, these will be ignored and not added to the certificate. > -> DNS = CN set in the Subject DN +> * If SANs are NOT provided and the \`Certificate Usage\` assigned is **HTTPS**, IP and DNS will be automatically added as SANs to an enrolled certificate associated with a NEW alias. +> +> * IP = Client Machine configured for the certificate store (excluding any port) +> +> * DNS = CN set in the Subject DN ## Caveats > [!NOTE] -> Reenrollment jobs will not replace or remove a client-server certificate with the same alias. They will also not remove -> the original certificate if a particular \`Certificate Usage\` had an associated cert. Since the camera has limited storage, -> it will be up to the user to remove any unused client-server certificates via the AXIS Network Camera GUI. +> v1.1.0 - A Reenrollment job will now replace the certificate contents for an existing alias on the camera, therefore, not requiring +> a new alias be supplied for every new certificate enrollment. +> +> If a new alias is supplied, a Reenrollment job will not remove the original certificate associated with the \`Certificate Usage\`. +> Since the camera has limited storage, it will be up to the user to remove any unused certificates via the AXIS Network Camera GUI. ## License diff --git a/scripts/store_types/bash/curl_create_store_types.sh b/scripts/store_types/bash/curl_create_store_types.sh new file mode 100755 index 0000000..3ab9e04 --- /dev/null +++ b/scripts/store_types/bash/curl_create_store_types.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash + +# Creates all 1 store types via the Keyfactor Command REST API using curl. +# +# Authentication (first matching method is used): +# OAuth access token: KEYFACTOR_AUTH_ACCESS_TOKEN +# OAuth client creds: KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET +# + KEYFACTOR_AUTH_TOKEN_URL +# Basic auth (AD): KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD + KEYFACTOR_DOMAIN +# +# Always required: +# KEYFACTOR_HOSTNAME Command hostname (e.g. my-command.example.com) +# +# Auto-generated by doctool generate-store-type-scripts — do not edit by hand. + +if [ -z "${KEYFACTOR_HOSTNAME}" ]; then + echo "ERROR: KEYFACTOR_HOSTNAME is required" + exit 1 +fi + +BASE_URL="https://${KEYFACTOR_HOSTNAME}/keyfactorapi" + +# --------------------------------------------------------------------------- +# Resolve auth +# --------------------------------------------------------------------------- +if [ -n "${KEYFACTOR_AUTH_ACCESS_TOKEN}" ]; then + BEARER_TOKEN="${KEYFACTOR_AUTH_ACCESS_TOKEN}" +elif [ -n "${KEYFACTOR_AUTH_CLIENT_ID}" ] && [ -n "${KEYFACTOR_AUTH_CLIENT_SECRET}" ] && [ -n "${KEYFACTOR_AUTH_TOKEN_URL}" ]; then + echo "Fetching OAuth token..." + BEARER_TOKEN=$(curl -s -X POST "${KEYFACTOR_AUTH_TOKEN_URL}" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "grant_type=client_credentials" \ + --data-urlencode "client_id=${KEYFACTOR_AUTH_CLIENT_ID}" \ + --data-urlencode "client_secret=${KEYFACTOR_AUTH_CLIENT_SECRET}" | jq -r '.access_token') + if [ -z "${BEARER_TOKEN}" ] || [ "${BEARER_TOKEN}" = "null" ]; then + echo "ERROR: Failed to fetch OAuth token from ${KEYFACTOR_AUTH_TOKEN_URL}" + exit 1 + fi +elif [ -n "${KEYFACTOR_USERNAME}" ] && [ -n "${KEYFACTOR_PASSWORD}" ] && [ -n "${KEYFACTOR_DOMAIN}" ]; then + BEARER_TOKEN="" +else + echo "ERROR: Authentication required. Set one of:" + echo " KEYFACTOR_AUTH_ACCESS_TOKEN" + echo " KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET + KEYFACTOR_AUTH_TOKEN_URL" + echo " KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD + KEYFACTOR_DOMAIN" + exit 1 +fi + +if [ -n "${BEARER_TOKEN}" ]; then + CURL_AUTH=("-H" "Authorization: Bearer ${BEARER_TOKEN}") +else + CURL_AUTH=("-u" "${KEYFACTOR_USERNAME}@${KEYFACTOR_DOMAIN}:${KEYFACTOR_PASSWORD}") +fi + +create_store_type() { + local name="$1" + local body="$2" + echo "Creating ${name} store type..." + response=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST "${BASE_URL}/certificatestoretypes" \ + -H "Content-Type: application/json" \ + -H "x-keyfactor-requested-with: APIClient" \ + "${CURL_AUTH[@]}" \ + -d "${body}") + if [ "$response" = "200" ] || [ "$response" = "201" ]; then + echo " OK (HTTP ${response})" + else + echo " FAILED (HTTP ${response})" + fi +} + +# --------------------------------------------------------------------------- +# AxisIPCamera — The IP address of the Camera. Sample is "192.167.231.174:44444". Include the port if necessary. +# --------------------------------------------------------------------------- +create_store_type "AxisIPCamera" '{ + "Name": "Axis IP Camera", + "ShortName": "AxisIPCamera", + "Capability": "AxisIPCamera", + "ServerRequired": true, + "BlueprintAllowed": false, + "PowerShell": false, + "CustomAliasAllowed": "Required", + "PrivateKeyAllowed": "Forbidden", + "SupportedOperations": { + "Add": true, + "Create": false, + "Discovery": false, + "Enrollment": true, + "Remove": true + }, + "PasswordOptions": { + "EntrySupported": false, + "StoreRequired": false, + "Style": "Default" + }, + "Properties": [ + { + "Name": "ServerUseSsl", + "DisplayName": "Use SSL", + "Type": "Bool", + "DependsOn": "", + "DefaultValue": "true", + "Required": true + } + ], + "EntryParameters": [ + { + "Name": "CertUsage", + "DisplayName": "Certificate Usage", + "Type": "MultipleChoice", + "RequiredWhen": { + "HasPrivateKey": false, + "OnAdd": true, + "OnRemove": false, + "OnReenrollment": true + }, + "Options": "HTTPS,IEEE802.X,MQTT,Trust,Other", + "Description": "The Certificate Usage to assign to the cert after enrollment. Can be left 'Other' to be assigned later." + } + ], + "StorePathDescription": "Enter the Serial Number of the camera e.g. `0b7c3d2f9e8a`", + "StorePathType": "", + "StorePathValue": "", + "JobProperties": [] +}' + + +echo "Completed." diff --git a/scripts/store_types/bash/kfutil_create_store_types.sh b/scripts/store_types/bash/kfutil_create_store_types.sh new file mode 100755 index 0000000..27dfa46 --- /dev/null +++ b/scripts/store_types/bash/kfutil_create_store_types.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +# Creates all 1 store types using kfutil. +# kfutil reads definitions from the Keyfactor integration catalog. +# +# Auth environment variables (first matching method is used): +# OAuth access token: KEYFACTOR_AUTH_ACCESS_TOKEN +# OAuth client creds: KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET +# + KEYFACTOR_AUTH_TOKEN_URL +# Basic auth (AD): KEYFACTOR_HOSTNAME + KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD +# + KEYFACTOR_DOMAIN +# +# Auto-generated by doctool generate-store-type-scripts — do not edit by hand. + +if ! command -v kfutil &> /dev/null; then + echo "kfutil could not be found. Please install kfutil" + echo "See https://github.com/Keyfactor/kfutil#quickstart" + exit 1 +fi + +if [ -z "$KEYFACTOR_HOSTNAME" ]; then + echo "KEYFACTOR_HOSTNAME not set — launching kfutil login" + kfutil login +fi + +kfutil store-types create --name "AxisIPCamera" + +echo "Done. All store types created." diff --git a/scripts/store_types/powershell/kfutil_create_store_types.ps1 b/scripts/store_types/powershell/kfutil_create_store_types.ps1 new file mode 100644 index 0000000..7280dd5 --- /dev/null +++ b/scripts/store_types/powershell/kfutil_create_store_types.ps1 @@ -0,0 +1,29 @@ +# Creates all 1 store types using kfutil. +# kfutil reads definitions from the Keyfactor integration catalog. +# +# Auth environment variables (first matching method is used): +# OAuth access token: KEYFACTOR_AUTH_ACCESS_TOKEN +# OAuth client creds: KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET +# + KEYFACTOR_AUTH_TOKEN_URL +# Basic auth (AD): KEYFACTOR_HOSTNAME + KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD +# + KEYFACTOR_DOMAIN +# +# Auto-generated by doctool generate-store-type-scripts — do not edit by hand. + +# Uncomment if kfutil is not in your PATH +# Set-Alias -Name kfutil -Value 'C:\Program Files\Keyfactor\kfutil\kfutil.exe' + +if ($null -eq (Get-Command "kfutil" -ErrorAction SilentlyContinue)) { + Write-Host "kfutil could not be found. Please install kfutil" + Write-Host "See https://github.com/Keyfactor/kfutil#quickstart" + exit 1 +} + +if (-not $env:KEYFACTOR_HOSTNAME) { + Write-Host "KEYFACTOR_HOSTNAME not set — launching kfutil login" + & kfutil login +} + +& kfutil store-types create --name "AxisIPCamera" + +Write-Host "Done. All store types created." diff --git a/scripts/store_types/powershell/restmethod_create_store_types.ps1 b/scripts/store_types/powershell/restmethod_create_store_types.ps1 new file mode 100644 index 0000000..e23ce6f --- /dev/null +++ b/scripts/store_types/powershell/restmethod_create_store_types.ps1 @@ -0,0 +1,122 @@ +# Creates all 1 store types via the Keyfactor Command REST API +# using PowerShell Invoke-RestMethod. +# +# Authentication (first matching method is used): +# OAuth access token: KEYFACTOR_AUTH_ACCESS_TOKEN +# OAuth client creds: KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET +# + KEYFACTOR_AUTH_TOKEN_URL +# Basic auth (AD): KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD + KEYFACTOR_DOMAIN +# +# Always required: +# KEYFACTOR_HOSTNAME Command hostname (e.g. my-command.example.com) +# +# Auto-generated by doctool generate-store-type-scripts — do not edit by hand. + +if (-not $env:KEYFACTOR_HOSTNAME) { + Write-Error "KEYFACTOR_HOSTNAME is required" + exit 1 +} + +$uri = "https://$($env:KEYFACTOR_HOSTNAME)/keyfactorapi/certificatestoretypes" +$headers = @{ + 'Content-Type' = "application/json" + 'x-keyfactor-requested-with' = "APIClient" +} + +# --------------------------------------------------------------------------- +# Resolve auth +# --------------------------------------------------------------------------- +if ($env:KEYFACTOR_AUTH_ACCESS_TOKEN) { + $headers['Authorization'] = "Bearer $($env:KEYFACTOR_AUTH_ACCESS_TOKEN)" +} elseif ($env:KEYFACTOR_AUTH_CLIENT_ID -and $env:KEYFACTOR_AUTH_CLIENT_SECRET -and $env:KEYFACTOR_AUTH_TOKEN_URL) { + Write-Host "Fetching OAuth token..." + $tokenBody = @{ + grant_type = 'client_credentials' + client_id = $env:KEYFACTOR_AUTH_CLIENT_ID + client_secret = $env:KEYFACTOR_AUTH_CLIENT_SECRET + } + $tokenResp = Invoke-RestMethod -Method Post -Uri $env:KEYFACTOR_AUTH_TOKEN_URL -Body $tokenBody + $headers['Authorization'] = "Bearer $($tokenResp.access_token)" +} elseif ($env:KEYFACTOR_USERNAME -and $env:KEYFACTOR_PASSWORD -and $env:KEYFACTOR_DOMAIN) { + $cred = [System.Convert]::ToBase64String( + [System.Text.Encoding]::ASCII.GetBytes( + "$($env:KEYFACTOR_USERNAME)@$($env:KEYFACTOR_DOMAIN):$($env:KEYFACTOR_PASSWORD)")) + $headers['Authorization'] = "Basic $cred" +} else { + Write-Error ("Authentication required. Set one of:`n" + + " KEYFACTOR_AUTH_ACCESS_TOKEN`n" + + " KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET + KEYFACTOR_AUTH_TOKEN_URL`n" + + " KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD + KEYFACTOR_DOMAIN") + exit 1 +} + +function New-StoreType { + param([string]$Name, [string]$Body) + Write-Host "Creating $Name store type..." + try { + Invoke-RestMethod -Method Post -Uri $uri -Headers $headers -Body $Body -ContentType "application/json" | Out-Null + Write-Host " OK" + } catch { + Write-Warning " FAILED: $($_.Exception.Message)" + } +} + +# --------------------------------------------------------------------------- +# AxisIPCamera — The IP address of the Camera. Sample is "192.167.231.174:44444". Include the port if necessary. +# --------------------------------------------------------------------------- +New-StoreType "AxisIPCamera" @' +{ + "Name": "Axis IP Camera", + "ShortName": "AxisIPCamera", + "Capability": "AxisIPCamera", + "ServerRequired": true, + "BlueprintAllowed": false, + "PowerShell": false, + "CustomAliasAllowed": "Required", + "PrivateKeyAllowed": "Forbidden", + "SupportedOperations": { + "Add": true, + "Create": false, + "Discovery": false, + "Enrollment": true, + "Remove": true + }, + "PasswordOptions": { + "EntrySupported": false, + "StoreRequired": false, + "Style": "Default" + }, + "Properties": [ + { + "Name": "ServerUseSsl", + "DisplayName": "Use SSL", + "Type": "Bool", + "DependsOn": "", + "DefaultValue": "true", + "Required": true + } + ], + "EntryParameters": [ + { + "Name": "CertUsage", + "DisplayName": "Certificate Usage", + "Type": "MultipleChoice", + "RequiredWhen": { + "HasPrivateKey": false, + "OnAdd": true, + "OnRemove": false, + "OnReenrollment": true + }, + "Options": "HTTPS,IEEE802.X,MQTT,Trust,Other", + "Description": "The Certificate Usage to assign to the cert after enrollment. Can be left 'Other' to be assigned later." + } + ], + "StorePathDescription": "Enter the Serial Number of the camera e.g. `0b7c3d2f9e8a`", + "StorePathType": "", + "StorePathValue": "", + "JobProperties": [] +} +'@ + + +Write-Host "Completed."