☰
TEA填充算法C#实现及QQ协议应用解析
2026/10/6 4:16:43 网站建设 项目流程

TEA填充算法这五个字,做QQ协议分析的人肯定不陌生。说得直白一点,早期QQ在加密网络数据之前,需要先把任意长度的明文“规整”成TEA算法能处理的8字节整数倍,加密后再发出去;接收方解密之后,再根据填充规则把原始数据准确还原回来。我最近用C#把这一整套逻辑重新实现了一遍,从填充、分组加密到解密、反填充,全部没有依赖任何第三方库。这篇文章就把完整代码、关键原理和实操里踩过的坑一起整理出来,给正在研究QQ协议、需要兼容老系统,或者单纯对TEA感兴趣的同学一份可以直接抄走的参考。

1. 为什么QQ协议会用到TEA填充

1.1 TEA这个老算法为什么值得研究

TEA的全称是Tiny Encryption Algorithm,1994年由剑桥大学的David Wheeler和Roger Needham提出。这个算法的设计目标非常明确:代码短、占用内存小、运行速度快。整个加密过程只用到加法和位运算,核心逻辑不到十行,所以在早期客户端、嵌入式设备上非常受欢迎。QQ早期版本选择TEA作为数据加密手段,就是看中它在性能和实现难度上的优势。

后来随着协议分析社区慢慢起来,TEA填充算法、TEA解密脚本几乎成了研究QQ协议入门的必修课。你会发现很多讨论QQ协议、写QQ机器人的老帖子里,最后都会落到一个TEA加解密的函数上。因为不管封包怎么构造,最终发送前都要过这一层加密;接收响应时,第一件事也是先解密。所以搞懂TEA和它的填充规则,实际上就把QQ协议里最基础的一块地基给挖通了。

1.2 “填充”到底解决了什么问题

分组加密算法有一个硬性要求,明文长度必须是分组长度的整数倍。TEA的分组是64位,也就是8字节。但网络上一个数据包的长度什么情况都有,可能是7字节,可能是100字节,也可能刚好8字节。如果明文长度不是8的倍数,最后一块不足8字节,整个数据就没法按照标准流程加密。

填充算法要做的就是两件事:第一,把明文补齐到8的倍数;第二,保证解密后能精确还原出原始长度。

这里有一个很容易忽略的点:哪怕明文长度已经是8的倍数,也依然要填充。为什么不直接跳过?因为解密端无法判断“最后这8字节本来就是原文的一部分”还是“这是填充补出来的”。所以在TEA填充算法里,惯例是至少补1个字节,让解密端总能从尾部标记判断真实长度。

打个生活比方。搬家装箱,箱子固定大小,东西装不满就拿泡棉塞满,然后在箱子外标记塞了几块泡棉。开箱时按标记把泡棉抽掉,剩下的才是真实物品。TEA填充做的事情完全一样,只不过它把标记直接写在了泡棉上。

2. TEA算法核心原理与参数约定

2.1 分组结构、密钥与轮函数

TEA是一个对称分组算法,固定处理64位(8字节)明文,密钥固定128位(16字节),标准迭代32轮。所谓“32轮”,实际代码里是32次循环,每次循环内包含两个半轮操作,分别更新分组的左半部分和右半部分。

16字节密钥会拆成4个32位无符号整数,记作k0、k1、k2、k3。64位明文也会拆成两个32位无符号整数,记作v0、v1。每一轮都会使用一个不断累加的sum值,每次增加一个固定常量delta = 0x9E3779B9。这个数字不是随便写的,它和黄金分割比例有关,作用是让每一轮使用的子密钥组合都不相同。

单轮核心逻辑可以浓缩成下面几行:

sum += Delta; v0 += ((v1 << 4) + k0) ^ (v1 + sum) ^ ((v1 >> 5) + k1); v1 += ((v0 << 4) + k2) ^ (v0 + sum) ^ ((v0 >> 5) + k3);

看起来就是移位、加法和异或的混合,但就是这几行操作,能把密钥和明文的每一位快速扩散到整个分组中。解密过程则是完全反向的操作,从sum的终值往回推。

2.2 填充规则的边界情况

前面说过,分组是8字节,所以填充的目标就是让明文长度对齐到8的倍数。设原始明文长度为n,那么填充长度padLen = 8 - (n % 8)。注意这个公式的结果范围是1到8,不是0到7。

当n是8的倍数时,padLen = 8,也就是说必须额外补8个字节。填充的每一个字节值都等于padLen。举个例子:明文长度是8,padLen就是8,加密前在尾部追加8个0x08;解密后看到最后一个字节是8,就把尾部8个字节全部丢弃,剩下的正好是原始8字节明文。

