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>
73 changes: 63 additions & 10 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,83 @@ 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 void RemoveCertificate(string alias)
{
try
{
Logger.MethodEntry();

RestApiResponse apiResponse = JsonConvert.DeserializeObject<RestApiResponse>(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<RestApiResponse>(httpResponse.Content);
if (apiResponse.Status == Constants.Status.Success)
{
Logger.MethodExit();
}
else
{
ErrorData error = JsonConvert.DeserializeObject<ErrorData>(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<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));
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
};
}
}
}
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