65.9K
CodeProject 正在变化。 阅读更多。
Home

在没有 SSL 的情况下创建非对称/对称安全流

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.93/5 (29投票s)

2009年10月24日

CPOL

7分钟阅读

viewsIcon

72511

downloadIcon

1105

本文解释了如何在不使用 SSL 或证书的情况下, 使用非对称加密进行连接, 并使用对称加密进行通信, 从而创建安全流

背景

很多时候,我创建了基于 TCP/IP 的程序,但没有为它们使用任何特殊的安全性。问题出现在我创建了一个聊天程序,想要发送登录/密码,并避免有人“嗅探网络”以查看此信息或甚至对话。
我使用了 SslStream。它有效,但获取证书是个问题,老实说,我唯一的问题是“嗅探”,而不是检查服务器是否真的是它应该是的服务器,或者客户端是否是真正的客户端。我只是接受了所有证书,我需要的是加密。

解决方案

好吧,我很快就寻找了没有证书的解决方案。我知道 SSL 使用非对称密钥连接,然后创建对称密钥以继续通信。查看 System.Security.Cryptography 时,我看到了 CryptoStream。我以为它是解决方案(至少对于对称部分),但事实并非如此。CryptoStream 是单向的(因此不适用于 TCP/IP),它的 Flush 不会刷新流,如果我使用 FlushFinalBlock,我必须处理流。所以,我决定直接研究对称和非对称算法。

经过一番研究,我创建了一个解决方案。也许不是最快的,但它有效。
在初始化期间,服务器创建一个非对称密钥并将公共部分发送给客户端。然后客户端创建一个对称密钥(目前只有他知道该密钥),并使用服务器公共密钥对其进行加密(因此,只有服务器知道如何解密它)。它将密钥发送给服务器,然后只使用这个对称密钥。

很简单,但由于非对称密钥和对称密钥是在连接期间创建的,因此没有其他人也知道密钥的可能性。而且,由于对称密钥是使用只有服务器知道如何解密的加密方式发送的,即使有人以良好的密码学知识嗅探网络,也无能为力。
那么,让我们看看实现。

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);
}

构造函数很长,但我将解释关键部分。它旨在能够接收已创建和初始化的 RSA 非对称算法以及用户选择的非对称算法。它还有其他构造函数,可以使用简单的 new RSACyptoServiceProvider SymmetricAlgorithm.Create 来初始化这些对象。在检查空参数并设置 BaseStream SymmetricAlgorithm 属性后,它必须决定是作为服务器还是作为客户端运行。我将从服务器开始,因为服务器是初始化过程的。

服务器

服务器发送正在使用的对称算法的名称,客户端将使用该名称检查兼容性,导出它生成的公共 RSA 密钥并将其发送给客户端,最后读取客户端将发送的对称密钥和初始化向量。

客户端

客户端执行服务器的反向过程。因此,它首先接收服务器使用的算法名称。如果算法名称的长度或名称本身不匹配,它会抛出异常。稍后,它接收服务器使用的 RSA 密钥,并发送其对称算法的密钥和初始化向量。

好的,我没有展示使用 RSA 密钥的加密,但它是由 p_ReadWithLength p_WriteWithLength 完成的,我稍后会展示。只是为了完成构造函数,它清除 RSA 密钥,创建对称加密器和解密器并初始化缓冲区。

让我们看看非对称加密

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);
}

Write 加密消息,然后发送加密消息的大小和消息本身。

Read 读取大小,然后读取消息,最后解密并返回解密后的消息。但是,等等,为什么我使用“BaseStream.Write”和 p_FullReadDirect?为什么不是 BaseStream.Read

我真的在考虑将其作为扩展方法。如果你看看 Read Write 是如何工作的,你会注意到区别。Write 只是写入所有请求的缓冲区或抛出异常。Read 更麻烦,因为你可以请求 1024 字节,它返回 3,因为它只读取了 3 字节。但我不希望这种情况发生,我想要完整的块,即使我需要多次调用 read。所以,p_FullReadDirect 看起来像这样

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;
  }
}

好的。初始化完成。此时,我们可以说我们已经完全实现了构造函数,我们已经使用了 RSA 算法(非对称),现在我们只需要关心真正的流。

那么,让我们首先了解加密器和解密器。至少,我理解的部分
加密器和解密器是 ICryptoTransform。在其中,我们有 TransformBlock TransformFinalBlock。我确实考虑过使用 TransformBlock,但在我的测试中,我加密一个块并尝试解密它,但什么也没发生。如果我连接这些块并在最后调用 TransformFinalBlock,我得到错误的结果,所以我决定只使用 TransformFinalBlock,它单独工作得很好。

问题是每次“最终加密”时,我可能会在消息中得到额外的长度。因此,我没有加密每次写入,而是将所有写入缓冲到内存流中,并在 Flush 期间,我加密所有写入并发送它们。

所以:

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;
  }
}

我总是将缓冲区容量重置为 32K,因为在一个时刻我们可以发送 1MB 的信息,但之后,我们继续使用 32KB。我可以将其配置,但它不会改变这里真正重要的事情
我首先将所有缓冲区分组,这不是问题。但是,当我发送它时,我还必须发送加密大小,因为要使用 TransformFinalBlock 解密,我们需要加载完整的块。解密本身不是很复杂,但读取方法很复杂。

为什么?
因为远程端发送 1MB 的数据。要解密,我必须读取 1MB 的数据并解密。但是,调用代码只想要读取 4 字节。我不能简单地丢弃缓冲区的其余部分,我必须读取请求缓冲区的一部分并更新内部位置,以便下一次读取可以读取缓冲区的另一部分。此外,我们可以有一个 16 字节的缓冲区和 1024 字节的读取,但在这种情况下,我们使用 Read 行为返回已读取 16 个字节。

那么,让我们看看

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;
}

当我们进行第一次读取时,我们在位置 0,readbuffer 的大小为 0,所以我们进入 if。我们将读取消息大小,如果我们的 readbuffer 长度不足,则创建一个新的,然后将“fullreadmessagesize。这样,我们就有了完整的加密缓冲区,因此将其解密到 readbuffer 变量中,并告知我们位于其开头。

离开 if 块,我们将执行检查,看读取是否想读取比我们剩余的更多字节。如果是这种情况,我们将减少 count 变量。然后,我们只需“BlockCopy”我们想要的块,更新 ReadPosition 并返回读取的字节数(count)。

好吧,就是这样。如果我们“逐字节”读取,我们将不断增加 ReadPosition,同时内存中仍然有一个有效的缓冲区。一旦它结束,我们将接收下一个。就是这样。流已经工作了。

用法

用法非常简单。当您获得 TCP/IP 连接时,您可能已经使用 GetStream() 来读取和写入连接。您只需要在 TCP/IP 流上创建一个新的 SecureStream 并告知它是客户端还是服务器,所有事情就都完成了。
所以,一个小例子,一个加密的“echo 端口”。

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
      {
      }
    }
  }
}

尝试通过 telnet 连接以查看差异。

好吧,如果您想要完整的源代码并运行示例,请下载项目。

最后,我希望这能帮助那些需要创建安全流但不想处理证书和 SSL 流的人。

历史

  • 2009 年 10 月 24 日:首次发布
© . All rights reserved.