diff --git a/src/Pepperdash Core/Logging/DebugWebsocketSink.cs b/src/Pepperdash Core/Logging/DebugWebsocketSink.cs index 3ff5cd8..7cf7c8f 100644 --- a/src/Pepperdash Core/Logging/DebugWebsocketSink.cs +++ b/src/Pepperdash Core/Logging/DebugWebsocketSink.cs @@ -9,39 +9,43 @@ using Serilog.Configuration; using WebSocketSharp.Server; using Crestron.SimplSharp; -using System.Net.Http; -using System.Text.RegularExpressions; using WebSocketSharp; +using System.Security.Authentication; +using WebSocketSharp.Net; +using X509Certificate2 = System.Security.Cryptography.X509Certificates.X509Certificate2; +using System.IO; +using Org.BouncyCastle.Asn1.X509; namespace PepperDash.Core { public class DebugWebsocketSink : ILogEventSink { - private HttpServer _server; - + private HttpServer _httpsServer; + private string _path = "/join"; + private const string _certificateName = "selfCres"; + private const string _certificatePassword = "cres12345"; public int Port { get { - if(_server == null) return 0; - return _server.Port; + if(_httpsServer == null) return 0; + return _httpsServer.Port; } } - public bool IsListening - { get - { - if (_server == null) return false; - return _server.IsListening; - } - } + public bool IsRunning { get => _httpsServer?.IsListening ?? false; } + private readonly IFormatProvider _formatProvider; public DebugWebsocketSink() { + + if (!File.Exists($"\\user\\{_certificateName}.pfx")) + CreateCert(null); + CrestronEnvironment.ProgramStatusEventHandler += type => { if (type == eProgramStatusEventType.Stopping) @@ -51,26 +55,133 @@ public DebugWebsocketSink() }; } + private void CreateCert(string[] args) + { + try + { + Debug.Console(0,$"CreateCert Creating Utility"); + //var utility = new CertificateUtility(); + var utility = new BouncyCertificate(); + Debug.Console(0, $"CreateCert Calling CreateCert"); + //utility.CreateCert(); + var ipAddress = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0); + var hostName = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_HOSTNAME, 0); + var domainName = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_DOMAIN_NAME, 0); + + Debug.Console(0, $"DomainName: {domainName} | HostName: {hostName} | {hostName}.{domainName}@{ipAddress}"); + + var certificate = utility.CreateSelfSignedCertificate($"CN={hostName}.{domainName}", new[] { $"{hostName}.{domainName}", ipAddress }, new[] { KeyPurposeID.IdKPServerAuth, KeyPurposeID.IdKPClientAuth }); + //Crestron fails to let us do this...perhaps it should be done through their Dll's but haven't tested + //Debug.Print($"CreateCert Storing Certificate To My.LocalMachine"); + //utility.AddCertToStore(certificate, StoreName.My, StoreLocation.LocalMachine); + Debug.Console(0, $"CreateCert Saving Cert to \\user\\"); + utility.CertificatePassword = _certificatePassword; + utility.WriteCertificate(certificate, @"\user\", _certificateName); + Debug.Console(0, $"CreateCert Ending CreateCert"); + } + catch (Exception ex) + { + Debug.Console(0, $"WSS CreateCert Failed\r\n{ex.Message}\r\n{ex.StackTrace}"); + } + } + public void Emit(LogEvent logEvent) { - if (_server == null || !_server.IsListening) return; + if (_httpsServer == null || !_httpsServer.IsListening) return; var message = logEvent.RenderMessage(_formatProvider); - _server.WebSocketServices.Broadcast(message); + _httpsServer.WebSocketServices.Broadcast(message); } public void StartServerAndSetPort(int port) { Debug.Console(0, "Starting Websocket Server on port: {0}", port); - _server = new HttpServer(port); - _server.AddWebSocketService(_path); - _server.Start(); + + + Start(port, $"\\user\\{_certificateName}.pfx", _certificatePassword, @"\html\wss"); + } + + private void Start(int port, string certPath = "", string certPassword = "", string rootPath = @"\html") + { + try + { + _httpsServer = new HttpServer(port, true) + { + RootPath = rootPath + }; + + if (!string.IsNullOrWhiteSpace(certPath)) + { + Debug.Console(0, "Assigning SSL Configuration"); + _httpsServer.SslConfiguration = new ServerSslConfiguration(new X509Certificate2(certPath, certPassword)) + { + ClientCertificateRequired = false, + CheckCertificateRevocation = false, + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls11 | SslProtocols.Tls, + //this is just to test, you might want to actually validate + ClientCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => + { + Debug.Console(0, "HTTPS ClientCerticateValidation Callback triggered"); + return true; + } + }; + } + Debug.Console(0, "Adding Debug Client Service"); + _httpsServer.AddWebSocketService("/echo"); + Debug.Console(0, "Assigning Log Info"); + _httpsServer.Log.Level = LogLevel.Trace; + _httpsServer.Log.Output = delegate + { + //Debug.Print(DebugLevel.WebSocket, "{1} {0}\rCaller:{2}\rMessage:{3}\rs:{4}", d.Level.ToString(), d.Date.ToString(), d.Caller.ToString(), d.Message, s); + }; + Debug.Console(0, "Starting"); + + //_httpsServer.OnGet += (sender, e) => + //{ + // Debug.Console(0, $"OnGet requesting {e.Request}"); + // var req = e.Request; + // var res = e.Response; + + // var path = req.RawUrl; + + // if (path == "/") + // path += "index.html"; + + // var localPath = Path.Combine(rootPath, path.Substring(1)); + + // byte[] contents; + // if (File.Exists(localPath)) + // contents = File.ReadAllBytes(localPath); + // else + // { + // e.Response.StatusCode = 404; + // contents = Encoding.UTF8.GetBytes("Path not found " + e.Request.RawUrl); + // } + + // var extention = Path.GetExtension(path); + // if (!_contentTypes.TryGetValue(extention, out var contentType)) + // contentType = "text/html"; + + // res.ContentLength64 = contents.LongLength; + + // res.Close(contents, true); + //}; + + _httpsServer.Start(); + Debug.Console(0, "Ready"); + } + catch (Exception ex) + { + Debug.Console(0, "WebSocket Failed to start {0}", ex.Message); + } } public void StopServer() { Debug.Console(0, "Stopping Websocket Server"); - _server.Stop(); + _httpsServer?.Stop(); + + _httpsServer = null; } } @@ -115,39 +226,7 @@ protected override void OnOpen() var url = Context.WebSocket.Url; Debug.Console(2, Debug.ErrorLogLevel.Notice, "New WebSocket Connection from: {0}", url); - //var match = Regex.Match(url.AbsoluteUri, "(?:ws|wss):\\/\\/.*(?:\\/mc\\/api\\/ui\\/join\\/)(.*)"); - - //if (match.Success) - //{ - // var clientId = match.Groups[1].Value; - - // // Inform controller of client joining - // if (Controller != null) - // { - // var clientJoined = new MobileControlResponseMessage - // { - // Type = "/system/roomKey", - // ClientId = clientId, - // Content = RoomKey, - // }; - - // Controller.SendMessageObjectToDirectClient(clientJoined); - - // var bridge = Controller.GetRoomBridge(RoomKey); - - // SendUserCodeToClient(bridge, clientId); - - // bridge.UserCodeChanged += (sender, args) => SendUserCodeToClient((MobileControlEssentialsRoomBridge)sender, clientId); - // } - // else - // { - // Debug.Console(2, "WebSocket UiClient Controller is null"); - // } - //} - _connectionTime = DateTime.Now; - - // TODO: Future: Check token to see if there's already an open session using that token and reject/close the session } protected override void OnMessage(MessageEventArgs e) @@ -165,7 +244,7 @@ protected override void OnClose(CloseEventArgs e) } - protected override void OnError(ErrorEventArgs e) + protected override void OnError(WebSocketSharp.ErrorEventArgs e) { base.OnError(e); diff --git a/src/Pepperdash Core/PepperDash_Core.csproj b/src/Pepperdash Core/PepperDash_Core.csproj index 2cdf3af..db72046 100644 --- a/src/Pepperdash Core/PepperDash_Core.csproj +++ b/src/Pepperdash Core/PepperDash_Core.csproj @@ -31,6 +31,7 @@ + Full diff --git a/src/Pepperdash Core/Web/BouncyCertificate.cs b/src/Pepperdash Core/Web/BouncyCertificate.cs new file mode 100644 index 0000000..c969235 --- /dev/null +++ b/src/Pepperdash Core/Web/BouncyCertificate.cs @@ -0,0 +1,360 @@ +using Crestron.SimplSharp; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Org.BouncyCastle.Asn1; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Prng; +using Org.BouncyCastle.Math; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Org.BouncyCastle.Utilities; +using Org.BouncyCastle.X509; +using X509Certificate2 = System.Security.Cryptography.X509Certificates.X509Certificate2; +using X509KeyStorageFlags = System.Security.Cryptography.X509Certificates.X509KeyStorageFlags; +using X509ContentType = System.Security.Cryptography.X509Certificates.X509ContentType; +using System.Text; +using Org.BouncyCastle.Crypto.Operators; +using System.Numerics; +using System.Security.Cryptography.X509Certificates; +using BigInteger = Org.BouncyCastle.Math.BigInteger; +using X509Certificate = Org.BouncyCastle.X509.X509Certificate; + +namespace PepperDash.Core +{ + /// + /// Taken From https://github.com/rlipscombe/bouncy-castle-csharp/ + /// + internal class BouncyCertificate + { + public string CertificatePassword { get; set; } = "password"; + public X509Certificate2 LoadCertificate(string issuerFileName, string password) + { + // We need to pass 'Exportable', otherwise we can't get the private key. + var issuerCertificate = new X509Certificate2(issuerFileName, password, X509KeyStorageFlags.Exportable); + return issuerCertificate; + } + + public X509Certificate2 IssueCertificate(string subjectName, X509Certificate2 issuerCertificate, string[] subjectAlternativeNames, KeyPurposeID[] usages) + { + // It's self-signed, so these are the same. + var issuerName = issuerCertificate.Subject; + + var random = GetSecureRandom(); + var subjectKeyPair = GenerateKeyPair(random, 2048); + + var issuerKeyPair = DotNetUtilities.GetKeyPair(issuerCertificate.PrivateKey); + + var serialNumber = GenerateSerialNumber(random); + var issuerSerialNumber = new BigInteger(issuerCertificate.GetSerialNumber()); + + const bool isCertificateAuthority = false; + var certificate = GenerateCertificate(random, subjectName, subjectKeyPair, serialNumber, + subjectAlternativeNames, issuerName, issuerKeyPair, + issuerSerialNumber, isCertificateAuthority, + usages); + return ConvertCertificate(certificate, subjectKeyPair, random); + } + + public X509Certificate2 CreateCertificateAuthorityCertificate(string subjectName, string[] subjectAlternativeNames, KeyPurposeID[] usages) + { + // It's self-signed, so these are the same. + var issuerName = subjectName; + + var random = GetSecureRandom(); + var subjectKeyPair = GenerateKeyPair(random, 2048); + + // It's self-signed, so these are the same. + var issuerKeyPair = subjectKeyPair; + + var serialNumber = GenerateSerialNumber(random); + var issuerSerialNumber = serialNumber; // Self-signed, so it's the same serial number. + + const bool isCertificateAuthority = true; + var certificate = GenerateCertificate(random, subjectName, subjectKeyPair, serialNumber, + subjectAlternativeNames, issuerName, issuerKeyPair, + issuerSerialNumber, isCertificateAuthority, + usages); + return ConvertCertificate(certificate, subjectKeyPair, random); + } + + public X509Certificate2 CreateSelfSignedCertificate(string subjectName, string[] subjectAlternativeNames, KeyPurposeID[] usages) + { + // It's self-signed, so these are the same. + var issuerName = subjectName; + + var random = GetSecureRandom(); + var subjectKeyPair = GenerateKeyPair(random, 2048); + + // It's self-signed, so these are the same. + var issuerKeyPair = subjectKeyPair; + + var serialNumber = GenerateSerialNumber(random); + var issuerSerialNumber = serialNumber; // Self-signed, so it's the same serial number. + + const bool isCertificateAuthority = false; + var certificate = GenerateCertificate(random, subjectName, subjectKeyPair, serialNumber, + subjectAlternativeNames, issuerName, issuerKeyPair, + issuerSerialNumber, isCertificateAuthority, + usages); + return ConvertCertificate(certificate, subjectKeyPair, random); + } + + private SecureRandom GetSecureRandom() + { + // Since we're on Windows, we'll use the CryptoAPI one (on the assumption + // that it might have access to better sources of entropy than the built-in + // Bouncy Castle ones): + var randomGenerator = new CryptoApiRandomGenerator(); + var random = new SecureRandom(randomGenerator); + return random; + } + + private X509Certificate GenerateCertificate(SecureRandom random, + string subjectName, + AsymmetricCipherKeyPair subjectKeyPair, + BigInteger subjectSerialNumber, + string[] subjectAlternativeNames, + string issuerName, + AsymmetricCipherKeyPair issuerKeyPair, + BigInteger issuerSerialNumber, + bool isCertificateAuthority, + KeyPurposeID[] usages) + { + var certificateGenerator = new X509V3CertificateGenerator(); + + certificateGenerator.SetSerialNumber(subjectSerialNumber); + + var issuerDN = new X509Name(issuerName); + certificateGenerator.SetIssuerDN(issuerDN); + + // Note: The subject can be omitted if you specify a subject alternative name (SAN). + var subjectDN = new X509Name(subjectName); + certificateGenerator.SetSubjectDN(subjectDN); + + // Our certificate needs valid from/to values. + var notBefore = DateTime.UtcNow.Date; + var notAfter = notBefore.AddYears(2); + + certificateGenerator.SetNotBefore(notBefore); + certificateGenerator.SetNotAfter(notAfter); + + // The subject's public key goes in the certificate. + certificateGenerator.SetPublicKey(subjectKeyPair.Public); + + AddAuthorityKeyIdentifier(certificateGenerator, issuerDN, issuerKeyPair, issuerSerialNumber); + AddSubjectKeyIdentifier(certificateGenerator, subjectKeyPair); + //AddBasicConstraints(certificateGenerator, isCertificateAuthority); + + if (usages != null && usages.Any()) + AddExtendedKeyUsage(certificateGenerator, usages); + + if (subjectAlternativeNames != null && subjectAlternativeNames.Any()) + AddSubjectAlternativeNames(certificateGenerator, subjectAlternativeNames); + + // Set the signature algorithm. This is used to generate the thumbprint which is then signed + // with the issuer's private key. We'll use SHA-256, which is (currently) considered fairly strong. + const string signatureAlgorithm = "SHA256WithRSA"; + + // The certificate is signed with the issuer's private key. + ISignatureFactory signatureFactory = new Asn1SignatureFactory(signatureAlgorithm, issuerKeyPair.Private, random); + var certificate = certificateGenerator.Generate(signatureFactory); + return certificate; + } + + /// + /// The certificate needs a serial number. This is used for revocation, + /// and usually should be an incrementing index (which makes it easier to revoke a range of certificates). + /// Since we don't have anywhere to store the incrementing index, we can just use a random number. + /// + /// + /// + private BigInteger GenerateSerialNumber(SecureRandom random) + { + var serialNumber = + BigIntegers.CreateRandomInRange( + BigInteger.One, BigInteger.ValueOf(Int64.MaxValue), random); + return serialNumber; + } + + /// + /// Generate a key pair. + /// + /// The random number generator. + /// The key length in bits. For RSA, 2048 bits should be considered the minimum acceptable these days. + /// + private AsymmetricCipherKeyPair GenerateKeyPair(SecureRandom random, int strength) + { + var keyGenerationParameters = new KeyGenerationParameters(random, strength); + + var keyPairGenerator = new RsaKeyPairGenerator(); + keyPairGenerator.Init(keyGenerationParameters); + var subjectKeyPair = keyPairGenerator.GenerateKeyPair(); + return subjectKeyPair; + } + + /// + /// Add the Authority Key Identifier. According to http://www.alvestrand.no/objectid/2.5.29.35.html, this + /// identifies the public key to be used to verify the signature on this certificate. + /// In a certificate chain, this corresponds to the "Subject Key Identifier" on the *issuer* certificate. + /// The Bouncy Castle documentation, at http://www.bouncycastle.org/wiki/display/JA1/X.509+Public+Key+Certificate+and+Certification+Request+Generation, + /// shows how to create this from the issuing certificate. Since we're creating a self-signed certificate, we have to do this slightly differently. + /// + /// + /// + /// + /// + private void AddAuthorityKeyIdentifier(X509V3CertificateGenerator certificateGenerator, + X509Name issuerDN, + AsymmetricCipherKeyPair issuerKeyPair, + BigInteger issuerSerialNumber) + { + var authorityKeyIdentifierExtension = + new AuthorityKeyIdentifier( + SubjectPublicKeyInfoFactory.CreateSubjectPublicKeyInfo(issuerKeyPair.Public), + new GeneralNames(new GeneralName(issuerDN)), + issuerSerialNumber); + certificateGenerator.AddExtension( + X509Extensions.AuthorityKeyIdentifier.Id, false, authorityKeyIdentifierExtension); + } + + /// + /// Add the "Subject Alternative Names" extension. Note that you have to repeat + /// the value from the "Subject Name" property. + /// + /// + /// + private void AddSubjectAlternativeNames(X509V3CertificateGenerator certificateGenerator, + IEnumerable subjectAlternativeNames) + { + var subjectAlternativeNamesExtension = + new DerSequence( + subjectAlternativeNames.Select(name => new GeneralName(GeneralName.DnsName, name)) + .ToArray()); + certificateGenerator.AddExtension( + X509Extensions.SubjectAlternativeName.Id, false, subjectAlternativeNamesExtension); + } + + /// + /// Add the "Extended Key Usage" extension, specifying (for example) "server authentication". + /// + /// + /// + private void AddExtendedKeyUsage(X509V3CertificateGenerator certificateGenerator, KeyPurposeID[] usages) + { + certificateGenerator.AddExtension( + X509Extensions.ExtendedKeyUsage.Id, false, new ExtendedKeyUsage(usages)); + } + + /// + /// Add the "Basic Constraints" extension. + /// + /// + /// + private void AddBasicConstraints(X509V3CertificateGenerator certificateGenerator, + bool isCertificateAuthority) + { + certificateGenerator.AddExtension( + X509Extensions.BasicConstraints.Id, true, new BasicConstraints(isCertificateAuthority)); + } + + /// + /// Add the Subject Key Identifier. + /// + /// + /// + private void AddSubjectKeyIdentifier(X509V3CertificateGenerator certificateGenerator, + AsymmetricCipherKeyPair subjectKeyPair) + { + var subjectKeyIdentifierExtension = + new SubjectKeyIdentifier( + SubjectPublicKeyInfoFactory.CreateSubjectPublicKeyInfo(subjectKeyPair.Public)); + certificateGenerator.AddExtension( + X509Extensions.SubjectKeyIdentifier.Id, false, subjectKeyIdentifierExtension); + } + + private X509Certificate2 ConvertCertificate(X509Certificate certificate, + AsymmetricCipherKeyPair subjectKeyPair, + SecureRandom random) + { + // Now to convert the Bouncy Castle certificate to a .NET certificate. + // See http://web.archive.org/web/20100504192226/http://www.fkollmann.de/v2/post/Creating-certificates-using-BouncyCastle.aspx + // ...but, basically, we create a PKCS12 store (a .PFX file) in memory, and add the public and private key to that. + var store = new Pkcs12Store(); + + // What Bouncy Castle calls "alias" is the same as what Windows terms the "friendly name". + string friendlyName = certificate.SubjectDN.ToString(); + + // Add the certificate. + var certificateEntry = new X509CertificateEntry(certificate); + store.SetCertificateEntry(friendlyName, certificateEntry); + + // Add the private key. + store.SetKeyEntry(friendlyName, new AsymmetricKeyEntry(subjectKeyPair.Private), new[] { certificateEntry }); + + // Convert it to an X509Certificate2 object by saving/loading it from a MemoryStream. + // It needs a password. Since we'll remove this later, it doesn't particularly matter what we use. + + var stream = new MemoryStream(); + store.Save(stream, CertificatePassword.ToCharArray(), random); + + var convertedCertificate = + new X509Certificate2(stream.ToArray(), + CertificatePassword, + X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable); + return convertedCertificate; + } + + public void WriteCertificate(X509Certificate2 certificate, string outputDirectory, string certName) + { + // This password is the one attached to the PFX file. Use 'null' for no password. + // Create PFX (PKCS #12) with private key + try + { + var pfx = certificate.Export(X509ContentType.Pfx, CertificatePassword); + File.WriteAllBytes($"{Path.Combine(outputDirectory, certName)}.pfx", pfx); + } + catch (Exception ex) + { + CrestronConsole.PrintLine($"Failed to write x509 cert pfx\r\n{ex.Message}"); + } + // Create Base 64 encoded CER (public key only) + using (var writer = new StreamWriter($"{Path.Combine(outputDirectory, certName)}.cer", false)) + { + try + { + var contents = $"-----BEGIN CERTIFICATE-----\r\n{Convert.ToBase64String(certificate.Export(X509ContentType.Cert), Base64FormattingOptions.InsertLineBreaks)}\r\n-----END CERTIFICATE-----"; + writer.Write(contents); + } + catch (Exception ex) + { + CrestronConsole.PrintLine($"Failed to write x509 cert cer\r\n{ex.Message}"); + } + } + } + public bool AddCertToStore(X509Certificate2 cert, System.Security.Cryptography.X509Certificates.StoreName st, System.Security.Cryptography.X509Certificates.StoreLocation sl) + { + bool bRet = false; + + try + { + var store = new System.Security.Cryptography.X509Certificates.X509Store(st, sl); + store.Open(System.Security.Cryptography.X509Certificates.OpenFlags.ReadWrite); + store.Add(cert); + + store.Close(); + bRet = true; + } + catch (Exception ex) + { + CrestronConsole.PrintLine($"AddCertToStore Failed\r\n{ex.Message}\r\n{ex.StackTrace}"); + } + + return bRet; + } + } +} \ No newline at end of file