这里我用了PKCS7风格的填充方式,这也是QQ早期协议资料里最常见的填充思路。具体到不同版本,填充值细节可能略有差异,但核心原则一致:尾部标记必须能推导出原始长度,且填充后的总长度必须是8的倍数。

另一个需要注意的点是字节序。TEA算法标准按大端序解析数据,也就是4字节中最高位字节在最前面。而C#运行的主流平台都是小端序,如果直接用BitConverter.ToInt32去读,读出来的值和协议里真正想要的数值大概率是反的。这也是从其他语言移植TEA到C#时最容易翻车的地方。

3. C#完整实现:从填充函数到调用示例

3.1 填充与反填充的实现

先把填充这部分独立出来,方便后续复用。Pad函数负责把明文补齐,Unpad函数负责在解密后还原出原始数据。

public static class TeaPadding { public static byte[] Pad(byte[] input) { if (input == null) throw new ArgumentNullException(nameof(input)); int padLen = 8 - (input.Length % 8); byte[] padded = new byte[input.Length + padLen]; Buffer.BlockCopy(input, 0, padded, 0, input.Length); for (int i = 0; i < padLen; i++) padded[input.Length + i] = (byte)padLen; return padded; } public static byte[] Unpad(byte[] input) { if (input == null || input.Length == 0 || input.Length % 8 != 0) throw new ArgumentException("输入必须是8字节倍数的非空数据"); int padLen = input[input.Length - 1]; if (padLen < 1 || padLen > 8) throw new InvalidOperationException("填充值非法: " + padLen); byte[] result = new byte[input.Length - padLen]; Buffer.BlockCopy(input, 0, result, 0, result.Length); return result; } }

注意:Unpad里对padLen做了严格校验。如果你解密后拿到一个不合理的填充值,大概率不是填充的问题,而是密钥不对、密文被截断,或者协议版本的填充规则和你想的不一样。这个校验能帮你快速发现异常,而不是让脏数据继续往后走。

3.2 TEA核心加解密类

接下来是TEA算法的核心。我单独封装了一个静态类,提供单分组加解密和任意长度数据的循环处理两个入口。

