Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 42 additions & 35 deletions AxisIPCamera/AxisIPCamera.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,47 +2,54 @@

<PropertyGroup>
<AppendTargetFrameworkToOutputPath>true</AppendTargetFrameworkToOutputPath>
<TargetFrameworks>net6.0;net8.0</TargetFrameworks>
<TargetFramework>net8.0</TargetFramework>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<ImplicitUsings>disable</ImplicitUsings>
<RootNamespace>Keyfactor.Extensions.Orchestrator.AxisIPCamera</RootNamespace>
<FileVersion>1.1.0</FileVersion>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="BouncyCastle.NetCore" Version="2.2.1" />
<PackageReference Include="Keyfactor.Logging" Version="1.1.1" />

<None Update="manifest.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

<PackageReference Include="Keyfactor.Orchestrators.IOrchestratorJobExtensions" Version="1.0.0" />

<PackageReference Include="RestSharp" Version="112.1.0" />

<None Update="Files\SetHttpsBinding.xml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

<None Update="Files\SetIEEEBinding.xml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

<None Update="Files\SetMQTTBinding.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

<None Update="Files\GetHttpsBinding.xml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

<None Update="Files\GetIEEEBinding.xml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

<None Update="Files\GetMQTTBinding.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.1" />

<PackageReference Include="Keyfactor.Logging" Version="1.3.0" />

<PackageReference Include="Keyfactor.Orchestrators.IOrchestratorJobExtensions" Version="1.0.0" />

<PackageReference Include="Keyfactor.PKI" Version="8.3.1" />

<PackageReference Include="RestSharp" Version="112.1.0" />

<PackageReference Include="System.Formats.Asn1" Version="8.0.1" />

<None Update="manifest.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

<None Update="Files\SetHttpsBinding.xml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

<None Update="Files\SetIEEEBinding.xml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

<None Update="Files\SetMQTTBinding.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

<None Update="Files\GetHttpsBinding.xml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

<None Update="Files\GetIEEEBinding.xml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

<None Update="Files\GetMQTTBinding.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>

</Project>
75 changes: 62 additions & 13 deletions AxisIPCamera/Client/AxisHttpClient.cs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -524,30 +524,79 @@ 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<RestApiResponse>(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<ErrorData>(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);
}
}

/// <summary>
/// Removes a certificate with private key from the device.
/// </summary>
/// <param name="alias">Unique identifier of the CA certificate to be removed</param>
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 })
{
var decodedStatus = DecodeHttpStatus(httpResponse);

Logger.LogWarning($"HTTP Request unsuccessful - HTTP Response: {decodedStatus}");
context.AddWarning(decodedStatus);
}

// Decode the API response for more information
if (httpResponse != null && string.IsNullOrEmpty(httpResponse.Content))
{
Logger.LogError("No content returned from HTTP Response");
context.AddError($"No content returned from HTTP Response for {nameof(Method.Delete)} {deleteCertResource}");
}
else
{
RestApiResponse apiResponse = JsonConvert.DeserializeObject<RestApiResponse>(httpResponse.Content);
if (apiResponse.Status == Constants.Status.Success)
{
Logger.MethodExit();
}
else
if (apiResponse.Status != Constants.Status.Success)
{
ErrorData error = JsonConvert.DeserializeObject<ErrorData>(httpResponse.Content);
throw new Exception(
$"API error encountered - {error.ErrorInfo.Message} - (Code: {error.ErrorInfo.Code})");
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)
{
Logger.LogError("Error completing CA certificate remove: " + LogHandler.FlattenException(e));
Logger.LogError("Error completing certificate remove: " + LogHandler.FlattenException(e));
throw new Exception(e.Message);
}
}
Expand Down
16 changes: 14 additions & 2 deletions AxisIPCamera/Helpers/DeviceCertValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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));

Expand Down Expand Up @@ -214,8 +226,8 @@ private static bool VerifyAkiSkiChain(List<X509Certificate> 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++)
{
Expand Down
64 changes: 64 additions & 0 deletions AxisIPCamera/Helpers/SANBuilder.cs
Original file line number Diff line number Diff line change
@@ -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<string> BuildSANList(Dictionary<string, string[]> sans, ILogger logger)
{
var parts = new List<string>();

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;
}

/// <summary>
/// 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.
/// </summary>
private static string NormalizeSanKey(string key)
{
return key.Trim().ToLower() switch
{
"dns" => "DNS",
"ip" or "ip4" or "ip6" => "IP",
_ => key.ToLower() // default
};
}
}
}
9 changes: 5 additions & 4 deletions AxisIPCamera/Inventory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
Expand All @@ -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
Expand All @@ -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))
{
Expand Down Expand Up @@ -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
Expand Down
33 changes: 31 additions & 2 deletions AxisIPCamera/Model/Constants.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
// 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,
// 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.Globalization;
using System.IO;
using System.Text.RegularExpressions;
using Newtonsoft.Json;
using Org.BouncyCastle.OpenSsl;
using Org.BouncyCastle.Pkcs;
Expand All @@ -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";
Expand Down Expand Up @@ -248,4 +250,31 @@ public override void WriteJson(
}
}
}

public static class CertificateName
{
/// <summary>
/// Returns a UTC-based suffix, i.e. "2602171544"
/// </summary>
public static string GetUtcSuffix() =>
DateTime.UtcNow.ToString("yyMMddHHmm", CultureInfo.InvariantCulture);

/// <summary>
/// Creates a unique certificate name by appending ['_' + Utc DateTime suffix] to the end of the user-supplied certificate name.
/// Example: "_2602171544"
/// </summary>
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();
}
}
}
Loading