Click here to Skip to main content
15,879,348 members
Articles / Programming Languages / C#

Creating an Asymmetric/Symmetric Secure Stream without SSL

Rate me:
Please Sign up or sign in to vote.
4.93/5 (29 votes)
24 Oct 2009CPOL7 min read 69.6K   1.1K   73   25
The article explains how to create a secure stream that uses asymmetric cryptography to connect and symmetric cryptography to continue without the need for SSL or Certificates

Background

Many times, I created TCP/IP based programs, but without using any special security for them. The problem appeared when I created a chat program and wanted to send login/password and also avoid someone "sniffing the network" to see this information or even the conversation.
I used SslStream. It works, but acquiring the certificates is the problem and, to be honest, my only problem was about the "sniffing", not about checking if the server is REALLY the server it should be, or if the client is the real client. I simply accepted all certificates, I needed the cryptography.

The Solution

Well, I soon looked for the solution without the certificates. I knew that SSL uses an asymmetric key to connect, and then creates a symmetric key to continue its communication. Looking at the System.Security.Cryptography I saw the CryptoStream. I thought it was the solution (at least for the symmetric part), but it wasn't. The CryptoStream is unidirectional (so it is not OK for TCP/IP), its Flush doesn't flush the stream and, if I use FlushFinalBlock, I must dispose the stream. So, I decided to look at the Symmetric and Asymmetric algorithms directly.

After some study, I created a solution. Maybe not the fastest one, but it works.
During initialization, the server creates an asymmetric key and sends the public part to the client. The client then creates an symmetric key (at the moment only he knows the key) and encrypts it using the server public key (so, only the server knows how to decrypt it). It sends the key to the server, and then, only this symmetric key is used.

Simple, but as the asymmetric and the symmetric key are created during connection, there is no chance of someone else also knowing the keys. And, as the symmetric key is sent using the cryptography that only the server knows how to decrypt, even someone sniffing the network with a good cryptography knowledge will not have anything to do.
So, let's see the implementation.

C#
public SecureStream(Stream baseStream, RSACryptoServiceProvider rsa, 
	SymmetricAlgorithm symmetricAlgorithm, bool runAsServer)
{
  if (baseStream == null)
    throw new ArgumentNullException("baseStream");
  
  if (rsa == null)
    throw new ArgumentNullException("rsa");
    
  if (symmetricAlgorithm == null)
    throw new ArgumentNullException("symmetricAlgorithm");

  BaseStream = baseStream;
  SymmetricAlgorithm = symmetricAlgorithm;

  string symmetricTypeName = symmetricAlgorithm.GetType().ToString();
  byte[] symmetricTypeBytes = Encoding.UTF8.GetBytes(symmetricTypeName);
  if (runAsServer)
  {
    byte[] sizeBytes = BitConverter.GetBytes(symmetricTypeBytes.Length);
    baseStream.Write(sizeBytes, 0, sizeBytes.Length);
    baseStream.Write(symmetricTypeBytes, 0, symmetricTypeBytes.Length);
  
    byte[] bytes = rsa.ExportCspBlob(false);
    sizeBytes = BitConverter.GetBytes(bytes.Length);
    baseStream.Write(sizeBytes, 0, sizeBytes.Length);
    baseStream.Write(bytes, 0, bytes.Length);
    
    symmetricAlgorithm.Key = p_ReadWithLength(rsa);;
    symmetricAlgorithm.IV = p_ReadWithLength(rsa);
  }
  else
  {
    // ok. We run as the client, so first we first check the
    // algorithm types and then receive the asymmetric
    // key from the server.
    
    // symmetricAlgorithm
    var sizeBytes = new byte[4];
    p_FullReadDirect(sizeBytes);
    var stringLength = BitConverter.ToInt32(sizeBytes, 0);
    
    if (stringLength != symmetricTypeBytes.Length)
      throw new ArgumentException
	("Server and client must use the same SymmetricAlgorithm class.");
    
    var stringBytes = new byte[stringLength];
    p_FullReadDirect(stringBytes);
    var str = Encoding.UTF8.GetString(stringBytes);
    if (str != symmetricTypeName)
      throw new ArgumentException
	("Server and client must use the same SymmetricAlgorithm class.");

    // public key.
    sizeBytes = new byte[4];
    p_FullReadDirect(sizeBytes);
    int asymmetricKeyLength = BitConverter.ToInt32(sizeBytes, 0);
    byte[] bytes = new byte[asymmetricKeyLength];
    p_FullReadDirect(bytes);
    rsa.ImportCspBlob(bytes);
    
    // Now that we have the asymmetricAlgorithm set, and considering
    // that the symmetricAlgorithm initializes automatically, we must
    // only send the key.
    p_WriteWithLength(rsa, symmetricAlgorithm.Key);
    p_WriteWithLength(rsa, symmetricAlgorithm.IV);
  }
      
  // After the object initialization being done, be it a client or a
  // server, we can dispose the assymetricAlgorithm.
  rsa.Clear();
  
  Decryptor = symmetricAlgorithm.CreateDecryptor();
  Encryptor = symmetricAlgorithm.CreateEncryptor();
  
  fReadBuffer = new byte[0];
  fWriteBuffer = new MemoryStream(32 * 1024);
}