public static class TeaCipher { private const uint Delta = 0x9E3779B9; private const int Rounds = 32; // 处理任意长度数据,要求 data 长度是 8 的倍数 public static byte[] Encrypt(byte[] data, byte[] key) { if (data == null || data.Length == 0 || data.Length % 8 != 0) throw new ArgumentException("明文长度必须是8的倍数"); if (key == null || key.Length != 16) throw new ArgumentException("TEA密钥必须是16字节"); byte[] result = new byte[data.Length]; for (int offset = 0; offset < data.Length; offset += 8) { byte[] block = EncryptBlock(data, offset, key); Buffer.BlockCopy(block, 0, result, offset, 8); } return result; } // 处理任意长度数据,要求 data 长度是 8 的倍数 public static byte[] Decrypt(byte[] data, byte[] key) { if (data == null || data.Length == 0 || data.Length % 8 != 0) throw new ArgumentException("密文长度必须是8的倍数"); if (key == null || key.Length != 16) throw new ArgumentException("TEA密钥必须是16字节"); byte[] result = new byte[data.Length]; for (int offset = 0; offset < data.Length; offset += 8) { byte[] block = DecryptBlock(data, offset, key); Buffer.BlockCopy(block, 0, result, offset, 8); } return result; } private static byte[] EncryptBlock(byte[] data, int offset, byte[] key) { uint v0 = Be32(data, offset); uint v1 = Be32(data, offset + 4); uint sum = 0; uint k0 = Be32(key, 0); uint k1 = Be32(key, 4); uint k2 = Be32(key, 8); uint k3 = Be32(key, 12); for (int i = 0; i < Rounds; i++) { sum += Delta; v0 += ((v1 << 4) + k0) ^ (v1 + sum) ^ ((v1 >> 5) + k1); v1 += ((v0 << 4) + k2) ^ (v0 + sum) ^ ((v0 >> 5) + k3); } byte[] block = new byte[8]; Wb32(block, 0, v0); Wb32(block, 4, v1); return block; } private static byte[] DecryptBlock(byte[] data, int offset, byte[] key) { uint v0 = Be32(data, offset); uint v1 = Be32(data, offset + 4); uint sum = Delta * (uint)Rounds; uint k0 = Be32(key, 0); uint k1 = Be32(key, 4); uint k2 = Be32(key, 8); uint k3 = Be32(key, 12); for (int i = 0; i < Rounds; i++) { v1 -= ((v0 << 4) + k2) ^ (v0 + sum) ^ ((v0 >> 5) + k3); v0 -= ((v1 << 4) + k0) ^ (v1 + sum) ^ ((v1 >> 5) + k1); sum -= Delta; } byte[] block = new byte[8]; Wb32(block, 0, v0); Wb32(block, 4, v1); return block; } // 大端读取4字节为uint private static uint Be32(byte[] data, int offset) { return ((uint)data[offset] << 24) | ((uint)data[offset + 1] << 16) | ((uint)data[offset + 2] << 8) | (uint)data[offset + 3]; } // 大端写入4字节 private static void Wb32(byte[] data, int offset, uint value) { data[offset] = (byte)(value >> 24); data[offset + 1] = (byte)(value >> 16); data[offset + 2] = (byte)(value >> 8); data[offset + 3] = (byte)value; } }

有一点想特别提醒:中间变量全部用uint,不要用int。C#里int右移是算术移位,遇到负数会在高位补1,而TEA算法要求的是逻辑移位,也就是高位补0。用uint类型,右移操作符天然就是逻辑移位,可以避免很多莫名其妙的乱码问题。

3.3 带随机前缀的封装与测试样例

TEA的多个分组如果直接按顺序拼接加密,本质上是ECB模式。ECB有一个特征:如果两段明文的开头几个分组相同,那么密文开头也相同。网络协议里这种特征很容易被对方捕捉到,所以QQ早期协议的做法是:加密前在明文最前面放4个随机字节作为前缀,解密后把这4个字节丢弃。这样即使原始内容完全一样,因为随机前缀不同,最终密文也完全不同。

下面是带随机前缀的封装:

using System.Security.Cryptography; public static class QqTeaHelper { public static byte[] Encrypt(byte[] plain, byte[] key) { byte[] padded = TeaPadding.Pad(plain); return TeaCipher.Encrypt(padded, key); } public static byte[] Decrypt(byte[] cipher, byte[] key) { byte[] padded = TeaCipher.Decrypt(cipher, key); return TeaPadding.Unpad(padded); } public static byte[] EncryptWithRandomPrefix(byte[] plain, byte[] key) { byte[] prefixed = new byte[4 + plain.Length]; RandomNumberGenerator.Fill(prefixed.AsSpan(0, 4)); Buffer.BlockCopy(plain, 0, prefixed, 4, plain.Length); return Encrypt(prefixed, key); } public static byte[] DecryptWithRandomPrefix(byte[] cipher, byte[] key) { byte[] plainWithPrefix = Decrypt(cipher, key); if (plainWithPrefix.Length < 4) throw new InvalidOperationException("解密后数据长度异常"); byte[] result = new byte[plainWithPrefix.Length - 4]; Buffer.BlockCopy(plainWithPrefix, 4, result, 0, result.Length); return result; } }

RandomNumberGenerator.Fill是.NET Core 3.0以后才有的API,.NET Framework里要用RNGCryptoServiceProvider替代。我实际测试时用了一个很简单的自检流程:随机生成一段明文,随机生成16字节密钥,先EncryptWithRandomPrefix加密,再DecryptWithRandomPrefix解密,用Assert或直接比较字节数组确认还原后的数据和原始明文完全一致。

测试代码大概长这样:

byte[] key = Encoding.UTF8.GetBytes("0123456789ABCDEF"); byte[] plain = Encoding.UTF8.GetBytes("Hello, QQ TEA Padding!"); byte[] cipher = QqTeaHelper.EncryptWithRandomPrefix(plain, key); byte[] restored = QqTeaHelper.DecryptWithRandomPrefix(cipher, key); string restoredText = Encoding.UTF8.GetString(restored); Console.WriteLine(restoredText); // 输出: Hello, QQ TEA Padding!

4. 实操中遇到的坑与排查技巧

4.1 明文编码不一致导致解密乱码

这是很多新手第一个踩的坑。加密前用UTF-8把字符串转成字节数组,解密后却用GBK解码,结果自然是乱码。反过来也一样。尤其是QQ早期协议里,很多历史资料默认使用GBK/GB2312,如果你用UTF-8去解析解密后的数据,所有中文都是乱码,而且完全看不出来是编码问题还是解密失败。

一个比较稳妥的做法是:在项目里统一定义一个编码常量,加密解密两端都使用同一个编码。如果要做协议兼容,先确认对方用的编码,再决定用什么Encoding。

4.2 密钥长度与密钥来源

TEA密钥必须严格16字节。很多人会把一个字符串直接Encoding.UTF8.GetBytes,如果字符串里包含中文,一个字符可能占3个字节,长度就超过16了;如果包含英文字符,可能正好16字节。这种不确定性会让调试变得特别难受。

更常见的问题是密钥来源。QQ登录完成后,客户端和服务端会协商出会话密钥,不同数据包可能使用不同的密钥。如果你拿错了key去解密,程序不会报错,只是解密结果是一堆看似随机的字节。遇到这种情况,建议先用确定性的测试向量验证算法本身没问题,再去怀疑业务层的密钥取用逻辑。

4.3 大端小端混用的后果

我在第一次用BitConverter直接读32位整数的时候,加密结果和参考实现完全对不上。后来逐字节对比才发现,BitConverter在小端机器上把0x12345678读成了0x78563412,而TEA标准按大端解析数据。这个问题非常隐蔽,因为错得很规则,看起来像密钥不对,实际是字节序不对。

解决方法是自己移位拼接,就是代码里的Be32和Wb32两个函数。读取时按高位在前组装,写入时也按高位在前后移。只要你所有涉及32位字的地方都走这两个函数,就不会出现字节序问题。

4.4 填充值越界与异常处理速查表

解密时如果最后一个字节不是1到8,我的Unpad函数会直接抛异常。这通常是好事,说明某个环节出了问题。下面是我整理的排查速查表:

现象可能原因排查方向
解密后全是乱码但程序不报错密钥不对、编码不一致、字节序错误先用已知测试向量验证算法正确性,再检查key和编码
抛异常“填充值非法”密钥错误、密文被截断、填充规则不一致检查密文长度合法性,确认协议使用的填充规则
解密出的内容开头不对使用了带随机前缀的封装但解密侧没有剥离检查是否把随机前缀当成了业务数据
明文长度是8的倍数但解密结果少了8字节没有做最小填充,或填充值处理有误确认加密端是否在长度对齐时也补了8字节

这些坑几乎每一个我都遇到过。尤其是填充边界,看起来是小问题,但一旦出错,数据长度对不上,后续所有解析逻辑都会崩。

5. 性能、安全与扩展方向

5.1 性能实测与优化建议

TEA本身就是轻量算法,C#实现也不会慢。我在普通笔记本上实测,1MB数据做一次TEA加密大概在10到20毫秒之间,具体数值和CPU、Debug/Release模式有关,但整体来说性能完全不是瓶颈。

如果要在高吞吐场景下使用,可以考虑三个优化方向:第一,用Span<byte>代替数组拷贝,减少内存分配;第二,把32轮循环手动展开,减少循环判断开销;第三,ECB模式天然可以并行,可以用Parallel.For对不重叠的分组同时加密。当然对绝大多数协议场景来说,这些优化都属于过度设计,不需要一开始就做。

5.2 安全提醒:TEA不适用于新系统

TEA在密码学历史上占有一席之地,但它的安全性在今天已经不够看了。学术界对TEA有多轮分析,存在相关密钥攻击等问题。所以新项目千万不要把TEA当主力加密算法,C#里做对称加密首选AES,直接用System.Security.Cryptography.Aes类就好,既安全又省事。

这里写TEA和TEA填充算法实现,主要用途是协议兼容、老系统迁移、历史数据解密,或者纯粹学习分组密码原理。如果是做安全相关的核心功能,请果断选现代加密方案。

5.3 算法变种:XTEA、XXTEA与扩展思路

TEA后来还有一个改进版本XTEA,主要调整了密钥调度逻辑,修复了TEA的某些理论弱点。XXTEA则更激进,直接把整个数据块当作一个整体处理,分组大小可以变化。如果你对QQ协议里的TEA实现已经吃透了,下一步可以看看这些变种,会有很多启发。

算法分组长度密钥长度典型轮数主要特点
TEA64位128位32轮代码极简,历史影响大
XTEA64位128位32轮改进密钥调度,更安全
XXTEA可变分组128位依赖分组大小整块处理,减少模式问题
AES128位128/192/256位10/12/14轮现代工业标准,安全可靠

QQ不同版本协议对TEA的使用方式也有细微差别,比如随机前缀长度、填充规则、外层封装格式都可能不一样。如果你想做更完整的兼容,建议把填充、前缀、分组逻辑都拆成可配置的独立模块,这样遇到协议版本差异时只需要改配置,不需要重写加解密核心。

最后再分享一个调试时的习惯:写加密算法一定要准备一组已知明文密文对,用单元测试固化下来。网上可以搜到标准TEA测试向量,最经典的是全零密钥加密全零明文的那个组。我刚开始写C#版TEA时,所有代码看起来都正确,但加密结果就是和参考实现不一致,后来逐字节对比才发现是某个移位操作被当成了有符号处理。把测试用例锁死,后续再怎么改代码、换平台都不怕。TEA本身不难,难的是把边界条件和字节序处理干净。希望这篇文章能把你的路铺平一点。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询