diff --git a/BoschIPCamera/Client/BoschIPCameraClient.cs b/BoschIPCamera/Client/BoschIPCameraClient.cs index 6245868..d2d2057 100644 --- a/BoschIPCamera/Client/BoschIPCameraClient.cs +++ b/BoschIPCamera/Client/BoschIPCameraClient.cs @@ -1,649 +1,679 @@ -// Copyright 2023 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.IO; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.ServiceModel; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using System.Xml; -using Keyfactor.Logging; -using Keyfactor.Orchestrators.Extensions; -using Keyfactor.Orchestrators.Extensions.Interfaces; -using Microsoft.Extensions.Logging; - -namespace Keyfactor.Extensions.Orchestrator.BoschIPCamera.Client -{ - public class BoschIpCameraClient - { - private readonly string _cameraUrl; - private readonly string _baseUrl; - private readonly HttpClient _client; - private readonly ILogger _logger; - private readonly CredentialCache _digestCredential; - private HttpResponseMessage _response; - - public BoschIpCameraClient(JobConfiguration config, CertificateStore store, IPAMSecretResolver pam, ILogger logger) - { - _logger = logger; - _logger.LogTrace("Starting Bosch IP Camera Client config"); - - if (config.UseSSL) - { - _baseUrl = $"https://{store.ClientMachine}"; - _cameraUrl = $"https://{store.ClientMachine}/rcp.xml?"; - } - else - { - _baseUrl = $"http://{store.ClientMachine}"; - _cameraUrl = $"http://{store.ClientMachine}/rcp.xml?"; - } - - _logger.LogDebug($"Base URL: {_baseUrl}"); - _logger.LogDebug($"Camera API URL: {_cameraUrl}"); - - var username = ResolvePamField(pam, config.ServerUsername, "Server Username"); - var password = ResolvePamField(pam, config.ServerPassword, "Server Password"); - - var credentials = $"{username}:{password}"; - var encodedCredentials = Convert.ToBase64String(Encoding.ASCII.GetBytes(credentials)); - - _client = new HttpClient(); - _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", encodedCredentials); - - // for use in reenrollment cert upload calls - _digestCredential = new CredentialCache - { - {new Uri(_baseUrl), "Digest", new NetworkCredential(username, password)} - }; - } - - public Dictionary ListCerts() - { - _logger.MethodEntry(LogLevel.Debug); - var api = Constants.API.BuildRequestUri( - Constants.API.Endpoints.CERTIFICATE_LIST, - Constants.API.Type.P_OCTET, - Constants.API.Direction.READ - ); - var requestUri = $"{_cameraUrl}{api}"; - - var request = new HttpRequestMessage(HttpMethod.Get, requestUri); - - _logger.LogTrace($"Sending API request: {requestUri}"); - var task = _client.SendAsync(request); - task.Wait(); - var cameras = GetCameraCertList(task.Result.Content.ReadAsStringAsync().Result); - var files = new Dictionary(); - foreach (var c in cameras) - { - Download(c).Wait(); - files.Add(c, _response.Content.ReadAsStringAsync().Result); - } - - return files; - } - - public string CertCreate(Dictionary subject, string certificateName) - { - _logger.MethodEntry(LogLevel.Debug); - try - { - var myId = HexadecimalEncoding.ToHexNoPadding(certificateName); - var payload = $"{HexadecimalEncoding.ToHexWithPrefix(certificateName, 4, '0')}0000{myId}"; - - // RAW HEX: "length" + "tag" + "content" - // length is full byte count of header (length + tag) + content - var keyType = "0008" + "0001" + "00000001"; - var requesttype = "0008" + "0002" + "00000000"; - - payload += keyType; - payload += requesttype; - - // CN is expected - var myCommon = HexadecimalEncoding.ToHexWithPadding(subject["CN"]); - payload += $"{HexadecimalEncoding.ToHexStringLengthWithPadding(subject["CN"], 4, '0')}0005{myCommon}"; - - if (subject.ContainsKey("O")) - { - var myOrg = HexadecimalEncoding.ToHexWithPadding(subject["O"]); - payload += $"{HexadecimalEncoding.ToHexStringLengthWithPadding(subject["O"], 4, '0')}0006{myOrg}"; - } - - if (subject.ContainsKey("OU")) - { - var myUnit = HexadecimalEncoding.ToHexWithPadding(subject["OU"]); - payload += $"{HexadecimalEncoding.ToHexStringLengthWithPadding(subject["OU"], 4, '0')}0007{myUnit}"; - } - - if (subject.ContainsKey("L")) - { - var myCity = HexadecimalEncoding.ToHexWithPadding(subject["L"]); - payload += $"{HexadecimalEncoding.ToHexStringLengthWithPadding(subject["L"], 4, '0')}0008{myCity}"; - } - - if (subject.ContainsKey("C")) - { - var myCountry = HexadecimalEncoding.ToHexWithPadding(subject["C"]); - payload += $"{HexadecimalEncoding.ToHexStringLengthWithPadding(subject["C"], 4, '0')}0009{myCountry}"; - } - - if (subject.ContainsKey("ST")) - { - var myProvince = HexadecimalEncoding.ToHexWithPadding(subject["ST"]); - payload += $"{HexadecimalEncoding.ToHexStringLengthWithPadding(subject["ST"], 4, '0')}000A{myProvince}"; - } - - GenerateCsrOnCameraAsync(payload).Wait(); - var returnCode = parseCameraResponse(_response.Content.ReadAsStringAsync().Result); - if (returnCode != null) - { - _logger.LogError($"Camera failed to generate CSR with error code {returnCode}"); - return returnCode; - } - - _logger.LogInformation($"CSR call completed successfully for {certificateName}"); - return "pass"; - } - catch (ProtocolException ex) - { - _logger.LogError($"CSR call failed with the following error: {ex}"); - return ex.ToString(); - } - } - - - //Call the camera to generate a CSR - private async Task GenerateCsrOnCameraAsync(string payload) - { - var api = Constants.API.BuildRequestUri( - Constants.API.Endpoints.CERTIFICATE_REQUEST, - Constants.API.Type.P_OCTET, - Constants.API.Direction.WRITE, - Uri.EscapeDataString(payload) - ); - var requestUri = $"{_cameraUrl}{api}"; - - var cancellationTokenSource = new CancellationTokenSource(); - var token = cancellationTokenSource.Token; - - _logger.LogTrace($"Sending API request: {requestUri}"); - _response = await _client.GetAsync(requestUri, token); - } - - public string DownloadCsrFromCamera(string certName) - { - _logger.MethodEntry(LogLevel.Debug); - _logger.LogTrace("Download " + certName + " CSR from Camera: " + _cameraUrl); - var haveCsr = false; - var count = 0; - string csrResult = null; - //keep trying until we get the cert or try 30 times (wait 5 seconds each time) - while (!haveCsr && count <= 30) - try - { - Thread.Sleep(5000); - count++; - Download(certName, "?type=csr").Wait(); - csrResult= _response.Content.ReadAsStringAsync().Result; - if (csrResult.Contains("-----BEGIN CERTIFICATE REQUEST-----")) - haveCsr = true; - } - catch (Exception ex) - { - _logger.LogTrace("CSR Download failed with the following error: " + ex); - } - - return csrResult; - } - - public void UploadCert(string fileName, string fileData) - { - _logger.MethodEntry(LogLevel.Debug); - _logger.LogTrace("Starting Cert upload to camera " + _baseUrl); - - var boundary = "----------" + DateTime.Now.Ticks.ToString("x"); - var fileHeader = - $"Content-Disposition: form-data; name=\"certUsageUnspecified\"; filename=\"{fileName}\";\r\nContent-Type: application/x-x509-ca-cert\r\n\r\n"; - - var authRequest = (HttpWebRequest)WebRequest.Create(_baseUrl + "/upload.htm"); - authRequest.Method = "GET"; - authRequest.Credentials = _digestCredential; - authRequest.PreAuthenticate = true; - - try - { - _logger.LogTrace("Get Auth call to camera on " + _baseUrl); - authRequest.GetResponse(); - } - catch (Exception e) - { - _logger.LogError(e.Message); - } - - var count = 0; - //keep trying until we get the cert on camera or try 5 times - while (count <= 5) - { - try - { - count++; - _logger.LogTrace("Post call to camera on " + _baseUrl); - var httpWebRequest = (HttpWebRequest)WebRequest.Create(_baseUrl + "/upload.htm"); - httpWebRequest.Credentials = _digestCredential; - httpWebRequest.ContentType = "multipart/form-data; boundary=" + boundary; - httpWebRequest.Method = "POST"; - - var requestStream = httpWebRequest.GetRequestStream(); - WriteToStream(requestStream, "--" + boundary + "\r\n"); - WriteToStream(requestStream, fileHeader); - WriteToStream(requestStream, fileData); - WriteToStream(requestStream, "\r\n--" + boundary + "--\r\n"); - - var myHttpWebResponse = (HttpWebResponse)httpWebRequest.GetResponse(); - - - var responseStream = myHttpWebResponse.GetResponseStream(); - - var myStreamReader = new StreamReader(responseStream ?? throw new InvalidOperationException(), Encoding.Default); - - myStreamReader.ReadToEnd(); - - myStreamReader.Close(); - responseStream.Close(); - - myHttpWebResponse.Close(); - return; - } - catch (Exception e) - { - _logger.LogError(e.Message); - _logger.LogTrace("Failed to push cert on attempt " + count + " trying again if less than or equal to 5"); - } - } - } - - private static void WriteToStream(Stream s, string txt) - { - var bytes = Encoding.UTF8.GetBytes(txt); - s.Write(bytes, 0, bytes.Length); - } - - private async Task Download(string certName, string paramString = "") - { - var source = new CancellationTokenSource(); - var token = source.Token; - - var cameraUrl = $"{_baseUrl}/cert_download/{certName.Replace(" ", "%20")}.pem{paramString}"; - _logger.LogTrace("Camera URL for download: " + cameraUrl); - - _response = await _client.GetAsync(cameraUrl, token); - - } - - - // Enable/Disable 802.1x setting on the camera - public string Change8021XSettings(bool onOffSwitch) - { - _logger.MethodEntry(LogLevel.Debug); - _logger.LogTrace($"Changing Camera 802.1x setting to {(onOffSwitch ? "1" : "0")} on Camera: {_cameraUrl}"); - - try - { - Change8021X(onOffSwitch).Wait(); - var returnCode = parseCameraResponse(_response.Content.ReadAsStringAsync().Result); - if (returnCode != null) - { - _logger.LogError("Camera failed to change 802.1x with error code " + returnCode); - return returnCode; - } - - _logger.LogInformation("802.1x setting changed successfully for " + _cameraUrl); - return "pass"; - } - catch (Exception ex) - { - _logger.LogError("802.1x setting change failed with the following error: " + ex); - return ex.ToString(); - } - } - - // Enable/Disable 802.1x on the camera after the certs are in place - // onOffSwitch - "0" means off, "1" means on - private async Task Change8021X(bool onOffSwitch) - { - var source = new CancellationTokenSource(); - var token = source.Token; - - var api = Constants.API.BuildRequestUri( - Constants.API.Endpoints.EAP_ENABLE, - Constants.API.Type.T_OCTET, - Constants.API.Direction.WRITE, - Uri.EscapeDataString(onOffSwitch ? "1" : "0") - ); - var requestUri = $"{_cameraUrl}{api}"; - - var request = new HttpRequestMessage(HttpMethod.Get, requestUri); - - _logger.LogTrace($"Sending API request: {requestUri}"); - _response = await _client.SendAsync(request, token); - if (!_response.IsSuccessStatusCode) - throw new Exception($"Request failed with status code {_response.StatusCode}"); - } - - - public string RebootCamera() - { - _logger.MethodEntry(LogLevel.Debug); - _logger.LogTrace("Rebooting camera : " + _cameraUrl); - - try - { - Reboot().Wait(); - var returnCode = parseCameraResponse(_response.Content.ReadAsStringAsync().Result); - if (returnCode != null) - { - _logger.LogError("Camera failed to Reboot with error code " + returnCode); - return returnCode; - } - - _logger.LogInformation("Camera rebooted successfully " + _cameraUrl); - return "pass"; - } - catch (Exception ex) - { - _logger.LogError("Failed to Reboot Camera " + _cameraUrl + " with the following error: " + ex); - return ex.ToString(); - } - } - - private async Task Reboot() - { - var source = new CancellationTokenSource(); - var token = source.Token; - - var api = Constants.API.BuildRequestUri( - Constants.API.Endpoints.BOARD_RESET, - Constants.API.Type.F_FLAG, - Constants.API.Direction.WRITE, - "1" // sending 1 reboots camera - ); - var requestUri = $"{_cameraUrl}{api}"; - - var request = new HttpRequestMessage(HttpMethod.Get, requestUri); - - _logger.LogTrace($"Sending API request: {requestUri}"); - _response = await _client.SendAsync(request, token); - if(!_response.IsSuccessStatusCode) - throw new Exception($"Request failed with status code {_response.StatusCode}"); - - } - - // get the cert usage - public Dictionary GetCertUsageList() - { - _logger.MethodEntry(LogLevel.Debug); - _logger.LogTrace($"Get cert usage list for camera " + _cameraUrl); - - // list of cert usage types - var certUsages = new List() { - Constants.CertificateUsage.HTTPS, - Constants.CertificateUsage.EAP_TLS_Client, - Constants.CertificateUsage.TLS_DATE_Client - }; - - var usages = new Dictionary(); - foreach(var usage in certUsages) - { - string certWithUsage = GetCertWithUsage(usage); - if (string.IsNullOrWhiteSpace(certWithUsage)) - { - continue; // no cert name found with this particular usage - } - usages[certWithUsage] = usage; - } - - return usages; - } - - // get certs with usage - private string GetCertWithUsage(Constants.CertificateUsage usage) - { - var source = new CancellationTokenSource(); - var token = source.Token; - - // payload = length + tag (0) + cert usage starting with 0 bit for end cert - var payload = "0x" + "0008" + "0000" + usage.ToUsageCode(); - - var api = Constants.API.BuildRequestUri( - Constants.API.Endpoints.CERTIFICATE_USAGE, - Constants.API.Type.P_OCTET, - Constants.API.Direction.READ, - Uri.EscapeDataString(payload) - ); - var requestUri = $"{_cameraUrl}{api}"; - - var request = new HttpRequestMessage(HttpMethod.Get, requestUri); - - _logger.LogTrace($"Sending API request: {requestUri}"); - _response = _client.SendAsync(request, token).Result; - if (!_response.IsSuccessStatusCode) - throw new Exception($"Request failed with status code {_response.StatusCode}"); - - var responseText = _response.Content.ReadAsStringAsync().Result; - - var taggedResponses = ParseStringListResponse(responseText); - - if (taggedResponses.Count == 2) - { - // 2 responses - first tag 0000 is usage, tag 0001 is the cert name - // cert name is tagged with '0001' in response - return taggedResponses["0001"]; - } - else - { - return ""; - } - } - - //set the cert usage on a cert - public string SetCertUsage(string certName, Constants.CertificateUsage usageCode) - { - _logger.MethodEntry(LogLevel.Debug); - _logger.LogTrace($"Setting cert usage to {usageCode.ToReadableText()} for cert {certName} for camera {_cameraUrl}"); - var payload = "0x00080000" + usageCode.ToUsageCode(); - var myId = HexadecimalEncoding.ToHexNoPadding(certName); - var additionalPayload = payload + HexadecimalEncoding.ToHex(certName, 4, '0') + "0001" + myId; - - try - { - SetCertUsage(additionalPayload).Wait(); - var returnCode = parseCameraResponse(_response.Content.ReadAsStringAsync().Result); - if (returnCode != null) - { - _logger.LogError($"Setting cert usage to {usageCode.ToReadableText()} for cert {certName} for camera {_cameraUrl} failed with error code {returnCode}"); - return returnCode; - } - - _logger.LogInformation($"Successfully changed cert usage to {usageCode.ToReadableText()} for cert {certName} for camera {_cameraUrl}"); - return "pass"; - } - catch (Exception ex) - { - _logger.LogError($"Cert usage change failed with the following error: {ex}"); - return ex.ToString(); - } - } - - //can be used to reset/clear existing cert usage and to set cert usage on a specific cert - private async Task SetCertUsage(string payload) - { - var source = new CancellationTokenSource(); - var token = source.Token; - - var api = Constants.API.BuildRequestUri( - Constants.API.Endpoints.CERTIFICATE_USAGE, - Constants.API.Type.P_OCTET, - Constants.API.Direction.WRITE, - Uri.EscapeDataString(payload) - ); - var requestUri = $"{_cameraUrl}{api}"; - - var request = new HttpRequestMessage(HttpMethod.Get, requestUri); - - _logger.LogTrace($"Sending API request: {requestUri}"); - _response = await _client.SendAsync(request, token); - if (!_response.IsSuccessStatusCode) - throw new Exception($"Request failed with status code {_response.StatusCode}"); - } - - - //Delete the cert by name - public string DeleteCertByName(string certName) - { - _logger.MethodEntry(LogLevel.Debug); - _logger.LogTrace("Delete cert " + certName + " for camera " + _cameraUrl); - var myId = HexadecimalEncoding.ToHexNoPadding(certName); - var payload = HexadecimalEncoding.ToHexWithPrefix(certName, 4, '0') + "0000" + myId + - "00040002" + "00080003000000FF"; - - try - { - //first reset the cert usage - DeleteCert(payload).Wait(); - var returnCode = parseCameraResponse(_response.Content.ReadAsStringAsync().Result); - if (returnCode != null) - { - _logger.LogError("Deleting cert " + certName + " for camera " + _cameraUrl + - " failed with error code " + returnCode); - return returnCode; - } - - _logger.LogInformation("Successfully deleted cert " + certName + " for camera " + _cameraUrl); - return "pass"; - } - catch (Exception ex) - { - _logger.LogError("Deleting cert failed with the following error: " + ex); - return ex.ToString(); - } - } - - //delete a cert on camera - private async Task DeleteCert(string payload) - { - var api = Constants.API.BuildRequestUri( - Constants.API.Endpoints.CERTIFICATE, - Constants.API.Type.P_OCTET, - Constants.API.Direction.WRITE, - Uri.EscapeDataString(payload) - ); - var requestUri = $"{_cameraUrl}{api}"; - - var request = new HttpRequestMessage(HttpMethod.Get, requestUri); - - _logger.LogTrace($"Sending API request: {requestUri}"); - _response = await _client.SendAsync(request); - } - - - //returns error code if camera call fails, blank if successful - private string parseCameraResponse(string response) - { - _logger.LogTrace($"Reading camera response for potential error: {response}"); - string errorCode = null; - var xmlResponse = new XmlDocument(); - xmlResponse.LoadXml(response); - - var errorList = xmlResponse.GetElementsByTagName("err"); - if (errorList.Count > 0) errorCode = errorList[0].InnerXml; - return errorCode; - } - - public List GetCameraCertList(string response) - { - _logger.MethodEntry(LogLevel.Debug); - var xmlResponse = new XmlDocument(); - xmlResponse.LoadXml(response); - - // Parse raw hex content from the response - var s = - xmlResponse.GetElementsByTagName("str")[0].InnerText - .Replace(" ", "") - .Replace("\r", "") - .Replace("\n", ""); - - // Record structure starts with 2 bytes representing length of the record, followed by 6 more bytes, then filename, then a zero byte. - // Iterate through records by reading length tag, extracting the filename in hex and converting. - var certNames = new List(); - Func getName = (s, start) => s.Substring(start, s.IndexOf("00", start) - start); - for (var i = 0; i < s.Length; i += Convert.ToInt32(s.Substring(i, 4), 16) * 2) - certNames.Add(HexadecimalEncoding.FromHex(getName(s, i + 16))); - return certNames; - } - - public Dictionary ParseStringListResponse(string response) - { - _logger.MethodEntry(LogLevel.Debug); - var xmlResponse = new XmlDocument(); - xmlResponse.LoadXml(response); - - // Parse raw hex content from the response - var rawHex = - xmlResponse.GetElementsByTagName("str")[0].InnerText - .Replace(" ", "") - .Replace("\r", "") - .Replace("\n", ""); - - var taggedResponses = new Dictionary(); - - var indexStart = 0; - while (indexStart < rawHex.Length) - { - // NOTE: 1 byte is equivalent to 2 hex chars. so the "length" or numOfBytes *2 is actual char count for a full entry - // first 4 chars are length of a response entry - var hexLength = rawHex.Substring(indexStart, 4); - var numOfBytes = Convert.ToInt32(hexLength, 16); - // next 4 chars are hex code of tag - var tag = rawHex.Substring(indexStart + 4, 4); - // length minus 4 bytes (for length and tag entries) is remaining count of bytes (2 chars each) to evaluate for actual value - var remainingBytes = numOfBytes - 4; - - var value = ""; - if (remainingBytes > 0) - { - // value starts at index start + 8, and char length is remaining bytes * 2 - var hexValue = rawHex.Substring(indexStart + 8, remainingBytes * 2); - value = HexadecimalEncoding.FromHex(hexValue); - } - - taggedResponses[tag] = value; - indexStart += numOfBytes * 2; - } - - return taggedResponses; - } - - private string ResolvePamField(IPAMSecretResolver pam, string key, string fieldName) - { - _logger.LogTrace($"Attempting to resolve PAM eligible field: '{fieldName}'"); - return string.IsNullOrEmpty(key) ? key : pam.Resolve(key); - } - } +// 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.IO; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.ServiceModel; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using Keyfactor.Logging; +using Keyfactor.Orchestrators.Extensions; +using Keyfactor.Orchestrators.Extensions.Interfaces; +using Microsoft.Extensions.Logging; + +namespace Keyfactor.Extensions.Orchestrator.BoschIPCamera.Client +{ + public class BoschIpCameraClient + { + private readonly string _cameraUrl; + private readonly string _baseUrl; + private readonly HttpClient _client; + private readonly ILogger _logger; + private readonly CredentialCache _digestCredential; + private HttpResponseMessage _response; + + public BoschIpCameraClient(JobConfiguration config, CertificateStore store, IPAMSecretResolver pam, ILogger logger) + { + _logger = logger; + _logger.LogTrace("Starting Bosch IP Camera Client config"); + + if (config.UseSSL) + { + _baseUrl = $"https://{store.ClientMachine}"; + _cameraUrl = $"https://{store.ClientMachine}/rcp.xml?"; + } + else + { + _baseUrl = $"http://{store.ClientMachine}"; + _cameraUrl = $"http://{store.ClientMachine}/rcp.xml?"; + } + + _logger.LogDebug($"Base URL: {_baseUrl}"); + _logger.LogDebug($"Camera API URL: {_cameraUrl}"); + + var username = ResolvePamField(pam, config.ServerUsername, "Server Username"); + var password = ResolvePamField(pam, config.ServerPassword, "Server Password"); + + var credentials = $"{username}:{password}"; + var encodedCredentials = Convert.ToBase64String(Encoding.ASCII.GetBytes(credentials)); + + _client = new HttpClient(); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", encodedCredentials); + + // for use in reenrollment cert upload calls + _digestCredential = new CredentialCache + { + {new Uri(_baseUrl), "Digest", new NetworkCredential(username, password)} + }; + } + + public Dictionary ListCerts() + { + _logger.MethodEntry(LogLevel.Debug); + var api = Constants.API.BuildRequestUri( + Constants.API.Endpoints.CERTIFICATE_LIST, + Constants.API.Type.P_OCTET, + Constants.API.Direction.READ + ); + var requestUri = $"{_cameraUrl}{api}"; + + var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + + _logger.LogTrace($"Sending API request: {requestUri}"); + var task = _client.SendAsync(request); + task.Wait(); + var cameras = GetCameraCertList(task.Result.Content.ReadAsStringAsync().Result); + var files = new Dictionary(); + foreach (var c in cameras) + { + Download(c).Wait(); + files.Add(c, _response.Content.ReadAsStringAsync().Result); + } + + return files; + } + + public string CertCreate(Dictionary subject, string certificateName, Constants.CertificateKeyType keyEnum) + { + _logger.MethodEntry(LogLevel.Debug); + try + { + var myId = HexadecimalEncoding.ToHexNoPadding(certificateName); + var payload = $"{HexadecimalEncoding.ToHexWithPrefix(certificateName, 4, '0')}0000{myId}"; + + // get the 8-digit hex code that corresponds to the correct key type + string keyCode = keyEnum.ToKeyTypeCode(); + + // RAW HEX: "length" + "tag" + "content" + // length is full byte count of header (length + tag) + content + var keyType = "0008" + "0001" + keyCode; + var requesttype = "0008" + "0002" + "00000000"; + + payload += keyType; + payload += requesttype; + + // CN is expected + var myCommon = HexadecimalEncoding.ToHexWithPadding(subject["CN"]); + _logger.LogTrace($"Encoding CN '{subject["CN"]}' into camera payload"); + payload += $"{HexadecimalEncoding.ToHexStringLengthWithPadding(subject["CN"], 4, '0')}0005{myCommon}"; + + if (subject.ContainsKey("O")) + { + var myOrg = HexadecimalEncoding.ToHexWithPadding(subject["O"]); + payload += $"{HexadecimalEncoding.ToHexStringLengthWithPadding(subject["O"], 4, '0')}0006{myOrg}"; + } + + if (subject.ContainsKey("OU")) + { + var myUnit = HexadecimalEncoding.ToHexWithPadding(subject["OU"]); + payload += $"{HexadecimalEncoding.ToHexStringLengthWithPadding(subject["OU"], 4, '0')}0007{myUnit}"; + } + + if (subject.ContainsKey("L")) + { + var myCity = HexadecimalEncoding.ToHexWithPadding(subject["L"]); + payload += $"{HexadecimalEncoding.ToHexStringLengthWithPadding(subject["L"], 4, '0')}0008{myCity}"; + } + + if (subject.ContainsKey("C")) + { + var myCountry = HexadecimalEncoding.ToHexWithPadding(subject["C"]); + payload += $"{HexadecimalEncoding.ToHexStringLengthWithPadding(subject["C"], 4, '0')}0009{myCountry}"; + } + + if (subject.ContainsKey("ST")) + { + var myProvince = HexadecimalEncoding.ToHexWithPadding(subject["ST"]); + payload += $"{HexadecimalEncoding.ToHexStringLengthWithPadding(subject["ST"], 4, '0')}000A{myProvince}"; + } + + GenerateCsrOnCameraAsync(payload).Wait(); + var returnCode = parseCameraResponse(_response.Content.ReadAsStringAsync().Result); + if (returnCode != null) + { + _logger.LogError($"Camera failed to generate CSR with error code {returnCode}"); + return returnCode; + } + + _logger.LogInformation($"CSR call completed successfully for {certificateName}"); + return "pass"; + } + catch (ProtocolException ex) + { + _logger.LogError($"CSR call failed with the following error: {ex}"); + return ex.ToString(); + } + } + + + //Call the camera to generate a CSR + private async Task GenerateCsrOnCameraAsync(string payload) + { + var api = Constants.API.BuildRequestUri( + Constants.API.Endpoints.CERTIFICATE_REQUEST, + Constants.API.Type.P_OCTET, + Constants.API.Direction.WRITE, + Uri.EscapeDataString(payload) + ); + var requestUri = $"{_cameraUrl}{api}"; + + using var cancellationTokenSource = new CancellationTokenSource(); + var token = cancellationTokenSource.Token; + + _logger.LogTrace($"Sending API request: {requestUri}"); + _response = await _client.GetAsync(requestUri, token); + } + + public string DownloadCsrFromCamera(string certName) + { + _logger.MethodEntry(LogLevel.Debug); + _logger.LogTrace("Download " + certName + " CSR from Camera: " + _cameraUrl); + var haveCsr = false; + var count = 0; + string csrResult = null; + //keep trying until we get the cert or try 30 times (wait 5 seconds each time) + while (!haveCsr && count <= 30) + try + { + Thread.Sleep(5000); + count++; + Download(certName, "?type=csr").Wait(); + csrResult= _response.Content.ReadAsStringAsync().Result; + if (csrResult.Contains("-----BEGIN CERTIFICATE REQUEST-----")) + haveCsr = true; + } + catch (Exception ex) + { + _logger.LogTrace("CSR Download failed with the following error: " + ex); + } + + return csrResult; + } + + public void UploadCert(string fileName, string fileData) + { + _logger.MethodEntry(LogLevel.Debug); + _logger.LogTrace("Starting Cert upload to camera " + _baseUrl); + + var boundary = "----------" + DateTime.Now.Ticks.ToString("x"); + var fileHeader = + $"Content-Disposition: form-data; name=\"certUsageUnspecified\"; filename=\"{fileName}\";\r\nContent-Type: application/x-x509-ca-cert\r\n\r\n"; + + var authRequest = (HttpWebRequest)WebRequest.Create(_baseUrl + "/upload.htm"); + authRequest.Method = "GET"; + authRequest.Credentials = _digestCredential; + authRequest.PreAuthenticate = true; + + try + { + _logger.LogTrace("Get Auth call to camera on " + _baseUrl); + using var response = authRequest.GetResponse(); + } + catch (Exception e) + { + _logger.LogError(e.Message); + } + + var count = 0; + //keep trying until we get the cert on camera or try 5 times + while (count <= 5) + { + try + { + count++; + _logger.LogTrace("Post call to camera on " + _baseUrl); + var httpWebRequest = (HttpWebRequest)WebRequest.Create(_baseUrl + "/upload.htm"); + httpWebRequest.Credentials = _digestCredential; + httpWebRequest.ContentType = "multipart/form-data; boundary=" + boundary; + httpWebRequest.Method = "POST"; + + var requestStream = httpWebRequest.GetRequestStream(); + WriteToStream(requestStream, "--" + boundary + "\r\n"); + WriteToStream(requestStream, fileHeader); + WriteToStream(requestStream, fileData); + WriteToStream(requestStream, "\r\n--" + boundary + "--\r\n"); + + var myHttpWebResponse = (HttpWebResponse)httpWebRequest.GetResponse(); + + + var responseStream = myHttpWebResponse.GetResponseStream(); + + var myStreamReader = new StreamReader(responseStream ?? throw new InvalidOperationException(), Encoding.Default); + + myStreamReader.ReadToEnd(); + + myStreamReader.Close(); + responseStream.Close(); + + myHttpWebResponse.Close(); + return; + } + catch (Exception e) + { + _logger.LogError(e.Message); + _logger.LogTrace("Failed to push cert on attempt " + count + " trying again if less than or equal to 5"); + } + } + } + + private static void WriteToStream(Stream s, string txt) + { + var bytes = Encoding.UTF8.GetBytes(txt); + s.Write(bytes, 0, bytes.Length); + } + + private async Task Download(string certName, string paramString = "") + { + using var source = new CancellationTokenSource(); + var token = source.Token; + + var cameraUrl = $"{_baseUrl}/cert_download/{certName.Replace(" ", "%20")}.pem{paramString}"; + _logger.LogTrace("Camera URL for download: " + cameraUrl); + + _response = await _client.GetAsync(cameraUrl, token); + + } + + + // Enable/Disable 802.1x setting on the camera + public string Change8021XSettings(bool onOffSwitch) + { + _logger.MethodEntry(LogLevel.Debug); + _logger.LogTrace($"Changing Camera 802.1x setting to {(onOffSwitch ? "1" : "0")} on Camera: {_cameraUrl}"); + + try + { + Change8021X(onOffSwitch).Wait(); + var returnCode = parseCameraResponse(_response.Content.ReadAsStringAsync().Result); + if (returnCode != null) + { + _logger.LogError("Camera failed to change 802.1x with error code " + returnCode); + return returnCode; + } + + _logger.LogInformation("802.1x setting changed successfully for " + _cameraUrl); + return "pass"; + } + catch (Exception ex) + { + _logger.LogError("802.1x setting change failed with the following error: " + ex); + return ex.ToString(); + } + } + + // Enable/Disable 802.1x on the camera after the certs are in place + // onOffSwitch - "0" means off, "1" means on + private async Task Change8021X(bool onOffSwitch) + { + using var source = new CancellationTokenSource(); + var token = source.Token; + + var api = Constants.API.BuildRequestUri( + Constants.API.Endpoints.EAP_ENABLE, + Constants.API.Type.T_OCTET, + Constants.API.Direction.WRITE, + Uri.EscapeDataString(onOffSwitch ? "1" : "0") + ); + var requestUri = $"{_cameraUrl}{api}"; + + var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + + _logger.LogTrace($"Sending API request: {requestUri}"); + _response = await _client.SendAsync(request, token); + if (!_response.IsSuccessStatusCode) + throw new Exception($"Request failed with status code {_response.StatusCode}"); + } + + + public string RebootCamera() + { + _logger.MethodEntry(LogLevel.Debug); + _logger.LogTrace("Rebooting camera : " + _cameraUrl); + + try + { + Reboot().Wait(); + var returnCode = parseCameraResponse(_response.Content.ReadAsStringAsync().Result); + if (returnCode != null) + { + _logger.LogError("Camera failed to Reboot with error code " + returnCode); + return returnCode; + } + + _logger.LogInformation("Camera rebooted successfully " + _cameraUrl); + return "pass"; + } + catch (Exception ex) + { + _logger.LogError("Failed to Reboot Camera " + _cameraUrl + " with the following error: " + ex); + return ex.ToString(); + } + } + + private async Task Reboot() + { + using var source = new CancellationTokenSource(); + var token = source.Token; + + var api = Constants.API.BuildRequestUri( + Constants.API.Endpoints.BOARD_RESET, + Constants.API.Type.F_FLAG, + Constants.API.Direction.WRITE, + "1" // sending 1 reboots camera + ); + var requestUri = $"{_cameraUrl}{api}"; + + var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + + _logger.LogTrace($"Sending API request: {requestUri}"); + _response = await _client.SendAsync(request, token); + if(!_response.IsSuccessStatusCode) + throw new Exception($"Request failed with status code {_response.StatusCode}"); + + } + + // get the cert usage + public Dictionary GetCertUsageList() + { + _logger.MethodEntry(LogLevel.Debug); + _logger.LogTrace($"Get cert usage list for camera " + _cameraUrl); + + // list of cert usage types + var certUsages = new List() { + Constants.CertificateUsage.HTTPS, + Constants.CertificateUsage.EAP_TLS_Client, + Constants.CertificateUsage.TLS_DATE_Client + }; + + var usages = new Dictionary(); + foreach(var usage in certUsages) + { + string certWithUsage = GetCertWithUsage(usage); + if (string.IsNullOrWhiteSpace(certWithUsage)) + { + continue; // no cert name found with this particular usage + } + usages[certWithUsage] = usage; + } + + return usages; + } + + // get certs with usage + public string GetCertWithUsage(Constants.CertificateUsage usage) + { + _logger.MethodEntry(LogLevel.Debug); + _logger.LogTrace($"Get cert with usage '{usage.ToReadableText()}' for camera " + _cameraUrl); + + using var source = new CancellationTokenSource(); + var token = source.Token; + + // payload = length + tag (0) + cert usage starting with 0 bit for end cert + var payload = "0x" + "0008" + "0000" + usage.ToUsageCode(); + + var api = Constants.API.BuildRequestUri( + Constants.API.Endpoints.CERTIFICATE_USAGE, + Constants.API.Type.P_OCTET, + Constants.API.Direction.READ, + Uri.EscapeDataString(payload) + ); + var requestUri = $"{_cameraUrl}{api}"; + + var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + + _logger.LogTrace($"Sending API request: {requestUri}"); + _response = _client.SendAsync(request, token).Result; + if (!_response.IsSuccessStatusCode) + throw new Exception($"Request failed with status code {_response.StatusCode}"); + + var responseText = _response.Content.ReadAsStringAsync().Result; + + var taggedResponses = ParseStringListResponse(responseText); + + if (taggedResponses.Count == 2) + { + // 2 responses - first tag 0000 is usage, tag 0001 is the cert name + // cert name is tagged with '0001' in response + return taggedResponses["0001"]; + } + else + { + return ""; + } + } + + //set the cert usage on a cert + public string SetCertUsage(string certName, Constants.CertificateUsage usageCode) + { + _logger.MethodEntry(LogLevel.Debug); + _logger.LogTrace($"Setting cert usage to {usageCode.ToReadableText()} for cert {certName} for camera {_cameraUrl}"); + var payload = "0x00080000" + usageCode.ToUsageCode(); + var myId = HexadecimalEncoding.ToHexNoPadding(certName); + var additionalPayload = payload + HexadecimalEncoding.ToHex(certName, 4, '0') + "0001" + myId; + + try + { + SetCertUsage(additionalPayload).Wait(); + var returnCode = parseCameraResponse(_response.Content.ReadAsStringAsync().Result); + if (returnCode != null) + { + _logger.LogError($"Setting cert usage to {usageCode.ToReadableText()} for cert {certName} for camera {_cameraUrl} failed with error code {returnCode}"); + return returnCode; + } + + _logger.LogInformation($"Successfully changed cert usage to {usageCode.ToReadableText()} for cert {certName} for camera {_cameraUrl}"); + return "pass"; + } + catch (Exception ex) + { + _logger.LogError($"Cert usage change failed with the following error: {ex}"); + return ex.ToString(); + } + } + + //can be used to reset/clear existing cert usage and to set cert usage on a specific cert + private async Task SetCertUsage(string payload) + { + using var source = new CancellationTokenSource(); + var token = source.Token; + + var api = Constants.API.BuildRequestUri( + Constants.API.Endpoints.CERTIFICATE_USAGE, + Constants.API.Type.P_OCTET, + Constants.API.Direction.WRITE, + Uri.EscapeDataString(payload) + ); + var requestUri = $"{_cameraUrl}{api}"; + + var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + + _logger.LogTrace($"Sending API request: {requestUri}"); + _response = await _client.SendAsync(request, token); + if (!_response.IsSuccessStatusCode) + throw new Exception($"Request failed with status code {_response.StatusCode}"); + } + + + //Delete the cert by name + public string DeleteCertByName(string certName) + { + _logger.MethodEntry(LogLevel.Debug); + _logger.LogTrace("Delete cert " + certName + " for camera " + _cameraUrl); + var myId = HexadecimalEncoding.ToHexNoPadding(certName); + var payload = HexadecimalEncoding.ToHexWithPrefix(certName, 4, '0') + "0000" + myId + + "00040002" + "00080003000000FF"; + + try + { + //first reset the cert usage + DeleteCert(payload).Wait(); + var returnCode = parseCameraResponse(_response.Content.ReadAsStringAsync().Result); + if (returnCode != null) + { + _logger.LogError("Deleting cert " + certName + " for camera " + _cameraUrl + + " failed with error code " + returnCode); + return returnCode; + } + + _logger.LogInformation("Successfully deleted cert " + certName + " for camera " + _cameraUrl); + return "pass"; + } + catch (Exception ex) + { + _logger.LogError("Deleting cert failed with the following error: " + ex); + return ex.ToString(); + } + } + + //delete a cert on camera + private async Task DeleteCert(string payload) + { + var api = Constants.API.BuildRequestUri( + Constants.API.Endpoints.CERTIFICATE, + Constants.API.Type.P_OCTET, + Constants.API.Direction.WRITE, + Uri.EscapeDataString(payload) + ); + var requestUri = $"{_cameraUrl}{api}"; + + var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + + _logger.LogTrace($"Sending API request: {requestUri}"); + _response = await _client.SendAsync(request); + } + + + //returns error code if camera call fails, blank if successful + private string parseCameraResponse(string response) + { + _logger.LogTrace($"Reading camera response for potential error: {response}"); + string errorCode = null; + var xmlResponse = new XmlDocument(); + xmlResponse.LoadXml(response); + + var errorList = xmlResponse.GetElementsByTagName("err"); + if (errorList.Count > 0) errorCode = errorList[0].InnerXml; + return errorCode; + } + + public List GetCameraCertList(string response) + { + _logger.MethodEntry(LogLevel.Debug); + var xmlResponse = new XmlDocument(); + xmlResponse.LoadXml(response); + + // Parse raw hex content from the response + var s = + xmlResponse.GetElementsByTagName("str")[0].InnerText + .Replace(" ", "") + .Replace("\r", "") + .Replace("\n", ""); + + // Record structure starts with 2 bytes representing length of the record, followed by 6 more bytes, then filename, then a zero byte. + // Iterate through records by reading length tag, extracting the filename in hex and converting. + var certNames = new List(); + Func getName = (s1, start) => s1.Substring(start, s1.IndexOf("00", start) - start); + + for (var i = 0; i < s.Length; i += Convert.ToInt32(s.Substring(i, 4), 16) * 2) + { + // Bosch cameras have different Certificate Types to identify entities, such as CSRs, private keys, etc. + // For any type that is NOT a 'Certificate' or 'Trusted Certificate', do not include in the list + + // Get the current record + var recordLen = Convert.ToInt32(s.Substring(i, 4), 16) * 2; + var record = s.Substring(i, recordLen); + + // Find the first occurrence of "00080002" which marks the start of the Type field + var typeStartIndex = record.IndexOf("00080002", StringComparison.Ordinal); + + // Read the next 16 digits,and then get the last 4, which will map to the specific Type + string type = record.Substring(typeStartIndex, 16).Substring(12, 4); + Constants.CertificateType typeEnum = Constants.ParseCertificateType(type); + _logger.LogDebug($"Type Bits: {type}"); + _logger.LogDebug($"Type: {typeEnum.ToReadableText()}"); + + if (typeEnum is Constants.CertificateType.CERTIFICATE or Constants.CertificateType.TRUSTED_CERTIFICATE) + { + certNames.Add(HexadecimalEncoding.FromHex(getName(s, i + 16))); + } + } + + return certNames; + } + + public Dictionary ParseStringListResponse(string response) + { + _logger.MethodEntry(LogLevel.Debug); + var xmlResponse = new XmlDocument(); + xmlResponse.LoadXml(response); + + // Parse raw hex content from the response + var rawHex = + xmlResponse.GetElementsByTagName("str")[0].InnerText + .Replace(" ", "") + .Replace("\r", "") + .Replace("\n", ""); + + var taggedResponses = new Dictionary(); + + var indexStart = 0; + while (indexStart < rawHex.Length) + { + // NOTE: 1 byte is equivalent to 2 hex chars. so the "length" or numOfBytes *2 is actual char count for a full entry + // first 4 chars are length of a response entry + var hexLength = rawHex.Substring(indexStart, 4); + var numOfBytes = Convert.ToInt32(hexLength, 16); + // next 4 chars are hex code of tag + var tag = rawHex.Substring(indexStart + 4, 4); + // length minus 4 bytes (for length and tag entries) is remaining count of bytes (2 chars each) to evaluate for actual value + var remainingBytes = numOfBytes - 4; + + var value = ""; + if (remainingBytes > 0) + { + // value starts at index start + 8, and char length is remaining bytes * 2 + var hexValue = rawHex.Substring(indexStart + 8, remainingBytes * 2); + value = HexadecimalEncoding.FromHex(hexValue); + } + + taggedResponses[tag] = value; + indexStart += numOfBytes * 2; + } + + return taggedResponses; + } + + private string ResolvePamField(IPAMSecretResolver pam, string key, string fieldName) + { + _logger.LogTrace($"Attempting to resolve PAM eligible field: '{fieldName}'"); + return string.IsNullOrEmpty(key) ? key : pam.Resolve(key); + } + } } \ No newline at end of file diff --git a/BoschIPCamera/Client/Constants.cs b/BoschIPCamera/Client/Constants.cs index 69aeaf0..64dfcb1 100644 --- a/BoschIPCamera/Client/Constants.cs +++ b/BoschIPCamera/Client/Constants.cs @@ -1,118 +1,287 @@ -// Copyright 2023 Keyfactor -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -namespace Keyfactor.Extensions.Orchestrator.BoschIPCamera.Client -{ - public static class Constants - { - public enum CertificateUsage - { - None, - HTTPS, // 0000 0000 - EAP_TLS_Client, // 0000 0001 - TLS_DATE_Client // 0000 0002 - } - - public static CertificateUsage ParseCertificateUsage(string usageText) - { - switch (usageText) - { - case "00000000": - case "HTTPS": - return CertificateUsage.HTTPS; - case "00000001": - case "EAP-TLS-client": - return CertificateUsage.EAP_TLS_Client; - case "00000002": - case "TLS-DATE-client": - return CertificateUsage.TLS_DATE_Client; - case "": - case null: - default: - return CertificateUsage.None; - } - } - - public static string ToReadableText(this CertificateUsage usage) - { - switch (usage) - { - case CertificateUsage.HTTPS: - return "HTTPS"; - case CertificateUsage.EAP_TLS_Client: - return "EAP-TLS-client"; - case CertificateUsage.TLS_DATE_Client: - return "TLS-DATE-client"; - case CertificateUsage.None: - default: - return ""; - } - } - - public static string ToUsageCode(this CertificateUsage usage) - { - switch (usage) - { - case CertificateUsage.HTTPS: - return "00000000"; - case CertificateUsage.EAP_TLS_Client: - return "00000001"; - case CertificateUsage.TLS_DATE_Client: - return "00000002"; - case CertificateUsage.None: - default: - return ""; - } - } - - public static class API - { - public static class Endpoints - { - public static string CERTIFICATE = "0x0BE9"; - public static string CERTIFICATE_LIST = "0x0BEB"; - public static string CERTIFICATE_REQUEST = "0x0BEC"; - public static string CERTIFICATE_USAGE = "0x0BF2"; - public static string EAP_ENABLE = "0x09EB"; - public static string BOARD_RESET = "0x0811"; - } - - public static class Type - { - public static string T_OCTET = "T_OCTET"; - public static string P_OCTET = "P_OCTET"; - public static string F_FLAG = "F_FLAG"; - - } - - public static class Direction - { - public static string READ = "READ"; - public static string WRITE = "WRITE"; - } - - public static string BuildRequestUri(string endpoint, string type, string direction, string payload = null) - { - string uri = $"command={endpoint}&type={type}&direction={direction}&num=1"; - - if (!string.IsNullOrWhiteSpace(payload)) - { - uri = $"{uri}&payload={payload}"; - } - - return uri; - } - } - } -} +// 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.Text.RegularExpressions; + +namespace Keyfactor.Extensions.Orchestrator.BoschIPCamera.Client +{ + public static class Constants + { + public enum CertificateUsage + { + None, + HTTPS, // 0000 0000 + EAP_TLS_Client, // 0000 0001 + TLS_DATE_Client // 0000 0002 + } + + public enum CertificateKeyType + { + Unknown, + RSA1024, // 0000 0000 + RSA2048, // 0000 0001 + ECC256, // 0000 0002 + RSA4096 // 0000 0003 + } + + public enum CertificateType + { + Unknown, + TRUSTED_CERTIFICATE, // 0000 0001 (cert only) + CSR, // 0000 0002 (CSR only) + PRIVATE_KEY, // 0000 0004 (key only --- rare/usually hidden) + ENCRYPTED_PRIVATE_KEY, // 0000 0008 (encrpyted key only) + CERTIFICATE, // 0000 0005 (cert + key) + CSR_KEY, // 0000 0006 (CSR + key) + ENCRYPTED_PKCS12 // 0000 0080 (encrypted PKCS#12) + } + + public static CertificateUsage ParseCertificateUsage(string usageText) + { + switch (usageText) + { + case "00000000": + case "HTTPS": + return CertificateUsage.HTTPS; + case "00000001": + case "EAP-TLS-client": + return CertificateUsage.EAP_TLS_Client; + case "00000002": + case "TLS-DATE-client": + return CertificateUsage.TLS_DATE_Client; + case "": + case null: + default: + return CertificateUsage.None; + } + } + + public static string ToReadableText(this CertificateUsage usage) + { + switch (usage) + { + case CertificateUsage.HTTPS: + return "HTTPS"; + case CertificateUsage.EAP_TLS_Client: + return "EAP-TLS-client"; + case CertificateUsage.TLS_DATE_Client: + return "TLS-DATE-client"; + case CertificateUsage.None: + default: + return ""; + } + } + + public static string ToUsageCode(this CertificateUsage usage) + { + switch (usage) + { + case CertificateUsage.HTTPS: + return "00000000"; + case CertificateUsage.EAP_TLS_Client: + return "00000001"; + case CertificateUsage.TLS_DATE_Client: + return "00000002"; + case CertificateUsage.None: + default: + return ""; + } + } + + /// + /// Maps the Keyfactor Command-provided key algorithm and size to the equivalent + /// CertificateKeyType enum. + /// ** NOTE: These values may need updated depending on the target camera OS. + /// + /// i.e. RSA, ECP + /// i.e. 2048, 256 + /// Enum representation of the [key algorithm]-[key size] the device API can interpret + public static CertificateKeyType MapKeyType(string keyAlgorithm, string keySize) + { + return keyAlgorithm switch + { + "RSA" when keySize == "1024" => CertificateKeyType.RSA1024, + "RSA" when keySize == "2048" => CertificateKeyType.RSA2048, + "ECDSA" when keySize == "256" => CertificateKeyType.ECC256, + "RSA" when keySize == "4096" => CertificateKeyType.RSA4096, + _ => CertificateKeyType.Unknown + }; + } + + /// + /// Maps the CertificateKeyType enumeration to a human-readable string representation + /// of the key algorithm and size. + /// ** NOTE: These values may need updated depending on the target camera OS. + /// + public static string ToReadableText(this CertificateKeyType keyType) + { + switch (keyType) + { + case CertificateKeyType.RSA1024: + return "RSA 1024"; + case CertificateKeyType.RSA2048: + return "RSA 2048"; + case CertificateKeyType.ECC256: + return "Elliptic Curve P256"; + case CertificateKeyType.RSA4096: + return "RSA 4096"; + case CertificateKeyType.Unknown: + return "Unknown"; + default: + return ""; + } + } + + /// + /// Maps the CertificateKeyType enumeration to the 8-digit hex code that the device can intepret. + /// ** NOTE: These values may need updated depending on the target camera OS. + /// + public static string ToKeyTypeCode(this CertificateKeyType keyType) + { + switch (keyType) + { + case CertificateKeyType.RSA1024: + return "00000000"; + case CertificateKeyType.RSA2048: + return "00000001"; + case CertificateKeyType.ECC256: + return "00000002"; + case CertificateKeyType.RSA4096: + return "00000003"; + case CertificateKeyType.Unknown: + default: + return ""; + } + } + + public static CertificateType ParseCertificateType(string typeText) + { + switch (typeText) + { + case "0001": + return CertificateType.TRUSTED_CERTIFICATE; + case "0002": + return CertificateType.CSR; + case "0004": + return CertificateType.PRIVATE_KEY; + case "0008": + return CertificateType.ENCRYPTED_PRIVATE_KEY; + case "0005": + return CertificateType.CERTIFICATE; + case "0006": + return CertificateType.CSR_KEY; + case "0080": + return CertificateType.ENCRYPTED_PKCS12; + case "": + case null: + default: + return CertificateType.Unknown; + } + } + + public static string ToReadableText(this CertificateType type) + { + switch (type) + { + case CertificateType.TRUSTED_CERTIFICATE: + return "Trusted Certificate"; + case CertificateType.CSR: + return "Signing Request"; + case CertificateType.PRIVATE_KEY: + return "Private Key"; + case CertificateType.ENCRYPTED_PRIVATE_KEY: + return "Encrypted Private Key"; + case CertificateType.CERTIFICATE: + return "Certificate"; + case CertificateType.CSR_KEY: + return "Signing Request"; + case CertificateType.ENCRYPTED_PKCS12: + return "Encrypted PKCS#12"; + case CertificateType.Unknown: + return "Unknown"; + default: + return ""; + } + } + + public static class CertName + { + /// + /// 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(); + } + } + + public static class API + { + public static class Endpoints + { + public static string CERTIFICATE = "0x0BE9"; + public static string CERTIFICATE_LIST = "0x0BEB"; + public static string CERTIFICATE_REQUEST = "0x0BEC"; + public static string CERTIFICATE_USAGE = "0x0BF2"; + public static string CERTIFICATE_OPTIONS = "0x0BED"; + public static string EAP_ENABLE = "0x09EB"; + public static string BOARD_RESET = "0x0811"; + } + + public static class Type + { + public static string T_OCTET = "T_OCTET"; + public static string P_OCTET = "P_OCTET"; + public static string F_FLAG = "F_FLAG"; + + } + + public static class Direction + { + public static string READ = "READ"; + public static string WRITE = "WRITE"; + } + + public static string BuildRequestUri(string endpoint, string type, string direction, string payload = null) + { + string uri = $"command={endpoint}&type={type}&direction={direction}&num=1"; + + if (!string.IsNullOrWhiteSpace(payload)) + { + uri = $"{uri}&payload={payload}"; + } + + return uri; + } + } + } +} diff --git a/BoschIPCamera/Jobs/Inventory.cs b/BoschIPCamera/Jobs/Inventory.cs index 41773fd..27d4a6b 100644 --- a/BoschIPCamera/Jobs/Inventory.cs +++ b/BoschIPCamera/Jobs/Inventory.cs @@ -1,4 +1,4 @@ -// Copyright 2023 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. @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +using System; using System.Collections.Generic; using System.Linq; using Keyfactor.Extensions.Orchestrator.BoschIPCamera.Client; @@ -36,41 +37,84 @@ public Inventory(IPAMSecretResolver pam) public string ExtensionName => "BoschIPCamera"; - public JobResult ProcessJob(InventoryJobConfiguration jobConfiguration, - SubmitInventoryUpdate submitInventoryUpdate) + public JobResult ProcessJob(InventoryJobConfiguration jobConfiguration, SubmitInventoryUpdate submitInventoryUpdate) { - _logger.MethodEntry(LogLevel.Debug); - var client = new BoschIpCameraClient(jobConfiguration, jobConfiguration.CertificateStoreDetails, _pam, _logger); + List inventoryItems; + + try + { + _logger.MethodEntry(LogLevel.Debug); + _logger.LogTrace( + $"Begin Inventory for Client Machine {jobConfiguration.CertificateStoreDetails.ClientMachine}..."); + var client = new BoschIpCameraClient(jobConfiguration, jobConfiguration.CertificateStoreDetails, _pam, + _logger); - var files = client.ListCerts(); - _logger.LogDebug($"Found {files.Count} certificates"); + var files = client.ListCerts(); + _logger.LogDebug($"Found {files.Count} certificates"); - // get cert usage - // need request cert usage lists for each cert usage type, and parse names from response to match types - // key = cert name, value = cert usage enum - var certUsages = client.GetCertUsageList(); - _logger.LogDebug($"Found {certUsages.Count} certificates with a matching usage"); + // get cert usage + // need request cert usage lists for each cert usage type, and parse names from response to match types + // key = cert name, value = cert usage enum + var certUsages = client.GetCertUsageList(); + _logger.LogDebug($"Found {certUsages.Count} certificates with a matching usage"); - var inventory = files.Select(f => new CurrentInventoryItem() - { - Alias = f.Key, - Certificates = new List() { f.Value }, - PrivateKeyEntry = false, - UseChainLevel = false, - Parameters = new Dictionary + inventoryItems = files.Select(f => new CurrentInventoryItem() { - { "Name", f.Key }, - { "CertificateUsage", certUsages.ContainsKey(f.Key) ? certUsages[f.Key].ToReadableText() : "" } - } - }).ToList(); + Alias = f.Key, + Certificates = new List() { f.Value }, + PrivateKeyEntry = false, + UseChainLevel = false, + Parameters = new Dictionary + { + { "Name", f.Key }, + { "CertificateUsage", certUsages.ContainsKey(f.Key) ? certUsages[f.Key].ToReadableText() : "" } + } + }).ToList(); + } + catch (Exception e1) + { + // Status: 2=Success, 3=Warning, 4=Error + return new JobResult() + { + Result = OrchestratorJobStatusJobResult.Failure, + JobHistoryId = jobConfiguration.JobHistoryId, + FailureMessage = $"Inventory Job Failed During Inventory Item Creation: {e1.Message} - Refer to the Orchestrator logs and Command API logs for more detailed information." + }; + } + + bool success = true; + Exception? error = null; - submitInventoryUpdate(inventory); + try + { + // Sends inventoried certificates back to KF Command + _logger.LogTrace("Submitting Inventory to Keyfactor via submitInventory.Invoke"); + success = submitInventoryUpdate.Invoke(inventoryItems); + } + catch (Exception e1) + { + success = false; + error = e1; + } + + if (!success) + { + // ** NOTE: If the cause of the submitInventory.Invoke exception is a communication issue between the Orchestrator server and the Command server, the job status returned here + // may not be reflected in Keyfactor Command. + return new JobResult() + { + Result = OrchestratorJobStatusJobResult.Failure, + JobHistoryId = jobConfiguration.JobHistoryId, + FailureMessage = $"Inventory Job Failed During Inventory Item Submission: {(error is not null ? error.ToString() : "Unknown error occurred.")} - " + + $"Refer to the Orchestrator logs and Command API logs for more detailed information." }; + } + + _logger.LogTrace("Successfully submitted Inventory To Keyfactor via submitInventory.Invoke"); return new JobResult() { Result = OrchestratorJobStatusJobResult.Success, - JobHistoryId = jobConfiguration.JobHistoryId, - FailureMessage = "" + JobHistoryId = jobConfiguration.JobHistoryId }; } } diff --git a/BoschIPCamera/Jobs/Management.cs b/BoschIPCamera/Jobs/Management.cs index 972b361..2148641 100644 --- a/BoschIPCamera/Jobs/Management.cs +++ b/BoschIPCamera/Jobs/Management.cs @@ -1,4 +1,4 @@ -// Copyright 2023 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. @@ -51,6 +51,10 @@ public JobResult ProcessJob(ManagementJobConfiguration jobConfiguration) public JobResult removeCert(ManagementJobConfiguration jobConfiguration) { + _logger.MethodEntry(LogLevel.Debug); + _logger.LogTrace( + $"Begin Management for Client Machine {jobConfiguration.CertificateStoreDetails.ClientMachine}..."); + _logger.LogTrace($"Management Config {JsonConvert.SerializeObject(jobConfiguration)}"); BoschIpCameraClient client = new BoschIpCameraClient(jobConfiguration, jobConfiguration.CertificateStoreDetails, _pam, _logger); diff --git a/BoschIPCamera/Jobs/Reenrollment.cs b/BoschIPCamera/Jobs/Reenrollment.cs index 423f524..d896c86 100644 --- a/BoschIPCamera/Jobs/Reenrollment.cs +++ b/BoschIPCamera/Jobs/Reenrollment.cs @@ -1,256 +1,325 @@ -// Copyright 2023 Keyfactor -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using Keyfactor.Extensions.Orchestrator.BoschIPCamera.Client; -using Keyfactor.Logging; -using Keyfactor.Orchestrators.Common.Enums; -using Keyfactor.Orchestrators.Extensions; -using Keyfactor.Orchestrators.Extensions.Interfaces; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Text; - -namespace Keyfactor.Extensions.Orchestrator.BoschIPCamera.Jobs -{ - public class Reenrollment : IReenrollmentJobExtension - { - public string ExtensionName => "BoschIPCamera"; - private readonly ILogger _logger; - private readonly IPAMSecretResolver _pam; - - public Reenrollment(IPAMSecretResolver pam) - { - _logger = LogHandler.GetClassLogger(); - _pam = pam; - } - - public JobResult ProcessJob(ReenrollmentJobConfiguration jobConfiguration, SubmitReenrollmentCSR submitReenrollmentUpdate) - { - _logger.MethodEntry(LogLevel.Debug); - return PerformReenrollment(jobConfiguration, submitReenrollmentUpdate); - } - - private JobResult PerformReenrollment(ReenrollmentJobConfiguration jobConfiguration, SubmitReenrollmentCSR submitReenrollment) - { - - try - { - _logger.MethodEntry(LogLevel.Debug); - - var client = new BoschIpCameraClient(jobConfiguration, jobConfiguration.CertificateStoreDetails, _pam, _logger); - - string certName = GetRequiredReenrollmentField(jobConfiguration.JobProperties, "Name").ToString(); - bool overwrite = (bool) GetRequiredReenrollmentField(jobConfiguration.JobProperties, "Overwrite"); - string csrInput = GetRequiredReenrollmentField(jobConfiguration.JobProperties, "subjectText").ToString(); - string certUsage = GetRequiredReenrollmentField(jobConfiguration.JobProperties, "CertificateUsage").ToString(); - - string returnCode; - string errorMessage; - string cameraUrl = jobConfiguration.CertificateStoreDetails.ClientMachine; - - // delete existing certificate if overwriting - if (overwrite) - { - returnCode = client.DeleteCertByName(certName); - - if (returnCode != "pass") - { - errorMessage = $"Error deleting existing certificate {certName} on camera {cameraUrl} with error code {returnCode}"; - _logger.LogError(errorMessage); - return new JobResult - { - Result = OrchestratorJobStatusJobResult.Failure, - JobHistoryId = jobConfiguration.JobHistoryId, - FailureMessage = errorMessage - }; - } - } - - // setup the CSR details - var csrSubject = SetupCsrSubject(csrInput); - - //generate the CSR on the camera - returnCode = client.CertCreate(csrSubject, certName); - - if (returnCode != "pass") - { - errorMessage = $"Error generating CSR for {certName} on camera {cameraUrl} with error code {returnCode}"; - _logger.LogError(errorMessage); - return new JobResult - { - Result = OrchestratorJobStatusJobResult.Failure, - JobHistoryId = jobConfiguration.JobHistoryId, - FailureMessage = errorMessage - }; - } - - //get the CSR from the camera - var csr = client.DownloadCsrFromCamera(certName); - _logger.LogTrace("Downloaded CSR: " + csr); - - // check that csr meets csr format - // 404 message response can be returned instead - if (!csr.StartsWith("-----BEGIN")) - { - // error downloaded, no CSR present - // likely due to existing cert that was not marked to ovewrite (delete) - errorMessage = $"Error retrieving CSR from camera {cameraUrl} - got response: {csr}"; - _logger.LogError(errorMessage); - return new JobResult - { - Result = OrchestratorJobStatusJobResult.Failure, - JobHistoryId = jobConfiguration.JobHistoryId, - FailureMessage = errorMessage - }; - } - - // sign CSR in Keyfactor - var x509Cert = submitReenrollment.Invoke(csr); - - if (x509Cert == null) - { - errorMessage = $"Error submitting CSR to Keyfactor. Certificate not received. CSR submitted: {csr}"; - _logger.LogError(errorMessage); - return new JobResult - { - Result = OrchestratorJobStatusJobResult.Failure, - JobHistoryId = jobConfiguration.JobHistoryId, - FailureMessage = errorMessage - }; - } - - // build PEM content - StringBuilder pemBuilder = new StringBuilder(); - pemBuilder.AppendLine("-----BEGIN CERTIFICATE-----"); - pemBuilder.AppendLine(Convert.ToBase64String(x509Cert.RawData, Base64FormattingOptions.InsertLineBreaks)); - pemBuilder.AppendLine("-----END CERTIFICATE-----"); - var pemCert = pemBuilder.ToString(); - - pemCert = pemCert.Replace("\r", ""); - _logger.LogTrace("Uploading cert: " + pemCert); - - // upload the signed cert to the camera - client.UploadCert(certName +".cer", pemCert); - - // turn on 802.1x - returnCode = client.Change8021XSettings(true); - if (returnCode != "pass") - { - errorMessage = $"Error setting 802.1x to on for {certName} on camera {cameraUrl} with error code {returnCode}"; - _logger.LogError(errorMessage); - return new JobResult - { - Result = OrchestratorJobStatusJobResult.Failure, - JobHistoryId = jobConfiguration.JobHistoryId, - FailureMessage = errorMessage - }; - } - - // set cert usage - Constants.CertificateUsage usageEnum = Constants.ParseCertificateUsage(certUsage); - - returnCode = client.SetCertUsage(certName, usageEnum); - if (returnCode != "pass") - { - errorMessage = $"Error setting certUsage of {certUsage} for certificate {certName} on camera {cameraUrl} with error code {returnCode}"; - _logger.LogError(errorMessage); - return new JobResult - { - Result = OrchestratorJobStatusJobResult.Failure, - JobHistoryId = jobConfiguration.JobHistoryId, - FailureMessage = errorMessage - }; - } - - //reboot the camera - client.RebootCamera(); - if (returnCode != "pass") - { - errorMessage = $"Error rebooting camera {cameraUrl} with error code {returnCode}"; - _logger.LogError(errorMessage); - return new JobResult - { - Result = OrchestratorJobStatusJobResult.Failure, - JobHistoryId = jobConfiguration.JobHistoryId, - FailureMessage = errorMessage - }; - } - - return new JobResult - { - Result = OrchestratorJobStatusJobResult.Success, - JobHistoryId = jobConfiguration.JobHistoryId, - FailureMessage = "" - }; - } - catch (Exception e) - { - _logger.LogError($"PerformReenrollment Error: {e.Message}"); - return new JobResult - { - Result = OrchestratorJobStatusJobResult.Failure, - JobHistoryId = jobConfiguration.JobHistoryId, - FailureMessage = e.Message - }; - } - - } - - private object GetRequiredReenrollmentField(Dictionary jobProperties, string fieldName) - { - _logger.LogTrace($"Checking for required field '{fieldName}' in Reenrollment Job Properties"); - - if (jobProperties.ContainsKey(fieldName)) - { - var requiredField = jobProperties[fieldName]; - if (requiredField != null) - { - _logger.LogTrace($"Required field '{fieldName}' found with value '{requiredField}"); - return requiredField; - } - else - { - string message = $"Required field '{fieldName}' was present in Reenrollment Job Properties but had no value"; - _logger.LogError(message); - throw new MissingFieldException(message); - } - } - else - { - string message = $"Required field '{fieldName}' was not present in the Reenrollment Job Properties"; - _logger.LogError(message); - throw new MissingFieldException(message); - } - } - - private Dictionary SetupCsrSubject(string subjectText) - { - var csrSubject = new Dictionary(); - _logger.LogTrace($"Parsing subject text: {subjectText}"); - var splitSubject = subjectText.Split(','); - foreach (string subjectElement in splitSubject) - { - _logger.LogTrace($"Splitting subject element: {subjectElement}"); - var splitSubjectElement = subjectElement.Split('='); - var name = splitSubjectElement[0].Trim(); - var value = splitSubjectElement[1].Trim(); - _logger.LogTrace($"Adding subject element: '{name}' with value '{value}'"); - csrSubject.Add(name, value); - } - - return csrSubject; - } - } -} +// 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 Keyfactor.Extensions.Orchestrator.BoschIPCamera.Client; +using Keyfactor.Logging; +using Keyfactor.Orchestrators.Common.Enums; +using Keyfactor.Orchestrators.Extensions; +using Keyfactor.Orchestrators.Extensions.Interfaces; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Text; + +namespace Keyfactor.Extensions.Orchestrator.BoschIPCamera.Jobs +{ + public class Reenrollment : IReenrollmentJobExtension + { + public string ExtensionName => "BoschIPCamera"; + private readonly ILogger _logger; + private readonly IPAMSecretResolver _pam; + + public Reenrollment(IPAMSecretResolver pam) + { + _logger = LogHandler.GetClassLogger(); + _pam = pam; + } + + public JobResult ProcessJob(ReenrollmentJobConfiguration jobConfiguration, SubmitReenrollmentCSR submitReenrollmentUpdate) + { + _logger.MethodEntry(LogLevel.Debug); + return PerformReenrollment(jobConfiguration, submitReenrollmentUpdate); + } + + private JobResult PerformReenrollment(ReenrollmentJobConfiguration jobConfiguration, SubmitReenrollmentCSR submitReenrollment) + { + + try + { + _logger.MethodEntry(LogLevel.Debug); + _logger.LogTrace( + $"Begin Reenrollment for Client Machine {jobConfiguration.CertificateStoreDetails.ClientMachine}..."); + + var client = new BoschIpCameraClient(jobConfiguration, jobConfiguration.CertificateStoreDetails, _pam, _logger); + + string certName = GetRequiredReenrollmentField(jobConfiguration.JobProperties, "Name").ToString(); + bool overwrite = (bool) GetRequiredReenrollmentField(jobConfiguration.JobProperties, "Overwrite"); + string csrInput = GetRequiredReenrollmentField(jobConfiguration.JobProperties, "subjectText").ToString(); + string certUsage = GetRequiredReenrollmentField(jobConfiguration.JobProperties, "CertificateUsage").ToString(); + string keyAlgorithm = GetRequiredReenrollmentField(jobConfiguration.JobProperties,"keyType").ToString(); + string keySize = GetRequiredReenrollmentField(jobConfiguration.JobProperties,"keySize").ToString(); + + string returnCode; + string errorMessage; + string cameraUrl = jobConfiguration.CertificateStoreDetails.ClientMachine; + bool oldCertExists = false; + + // get the existing certificate name associated with the supplied cert usage + Constants.CertificateUsage certUsageEnum = Constants.ParseCertificateUsage(certUsage); + string oldCertName = client.GetCertWithUsage(certUsageEnum); + if(!string.IsNullOrEmpty(oldCertName)) + { + oldCertExists = true; + _logger.LogDebug($"Found Existing cert name '{oldCertName}' with certificate usage '{certUsage}'"); + + // compare the old certificate name with the new certificate name --- + // 1) if the names are the same, append a reserved time-based suffix to the end of the name + // this new name [CertA_Timestamp] will be used to create the new cert + // OR + // 2) EDGE CASE: if the old certificate name currently tied to the cert usage does NOT match the new certificate 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 + certName = Constants.CertName.CreateUniqueCertName(certName); + _logger.LogDebug($"Name for new certificate has been updated to '{certName}' to ensure uniqueness"); + + + } + else + { + _logger.LogDebug($"No existing certificate found with certificate usage '{certUsage}'"); + + // if overwrite is checked, delete the existing certificate (if one exists) + // this is done to avoid errors generating a CSR with a name that is already in use; + // since the existing certificate is not currently bound, this will not cause an outage + if (overwrite) + { + returnCode = client.DeleteCertByName(certName); + + if (returnCode != "pass") + { + errorMessage = $"Error deleting existing certificate {certName} on camera {cameraUrl} with error code {returnCode}"; + _logger.LogError(errorMessage); + return new JobResult + { + Result = OrchestratorJobStatusJobResult.Failure, + JobHistoryId = jobConfiguration.JobHistoryId, + FailureMessage = errorMessage + }; + } + } + } + + // setup the CSR details + var csrSubject = SetupCsrSubject(csrInput); + + // map the key type and key size from the job properties to a corresponding key type available on the device + Constants.CertificateKeyType keyEnum = Constants.MapKeyType(keyAlgorithm,keySize); + + _logger.LogDebug($"Mapped Key Type: {keyEnum.ToReadableText()}"); + if (keyEnum == Constants.CertificateKeyType.Unknown) + { + errorMessage = $"The requested enrollment key algorithm '{keyAlgorithm}' and key size '{keySize}' is Unknown and cannot be used to create a CSR."; + _logger.LogError(errorMessage); + return new JobResult + { + Result = OrchestratorJobStatusJobResult.Failure, + JobHistoryId = jobConfiguration.JobHistoryId, + FailureMessage = errorMessage + }; + } + + //generate the CSR on the camera + returnCode = client.CertCreate(csrSubject, certName, keyEnum); + + if (returnCode != "pass") + { + errorMessage = $"Error generating CSR for {certName} on camera {cameraUrl} with error code {returnCode}"; + _logger.LogError(errorMessage); + return new JobResult + { + Result = OrchestratorJobStatusJobResult.Failure, + JobHistoryId = jobConfiguration.JobHistoryId, + FailureMessage = errorMessage + }; + } + + //get the CSR from the camera + var csr = client.DownloadCsrFromCamera(certName); + _logger.LogTrace("Downloaded CSR: " + csr); + + // check that csr meets csr format + // 404 message response can be returned instead + if (!csr.StartsWith("-----BEGIN")) + { + // error downloaded, no CSR present + // likely due to existing cert that was not marked to ovewrite (delete) + errorMessage = $"Error retrieving CSR from camera {cameraUrl} - got response: {csr}. " + + $"Possible reasons for error --- The requested enrollment key algorithm '{keyAlgorithm}' and key size '{keySize}' is not supported on this specific device; " + + $"The 'Name' provided for the new certificate already exists on the camera and Overwrite was not checked."; + _logger.LogError(errorMessage); + return new JobResult + { + Result = OrchestratorJobStatusJobResult.Failure, + JobHistoryId = jobConfiguration.JobHistoryId, + FailureMessage = errorMessage + }; + } + + // sign CSR in Keyfactor + var x509Cert = submitReenrollment.Invoke(csr); + + if (x509Cert == null) + { + errorMessage = $"Error submitting CSR to Keyfactor. Certificate not received. CSR submitted: {csr}"; + _logger.LogError(errorMessage); + return new JobResult + { + Result = OrchestratorJobStatusJobResult.Failure, + JobHistoryId = jobConfiguration.JobHistoryId, + FailureMessage = errorMessage + }; + } + + // build PEM content + StringBuilder pemBuilder = new StringBuilder(); + pemBuilder.AppendLine("-----BEGIN CERTIFICATE-----"); + pemBuilder.AppendLine(Convert.ToBase64String(x509Cert.RawData, Base64FormattingOptions.InsertLineBreaks)); + pemBuilder.AppendLine("-----END CERTIFICATE-----"); + var pemCert = pemBuilder.ToString(); + + pemCert = pemCert.Replace("\r", ""); + _logger.LogTrace("Uploading cert: " + pemCert); + + // upload the signed cert to the camera + client.UploadCert(certName +".cer", pemCert); + + // turn on 802.1x + returnCode = client.Change8021XSettings(true); + if (returnCode != "pass") + { + errorMessage = $"Error setting 802.1x to on for {certName} on camera {cameraUrl} with error code {returnCode}"; + _logger.LogError(errorMessage); + return new JobResult + { + Result = OrchestratorJobStatusJobResult.Failure, + JobHistoryId = jobConfiguration.JobHistoryId, + FailureMessage = errorMessage + }; + } + + // set cert usage + Constants.CertificateUsage usageEnum = Constants.ParseCertificateUsage(certUsage); + + returnCode = client.SetCertUsage(certName, usageEnum); + if (returnCode != "pass") + { + errorMessage = $"Error setting certUsage of {certUsage} for certificate {certName} on camera {cameraUrl} with error code {returnCode}"; + _logger.LogError(errorMessage); + return new JobResult + { + Result = OrchestratorJobStatusJobResult.Failure, + JobHistoryId = jobConfiguration.JobHistoryId, + FailureMessage = errorMessage + }; + } + + // delete existing certificate if overwriting and an existing certificate was previously bound to the cert usage + if (overwrite && oldCertExists) + { + returnCode = client.DeleteCertByName(oldCertName); + + if (returnCode != "pass") + { + errorMessage = $"Error deleting existing certificate {oldCertName} on camera {cameraUrl} with error code {returnCode}"; + _logger.LogError(errorMessage); + return new JobResult + { + Result = OrchestratorJobStatusJobResult.Failure, + JobHistoryId = jobConfiguration.JobHistoryId, + FailureMessage = errorMessage + }; + } + } + + //reboot the camera + client.RebootCamera(); + if (returnCode != "pass") + { + errorMessage = $"Error rebooting camera {cameraUrl} with error code {returnCode}"; + _logger.LogError(errorMessage); + return new JobResult + { + Result = OrchestratorJobStatusJobResult.Failure, + JobHistoryId = jobConfiguration.JobHistoryId, + FailureMessage = errorMessage + }; + } + + return new JobResult + { + Result = OrchestratorJobStatusJobResult.Success, + JobHistoryId = jobConfiguration.JobHistoryId, + FailureMessage = "" + }; + } + catch (Exception e) + { + _logger.LogError($"PerformReenrollment Error: {e.Message}"); + return new JobResult + { + Result = OrchestratorJobStatusJobResult.Failure, + JobHistoryId = jobConfiguration.JobHistoryId, + FailureMessage = e.Message + }; + } + + } + + private object GetRequiredReenrollmentField(Dictionary jobProperties, string fieldName) + { + _logger.LogTrace($"Checking for required field '{fieldName}' in Reenrollment Job Properties"); + + if (jobProperties.ContainsKey(fieldName)) + { + var requiredField = jobProperties[fieldName]; + if (requiredField != null) + { + _logger.LogTrace($"Required field '{fieldName}' found with value '{requiredField}'"); + return requiredField; + } + else + { + string message = $"Required field '{fieldName}' was present in Reenrollment Job Properties but had no value"; + _logger.LogError(message); + throw new MissingFieldException(message); + } + } + else + { + string message = $"Required field '{fieldName}' was not present in the Reenrollment Job Properties"; + _logger.LogError(message); + throw new MissingFieldException(message); + } + } + + private Dictionary SetupCsrSubject(string subjectText) + { + var csrSubject = new Dictionary(); + _logger.LogTrace($"Parsing subject text: {subjectText}"); + var splitSubject = subjectText.Split(','); + foreach (string subjectElement in splitSubject) + { + _logger.LogTrace($"Splitting subject element: {subjectElement}"); + var splitSubjectElement = subjectElement.Split('='); + var name = splitSubjectElement[0].Trim(); + var value = splitSubjectElement[1].Trim(); + + _logger.LogTrace($"Adding subject element: '{name}' with value '{value}'"); + csrSubject.Add(name, value); + } + + return csrSubject; + } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f53f74..eeb7ceb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +1.2.0 +- Support for additional ODKG key algorithms and sizes +- Improved ODKG workflow to prevent outages caused by deleting in-use certificate before new one is installed +- Update Inventory Job to filter out non-certificate types +- Addressed memory leak potential (CWE-404) + 1.1.2 - Update doc screenshots diff --git a/README.md b/README.md index e8184ee..5e7669a 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,37 @@ the Keyfactor Command Portal ![BoschIPCamera Custom Fields Tab](docsource/images/BoschIPCamera-custom-fields-store-type-dialog.png) + + ###### Server Username + Enter the username of the configured "service" user on the camera + + + > [!IMPORTANT] + > This field is created by the `Needs Server` on the Basic tab, do not create this field manually. + + + + + ###### Server Password + Enter the password of the configured "service" user on the camera + + + > [!IMPORTANT] + > This field is created by the `Needs Server` on the Basic tab, do not create this field manually. + + + + + ###### Use SSL + Select True or False depending on if SSL (HTTPS) should be used to communicate with the camera. + + ![BoschIPCamera Custom Field - ServerUseSsl](docsource/images/BoschIPCamera-custom-field-ServerUseSsl-dialog.png) + ![BoschIPCamera Custom Field - ServerUseSsl](docsource/images/BoschIPCamera-custom-field-ServerUseSsl-validation-options-dialog.png) + + + + + ##### Entry Parameters Tab | Name | Display Name | Description | Type | Default Value | Entry has a private key | Adding an entry | Removing an entry | Reenrolling an entry | @@ -165,21 +196,43 @@ the Keyfactor Command Portal ![BoschIPCamera Entry Parameters Tab](docsource/images/BoschIPCamera-entry-parameters-store-type-dialog.png) + + ##### Certificate Usage + The Certificate Usage to assign to the cert after upload. Can be left blank to be assigned later. + + ![BoschIPCamera Entry Parameter - CertificateUsage](docsource/images/BoschIPCamera-entry-parameters-store-type-dialog-CertificateUsage.png) + ![BoschIPCamera Entry Parameter - CertificateUsage](docsource/images/BoschIPCamera-entry-parameters-store-type-dialog-CertificateUsage-validation-options.png) + + + ##### Name (Alias) + The certificate Alias, entered again. + + ![BoschIPCamera Entry Parameter - Name](docsource/images/BoschIPCamera-entry-parameters-store-type-dialog-Name.png) + ![BoschIPCamera Entry Parameter - Name](docsource/images/BoschIPCamera-entry-parameters-store-type-dialog-Name-validation-options.png) + + + ##### Overwrite + Select `True` if using an existing Alias name to remove and replace an existing certificate. + + ![BoschIPCamera Entry Parameter - Overwrite](docsource/images/BoschIPCamera-entry-parameters-store-type-dialog-Overwrite.png) + ![BoschIPCamera Entry Parameter - Overwrite](docsource/images/BoschIPCamera-entry-parameters-store-type-dialog-Overwrite-validation-options.png) + + + ## Installation 1. **Download the latest Bosch IP Camera Universal Orchestrator extension from GitHub.** - Navigate to the [Bosch IP Camera Universal Orchestrator extension GitHub version page](https://github.com/Keyfactor/bosch-ipcamera-orchestrator/releases/latest). Refer to the compatibility matrix below to determine whether the `net6.0` or `net8.0` asset should be downloaded. Then, click the corresponding asset to download the zip archive. + Navigate to the [Bosch IP Camera Universal Orchestrator extension GitHub version page](https://github.com/Keyfactor/bosch-ipcamera-orchestrator/releases/latest). Refer to the compatibility matrix below to determine the asset should be downloaded. Then, click the corresponding asset to download the zip archive. | Universal Orchestrator Version | Latest .NET version installed on the Universal Orchestrator server | `rollForward` condition in `Orchestrator.runtimeconfig.json` | `bosch-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` | - | `11.6` _and_ newer | `net8.0` | | `net8.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` | + | `11.6` _and_ newer | `net8.0` | | `net8.0` | Unzip the archive containing extension assemblies to a known location. @@ -304,10 +357,18 @@ Please refer to the **Universal Orchestrator (remote)** usage section ([PAM prov **Reenrollment** -**Important!** When using Reenrollment, the subject needs to include the Camera's serial number as an element. The Camera automatically adds this to the CSR it generates, and Keyfactor will not enroll the CSR unless it is included. -For example, with a serial number of '1234' and a desired subject of CN=mycert, the Subject entered for a reenrollment should read: -Subject: `SERIALNUMBER=1234,CN=mycert` -The serial number is entered as the Store Path on the Certificate Store, and should be copied and entered as mentioned when running a reenrollment job. +> [!IMPORTANT] +> The Bosch camera requires certificate 'Name' to be unique. +> To avoid deleting an in-use certificate prior to its replacement with a like-named certificate and causing a brief outage during the transition, +> the integration will generate a unique name for the certificate if needed. +> The pattern used to generate a unique name will be reserved. +> Certificate name will be appended with the string "_yyMMddHHmm" using the current UTC date and time. + +> [!IMPORTANT] +> When using Reenrollment, the subject needs to include the Camera's serial number as an element. The Camera automatically adds this to the CSR it generates, and Keyfactor will not enroll the CSR unless it is included. +> For example, with a serial number of '1234' and a desired subject of CN=mycert, the Subject entered for a reenrollment should read: +> Subject: `SERIALNUMBER=1234,CN=mycert` +> The serial number is entered as the Store Path on the Certificate Store, and should be copied and entered as mentioned when running a reenrollment job. | Reenrollment Field | Value | Description | |-|-|-| @@ -332,6 +393,17 @@ __Keyfactor Command version 11+__: upload the script using the API [documented h After installing the PowerShell script, create a collection for each certificate type (or one for all cert types) used on cameras. Create an expiration alert and configure the Event Handler similar to the one below. +**Inventory** + +> [!IMPORTANT] +> Bosch cameras can store different types of data in the certificate store. Some of these types include, but are not limited to, the following: +> * "Signing requests" +> * "Private keys" +> * "Certificate" +> * "Trusted Certificate" +> +> This integration will only retrieve data that are marked as type "Certificate" or "Trusted Certificate". + ##### Event Handler Configuration Parameter Name |Type |Value ----------------|---------------|------------ diff --git a/docsource/content.md b/docsource/content.md index 20e05d4..3ba12d6 100644 --- a/docsource/content.md +++ b/docsource/content.md @@ -12,10 +12,18 @@ The Bosch IP Camera Orchestrator remotely manages certificates on the camera. **Reenrollment** -**Important!** When using Reenrollment, the subject needs to include the Camera's serial number as an element. The Camera automatically adds this to the CSR it generates, and Keyfactor will not enroll the CSR unless it is included. -For example, with a serial number of '1234' and a desired subject of CN=mycert, the Subject entered for a reenrollment should read: -Subject: `SERIALNUMBER=1234,CN=mycert` -The serial number is entered as the Store Path on the Certificate Store, and should be copied and entered as mentioned when running a reenrollment job. +> [!IMPORTANT] +> The Bosch camera requires certificate 'Name' to be unique. +> To avoid deleting an in-use certificate prior to its replacement with a like-named certificate and causing a brief outage during the transition, +> the integration will generate a unique name for the certificate if needed. +> The pattern used to generate a unique name will be reserved. +> Certificate name will be appended with the string "_yyMMddHHmm" using the current UTC date and time. + +> [!IMPORTANT] +> When using Reenrollment, the subject needs to include the Camera's serial number as an element. The Camera automatically adds this to the CSR it generates, and Keyfactor will not enroll the CSR unless it is included. +> For example, with a serial number of '1234' and a desired subject of CN=mycert, the Subject entered for a reenrollment should read: +> Subject: `SERIALNUMBER=1234,CN=mycert` +> The serial number is entered as the Store Path on the Certificate Store, and should be copied and entered as mentioned when running a reenrollment job. | Reenrollment Field | Value | Description | |-|-|-| @@ -38,7 +46,18 @@ __Keyfactor Command before version 11__: copy the PowerShell to the ExtensionLib __Keyfactor Command version 11+__: upload the script using the API [documented here](https://software.keyfactor.com/Core-OnPrem/v11.5/Content/ReferenceGuide/PowerShellScripts.htm) so it can be used in an Expiration Alert Handler After installing the PowerShell script, create a collection for each certificate type (or one for all cert types) used on cameras. Create an expiration alert and configure the Event Handler similar to the one below. - + +**Inventory** + +> [!IMPORTANT] +> Bosch cameras can store different types of data in the certificate store. Some of these types include, but are not limited to, the following: +> * "Signing requests" +> * "Private keys" +> * "Certificate" +> * "Trusted Certificate" +> +> This integration will only retrieve data that are marked as type "Certificate" or "Trusted Certificate". + ##### Event Handler Configuration Parameter Name |Type |Value ----------------|---------------|------------ diff --git a/docsource/images/BoschIPCamera-advanced-store-type-dialog.png b/docsource/images/BoschIPCamera-advanced-store-type-dialog.png index 4827627..368f75c 100644 Binary files a/docsource/images/BoschIPCamera-advanced-store-type-dialog.png and b/docsource/images/BoschIPCamera-advanced-store-type-dialog.png differ diff --git a/docsource/images/BoschIPCamera-basic-store-type-dialog.png b/docsource/images/BoschIPCamera-basic-store-type-dialog.png index 5e55754..10a6c04 100644 Binary files a/docsource/images/BoschIPCamera-basic-store-type-dialog.png and b/docsource/images/BoschIPCamera-basic-store-type-dialog.png differ diff --git a/docsource/images/BoschIPCamera-custom-field-ServerUseSsl-dialog.png b/docsource/images/BoschIPCamera-custom-field-ServerUseSsl-dialog.png new file mode 100644 index 0000000..1af1996 Binary files /dev/null and b/docsource/images/BoschIPCamera-custom-field-ServerUseSsl-dialog.png differ diff --git a/docsource/images/BoschIPCamera-custom-field-ServerUseSsl-validation-options-dialog.png b/docsource/images/BoschIPCamera-custom-field-ServerUseSsl-validation-options-dialog.png new file mode 100644 index 0000000..512fa0d Binary files /dev/null and b/docsource/images/BoschIPCamera-custom-field-ServerUseSsl-validation-options-dialog.png differ diff --git a/docsource/images/BoschIPCamera-custom-fields-store-type-dialog.png b/docsource/images/BoschIPCamera-custom-fields-store-type-dialog.png index 877b4d8..690c0f9 100644 Binary files a/docsource/images/BoschIPCamera-custom-fields-store-type-dialog.png and b/docsource/images/BoschIPCamera-custom-fields-store-type-dialog.png differ diff --git a/docsource/images/BoschIPCamera-entry-parameters-store-type-dialog-CertificateUsage-validation-options.png b/docsource/images/BoschIPCamera-entry-parameters-store-type-dialog-CertificateUsage-validation-options.png new file mode 100644 index 0000000..ae4ae11 Binary files /dev/null and b/docsource/images/BoschIPCamera-entry-parameters-store-type-dialog-CertificateUsage-validation-options.png differ diff --git a/docsource/images/BoschIPCamera-entry-parameters-store-type-dialog-CertificateUsage.png b/docsource/images/BoschIPCamera-entry-parameters-store-type-dialog-CertificateUsage.png new file mode 100644 index 0000000..6868517 Binary files /dev/null and b/docsource/images/BoschIPCamera-entry-parameters-store-type-dialog-CertificateUsage.png differ diff --git a/docsource/images/BoschIPCamera-entry-parameters-store-type-dialog.png b/docsource/images/BoschIPCamera-entry-parameters-store-type-dialog.png index caa195e..fdafe98 100644 Binary files a/docsource/images/BoschIPCamera-entry-parameters-store-type-dialog.png and b/docsource/images/BoschIPCamera-entry-parameters-store-type-dialog.png differ