The constructor is large, but I will explain the key parts. It was created to be able to receive an already created and initialized RSA asymmetric algorithm and asymmetric algorithm of the users' choice. It has other constructors to initialize these objects with simple new RSACyptoServiceProvider and SymmetricAlgorithm.Create. After checking for null arguments and setting BaseStream and SymmetricAlgorithm properties, it must decide if it will run as a server or as a client. I will begin with the server, as it is the server that initializes the process.

The Server

The server sends the name of the symmetric algorithm being used, which will be used by the client to check for compatibility, exports the public RSA key it generated and also sends it to the client and, finally, reads the symmetric key and initialization vector that will be sent by the client.

The Client

The client does the reverse process of the server. So, it first receives the algorithm named used by the server. If the length of the algorithm name or the name itself don't match, it throws an exception. Later, it receives the RSA key used by the server and sends the Key and Initialization Vector of its symmetric algorithm.

Ok, I didn't show the encryption with the RSA key, but it is done by the p_ReadWithLength and p_WriteWithLength, that I will show later. Only to finish the constructor, it clears the RSA key, creates the symmetric encryptor and decryptor and initializes the buffers.

Let's see the Asymmetric cryptography:

C#
private byte[] p_ReadWithLength(RSACryptoServiceProvider rsa)
{
  byte[] size = new byte[4];
  p_FullReadDirect(size);

  int count = BitConverter.ToInt32(size, 0);
  var bytes = new byte[count];
  int read = 0;
  while(read < count)
  {
    int readResult = BaseStream.Read(bytes, read, count - read);
    if (readResult == 0)
      throw new IOException("The stream was closed by the remote side.");
    
    read += readResult;
  }
  
  return rsa.Decrypt(bytes, false);
}
private void p_WriteWithLength(RSACryptoServiceProvider rsa, byte[] bytes)
{
  bytes = rsa.Encrypt(bytes, false);
  byte[] sizeBytes = BitConverter.GetBytes(bytes.Length);
  BaseStream.Write(sizeBytes, 0, sizeBytes.Length);
  BaseStream.Write(bytes, 0, bytes.Length);
}

The Write encrypts the message, and then sends the size of the encrypted message and the message itself.

The Read reads the size, then reads the message and to finish decrypts and returns the decrypted message. But, wait, why I use "BaseStream.Write" and p_FullReadDirect? Why not BaseStream.Read?

