diff --git a/ProtoSocket.sln b/ProtoSocket.sln index 751bf4f..ff68264 100644 --- a/ProtoSocket.sln +++ b/ProtoSocket.sln @@ -16,7 +16,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Example.Chat", "samples\Exa EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Example.Minecraft", "samples\Example.Minecraft\Example.Minecraft.csproj", "{D9E913D0-B075-489A-96AE-7389C86C790E}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example.Line", "samples\Example.Line\Example.Line.csproj", "{5C1CEDDA-43A7-4501-BF28-E4A8BC142574}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Example.Line", "samples\Example.Line\Example.Line.csproj", "{5C1CEDDA-43A7-4501-BF28-E4A8BC142574}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example.Ssl", "samples\Example.Ssl\Example.Ssl.csproj", "{8387CAE0-ADDC-4B06-BB97-17190D788EFE}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -40,6 +42,10 @@ Global {5C1CEDDA-43A7-4501-BF28-E4A8BC142574}.Debug|Any CPU.Build.0 = Debug|Any CPU {5C1CEDDA-43A7-4501-BF28-E4A8BC142574}.Release|Any CPU.ActiveCfg = Release|Any CPU {5C1CEDDA-43A7-4501-BF28-E4A8BC142574}.Release|Any CPU.Build.0 = Release|Any CPU + {8387CAE0-ADDC-4B06-BB97-17190D788EFE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8387CAE0-ADDC-4B06-BB97-17190D788EFE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8387CAE0-ADDC-4B06-BB97-17190D788EFE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8387CAE0-ADDC-4B06-BB97-17190D788EFE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -48,6 +54,7 @@ Global {3244FC3C-5703-4B56-A754-CC6D7DB87039} = {232DEEE3-098B-46F0-803A-591781CEB430} {D9E913D0-B075-489A-96AE-7389C86C790E} = {232DEEE3-098B-46F0-803A-591781CEB430} {5C1CEDDA-43A7-4501-BF28-E4A8BC142574} = {232DEEE3-098B-46F0-803A-591781CEB430} + {8387CAE0-ADDC-4B06-BB97-17190D788EFE} = {232DEEE3-098B-46F0-803A-591781CEB430} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {056A9443-6614-4870-A4BA-EE86AF7BF8C8} diff --git a/README.md b/README.md index 7d2e9ad..a74fd04 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,8 @@ await client.UpgradeAsync(upgrader); client.Mode = ProtocolMode.Active; ``` +You can find an example of using SSL [here](samples/Example.Ssl). + ### Statistics In the newer versions of ProtoSocket you can request network statistics from the peer without dynamic allocation. diff --git a/docs/img/example_ssl.png b/docs/img/example_ssl.png new file mode 100644 index 0000000..a15cf7b Binary files /dev/null and b/docs/img/example_ssl.png differ diff --git a/samples/Example.Ssl/Example.Ssl.csproj b/samples/Example.Ssl/Example.Ssl.csproj new file mode 100644 index 0000000..7f56308 --- /dev/null +++ b/samples/Example.Ssl/Example.Ssl.csproj @@ -0,0 +1,12 @@ + + + + Exe + netcoreapp2.2 + + + + + + + diff --git a/samples/Example.Ssl/Program.cs b/samples/Example.Ssl/Program.cs new file mode 100644 index 0000000..0e35945 --- /dev/null +++ b/samples/Example.Ssl/Program.cs @@ -0,0 +1,74 @@ +using System; +using System.Security.Cryptography.X509Certificates; +using System.Threading.Tasks; + +namespace Example.Ssl +{ + class Program + { + static Task Main(string[] args) + { + Server(); + return ClientAsync(); + } + + static void Server() + { + // configure the server + SslServer server = new SslServer(new X509Certificate2("ssl.p12")); + server.Configure("tcp://127.0.0.1:3001"); + + server.Connected += (o, e) => { + Console.WriteLine($"srv:{e.Peer.RemoteEndPoint}: connected"); + }; + + server.Disconnected += (o, e) => { + Console.WriteLine($"srv:{e.Peer.RemoteEndPoint}: disconnected"); + }; + + // start the server + server.Start(); + } + + static async Task ClientAsync() + { + // try and connect three times, on the third time we will show an error + SslClient client = null; + + for (int i = 0; i < 3; i++) { + client = new SslClient(); + + try { + await client.ConnectAsync(new Uri("tcp://127.0.0.1:3001")) + .ConfigureAwait(false); + break; + } catch(Exception ex) { + if (i == 2) { + Console.Error.WriteLine($"client:{ex.ToString()}"); + return; + } else { + await Task.Delay(1000) + .ConfigureAwait(false); + } + } + } + + // show a basic read line prompt, sending every frame to the server once enter is pressed + string line = null; + + do { + // read a line of data + Console.Write("> "); + line = (await Console.In.ReadLineAsync().ConfigureAwait(false)).Trim(); + + // send + await client.SendAsync(line) + .ConfigureAwait(false); + + // wait for reply + await client.ReceiveAsync() + .ConfigureAwait(false); + } while (!line.Equals("exit", StringComparison.OrdinalIgnoreCase)); + } + } +} diff --git a/samples/Example.Ssl/README.md b/samples/Example.Ssl/README.md new file mode 100644 index 0000000..b59cf6c --- /dev/null +++ b/samples/Example.Ssl/README.md @@ -0,0 +1,26 @@ +# SSL Example + +This example shows how to use SSL to secure your network traffic, this example does not verify that the certificate is signed by a CA but this is trivial to add. + +The example also provides a good `IProtocolCoder` implementation which can be extended to support any length-prefixed frame based protocol. + +Note that this example is not production ready, it does not perform any clientside validation that the certificate is trusted. Infact is specifically ignores any validation and will accept any certificate, some familiarity with `SslStream` will help translate to using `SslUpgrader` effectively. + +### Generating a certificate + +The example expects an X509 container named `ssl.p12` in the working directory when running, you can generate a (unsecured) p12 container using the following commands. This container must hold the private key as well as the public key, secured with no password. + +``` +openssl req -newkey rsa:2048 -nodes -keyout key.pem -x509 -days 365 -out ssl.pem +openssl pkcs12 -inkey key.pem -in ssl.pem -export -out ssl.p12 +``` + +#### Notes + +In order to upgrade your `ProtocolPeer`, you will need to explicitly configure the peer to use `ProtocolMode.Passive`. Otherwise the peer will start reading SSL layer frames as soon as the connection has been made, this is intended behaviour when `ProtocolMode.Active` is set but causes issues when you need to handoff negociation to an external library. + +## Usage + +Once a valid certificate has been generated you can type chat messages to the server, which will print them out. + + ![Example Ssl](../../docs/img/example_ssl.png) \ No newline at end of file diff --git a/samples/Example.Ssl/SslClient.cs b/samples/Example.Ssl/SslClient.cs new file mode 100644 index 0000000..18c9724 --- /dev/null +++ b/samples/Example.Ssl/SslClient.cs @@ -0,0 +1,38 @@ +using ProtoSocket; +using ProtoSocket.Upgraders; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +namespace Example.Ssl +{ + public class SslClient : ProtocolClient + { + public override async Task ConnectAsync(Uri uri) + { + // connect to the server + await base.ConnectAsync(uri).ConfigureAwait(false); + + // upgrade, this isn't setup to verify trust correctly and will blindly accept any certificate + // DO NOT USE IN PRODUCTION + try { + SslUpgrader upgrader = new SslUpgrader(uri.Host); + upgrader.RemoteValidationCallback = (o, crt, cert, sse) => { + return true; + }; + + await UpgradeAsync(upgrader).ConfigureAwait(false); + } catch(Exception) { + Dispose(); + throw; + } + + // enable active mode so frames start being read by ProtoSocket + Mode = ProtocolMode.Active; + } + + public SslClient() : base(new SslCoder(), new PeerConfiguration(ProtocolMode.Passive)) { + } + } +} diff --git a/samples/Example.Ssl/SslCoder.cs b/samples/Example.Ssl/SslCoder.cs new file mode 100644 index 0000000..3f8ddb1 --- /dev/null +++ b/samples/Example.Ssl/SslCoder.cs @@ -0,0 +1,130 @@ +using ProtoSocket; +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.IO.Pipelines; +using System.Text; + +namespace Example.Ssl +{ + public class SslCoder : IProtocolCoder + { + private byte[] _bytes; + private int _bytesLength; + private int _bytesOffset; + private State _state; + + public bool Read(PipeReader reader, CoderContext ctx, out string frame) + { + if (reader.TryRead(out ReadResult result) && !result.IsCompleted) { + // get the sequence buffer + ReadOnlySequence buffer = result.Buffer; + + try { + while (buffer.Length > 0) { + if (_state == State.Size) { + if (buffer.Length >= 2) { + // copy length from buffer + Span lengthBytes = stackalloc byte[2]; + + buffer.Slice(0, 2) + .CopyTo(lengthBytes); + + int length = BitConverter.ToUInt16(lengthBytes); + + if (length > 32768) + throw new ProtocolCoderException("The client sent an invalid frame length"); + + // increment the amount we were able to copy in + buffer = buffer.Slice(2); + + // move state to content if we have a message with data + if (length > 0) { + _bytes = ArrayPool.Shared.Rent(length); + _bytesLength = length; + _bytesOffset = 0; + _state = State.Content; + } else { + frame = string.Empty; + return true; + } + } else { + break; + } + } else if (_state == State.Content) { + if (buffer.Length >= 1) { + // figure out how much data we can read, and how much we actually have to read + int remainingBytes = _bytesLength - _bytesOffset; + int maxBytes = Math.Min((int)buffer.Length, remainingBytes); + + // copy into buffer + buffer.Slice(0, maxBytes) + .CopyTo(_bytes.AsSpan(_bytesOffset, maxBytes)); + + // increment offset by amount we copied + _bytesOffset += maxBytes; + buffer = buffer.Slice(maxBytes); + + // if we have filled the content array we can now produce a frame + if (_bytesOffset == _bytesLength) { + try { + frame = Encoding.UTF8.GetString(_bytes); + _state = State.Size; + return true; + } finally { + ArrayPool.Shared.Return(_bytes); + _bytes = null; + } + } + } + } + } + } finally { + reader.AdvanceTo(buffer.GetPosition(0), buffer.End); + } + } + + // we didn't find a frame + frame = default; + return false; + } + + public void Reset() + { + throw new NotSupportedException(); + } + + public void Write(Stream stream, string frame, CoderContext ctx) + { + // encode the frame into a UTF8 byte array + byte[] frameBytes = Encoding.UTF8.GetBytes(frame); + + if (frameBytes.Length > 32768) + throw new ProtocolCoderException("The frame is too large to write"); + + // encode the length + byte[] lengthBytes = BitConverter.GetBytes((ushort)frame.Length); + + // write to stream + stream.Write(lengthBytes); + stream.Write(frameBytes); + } + + /// + /// Defines the states for this coder. + /// + enum State + { + Size, + Content + } + + ~SslCoder() + { + // return the array back to the pool if we deconstruct before finishing the entire frame + if (_bytes != null) + ArrayPool.Shared.Return(_bytes); + } + } +} diff --git a/samples/Example.Ssl/SslConnection.cs b/samples/Example.Ssl/SslConnection.cs new file mode 100644 index 0000000..314196b --- /dev/null +++ b/samples/Example.Ssl/SslConnection.cs @@ -0,0 +1,47 @@ +using ProtoSocket; +using ProtoSocket.Upgraders; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +namespace Example.Ssl +{ + public class SslConnection : ProtocolConnection + { + protected async override void OnConnected(PeerConnectedEventArgs e) + { + // call connected, we can't upgrade until the peer has been marked as connected + base.OnConnected(e); + + // upgrade, if an error occurs log + try { + SslUpgrader upgrader = new SslUpgrader(((SslServer)Server).Certificate); + + await UpgradeAsync(upgrader); + } catch(Exception ex) { + Console.Error.WriteLine($"err:{e.Peer.RemoteEndPoint}: failed to upgrade SSL: {ex.ToString()}"); + return; + } + + // enable active mode so frames start being read by ProtoSocket + Mode = ProtocolMode.Active; + } + + protected override bool OnReceived(PeerReceivedEventArgs e) + { + // log message + Console.WriteLine($"msg:{e.Peer.RemoteEndPoint}: {e.Frame}"); + + // send an empty frame reply, we send as a fire and forget for the purposes of simplicity + // any exception will be lost to the ether + Task _ = SendAsync(string.Empty); + + // indicates that we observed this frame, it will still call Notify/etc and other handlers but it won't add to the receive queue + return true; + } + + public SslConnection(ProtocolServer server, ProtocolCoderFactory coderFactory, PeerConfiguration configuration = null) : base(server, coderFactory, configuration) { + } + } +} diff --git a/samples/Example.Ssl/SslServer.cs b/samples/Example.Ssl/SslServer.cs new file mode 100644 index 0000000..dc1dff7 --- /dev/null +++ b/samples/Example.Ssl/SslServer.cs @@ -0,0 +1,23 @@ +using ProtoSocket; +using System; +using System.Collections.Generic; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace Example.Ssl +{ + public class SslServer : ProtocolServer + { + private X509Certificate2 _cert; + + internal X509Certificate2 Certificate { + get { + return _cert; + } + } + + public SslServer(X509Certificate2 cert) : base(p => new SslCoder(), new PeerConfiguration(ProtocolMode.Passive)) { + _cert = cert; + } + } +} diff --git a/src/ProtoSocket/PeerConfiguration.cs b/src/ProtoSocket/PeerConfiguration.cs index 47b9e6d..668b4cf 100644 --- a/src/ProtoSocket/PeerConfiguration.cs +++ b/src/ProtoSocket/PeerConfiguration.cs @@ -42,7 +42,7 @@ public sealed class PeerConfiguration /// The buffer size. public PeerConfiguration(ProtocolMode protocolMode = ProtocolMode.Active, int bufferSize = 8192) { BufferSize = bufferSize; - Mode = ProtocolMode.Active; + Mode = protocolMode; } } } diff --git a/src/ProtoSocket/ProtoSocket.csproj b/src/ProtoSocket/ProtoSocket.csproj index 5b7fdb5..6fd49ff 100644 --- a/src/ProtoSocket/ProtoSocket.csproj +++ b/src/ProtoSocket/ProtoSocket.csproj @@ -4,7 +4,7 @@ netstandard1.3;net46 Alan Doherty & WIFIPLUG Ltd Alan Doherty - 0.5.4 + 0.6.0 true A networking library for frame-based, performant asynchronous sockets on .NET Core Alan Doherty 2018 @@ -13,8 +13,8 @@ https://s3-eu-west-1.amazonaws.com/assets.alandoherty.co.uk/github/protosocket-net-nuget.png s git - 0.5.4.0 - 0.5.4.0 + 0.6.0.0 + 0.6.0.0 diff --git a/src/ProtoSocket/ProtocolPeer.cs b/src/ProtoSocket/ProtocolPeer.cs index 85e9ca3..8fb7cc5 100644 --- a/src/ProtoSocket/ProtocolPeer.cs +++ b/src/ProtoSocket/ProtocolPeer.cs @@ -831,21 +831,9 @@ public async Task UpgradeAsync(IProtocolUpgrader upgrader) { throw new InvalidOperationException("The peer is already upgrading"); else if (_state != ProtocolState.Connected) throw new InvalidOperationException("The peer must be connected to upgrade"); + else if (_mode != ProtocolMode.Passive) + throw new InvalidOperationException("The peer must be in passive mode to upgrade"); - // raw upgrade - await UpgradeRawAsync(upgrader).ConfigureAwait(false); - - // restart read loop - _state = ProtocolState.Connected; - ReadLoop(); - } - - /// - /// Upgrades the peer with the provided upgrader. Does not check/change the state or restart the read loop. - /// - /// The upgrader. - /// - private async Task UpgradeRawAsync(IProtocolUpgrader upgrader) { // update state OnStateChanged(new PeerStateChangedEventArgs() { OldState = _state, @@ -868,9 +856,7 @@ private async Task UpgradeRawAsync(IProtocolUpgrader upgrader) { NewState = ProtocolState.Connected }); - // create new read cancel source and restart loop - _readCancelSource = new CancellationTokenSource(); - _readDisposeCancelSource = CancellationTokenSource.CreateLinkedTokenSource(_readCancelSource.Token, _disposeCancelSource.Token); + _state = ProtocolState.Connected; } /// diff --git a/src/ProtoSocket/Upgraders/SslUpgrader.cs b/src/ProtoSocket/Upgraders/SslUpgrader.cs index c73eecb..da820d6 100644 --- a/src/ProtoSocket/Upgraders/SslUpgrader.cs +++ b/src/ProtoSocket/Upgraders/SslUpgrader.cs @@ -16,12 +16,25 @@ public class SslUpgrader : IProtocolUpgrader { #region Fields private X509Certificate2 _cert; - private bool _clientCertRequired; private string _targetHost; - private SslProtocols _protocols = SslProtocols.Tls | SslProtocols.Tls11 | SslProtocols.Tls12; #endregion #region Properties + /// + /// Gets or sets if the certificate should be verified for revocation. + /// + public bool CheckCertificateRevocation { get; set; } = true; + + /// + /// Gets or sets the callback which is called to verify a remote certificate. + /// + public RemoteCertificateValidationCallback RemoteValidationCallback { get; set; } = null; + + /// + /// Gets or sets the callback which is called to select a local certificate. + /// + public LocalCertificateSelectionCallback LocalSelectionCallback { get; set; } = null; + /// /// Gets the certificate. /// @@ -43,24 +56,12 @@ public string TargetHost { /// /// Gets or sets if the client certificate is required. /// - public bool ClientCertificateRequired { - get { - return _clientCertRequired; - } set { - _clientCertRequired = value; - } - } + public bool ClientCertificateRequired { get; set; } = false; /// /// Gets or sets the protocols. /// - public SslProtocols Protocols { - get { - return _protocols; - } set { - _protocols = value; - } - } + public SslProtocols Protocols { get; set; } = SslProtocols.Tls | SslProtocols.Tls11 | SslProtocols.Tls12; #endregion #region Methods @@ -78,12 +79,12 @@ public async Task UpgradeAsync(Stream stream, IProtocolPeer peer) { throw new InvalidOperationException("The client connection cannot upgrade to SSL without a target hostname"); // authenticate - SslStream sslStream = new SslStream(stream, true); + SslStream sslStream = new SslStream(stream, true, RemoteValidationCallback, LocalSelectionCallback); if (peer.Side == ProtocolSide.Server) - await sslStream.AuthenticateAsServerAsync(_cert, _clientCertRequired, _protocols, true).ConfigureAwait(false); + await sslStream.AuthenticateAsServerAsync(_cert, ClientCertificateRequired, Protocols, CheckCertificateRevocation).ConfigureAwait(false); else - await sslStream.AuthenticateAsClientAsync(_targetHost, new X509CertificateCollection(), _protocols, true).ConfigureAwait(false); + await sslStream.AuthenticateAsClientAsync(_targetHost, new X509CertificateCollection(), Protocols, CheckCertificateRevocation).ConfigureAwait(false); return sslStream; } @@ -107,4 +108,4 @@ public SslUpgrader(X509Certificate2 cert) { } #endregion } -} +} \ No newline at end of file