Quite a lengthy post here with a lot of code in the hope that my experience of building an integrity-checking SSL (text-only for now) communication system will be of use to somebody else.
The way the system I have designed works is thus:
- Server sends banner
- Client sends login
- Server verifies and then either client or server is free to send commands with sequence numbers
The conditions are that the system must verify every single line of text sent via some kind of CRC (using MD5 here) and disconnect gracefully, raising an event to tell the host application so, if there is a problem.
First of all we need to define some kind of protocol between the client and server. Here's what I came up with for a skeleton.
ProtocolText.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace Shared
{
public static class ProtocolText
{
static string _banner = "AUTH";
public static string BANNER
{
get { return _banner; }
set { _banner = value; }
}
static string _positive = "YES";
public static string Positive
{
get { return ProtocolText._positive; }
set { ProtocolText._positive = value; }
}
static string _negative = "NO";
public static string Negative
{
get { return ProtocolText._negative; }
set { ProtocolText._negative = value; }
}
static string _LOGINPrefix = "LOGIN";
public static string LOGINPrefix
{
get { return ProtocolText._LOGINPrefix; }
set { ProtocolText._LOGINPrefix = value; }
}
static string _QUIT = "GOODBYE";
public static string QUIT
{
get { return ProtocolText._QUIT; }
set { ProtocolText._QUIT = value; }
}
}
}
Next up, some form of wrapping "commands" inside an API. These are actually just text, but putting them into objects has obvious usability implications.
aCommand.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace Shared.Commands
{
public enum CommandType
{
BANNER,
AUTH,
QUIT,
PREPAREINDEX
}
public enum Response
{
TERMINAL,
ERROR,
WARNING,
INFORMATION,
SUCCESS
}
public abstract class aCommand
{
long _sequenceNumber = 0;
string[] _commandText = null;
bool _hasCommandsToSend = false;
string _information = string.Empty;
List<string> _response = new List<string>();
public string Information
{
get { return _information; }
}
public long SequenceNumber
{
get
{
return _sequenceNumber;
}
set
{
_sequenceNumber = value;
}
}
public string[] CommandText
{
get
{
return _commandText;
}
}
public bool HasCommandsToSend
{
get
{
return _hasCommandsToSend;
}
set
{
_hasCommandsToSend = value;
}
}
public List<string> ResponseText
{
get { return _response; }
}
protected void setCommandText(string[] commands)
{
_hasCommandsToSend = true;
_commandText = commands;
}
protected void setInformation(string info)
{
_information = info;
}
public void AddResponse(string msg)
{
_response.Add(msg);
}
public abstract CommandType CommandType { get; }
public abstract Response ResponseDone();
}
}
Also in our shared library (this is referenced by both the client and the server) we need the CRC checker.
CRC.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Security.Cryptography;
using System.IO;
namespace Shared
{
public class CRC
{
static MD5CryptoServiceProvider cSP = new MD5CryptoServiceProvider();
public static string ComputeCRC(string message)
{
return BitConverter.ToString(cSP.ComputeHash(System.Text.ASCIIEncoding.ASCII.GetBytes(message)));
}
public static string ComputeCRC(Stream stream)
{
return BitConverter.ToString(cSP.ComputeHash(stream));
}
}
}
Now, the workhorse itself, the actual client. This is responsible for threading, SSL initiation (to some extent), CRC checking and passing message responses to the correct places.
aClient.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Net.Sockets;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
using System.Security.Cryptography;
using System.IO;
using System.Threading;
using System.Text.RegularExpressions;
using System.Diagnostics;
using Shared.Commands;
namespace Shared
{
public enum ClientEvents
{
OnAuthFailure,
OnAuthSuccess
}
public abstract class aClient
{
TcpClient _client = null;
SslStream _ssl = null;
StreamReader _reader = null;
StreamWriter _writer = null;
bool _shutdown = false;
bool _isConnecting = false;
string _server = string.Empty;
int _sequenceNumber = 0;
int _connectionAttempts = 0;
List<aCommand> _commandQueue = new List<aCommand>();
ManualResetEvent _hasMessages = new ManualResetEvent(false);
ManualResetEvent _connectingWait = new ManualResetEvent(false);
Regex msgMatcher = new Regex(@"^(\d+)\s(.+)\sCRC(.+)$");
public delegate void ClientEvent(aClient client);
public event ClientEvent OnAuthFailure;
public event ClientEvent OnAuthSuccess;
public event ClientEvent OnShutdown;
protected State _state = State.Not_Connected;
public abstract void initSSL(SslStream ssl, string hostname);
public abstract void connectionInit();
public abstract void processResponse(Response response, aCommand command);
public abstract void processNewCommand(string commandText, long sequenceNumber);
protected enum State
{
Not_Connected,
Connected,
Authenticated
}
public aClient(TcpClient client, string hostname)
{
_client = client;
_server = hostname;
}
public void Start()
{
ThreadStart ts = new ThreadStart(messageLoop);
Thread t = new Thread(ts);
t.Start();
}
protected virtual void onClientEvent(ClientEvents eventType)
{
ClientEvent handler = null;
switch (eventType)
{
case ClientEvents.OnAuthFailure:
handler = OnAuthFailure;
break;
case ClientEvents.OnAuthSuccess:
handler = OnAuthSuccess;
break;
}
if (handler != null)
{
handler(this);
}
}
private void initConnection()
{
if (_shutdown) return;
if (!_isConnecting)
{
_isConnecting = true;
_connectingWait.Reset();
try
{
_ssl = new SslStream(_client.GetStream(), false, new RemoteCertificateValidationCallback(ValidateServerCertificate));
initSSL(_ssl, _server);
_state = State.Connected;
_reader = new StreamReader(_ssl);
_writer = new StreamWriter(_ssl);
connectionInit();
_connectionAttempts = 0;
}
catch (Exception ex)
{
Trace.WriteLine(ex.Message + Environment.NewLine + ex.StackTrace);
_connectionAttempts++;
}
finally
{
_connectingWait.Set();
_isConnecting = false;
}
}
else
{
_connectingWait.WaitOne();
}
}
private void handleException(Exception ex)
{
if(ex!=null)
Trace.WriteLine(ex.Message + Environment.NewLine + ex.StackTrace);
if (_state == State.Connected)
{
if (OnAuthFailure != null) OnAuthFailure(this);
Shutdown();
}
else
{
Shutdown();
}
}
private void sendLoop()
{
while (!_shutdown)
{
IEnumerable<aCommand> changedCommands = _commandQueue.Where(delegate(aCommand tmpCommand)
{ return tmpCommand.HasCommandsToSend == true; });
foreach (aCommand clientCommand in changedCommands)
{
clientCommand.HasCommandsToSend = false;
bool setSequenceNumber = false;
if (clientCommand.SequenceNumber == 0)
{
setSequenceNumber = true;
if (_sequenceNumber == int.MaxValue)
{
}
_sequenceNumber++;
clientCommand.SequenceNumber = _sequenceNumber;
}
try
{
foreach (string commandLine in clientCommand.CommandText)
{
string msg = string.Format("{0} {1}", clientCommand.SequenceNumber, commandLine);
string crc = Shared.CRC.ComputeCRC(msg);
string msgToSend = string.Format("{0} CRC{1}", msg, crc);
_writer.WriteLine(msgToSend);
_writer.Flush();
}
string doneMsg = string.Format("{0} {1}", clientCommand.SequenceNumber, "DONE");
string doneCrc = Shared.CRC.ComputeCRC(doneMsg);
string doneMsgToSend = string.Format("{0} CRC{1}", doneMsg, doneCrc);
_writer.WriteLine(doneMsgToSend);
_writer.Flush();
}
catch (Exception ex)
{
if (setSequenceNumber) _sequenceNumber--;
handleException(ex);
}
}
if (changedCommands.Count() == 0)
{
_hasMessages.WaitOne();
_hasMessages.Reset();
}
}
}
private void messageLoop()
{
initConnection();
ThreadStart ts = new ThreadStart(sendLoop);
Thread t = new Thread(ts);
t.Start();
while (!_shutdown)
{
string msg = string.Empty;
try
{
msg = _reader.ReadLine();
if (msg == null) handleException(null);
Trace.WriteLine(msg);
}
catch (Exception ex)
{
handleException(ex);
}
if (_shutdown) break;
Match msgMatch = msgMatcher.Match(msg);
if (!msgMatch.Success || msgMatch.Groups.Count != 4)
{
handleCRCException();
}
else
{
int commandSeq = 0;
if (!int.TryParse(msgMatch.Groups[1].Captures[0].Value, out commandSeq))
{
handleCRCException();
}
else
{
string messageContents = msgMatch.Groups[2].Captures[0].Value;
string targetCRC = msgMatch.Groups[3].Captures[0].Value;
if (targetCRC != Shared.CRC.ComputeCRC(string.Format("{0} {1}", commandSeq, messageContents)))
{
handleCRCException();
}
IEnumerable<aCommand> commands = _commandQueue.Where(delegate(aCommand tmpCommand)
{ return tmpCommand.SequenceNumber == commandSeq; });
if (commands.Count() == 0)
{
processNewCommand(messageContents, commandSeq);
}
else
{
aCommand command = commands.ElementAt(0);
if (command == null)
{
handleCRCException();
}
else
{
if (messageContents.StartsWith("DONE"))
{
processResponse(command.ResponseDone(), command);
}
else
{
command.AddResponse(messageContents);
}
}
}
}
}
}
}
private void handleCRCException()
{
Trace.WriteLine("CRC error in server response. Resetting connection.");
handleException(null);
}
protected void sendCommand(aCommand command)
{
_commandQueue.Add(command);
_hasMessages.Set();
}
protected void sendCommand()
{
_hasMessages.Set();
}
private static bool ValidateServerCertificate(
object sender,
X509Certificate certificate,
X509Chain chain,
SslPolicyErrors sslPolicyErrors)
{
return true;
}
public void Shutdown()
{
_state = State.Not_Connected;
_reader.Close();
_writer.Close();
_ssl.Close();
_shutdown = true;
_connectingWait.Set();
_hasMessages.Set();
if (OnShutdown != null) OnShutdown(this);
}
}
}
So, to actually use these classes we need to implement a server object, a client object, some commands and server and client wrappers to instantiate a TcpClient and then pass it to the aClient inheritors.
ServerNetworkClient.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Shared;
using System.Security.Cryptography.X509Certificates;
using System.Net.Sockets;
using System.Diagnostics;
namespace Server
{
class ServerNetworkClient : aClient
{
X509Certificate cert = X509Certificate.CreateFromCertFile("PUTYOURCERTIFICATEHERE-USE-MAKECERT-TO-GENERATE.cer");
public ServerNetworkClient(TcpClient client) : base(client, "") { }
public override void initSSL(System.Net.Security.SslStream ssl, string hostname)
{
Trace.TraceInformation("Authenticating as server for SSL.");
ssl.AuthenticateAsServer(cert, false, System.Security.Authentication.SslProtocols.Tls, false);
}
public override void connectionInit()
{
ServerCommands.AUTHCommand authBanner = new ServerCommands.AUTHCommand("hello");
this.sendCommand(authBanner);
}
public override void processNewCommand(string messageText, long sequenceNumber)
{
}
public override void processResponse(Shared.Commands.Response response, Shared.Commands.aCommand command)
{
switch (command.CommandType)
{
case Shared.Commands.CommandType.BANNER:
handleAuth(response, command);
break;
}
}
private void handleAuth(Shared.Commands.Response response, Shared.Commands.aCommand command)
{
switch (response)
{
case Shared.Commands.Response.SUCCESS:
base.onClientEvent(ClientEvents.OnAuthSuccess);
_state = State.Authenticated;
sendCommand();
break;
default:
base.onClientEvent(ClientEvents.OnAuthFailure);
ServerCommands.QUITCommand quit = new ServerCommands.QUITCommand();
this.sendCommand(quit);
Shutdown();
break;
}
}
}
}
... and the client implementation.
ClientNetworkClient.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Net.Sockets;
using System.Diagnostics;
using Shared;
namespace Client
{
class ClientNetworkClient : aClient
{
public ClientNetworkClient(TcpClient client, string hostname) : base(client, hostname) { }
public override void initSSL(System.Net.Security.SslStream ssl, string hostname)
{
Trace.TraceInformation("Authenticating as client for SSL.");
ssl.AuthenticateAsClient(hostname, null, System.Security.Authentication.SslProtocols.Tls, false);
}
public override void connectionInit()
{
}
public override void processNewCommand(string messageText, long sequenceNumber)
{
if (messageText == ProtocolText.BANNER)
{
ClientCommands.AUTHCommand AUTH = new ClientCommands.AUTHCommand("hello");
AUTH.SequenceNumber = sequenceNumber;
sendCommand(AUTH);
return;
}
if (messageText == ProtocolText.QUIT)
{
Trace.WriteLine("Received QUIT command from server.");
Shutdown();
}
}
public override void processResponse(Shared.Commands.Response response, Shared.Commands.aCommand command)
{
switch (command.CommandType)
{
case Shared.Commands.CommandType.AUTH:
handleAuth(response, command);
break;
}
}
private void handleAuth(Shared.Commands.Response response, Shared.Commands.aCommand command)
{
switch (response)
{
case Shared.Commands.Response.INFORMATION:
Trace.WriteLine(command.Information);
break;
case Shared.Commands.Response.SUCCESS:
base.onClientEvent(ClientEvents.OnAuthSuccess);
_state = State.Authenticated;
break;
default:
base.onClientEvent(ClientEvents.OnAuthFailure);
break;
}
}
}
}
Now we need some ways to actually do stuff. In this example we therefore need Client and Server versions of the following commands: AUTH/BANNER and QUIT.
ServerAUTHCommand.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Shared.Commands;
using System.Diagnostics;
using Shared;
namespace Server.ServerCommands
{
class AUTHCommand : aCommand
{
string _bannerText = string.Empty;
string _password = string.Empty;
public AUTHCommand(string password)
{
Trace.TraceInformation("Created a server banner.");
_bannerText = ProtocolText.BANNER;
_password = password;
setCommandText(new string[] { _bannerText });
}
#region aCommand Members
public override CommandType CommandType
{
get { return CommandType.BANNER; }
}
public override Response ResponseDone()
{
if (ResponseText.Count == 0)
{
setCommandText(new string[] { ProtocolText.Positive });
return Response.TERMINAL;
}
else if (ResponseText[0] != string.Format("{0} {1}", ProtocolText.LOGINPrefix, _password))
{
setCommandText(new string[] { ProtocolText.Negative });
return Response.TERMINAL;
}
setCommandText(new string[] { ProtocolText.Positive });
return Response.SUCCESS;
}
#endregion
}
}
ServerQUITCommand.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Shared;
using Shared.Commands;
using System.Diagnostics;
namespace Server.ServerCommands
{
class QUITCommand : aCommand
{
#region aCommand Members
public QUITCommand()
{
Trace.WriteLine("Created a server QUIT command.");
this.setCommandText(new string[] { ProtocolText.QUIT });
}
public override CommandType CommandType
{
get { return CommandType.QUIT; }
}
public override Response ResponseDone()
{
if (ResponseText[ResponseText.Count - 1] == ProtocolText.Positive)
{
return Response.SUCCESS;
}
else
{
return Response.ERROR;
}
}
#endregion
}
}
ClientAUTHCommand.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Shared.Commands;
using System.Diagnostics;
using Shared;
namespace Client.ClientCommands
{
class AUTHCommand: aCommand
{
enum AUTHState
{
AUTH,
Response
}
string _password = string.Empty;
AUTHState _state = AUTHState.AUTH;
#region aClientCommand Members
public override CommandType CommandType
{
get { return CommandType.AUTH; }
}
public override Response ResponseDone()
{
if (_state == AUTHState.AUTH)
{
_state = AUTHState.Response;
setInformation("Client was in the AUTH phase and as yet has no response.");
return Response.INFORMATION;
}
if (ResponseText[ResponseText.Count - 1] == ProtocolText.Positive)
{
return Response.SUCCESS;
}
else
{
return Response.TERMINAL;
}
}
#endregion
public AUTHCommand(string password)
{
Trace.TraceInformation("Created a client AUTH response.");
_password = password;
this.setCommandText(new string[] { string.Format("{0} {1}", ProtocolText.LOGINPrefix, _password) });
}
}
}
Right, now the final two components - server and client wrappers to create those pesky TcpClients!
ClientWrapper.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Net.Sockets;
using Shared;
namespace Client
{
class ClientWrapper
{
public delegate void ClientConnectAttempt(aClient client);
public event ClientConnectAttempt OnClientConnectAttemptComplete;
string _host = string.Empty;
int _port = 0;
TcpClient _client = null;
public ClientWrapper(string host, int port)
{
_host = host;
_port = port;
_client = new TcpClient();
_client.BeginConnect(host, port, new AsyncCallback(beginConnectTcpClientCallback), _client);
}
private void beginConnectTcpClientCallback(IAsyncResult ar)
{
TcpClient client = (TcpClient)ar.AsyncState;
lock (this)
{
aClient cnc = null;
if (client.Connected)
{
cnc = new ClientNetworkClient(client, _host);
}
if (OnClientConnectAttemptComplete != null)
{
OnClientConnectAttemptComplete(cnc);
}
}
}
}
}
Server.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Net.Sockets;
using System.Threading;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
using Shared;
using System.Diagnostics;
namespace Server
{
class Server
{
ManualResetEvent _tcpClientConnected = new ManualResetEvent(false);
TcpListener _listener = null;
bool _shutdown = false;
List<aClient> _clients = new List<aClient>();
public Server(int port)
{
_listener = new TcpListener(System.Net.IPAddress.Any, port);
_listener.Start();
while (!_shutdown)
{
DoBeginAcceptTcpClient(_listener);
}
_listener.Stop();
}
public void Shutdown()
{
_shutdown = true;
_tcpClientConnected.Set();
}
private void DoBeginAcceptTcpClient(TcpListener
listener)
{
_tcpClientConnected.Reset();
System.Diagnostics.Trace.TraceInformation("Waiting for a connection...");
listener.BeginAcceptTcpClient(
new AsyncCallback(DoAcceptTcpClientCallback),
listener);
_tcpClientConnected.WaitOne();
}
private void DoAcceptTcpClientCallback(IAsyncResult ar)
{
TcpListener listener = (TcpListener)ar.AsyncState;
TcpClient client = listener.EndAcceptTcpClient(ar);
System.Diagnostics.Trace.TraceInformation("Client connected completed");
ServerNetworkClient nc = new ServerNetworkClient(client);
nc.OnAuthFailure += new aClient.ClientEvent(nc_OnAuthFailure);
nc.OnAuthSuccess += new aClient.ClientEvent(nc_OnAuthSuccess);
_clients.Add(nc);
nc.Start();
_tcpClientConnected.Set();
}
void nc_OnAuthSuccess(aClient client)
{
Trace.TraceInformation("Succesfully logged in.");
}
void nc_OnAuthFailure(aClient client)
{
Trace.TraceInformation("Login failure.");
_clients.Remove(client);
}
}
}
So, the final things you need to get up and running are examples of a client and server Program.cs to actually start the processes.
ClientProgram.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Shared;
namespace Client
{
class Program
{
static aClient _client = null;
static void Main(string[] args)
{
System.Diagnostics.Trace.TraceInformation("Client");
Console.WriteLine("Sleeping for 2 seconds to let server start.");
System.Threading.Thread.Sleep(2000);
ClientWrapper cw = new ClientWrapper("localhost", 9000);
cw.OnClientConnectAttemptComplete += new ClientWrapper.ClientConnectAttempt(cw_OnClientConnectAttemptComplete);
Console.WriteLine("Running. Press any key to end.");
Console.ReadKey();
}
static void cw_OnClientConnectAttemptComplete(aClient client)
{
if (client != null)
{
client.OnAuthFailure += new aClient.ClientEvent(c_OnAuthFailure);
client.OnAuthSuccess += new aClient.ClientEvent(c_OnAuthSuccess);
client.Start();
_client = client;
}
else
{
Console.WriteLine("Client timed out.");
}
}
static void c_OnAuthSuccess(aClient client)
{
System.Diagnostics.Trace.TraceInformation("Client logged in.");
System.Threading.Thread.Sleep(1000);
client.Shutdown();
}
static void c_OnAuthFailure(aClient client)
{
System.Diagnostics.Trace.TraceInformation("Client login failure.");
}
}
}
ServerProgram.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.IO;
using System.Windows.Forms;
namespace Server
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Server has started.");
Server s = new Server(9000);
Console.WriteLine("Server has ended.");
Console.ReadKey();
return;
}
}
}
And there you have it - CRC verified, SSL enabled communications! My next update is for binary data transfer.