I am really thinking about making this an extension method. If you look at how Read and Write works, you will notice the difference. Write simply writes all the requested buffers or throws an exception. Read is more problematic, as you can ask for 1024 bytes, and it returns 3, because it read only 3 bytes. But I don't expect this to happen, I want the full block, even if I need to call read many times. So, p_FullReadDirect looks like this:

C#
private void p_FullReadDirect(byte[] bytes, int length)
{
  int read = 0;
  while(read < length)
  {
    int readResult = BaseStream.Read(bytes, read, length - read);
    
    if (readResult == 0)
      throw new IOException("The stream was closed by the remote side.");
    
    read += readResult;
  }
}

Ok. The initialization is done. At this moment, we can say that we have the fully implemented constructor, we already used the RSA algorithm (the asymmetric) and now we only need to care about the real streaming.

So, let's first understand the Encryptor and Decryptor. At least, the part I understood:
The Encryptor and the Decryptor are ICryptoTransform. In it, we have the TransformBlock and TransformFinalBlock. I really considered using TransformBlock, but in my tests, I encrypt a block and try to decrypt it, and nothing happens. If I join the blocks and at the end call TransformFinalBlock, I get the wrong result, so I decided to use only TransformFinalBlock, that alone works fine.

The problem is that at each "final encryption", I can end-up with an extra size in the message. So, instead of encrypting each write, I buffer all of them in a memory stream and, during Flush, I encrypt all of the writes and send them.

So:

C#
public override void Write(byte[] buffer, int offset, int count)
{
  fWriteBuffer.Write(buffer, offset, count);
}
public override void Flush()
{
  if (fWriteBuffer.Length > 0)
  {
    var encryptedBuffer = Encryptor.TransformFinalBlock
		(fWriteBuffer.GetBuffer(), 0, (int)fWriteBuffer.Length);
    var size = BitConverter.GetBytes(encryptedBuffer.Length);
    BaseStream.Write(size, 0, size.Length);
    BaseStream.Write(encryptedBuffer, 0, encryptedBuffer.Length);
    BaseStream.Flush();
    
    fWriteBuffer.SetLength(0);
    fWriteBuffer.Capacity = 32 * 1024;
  }
}

I always reset the buffer capacity to 32K, as in one single moment we can send 1MB of information but, after, we continue with 32KB. I could make it configurable, but it will not change the real important thing here:
I first group all the buffers, which is not the problem. But, when I send it, I must also send the encrypted size, as to Decrypt with TransformFinalBlock we need the full block to be loaded. The decryption itself is not very complicated, but the read method is.

Why?
Because the remote side sends 1MB of data. To decrypt, I must read the 1MB of data and decrypt. But, the calling code only wants to read 4 bytes. I can't simply discard the rest of the buffer, I must read the part of the requested buffer and update the internal position, so the next read can read another part of the buffer. Also, we can have a buffer of 16 bytes and a read of 1024 bytes but, in this case, we use the Read behavior of returning that one 16 was read.

So, let's see:

C#
public override int Read(byte[] buffer, int offset, int count)
{
  if (fReadPosition == fReadBuffer.Length)
  {
    p_ReadDirect(fSizeBytes);
    int readLength = BitConverter.ToInt32(fSizeBytes, 0);
    
    if (fReadBuffer.Length < readLength)
      fReadBuffer = new byte[readLength];
      
    p_FullReadDirect(fReadBuffer, readLength);
    fReadBuffer = Decryptor.TransformFinalBlock(fReadBuffer, 0, readLength);
    
    fReadPosition = 0;
  }
  
  int diff = fReadBuffer.Length - fReadPosition;
  if (count > diff)
    count = diff;
  
  Buffer.BlockCopy(fReadBuffer, fReadPosition, buffer, offset, count);
  fReadPosition += count;
  return count;
}

When we do the first read, we are at position 0, and the readbuffer has size 0, so we enter the if. We will read the message size, create a new readbuffer if ours does not have enough length and then will "fullread" the messagesize. With this, we have the full encrypted buffer, so decrypt it to the readbuffer variable and tell that we are at the beginning of it.

