From 89c23f5432005b38c9796f79a65c16a80932d5e1 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 7 May 2026 09:01:27 -0600 Subject: [PATCH 1/5] feat: enhance certificate handling with BouncyCastle and remove firmware version checks Co-authored-by: Copilot --- .../Logging/DebugWebsocketSink.cs | 70 +++++++++++++++++-- src/PepperDash.Essentials/ControlSystem.cs | 28 ++++---- 2 files changed, 77 insertions(+), 21 deletions(-) diff --git a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs index e083da2d..1f4dda7a 100644 --- a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs +++ b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs @@ -15,10 +15,12 @@ using Org.BouncyCastle.Asn1.X509; using Org.BouncyCastle.Crypto; using Org.BouncyCastle.Crypto.Generators; using Org.BouncyCastle.Crypto.Operators; +using Org.BouncyCastle.Crypto.Parameters; using Org.BouncyCastle.Math; using Org.BouncyCastle.Pkcs; using Org.BouncyCastle.Security; using Org.BouncyCastle.X509; +using System.Security.Cryptography; using Serilog.Formatting; using Serilog.Formatting.Json; @@ -172,7 +174,15 @@ namespace PepperDash.Core using (var ms = new MemoryStream()) { - pkcs12Store.Save(ms, _certificatePassword.ToCharArray(), random); + var passwordChars = _certificatePassword.ToCharArray(); + try + { + pkcs12Store.Save(ms, passwordChars, random); + } + finally + { + Array.Clear(passwordChars, 0, passwordChars.Length); + } File.WriteAllBytes(outputPath, ms.ToArray()); } @@ -215,23 +225,69 @@ namespace PepperDash.Core private static X509Certificate2 LoadOrRecreateCert(string certPath, string certPassword) { + if (!File.Exists(certPath)) + CreateCert(); + try { - // EphemeralKeySet is required on Linux/OpenSSL (Crestron 4-series) to avoid - // key-container persistence failures, and avoids the private key export restriction. - return new X509Certificate2(certPath, certPassword, X509KeyStorageFlags.EphemeralKeySet); + return LoadCertFromBouncyCastle(certPath, certPassword); } catch (Exception ex) { - // Cert is stale or was generated by an incompatible library (e.g. old BouncyCastle output). - // Delete it, regenerate with the BCL path, and retry once. + // Cert is corrupt or was written by an incompatible tool — delete and regenerate once. CrestronConsole.PrintLine(string.Format("SSL cert load failed ({0}); regenerating...", ex.Message)); try { File.Delete(certPath); } catch { } CreateCert(); - return new X509Certificate2(certPath, certPassword, X509KeyStorageFlags.EphemeralKeySet); + return LoadCertFromBouncyCastle(certPath, certPassword); } } + /// + /// Loads a PKCS#12 file written by BouncyCastle and returns an with + /// private key attached via . + /// Using BouncyCastle's own reader avoids the .NET/Mono PFX parser, which can reject + /// BouncyCastle-generated archives on the Crestron runtime. + /// + private static X509Certificate2 LoadCertFromBouncyCastle(string certPath, string certPassword) + { + var passwordChars = certPassword.ToCharArray(); + try + { + using (var stream = File.OpenRead(certPath)) + { + var store = new Pkcs12StoreBuilder().Build(); + store.Load(stream, passwordChars); + + foreach (string alias in store.Aliases) + { + if (!store.IsKeyEntry(alias)) continue; + + var keyEntry = store.GetKey(alias); + var certChain = store.GetCertificateChain(alias); + if (certChain == null || certChain.Length == 0) continue; + + // Build X509Certificate2 from raw DER — no PFX parsing by .NET needed. + var cert = new X509Certificate2(certChain[0].Certificate.GetEncoded()); + + // Attach the private key via RSACryptoServiceProvider (available on all target runtimes). + var rsaParams = DotNetUtilities.ToRSAParameters( + (RsaPrivateCrtKeyParameters)keyEntry.Key); + var rsa = new RSACryptoServiceProvider(); + rsa.ImportParameters(rsaParams); + cert.PrivateKey = rsa; + + return cert; + } + } + } + finally + { + Array.Clear(passwordChars, 0, passwordChars.Length); + } + + throw new InvalidOperationException("No key entry found in PKCS#12 store: " + certPath); + } + private void Start(int port, string certPath = "", string certPassword = "") { try diff --git a/src/PepperDash.Essentials/ControlSystem.cs b/src/PepperDash.Essentials/ControlSystem.cs index e6c61c60..d4a7545a 100644 --- a/src/PepperDash.Essentials/ControlSystem.cs +++ b/src/PepperDash.Essentials/ControlSystem.cs @@ -27,7 +27,7 @@ namespace PepperDash.Essentials private CEvent _initializeEvent; private const long StartupTime = 500; - private const string minimumFirmwareVersion = "2.8006.00110"; + // private const string minimumFirmwareVersion = "2.8006.00110"; /// /// Initializes a new instance of the ControlSystem class @@ -50,21 +50,21 @@ namespace PepperDash.Essentials { // Get FW version and stop if it's too low to run this version of Essentials. Must be greater than v2.8006.00110 - var fwVersion = InitialParametersClass.FirmwareVersion; + // var fwVersion = InitialParametersClass.FirmwareVersion; - Debug.LogInformation("Control System Hardware Version: {fwVersion}", fwVersion); + // Debug.LogInformation("Control System Hardware Version: {fwVersion}", fwVersion); - // split the version into parts and compare against minimumFirmwareVersion - var versionParts = fwVersion.Split('.').Select(int.Parse).ToArray(); - var minParts = minimumFirmwareVersion.Split('.').Select(int.Parse).ToArray(); - if (versionParts.Length < minParts.Length - || versionParts[0] < minParts[0] - || (versionParts[0] == minParts[0] && versionParts[1] < minParts[1]) - || (versionParts[0] == minParts[0] && versionParts[1] == minParts[1] && versionParts[2] <= minParts[2])) - { - Debug.LogFatal("Firmware version {fwVersion} is too low to run this version of Essentials. Please upgrade to greater than v{minimumFirmwareVersion}.", fwVersion, minimumFirmwareVersion); - return; - } + // // split the version into parts and compare against minimumFirmwareVersion + // var versionParts = fwVersion.Split('.').Select(int.Parse).ToArray(); + // var minParts = minimumFirmwareVersion.Split('.').Select(int.Parse).ToArray(); + // if (versionParts.Length < minParts.Length + // || versionParts[0] < minParts[0] + // || (versionParts[0] == minParts[0] && versionParts[1] < minParts[1]) + // || (versionParts[0] == minParts[0] && versionParts[1] == minParts[1] && versionParts[2] <= minParts[2])) + // { + // Debug.LogFatal("Firmware version {fwVersion} is too low to run this version of Essentials. Please upgrade to greater than v{minimumFirmwareVersion}.", fwVersion, minimumFirmwareVersion); + // return; + // } // If the control system is a DMPS type, we need to wait to exit this method until all devices have had time to activate // to allow any HD-BaseT DM endpoints to register first. From c52d585a0cc5b64484820589442ea9e9423272d3 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 7 May 2026 10:44:54 -0600 Subject: [PATCH 2/5] feat: Allow WebAPI to load if no config file is present --- .../Web/EssentialsWebApi.cs | 6 ++++++ src/PepperDash.Essentials/ControlSystem.cs | 19 ++++++++++++------- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs index 9e781883..375efdeb 100644 --- a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs +++ b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs @@ -20,6 +20,8 @@ namespace PepperDash.Essentials.Core.Web private readonly WebApiServer _debugServer; + + /// /// http(s)://{ipaddress}/cws/{basePath} /// http(s)://{ipaddress}/VirtualControl/Rooms/{roomId}/cws/{basePath} @@ -301,7 +303,11 @@ namespace PepperDash.Essentials.Core.Web { Debug.LogMessage(LogEventLevel.Information, this, "{routeName:l}: {routePath:l}/{routeUrl:l}", route.Name, path, route.Url); } + Debug.LogInformation(this, "Web API initialized and ready to accept requests"); + Debug.LogMessage(LogEventLevel.Information, this, new string('-', 50)); + + Debug.LogMessage(LogEventLevel.Information, this, "Developer Tools Web App available at: https://{currentIp}/cws/debug", currentIp); } } } \ No newline at end of file diff --git a/src/PepperDash.Essentials/ControlSystem.cs b/src/PepperDash.Essentials/ControlSystem.cs index d4a7545a..fe7bf807 100644 --- a/src/PepperDash.Essentials/ControlSystem.cs +++ b/src/PepperDash.Essentials/ControlSystem.cs @@ -274,10 +274,9 @@ namespace PepperDash.Essentials PluginLoader.LoadPlugins(); Debug.LogMessage(LogEventLevel.Information, "Folder structure verified. Loading config..."); - if (!ConfigReader.LoadConfig2()) + if (!ConfigReader.LoadConfig2() || ConfigReader.ConfigObject == null) { - Debug.LogMessage(LogEventLevel.Information, "Essentials Load complete with errors"); - return; + Debug.LogMessage(LogEventLevel.Warning, "Unable to load config file. Please ensure a valid config file is present and restart the program."); } Load(); @@ -399,6 +398,12 @@ namespace PepperDash.Essentials new Core.Monitoring.SystemMonitorController("systemMonitor")); } + if (ConfigReader.ConfigObject is null) + { + Debug.LogMessage(LogEventLevel.Warning, "LoadDevices: ConfigObject is null. Cannot load devices."); + return; + } + foreach (var devConf in ConfigReader.ConfigObject.Devices) { IKeyed newDev = null; @@ -452,7 +457,7 @@ namespace PepperDash.Essentials var tlc = TieLineCollection.Default; - if (ConfigReader.ConfigObject.TieLines == null) + if (ConfigReader.ConfigObject?.TieLines == null) { return; } @@ -749,7 +754,7 @@ namespace PepperDash.Essentials /// public void LoadRooms() { - if (ConfigReader.ConfigObject.Rooms == null) + if (ConfigReader.ConfigObject?.Rooms == null) { Debug.LogMessage(LogEventLevel.Information, "Notice: Configuration contains no rooms - Is this intentional? This may be a valid configuration."); return; @@ -786,13 +791,13 @@ namespace PepperDash.Essentials /// void LoadLogoServer() { - if (ConfigReader.ConfigObject.Rooms == null) + if (ConfigReader.ConfigObject?.Rooms == null) { Debug.LogMessage(LogEventLevel.Information, "No rooms configured. Bypassing Logo server startup."); return; } - if ( + if (ConfigReader.ConfigObject?.Rooms == null || !ConfigReader.ConfigObject.Rooms.Any( CheckRoomConfig)) { From 2530003a58315fa325a83f1ced7eaf0522dcfa28 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 7 May 2026 10:56:56 -0600 Subject: [PATCH 3/5] refactor: clean up whitespace and improve debug URL logging format --- src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs index 375efdeb..3cdb8433 100644 --- a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs +++ b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs @@ -20,7 +20,7 @@ namespace PepperDash.Essentials.Core.Web private readonly WebApiServer _debugServer; - + /// /// http(s)://{ipaddress}/cws/{basePath} @@ -262,7 +262,7 @@ namespace PepperDash.Essentials.Core.Web _server.Start(); _debugServer.Start(); - + GetPaths(); } @@ -307,7 +307,11 @@ namespace PepperDash.Essentials.Core.Web Debug.LogMessage(LogEventLevel.Information, this, new string('-', 50)); - Debug.LogMessage(LogEventLevel.Information, this, "Developer Tools Web App available at: https://{currentIp}/cws/debug", currentIp); + var debugAppUrl = CrestronEnvironment.DevicePlatform == eDevicePlatform.Server + ? $"https://{hostname}/VirtualControl/Rooms/{InitialParametersClass.RoomId}/cws/debug" + : $"https://{currentIp}/cws/debug"; + + Debug.LogMessage(LogEventLevel.Information, this, "Developer Tools Web App available at: {debugAppUrl:l}", debugAppUrl); } } } \ No newline at end of file From c050bb4eb342440c00a3acdecc42d5da496304d6 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 7 May 2026 11:01:10 -0600 Subject: [PATCH 4/5] feat: disable key persistence for RSA in DebugWebsocketSink and simplify config loading warning message --- src/PepperDash.Core/Logging/DebugWebsocketSink.cs | 1 + src/PepperDash.Essentials/ControlSystem.cs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs index 1f4dda7a..8986fcb4 100644 --- a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs +++ b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs @@ -273,6 +273,7 @@ namespace PepperDash.Core var rsaParams = DotNetUtilities.ToRSAParameters( (RsaPrivateCrtKeyParameters)keyEntry.Key); var rsa = new RSACryptoServiceProvider(); + rsa.PersistKeyInCsp = false; rsa.ImportParameters(rsaParams); cert.PrivateKey = rsa; diff --git a/src/PepperDash.Essentials/ControlSystem.cs b/src/PepperDash.Essentials/ControlSystem.cs index fe7bf807..db057cbf 100644 --- a/src/PepperDash.Essentials/ControlSystem.cs +++ b/src/PepperDash.Essentials/ControlSystem.cs @@ -276,7 +276,7 @@ namespace PepperDash.Essentials Debug.LogMessage(LogEventLevel.Information, "Folder structure verified. Loading config..."); if (!ConfigReader.LoadConfig2() || ConfigReader.ConfigObject == null) { - Debug.LogMessage(LogEventLevel.Warning, "Unable to load config file. Please ensure a valid config file is present and restart the program."); + Debug.LogMessage(LogEventLevel.Warning, "Unable to load config file."); } Load(); From 404728c708bb6f9750c1b30102cd3db57eea2fb8 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 7 May 2026 12:07:29 -0600 Subject: [PATCH 5/5] feat: refactor portal info command to improve configuration handling and response messages --- src/PepperDash.Essentials/ControlSystem.cs | 38 ++++++++++++++-------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/src/PepperDash.Essentials/ControlSystem.cs b/src/PepperDash.Essentials/ControlSystem.cs index db057cbf..5a0ba2af 100644 --- a/src/PepperDash.Essentials/ControlSystem.cs +++ b/src/PepperDash.Essentials/ControlSystem.cs @@ -130,14 +130,8 @@ namespace PepperDash.Essentials (ConfigReader.ConfigObject, Newtonsoft.Json.Formatting.Indented).Replace(Environment.NewLine, "\r\n")); }, "showconfig", "Shows the current running merged config", ConsoleAccessLevelEnum.AccessOperator); - CrestronConsole.AddNewConsoleCommand(s => - CrestronConsole.ConsoleCommandResponse( - "This system can be found at the following URLs:{2}" + - "System URL: {0}{2}" + - "Template URL: {1}{2}", - ConfigReader.ConfigObject.SystemUrl, - ConfigReader.ConfigObject.TemplateUrl, - CrestronEnvironment.NewLine), + CrestronConsole.AddNewConsoleCommand( + PrintPortalInfo, "portalinfo", "Shows portal URLS from configuration", ConsoleAccessLevelEnum.AccessOperator); @@ -160,6 +154,29 @@ namespace PepperDash.Essentials } } + private void PrintPortalInfo(string args) + { + if(ConfigReader.ConfigObject == null) + { + CrestronConsole.ConsoleCommandResponse("No configuration loaded. Cannot show portal URLs."); + return; + } + + if (string.IsNullOrEmpty(ConfigReader.ConfigObject.SystemUrl) && string.IsNullOrEmpty(ConfigReader.ConfigObject.TemplateUrl)) + { + CrestronConsole.ConsoleCommandResponse("No portal URLs defined in config."); + return; + } + + CrestronConsole.ConsoleCommandResponse( + "This system can be found at the following URLs:{2}" + + "System URL: {0}{2}" + + "Template URL: {1}{2}", + ConfigReader.ConfigObject?.SystemUrl, + ConfigReader.ConfigObject?.TemplateUrl, + CrestronEnvironment.NewLine); + } + /// /// DeterminePlatform method /// @@ -257,11 +274,6 @@ namespace PepperDash.Essentials PluginLoader.AddProgramAssemblies(); _ = new Core.DeviceFactory(); - // _ = new Devices.Common.DeviceFactory(); - // _ = new DeviceFactory(); - - // _ = new ProcessorExtensionDeviceFactory(); - // _ = new MobileControlFactory(); LoadAssets(Global.ApplicationDirectoryPathPrefix, Global.FilePathPrefix);