聊到QQ的TEA填充算法,很多C#开发者第一反应是:网上代码那么多,直接抄不就行了?但真到自己动手实现,才发现坑一个接一个。前阵子我在做一个需要兼容旧协议格式的小工具,不得不把这段加密逻辑用C#完整写一遍。整个过程下来,最折磨人的不是TEA本体,而是“填充”这一层。所以这篇文章想把我的C#实现方案和踩坑过程都交代清楚,给同样要跟QQ数据包打交道的朋友省点时间。
这套东西适合谁看?想理解TEA原理的C#后端工程师,在维护QQ机器人、协议兼容层或者老系统接口的同学,以及有一天要接手“祖传加密代码”的实习生。不需要密码学基础,但至少要看得懂byte数组和for循环。我会先讲原理,再给完整代码,最后附上我自己的排错清单。
1. 先搞懂QQ的TEA填充算法到底解决什么问题
1.1 为什么QQ选了TEA而不是AES
TEA(Tiny Encryption Algorithm)诞生于1994年,是David Wheeler和Roger Needham在剑桥大学搞出来的轻量级分组密码。它的特点是代码量极小、内存占用低、计算步骤简单,整个算法核心不到二十行代码就能写完。在QQ诞生的那个年代,很多客户端的硬件条件非常有限,手机内存可能只有几百KB,CPU连跑AES都嫌吃力。TEA以极低的开销完成了基础的数据保密任务,自然成了早期IM软件的心头好。
放到今天的C#环境里,你当然可以无脑上AES-FIPS,但如果要兼容历史协议,TEA就是绕不开的那座桥。我处理的那个老接口就是如此,你没法要求对方把整个链路升级到现代加密,只能由我这边去适配旧算法。
1.2 8字节分组与128位密钥决定了必须填充
TEA是分组加密算法,固定把明文切成8字节一组,密钥固定16字节。这种固定分组的特性带来一个最直接的问题:待加密的数据长度往往不是8的倍数。比如一个字符串“hello”换算成字节是5个字节,塞进8字节分组就会留下3个字节的空位。如果不填充,数据要么解不出来,要么加密端会越界读写,造成不可预期的后果。
QQ的TEA填充算法,本质上就是一套对齐方案,但它在对齐之外还做了点额外的事情:把原始长度写进数据头部。这个设计让解密端拿到数据后可以根据长度字段精确截取,不依赖外部传入的明文长度。相比标准PKCS#7,它多一步长度传递,反而更贴合QQ早期数据包紧凑的格式习惯。
1.3 适合什么场景和基础
如果你只是想系统学习分组密码,完全可以直接学AES;如果是为了兼容QQ历史协议、做协议分析工具、开发自己的调试助手,那这篇文章的代码就是给你准备的。我默认读者会用Visual Studio或者.NET CLI,会创建控制台项目,了解最基本的C#语法。不需要精通密码学,但要对大端和小端有概念——这一步会在后面狠狠绊倒不少人。
2. TEA算法核心:一轮加减异或,32轮循环
2.1 加密过程的数学骨架
TEA每次处理一个8字节的分组。这8字节会被拆成两个32位无符号整数,变量名叫v0和v1。16字节密钥同样被拆成4个32位无符号整数,分别叫k0、k1、k2、k3。整个算法里还有一个魔法常数delta,十六进制是0x9E3779B9。这个数来自黄金分割比例,作用是让每轮循环引入不同的“噪音”,防止轮与轮之间完全雷同。
加密循环固定执行32轮,但在C#代码里我写成for循环,每一轮操作两步:
uint sum = 0; for (int i = 0; i < 32; i++) { sum += Delta; v0 += ((v1 << 4) + k[0]) ^ (v1 + sum) ^ ((v1 >> 5) + k[1]); v1 += ((v0 << 4) + k[2]) ^ (v0 + sum) ^ ((v0 >> 5) + k[3]); }第一步更新v0,第二步更新v1。每一步里都是三个异或项的组合:左移4位加一个密钥、当前v值加sum、右移5位加另一个密钥。左移和右移的位数分别是4和5,不是随便写的,而是经过设计让高低位都能均匀混合。整个TEA没有S盒、没有置换表,完全靠加减异或和移位,这在现代CPU上执行成本极低。
2.2 解密为什么能完全还原
解密不是把加密代码倒着写一遍那么简单,关键在于sum的走向。加密时sum从0开始增加,每一轮加上delta,32轮之后停在0xC6EF3720。解密时要从这个值开始,每一轮减去delta,同时把v1和v0的更新顺序反过来:
uint sum = 0xC6EF3720; for (int i = 0; i < 32; i++) { v1 -= ((v0 << 4) + k[2]) ^ (v0 + sum) ^ ((v0 >> 5) + k[3]); v0 -= ((v1 << 4) + k[0]) ^ (v1 + sum) ^ ((v1 >> 5) + k[1]); sum -= Delta; }因为每轮运算的加法都是可逆的,解密时做减法就能把原来的值还原回来。必须强调的是:先更新v1再更新v0,和加密正好相反。如果你把顺序写反,即使代码逻辑没错,解出来也是一团乱码。这类“镜像操作”的问题,在下手写实现时非常容易忽略。
2.3 TEA的密钥扩展等于没有
AES加密前要先通过KeySchedule生成十轮子密钥,TEA则完全省掉了这一刀。每一轮加密都直接使用最初那4个uint作为子密钥,做简单排列组合。这样做的好处是代码短、启动速度快;坏处是密钥直接参与每一轮运算,理论上比现代算法更容易被分析。但在QQ这个场景里,TEA的强项是“跨平台兼容稳定”,只要密钥不泄露,实际项目中完全够用。
我见过有人为了增强安全性,试图在TEA外面再包一层AES。这种方案在安全性上没问题,但会彻底破坏协议兼容性,需要跟对端同时升级,一般得不偿失。
3. QQ填充算法的真实面貌:头部长度字段和零填充
3.1 分组加密的天然缺口
任何分组密码都必须面对“最后一块不满8字节”的问题。最常见的标准方案是PKCS#7,它根据缺少的字节数n,在尾部补n个字节,每个字节的值都等于n。比如明文长度是5字节,缺3字节,就补三个0x03;解密时读最后一个字节就能知道要删掉多少。
但QQ的填充风格跟纯标准PKCS#7不一样。QQ的做法是:先在明文前面加4字节大端长度字段,记录原始数据长度,然后再对整体做0x00补位。这种方式在解密端更友好,因为解出明文后直接读头4个字节就拿到了真实长度,不需要关心尾部到底补了几个0x00。
3.2 QQ风格填充的具体流程
假设原始明文是字节数组plain,长度是L。构造待加密前的缓冲区时,我按下面的顺序做:
- 新建一个容量 = 4 + L的临时缓冲区。
- 前4字节写入大端顺序的L。
- 从索引4开始,依次拷贝plain的所有字节。
- 计算需要补多少个0x00才能让总长度成为8的倍数。
- 在尾部补0x00。
- 对补齐后的整个字节数组做TEA分组加密。
注意“大端顺序”,在C#里不能直接用BitConverter,因为Windows上默认小端。后面代码我统一用BinaryPrimitives.WriteInt32BigEndian,这个问题就绕过去了。
3.3 解密时怎么剥掉填充
解密时同样按8字节分组解出整个缓冲区,然后做逆向操作:
- 读取前4个字节,按大端转成int得到真实长度L。
- 从索引4开始取出L个字节作为原始明文。
- 索引4+L之后的内容全部是填充字节,直接丢弃。
这个方案比PKCS#7的剥除方式更稳,因为原始长度被独立保存,就算尾部出现意外数据也不会影响截取结果。但如果对端是标准PKCS#7实现,QQ风格就会解错。我提供了两种填充模式,在调用加密入口时用一个bool参数指定,方便切换。
| 填充模式 | 加密前处理 | 解密后处理 | 适用场景 |
|---|---|---|---|
| Pkcs7 | 尾部补n个字节,值=n | 读最后1字节,去掉尾部n个 | 标准TEA、通用协议 |
| QqStyle | 头部写4字节长度,尾部补0x00 | 读头部长度字段,精确截取 | QQ旧版数据包、历史兼容 |
把两种模式封装在同一个类里,切起来只需改一个参数,是我觉得最省心的设计。
4. C#实现完整代码与关键细节
4.1 准备工作和项目结构
我这套实现基于.NET 6+,用控制台项目演示。如果你还在写.NET Framework 4.x,也不用慌,只要把BinaryPrimitives手动替换成字节移位操作,其余逻辑完全可以照搬。核心类叫QqTea,里面放四个静态方法:Encrypt、Decrypt、EncryptBlock、DecryptBlock。填充逻辑放在内部私有方法里,不外露。
新建一个控制台项目后,先把命名空间引好:
using System; using System.Buffers.Binary;BinaryPrimitives是.NET Core时代加入的字节序工具,可以明确指定大端或小端,比BitConverter跨平台安全得多。
4.2 加密主流程
主流程方法接收明文和密钥两个参数,顺便接收一个布尔值,决定走QQ风格填充还是PKCS7填充。完整代码如下:
public static byte[] Encrypt(byte[] plain, byte[] key, bool qqStyle) { if (plain == null) throw new ArgumentNullException(nameof(plain)); if (key == null || key.Length != 16) throw new ArgumentException("TEA密钥长度必须为16字节", nameof(key)); byte[] padded = qqStyle ? PadQqStyle(plain) : PadPkcs7(plain); byte[] result = new byte[padded.Length]; uint[] k = ToUInt32Array(key); for (int offset = 0; offset < padded.Length; offset += 8) { uint v0 = BinaryPrimitives.ReadUInt32BigEndian(padded.AsSpan(offset, 4)); uint v1 = BinaryPrimitives.ReadUInt32BigEndian(padded.AsSpan(offset + 4, 4)); EncryptBlock(ref v0, ref v1, k); BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan(offset, 4), v0); BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan(offset + 4, 4), v1); } return result; }这里用ref传入v0和v1,让EncryptBlock可以直接改这两个局部变量,省掉中间数组。padded长度已经保证是8的倍数,所以循环里不用担心越界。
4.3 填充内部方法
PadQqStyle和PadPkcs7分别处理两种填充,这是整个填充算法的重头戏:
private static byte[] PadQqStyle(byte[] data) { int len = data.Length; int headerLen = 4; int totalLen = headerLen + len; int paddedLen = (totalLen + 7) / 8 * 8; byte[] padded = new byte[paddedLen]; BinaryPrimitives.WriteInt32BigEndian(padded, len); Buffer.BlockCopy(data, 0, padded, headerLen, len); // 剩余字节默认就是0x00 return padded; } private static byte[] PadPkcs7(byte[] data) { int len = data.Length; int padLen = 8 - (len % 8); if (padLen == 0) padLen = 8; // 已经是8的倍数也要补一整个块 byte[] padded = new byte[len + padLen]; Buffer.BlockCopy(data, 0, padded, 0, len); for (int i = 0; i < padLen; i++) padded[len + i] = (byte)padLen; return padded; }PadPkcs7里有个细节很多人会忘记:当原始长度恰好是8的倍数时,PKCS7规定还是要补8个字节,否则解密端读到最后一个字节可能误判。我遇到过不少半路出家的实现,在这个边界条件上翻车。
4.4 解密主流程与填充剥离
解密是加密的逆过程,代码对称但容易写错,尤其是sum的初值。看完整实现:
public static byte[] Decrypt(byte[] cipher, byte[] key, bool qqStyle) { if (cipher == null || cipher.Length == 0 || cipher.Length % 8 != 0) throw new ArgumentException("密文长度必须为非0且为8的倍数", nameof(cipher)); if (key == null || key.Length != 16) throw new ArgumentException("TEA密钥长度必须为16字节", nameof(key)); byte[] plainPadded = new byte[cipher.Length]; uint[] k = ToUInt32Array(key); for (int offset = 0; offset < cipher.Length; offset += 8) { uint v0 = BinaryPrimitives.ReadUInt32BigEndian(cipher.AsSpan(offset, 4)); uint v1 = BinaryPrimitives.ReadUInt32BigEndian(cipher.AsSpan(offset + 4, 4)); DecryptBlock(ref v0, ref v1, k); BinaryPrimitives.WriteUInt32BigEndian(plainPadded.AsSpan(offset, 4), v0); BinaryPrimitives.WriteUInt32BigEndian(plainPadded.AsSpan(offset + 4, 4), v1); } if (qqStyle) { int realLen = BinaryPrimitives.ReadInt32BigEndian(plainPadded); if (realLen < 0 || realLen > plainPadded.Length - 4) throw new InvalidOperationException("解密后的长度字段非法"); byte[] result = new byte[realLen]; Buffer.BlockCopy(plainPadded, 4, result, 0, realLen); return result; } else { int padLen = plainPadded[plainPadded.Length - 1]; if (padLen < 0 || padLen > 8) throw new InvalidOperationException("解密后的填充值非法"); byte[] result = new byte[plainPadded.Length - padLen]; Buffer.BlockCopy(plainPadded, 0, result, 0, result.Length); return result; } }这里我加了一层防御性校验:QQ风格解出的realLen不会超过明文缓冲区长度减4,PKCS7解出的padLen也不会超过8。这些校验平时跑不到,但万一密钥错了,解出一堆随机字节,它们能让你快速定位问题,不至于拿乱码到处找bug。
4.5 底层块函数与字节序工具
最后是TEA的块函数和密钥转换,这部分代码浓缩了第2章的原理:
private static void EncryptBlock(ref uint v0, ref uint v1, uint[] k) { uint sum = 0; const uint delta = 0x9E3779B9; for (int i = 0; i < 32; i++) { sum += delta; v0 += ((v1 << 4) + k[0]) ^ (v1 + sum) ^ ((v1 >> 5) + k[1]); v1 += ((v0 << 4) + k[2]) ^ (v0 + sum) ^ ((v0 >> 5) + k[3]); } } private static void DecryptBlock(ref uint v0, ref uint v1, uint[] k) { uint sum = 0xC6EF3720; const uint delta = 0x9E3779B9; for (int i = 0; i < 32; i++) { v1 -= ((v0 << 4) + k[2]) ^ (v0 + sum) ^ ((v0 >> 5) + k[3]); v0 -= ((v1 << 4) + k[0]) ^ (v1 + sum) ^ ((v1 >> 5) + k[1]); sum -= delta; } } private static uint[] ToUInt32Array(byte[] key) { uint[] k = new uint[4]; for (int i = 0; i < 4; i++) k[i] = BinaryPrimitives.ReadUInt32BigEndian(key.AsSpan(i * 4, 4)); return k; }注意解密时sum = 0xC6EF3720,这正是delta乘以32之后的十六进制值。你也可以写成delta * 32,但写常数能避免编译器换算,运行速度更快一丁点,而且这个值是固定不变的。
5. 实测验证与常见异常排查
5.1 如何证明算法写对了
拿到代码第一件事不是对接QQ协议,而是先做自测。最直接的方法:随机生成一段明文和一个16字节密钥,调Encrypt得到密文,再调Decrypt还原,断言还原后的字节数组和原始明文完全一致。这个验证过了,说明TEA本体的加解密对称性和填充/去填充逻辑是自洽的。我习惯在程序里跑1000组随机明文,长度从0到1024随机变化,确保边界条件都覆盖到。
如果你手头有别的语言写的标准TEA库,比如Python的tea模块或者Go的仓库,也可以拿同样的密钥和明文跑一遍,输出应该逐字节一致。跨语言对比是排查字节序问题最有效的办法。当年我第一次实现这个算法时,C#自测全绿,但对不上Python结果,最后发现就是端序不一致。
5.2 用固定样例模拟QQ风格数据包
为了模拟QQ风格填充,可以构造一个带头部长度字段的样例。我这里用明文"hello"演示:
byte[] key = new byte[16] { 0x01, 0x02, 0x03, ... }; // 自行填入16字节 byte[] plain = Encoding.UTF8.GetBytes("hello"); byte[] cipher = QqTea.Encrypt(plain, key, qqStyle: true); byte[] red = QqTea.Decrypt(cipher, key, qqStyle: true); Console.WriteLine(Encoding.UTF8.GetString(red)); // hello如果要手动检查填充结果,可以把加密前的padded字节数组打出来。明文长度5,加上头部长度字段后总长度是9,补0x00到16字节,所以padded长度16。前4字节是大端0x00000005,随后是hello的5字节ASCII码,最后7个字节都是0。加密时这16字节被切成两个块,分别TEA加密,最终密文仍是16字节。
5.3 三个最常见的解密失败原因
我在实际调试中遇到过无数稀奇古怪的问题,归根结底集中在三处。
第一个是字节序错误。C#的BitConverter在Windows上默认小端,而QQ的TEA数据通常是大端。一旦你直接使用BitConverter,乍一看自测能过,但换到真实数据包就不行。解决办法是统一使用BinaryPrimitives.ReadUInt32BigEndian和WriteUInt32BigEndian,绝不混用。
第二个是密钥长度不对。TEA密钥固定16字节,但很多人会习惯性传入32字节或8字节。密钥长度错误会导致ToUInt32Array读取越界或读取到脏数据,加密出来的密文自然无法解密。我建议在入口方法里直接做长度校验,比在循环里越界崩溃好排查得多。
第三个是填充模式不匹配。加密端用了QQ风格,解密端却按PKCS7剥填充,结果就是在去填充时要么截多、要么截少。尤其是QQ风格,解密端如果没读头部长度字段,会把0x00填充物也当作明文的一部分。遇到这种情况,先确认两端qqStyle参数一致,再检查数据包格式是否真的是“头部长度+明文体”。
6. 从能用跑到好用:性能优化与工程化建议
6.1 为什么不用BitConverter而用BinaryPrimitives
很多旧代码习惯用BitConverter,但BitConverter的结果取决于运行平台的大小端。现代POSIX和Windows全走小端,似乎没什么问题,可一旦数据需要按网络字节序传输,小端直接读出来就是错的。BinaryPrimitives让字节序显式化,读大端就是BigEndian,读小端就是LittleEndian,代码一眼能看出语义。更重要的是,它在.NET 6+里是JIT友好方法,很多情况下能被内联成几条CPU指令,性能比BitConverter好。
如果你的项目因为历史原因困在.NET Framework 4.x,没有BinaryPrimitives,我建议封装两个工具方法:
static uint ReadUInt32BE(byte[] buffer, int offset) { return (uint)((buffer[offset] << 24) | (buffer[offset + 1] << 16) | (buffer[offset + 2] << 8) | buffer[offset + 3]); }这样至少保证字节序正确,后面再迁移到.NET 6也容易。
6.2 用Span减少中间数组分配
在加密循环里,我直接用padded.AsSpan(offset, 4)读取4字节,一次性把字节数据转成uint,避免先拷贝出临时byte[]再BitConverter。对大量短消息加密,这种减少分配的方式能明显降低GC压力。尤其QQ消息这种高频小包场景,每条消息省几次分配,放大到成千上万次调用,差距就出来了。
如果你有大批消息要做块加密,还可以考虑把Encrypt方法改造成支持ReadOnlySpan 输入,但底层还是要处理填充后的数组,因为要加密的数据必然经过一次缓冲区的重新组装。改造前后的收益核心在“少拷贝”而不是“零拷贝”,这一点要心里有数。
6.3 并行加密和C#异步
TEA在ECB模式下,不同分组之间互相独立,天然可以并行。如果你有很少的长消息,可以用Parallel.For遍历每个分组,或者用System.Threading.Tasks的Parallel类。但QQ协议里大多是短消息,并行带来的调度开销可能超过收益,所以我并不建议无脑并行。更合理的做法是保持串行,把精力花在避免分配上。
至于异步,加密本身是CPU密集型操作,async/await并不能提升吞吐,反而会增加上下文开销。正确做法是保留同步的Encrypt/Decrypt方法,只在外部调用时根据场景决定是否丢到线程池,比如用Task.Run包装。如果你想写一个Web API接口给QQ数据做加解密,接口层可以async,算法内部保持同步。
6.4 已经进入新时代,为什么还要学TEA
看到这里你可能会问:都什么年代了,还在给QQ的旧协议写C#实现?其实这种“过时”算法在存量系统里遍地都是。金融、政府、工业,甚至某些硬件设备里的协议,都还跑着几十年历史的加密逻辑。你会TEA,就等于能接手这些老项目;你会C#填坑,就意味着能在现代工程里把这些老逻辑包装成干净的API,这本身就是一种很值钱的能力。
我自己的工具里,现在已经把QqTea封装成一个静态类,上层只暴露Encrypt/Decrypt两个入口,调用方甚至不需要知道底层是TEA还是AES。后续如果协议升级到现代加密,我只需要换掉内部实现,接口完全不变。这也是做兼容层的一种很务实的思路。
实际用下来的体会是,TEA本身不难,难的是你永远要在“标准”和“兼容”之间找平衡。实现一套能跑的算法只需要两小时,但要让它在真实环境里对得上别人的数据包,可能还需要好几天。如果你现在也在跟QQ的TEA填充算法较劲,别急着怀疑人生,先检查字节序,再核对填充模式,最后用固定样例一次性打通,成功率会高很多。