Lefting the if block, we will execute check if the read wants to read more bytes than we still have. If that's the case, we reduce the count variable. Then, we simply "BlockCopy" the block we want, update the ReadPosition and return the number of bytes read (the count).

Well, that's it. If we read "byte by byte", we will keep incrementing ReadPosition while we still have a valid buffer in memory. As soon as it ends, we will receive the next one. And that's all. The stream is already working.

Usage

The usage is very simple. When you get a TCP/IP connection, you will probably already use the GetStream() to read and write to the connection. You will only need to create a new SecureStream over the TCP/IP stream and tell if it is the client or the server, and everything is done.
So, a little example, an encrypted "echo port".

C#
using System.IO;
using System.Net.Sockets;
using System.Threading;
using Pfz.Remoting;

namespace Server
{
  class Program
  {
    static void Main(string[] args)
    {
      var listener = new TcpListener(657);
      listener.Start();
      
      while(true)
      {
        TcpClient client = listener.AcceptTcpClient();
        Thread thread = new Thread(p_ClientConnected);
        thread.Start(client);
      }
    }
    private static void p_ClientConnected(object data)
    {
      try
      {
        using(TcpClient client = (TcpClient)data)
        {
          // if you don't want to encrypt the data, set stream to baseStream
          // directly.
          var baseStream = client.GetStream();
          var stream = new SecureStream(baseStream, true);
          using(var reader = new StreamReader(stream))
          {
            var writer = new StreamWriter(stream);
            
            while(true)
            {
              string line = reader.ReadLine();
              if (line == null)
                return;
              
              writer.WriteLine(line);
              writer.Flush();
            }
          }
        }
      }
      catch
      {
      }
    }
  }
}

Try connecting by the telnet to see the difference.

Well, download the projects if you want the full source-code and want to run the samples.

To finish, I hope this helps those who need to create secure streams, but don't want to deal with certificates and SSL streams.

History

  • 24th October, 2009: Initial post

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer (Senior) Microsoft
United States United States
I started to program computers when I was 11 years old, as a hobbyist, programming in AMOS Basic and Blitz Basic for Amiga.
At 12 I had my first try with assembler, but it was too difficult at the time. Then, in the same year, I learned C and, after learning C, I was finally able to learn assembler (for Motorola 680x0).
Not sure, but probably between 12 and 13, I started to learn C++. I always programmed "in an object oriented way", but using function pointers instead of virtual methods.

At 15 I started to learn Pascal at school and to use Delphi. At 16 I started my first internship (using Delphi). At 18 I started to work professionally using C++ and since then I've developed my programming skills as a professional developer in C++ and C#, generally creating libraries that help other developers do their work easier, faster and with less errors.

Want more info or simply want to contact me?
Take a look at: http://paulozemek.azurewebsites.net/
Or e-mail me at: paulozemek@outlook.com

