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






4.93/5 (29投票s)
本文解释了如何在不使用 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
长度不足,则创建一个新的,然后将“fullread
”messagesize
。这样,我们就有了完整的加密缓冲区,因此将其解密到 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 日:首次发布