Codeproject MVP 2012, 2015 & 2016
Microsoft MVP 2013-2014 (in October 2014 I started working at Microsoft, so I can't be a Microsoft MVP anymore).

Comments and Discussions

 
GeneralMy vote of 5 Pin
adriancs4-Jul-12 2:41
mvaadriancs4-Jul-12 2:41 
GeneralGreate example Pin
Member 364031215-May-11 21:03
Member 364031215-May-11 21:03 
GeneralRe: Greate example Pin
Paulo Zemek16-May-11 2:22
mvaPaulo Zemek16-May-11 2:22 
GeneralGreat work Pin
Marcelo Ricardo de Oliveira1-Jun-10 4:17
Marcelo Ricardo de Oliveira1-Jun-10 4:17 
GeneralA few Loopholes to take note of Pin
Dashmesh18-Mar-10 4:16
Dashmesh18-Mar-10 4:16 
Issues:

1. This method assumes that the other party is a trusted party.
2. It does not protect against repudiation.

Implications of the above:

For #1:
An attacker can impersonate as a valid system/user to push data to the other party potentially inducing inconsistencies into the system. This is possible as there is no centrally trusted entity in this PKI mechanism. Similarly, the client cannot trust the server too.

For #2:
Anybody can possibly update or get the datastream and there is no accountability as to who updated the system or pulled information from it.


There is another perinneal problem with PKI / symetric encryption - Key storage, that one must not ignore.

Bottomline:

The only place where this mechanism should be used (if it is not possible to set up a a proper Asymmetric encryption mechanism) is where the two parties are trusted and the channel needs to be encrypted. Generating a new set of keys for each new session would also be very expensive (time consuming) with the advantage that the keys would not need to be stored in the system. If one is storing the keys, use of one way SSL would be the better way to go forward.

Thanks.
QuestionWhy not use a streaming encryption mode Pin
Skippums7-Jan-10 11:40
Skippums7-Jan-10 11:40 
AnswerRe: Why not use a streaming encryption mode Pin
Paulo Zemek9-Jan-10 1:44
mvaPaulo Zemek9-Jan-10 1:44 
GeneralRe: Why not use a streaming encryption mode Pin
Skippums9-Jan-10 5:11
Skippums9-Jan-10 5:11 
GeneralRe: Why not use a streaming encryption mode Pin
Paulo Zemek9-Jan-10 5:38
mvaPaulo Zemek9-Jan-10 5:38 
GeneralMITM Attack Pin
MrProphet19-Dec-09 7:16
MrProphet19-Dec-09 7:16 
GeneralRe: MITM Attack Pin
Paulo Zemek19-Dec-09 13:26
mvaPaulo Zemek19-Dec-09 13:26 
GeneralRe: MITM Attack Pin
MrProphet11-Jul-10 11:50
MrProphet11-Jul-10 11:50 
GeneralRe: MITM Attack Pin
Paulo Zemek12-Jul-10 12:10
mvaPaulo Zemek12-Jul-10 12:10 
GeneralRe: MITM Attack Pin
MrProphet13-Jul-10 5:07
MrProphet13-Jul-10 5:07 
GeneralRe: MITM Attack Pin
Paulo Zemek13-Jul-10 5:16
mvaPaulo Zemek13-Jul-10 5:16 
QuestionCool example, but maybe there is simpler... Pin
Nicolas Dorier1-Nov-09 9:58
professionalNicolas Dorier1-Nov-09 9:58 
AnswerRe: Cool example, but maybe there is simpler... Pin
Paulo Zemek2-Nov-09 5:33
mvaPaulo Zemek2-Nov-09 5:33 
GeneralRe: Cool example, but maybe there is simpler... Pin
Nicolas Dorier2-Nov-09 10:22
professionalNicolas Dorier2-Nov-09 10:22 
GeneralRe: Cool example, but maybe there is simpler... Pin
Paulo Zemek3-Nov-09 0:06
mvaPaulo Zemek3-Nov-09 0:06 
GeneralInteresting Pin
supercat926-Oct-09 11:10
supercat926-Oct-09 11:10 
GeneralRe: Interesting Pin
Paulo Zemek26-Oct-09 13:18
mvaPaulo Zemek26-Oct-09 13:18 
GeneralRe: Interesting Pin
supercat927-Oct-09 6:26
supercat927-Oct-09 6:26 
GeneralRe: Interesting [modified] Pin
Paulo Zemek27-Oct-09 6:50
mvaPaulo Zemek27-Oct-09 6:50 
GeneralGreat Pin
Daniel Vaughan25-Oct-09 13:31
Daniel Vaughan25-Oct-09 13:31 
GeneralRe: Great Pin
Paulo Zemek26-Oct-09 1:34
mvaPaulo Zemek26-Oct-09 1:34 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.