一直有朋友在折腾QQ机器人,跑来问我TEA加解密到底怎么处理。这个算法名字听起来挺唬人,但真从零开始自己写一遍,你会发现它其实短小精悍,真正容易翻车的地方全在填充规则和字节序上。我当年做QQ协议分析时,为了把一个C++的TEA实现搬到C#环境,卡了整整一个晚上,最后定位到的问题居然是大端序转换写反了。这次把完整的C#实现、填充细节、踩坑记录都整理出来,给后面接手的兄弟省点时间。
这篇文章适合两类人:一类是正在做QQ机器人、QQ协议框架接入,需要处理登录和消息包加解密的产品开发者;另一类是想学TEA算法原理,但不想只抄网上一段代码、想搞清楚每个字节为什么这么处理的学习者。整篇文章围绕QQ协议里那个“4字节长度头 + 随机尾部填充 + 16轮TEA”的结构展开,代码可以直接复制去用,但更重要的是理解设计者的意图。
1. 为什么重提十几年前的QQ TEA算法
1.1 一段从QQ机器人入坑的经历
先说下背景。我最早接触QQ TEA,是因为要做一个群管理机器人。QQ的早期协议里,客户端和服务端通信的数据包,登录验证、好友操作、消息收发,很多关键字段都会用TEA加密后再走网络。想从协议层做机器人,第一步就必须能加解密TEA数据。
说实话,一开始我根本没把这个算法放眼里。TEA全称Tiny Encryption Algorithm,1994年由David Wheeler和Roger Needham两位教授设计,看名字就带着轻量级的调性。网上资料一搜一大堆,C++版、Java版、Python版都有,我心想改成C#还不简单?结果真动手就傻眼了:QQ里的TEA不是教科书原版,它改过循环轮数,还自定义了一套填充方案。如果照着标准TEA代码抄,解密出来的数据永远是一堆乱码。
后来翻了几份老协议文档,又对照其他语言的开源实现,才把整个逻辑理顺。QQ TEA实际上是“标准TEA算法 + 魔改轮数 + 自定义填充格式”三件套的组合。算法本体不难,难的是理解QQ协议设计者为什么这么拼装,以及在不同语言之间迁移时那些隐含约定。
1.2 TEA在QQ协议里的位置
在QQ的早期协议体系里,TEA主要扮演数据加密层的角色。客户端发起请求时,请求体经过序列化后,会用会话密钥做TEA加密,再封装成网络包。服务端收到后先解包,再用同样的密钥做TEA解密,还原出请求原文。响应数据也是同理,服务端加密返回,客户端解密读取。
这种设计在今天看来不算多新颖,放在当年却是兼顾安全性和性能的合理选择。TEA算法体积极小,纯查表加位移操作就能实现,硬件资源紧张的环境下也能跑得飞快。QQ的客户端版本千奇百怪,低端手机上也要能用,所以选TEA这种轻量级对称加密很务实。
我这次要实现的,就是这套加密链路里最关键的那一步:拿到一段明文,按QQ规则填充,用TEA加密;以及拿到一段密文,用TEA解密,再按规则把填充和长度头剥掉,还原出原始明文。搞清楚这一进一出,后面做协议交互就稳了。
2. 先搞懂QQ版TEA的加密结构与填充规律
2.1 TEA本体:64位块、128位密钥、16轮迭代
TEA算法本身是个分组密码,一次处理8字节明文,也就是64位。它把8字节分成两个32位整数v0和v1,再拿16字节密钥(128位)拆成4个32位整数k0、k1、k2、k3,用一套固定的数学变换反复迭代。
标准TEA迭代32轮,但QQ魔改成了16轮。16轮不是偷工减料,而是协议作者权衡安全性后做的选择。QQ那年代的计算环境,16轮足够打散数据特征,又比32轮省一半计算量。如果按标准32轮写,跟QQ不兼容;按16轮写,就能对上QQ的加解密结果。
每一轮的变换核心是这三行代码的思路:
sum += delta; v0 += ((v1 << 4) + k0) ^ (v1 + sum) ^ ((v1 >> 5) + k1); v1 += ((v0 << 4) + k2) ^ (v0 + sum) ^ ((v0 >> 5) + k3);这里面delta是一个固定魔法数0x9e3779b9,不多不少,是黄金分割比的某种整数表达。左移4位、右移5位、加密钥、异或sum,再叠加到另一个分组上,这就是典型的Feistel网络结构,设计目标是让数据经过多轮之后彻底混乱。
解密就是加密的逆过程,从sum的最终值往前倒推。16轮加密结束时sum已经累加了16次delta,所以解密时sum的初始值是delta乘以16,然后每轮递减delta,按反向顺序恢复v0、v1。具体代码在下一节展现。
2.2 QQ特有的填充方案:4字节长度头 + 随机尾部
TEA一次只能处理8字节整数倍的数据,但实际要加密的明文长度五花八门,所以必须填充。QQ没有采用常见的PKCS7填充,而是用了自己的一套规则,我拆成三部分讲。
第一部分是长度头。加密前,先把原始明文的长度写进前4个字节,大端序排列。举个例子,明文是“hello”,长度5,那前4字节就是0x00 0x00 0x00 0x05。这4字节不是干看着的,解密时程序要先读它,才知道原始数据实际多长,才能把真正的明文和填充垃圾区分开。
第二部分是原始明文本身,原封不动跟在长度头后面。第三部分是尾部随机填充,目的是把整个数据块的长度凑成8的倍数。这里有个细节很多人容易忽略:如果长度头加明文已经是8的倍数,尾部可以补也可以不补,取决于协议版本。我见过的一些老协议实现是总会额外补满一个块,但常见的WebQQ实现是不额外补,我也会按这个逻辑写。
为什么不用PKCS7?我分析有两个原因。一是QQ的数据包很多是二进制结构,不是整段字符串,用PKCS7那种按字节填充、填充值等于填充长度的方式,在解包时容易跟数据内容冲突,万一真实数据里恰好出现了跟填充标记相同的字节序列,就要花额外逻辑去区分。二是在头部记录长度这种方式,解包时可以先读4字节,按长度精确切分出原始数据,剩下的填充字节直接丢掉,处理起来干净利落。
2.3 解密时的长度校验与数据还原
解密流程跟加密对称。密文按8字节一组,每组做16轮TEA逆变换,得到一组8字节的明文块。所有明文块拼起来后,先读前4字节拿到原始长度len,然后从第4字节开始,往后取len个字节,那就是真正的原始数据。
这里必须做长度校验。如果解密后的数据不足4字节,肯定是密钥错误或数据损坏,直接抛异常。如果len大于解密后数据总长度减4,也说明数据不合法,拿这种长度去截数组会越界。我在C#代码里加了这些边界判断,防止线上环境里出现莫名其妙的内存异常。
填充用了随机字节而不是固定0,我一开始也觉得没必要。后来想明白了,如果尾部全是0,密文中的固定模式会增多,虽然TEA洗过一遍后不明显,但做协议分析时容易被人顺着模式猜结构。用随机字节填充,密文看起来更均匀,能增加一点逆向分析的难度。性能上没多大影响,随机数生成一次也就几微秒的事。
3. C#实现:从零把QQ TEA算法写出来
3.1 字节序处理的坑(大端还是小端)
C#写底层加密算法,第一个坑就是字节序。Java和C#的BitConverter通常默认按系统端序(小端)处理,但QQ的TEA实现里,明文长度头、分组数据转整数,通通都是大端序,也就是网络字节序。
我试过直接用BitConverter.ToUInt32去读,结果解出来的数字完全不对,因为同一段字节,不同端序解出的整数值千差万别。举个例子,四个字节01 02 03 04,大端解析是0x01020304,小端解析是0x04030201,天壤之别。
所以我干脆不依赖BitConverter,手写两个转换函数,用位运算手动拼大端序。一个把4字节转成uint32,一个把uint32拆回4字节。因为字节序的坑太隐蔽,我建议写算法时全部统一走这两个函数,不要混用。
3.2 核心加解密代码(16轮TEA)
下面是核心的TEA加解密方法。我用的是C#的uint类型,注意别用int,因为TEA里大量用到无符号整数溢出回绕,C#默认的计算环境中int溢出会抛异常,uint溢出是合法行为。实际写的时候,如果项目开了checked模式,还要在方法里显式用unchecked包一层。
private const uint Delta = 0x9e3779b9; private static void TeaEncrypt(uint[] v, uint[] k) { uint v0 = v[0], v1 = v[1]; uint sum = 0; uint k0 = k[0], k1 = k[1], k2 = k[2], k3 = k[3]; for (int i = 0; i < 16; i++) { sum += Delta; v0 += ((v1 << 4) + k0) ^ (v1 + sum) ^ ((v1 >> 5) + k1); v1 += ((v0 << 4) + k2) ^ (v0 + sum) ^ ((v0 >> 5) + k3); } v[0] = v0; v[1] = v1; } private static void TeaDecrypt(uint[] v, uint[] k) { uint v0 = v[0], v1 = v[1]; uint sum = Delta * 16; uint k0 = k[0], k1 = k[1], k2 = k[2], k3 = k[3]; for (int i = 0; i < 16; i++) { v1 -= ((v0 << 4) + k2) ^ (v0 + sum) ^ ((v0 >> 5) + k3); v0 -= ((v1 << 4) + k0) ^ (v1 + sum) ^ ((v1 >> 5) + k1); sum -= Delta; } v[0] = v0; v[1] = v1; }解密代码的顺序非常讲究,必须先恢复v1再恢复v0,这跟加密时先改v0再改v1是完全镜像的。如果写反了,解密出来就是乱码。我第一次写反过,排错排到怀疑人生。
要解释一下为什么加密过程中v0用了旧的v1,而v1用的是改完后的v0。这是Feistel网络的核心设计,保证每一轮变换都是可逆的。解密时逆向操作,先算v1的旧值,再算v0的旧值,顺序正好反过来。
3.3 填充与去填充的实现细节
填充代码我单独封装成方法,逻辑简单但容易出错,尤其是索引计算。数据布局是:前4字节长度头,中间原始数据,尾部随机字节。
private static byte[] Pad(byte[] data) { int headAndDataLen = 4 + data.Length; int paddedLen = (headAndDataLen + 7) / 8 * 8; byte[] padded = new byte[paddedLen]; padded[0] = (byte)((data.Length >> 24) & 0xFF); padded[1] = (byte)((data.Length >> 16) & 0xFF); padded[2] = (byte)((data.Length >> 8) & 0xFF); padded[3] = (byte)(data.Length & 0xFF); Array.Copy(data, 0, padded, 4, data.Length); Random rng = new Random(); for (int i = headAndDataLen; i < paddedLen; i++) { padded[i] = (byte)rng.Next(256); } return padded; }有个细节说一下,这里用Random生成随机填充。在加密频繁的场景下,每次new Random开销不大,但如果有极高并发,可以考虑改用线程安全的Random.Shared。我写的示例为清晰直接用new Random,真实项目里可以优化。
去填充的Unpad方法同样重要。解密后是一整段字节,前4字节是长度,后面的数据里有一部分是明文、一部分是填充垃圾。必须严格按长度头切分。
private static byte[] Unpad(byte[] decrypted) { if (decrypted.Length < 4) throw new InvalidOperationException("decrypted data is too short"); int len = (decrypted[0] << 24) | (decrypted[1] << 16) | (decrypted[2] << 8) | decrypted[3]; if (len < 0 || len > decrypted.Length - 4) throw new InvalidOperationException("invalid length in decrypted data"); byte[] result = new byte[len]; Array.Copy(decrypted, 4, result, 0, len); return result; }这里的移位运算有个坑:decrypted[0]是byte类型,如果直接左移24位,C#会先把它隐式转成int,而byte最高位是1时转成int是正数,不会出现符号扩展问题。但如果不小心用了sbyte,就会出一堆负数,长度直接错了。所以我统一用byte数组操作,从一开始就绝不用sbyte。
3.4 完整调用示例与验证
把加密、解密、填充、去填充整合到一个静态类里,公开Encrypt和Decrypt两个方法。整个实现里,所有分组处理都按8字节来,密钥统一要求16字节。
public static class QQTEA { public static byte[] Encrypt(byte[] data, byte[] key) { if (data == null || data.Length == 0) throw new ArgumentException("data must not be empty"); if (key == null || key.Length != 16) throw new ArgumentException("key must be 16 bytes"); byte[] padded = Pad(data); uint[] keyUint = BytesToUInts(key); int blockCount = padded.Length / 8; byte[] result = new byte[padded.Length]; for (int i = 0; i < blockCount; i++) { byte[] block = new byte[8]; Array.Copy(padded, i * 8, block, 0, 8); uint[] v = BytesToUInts(block); TeaEncrypt(v, keyUint); byte[] enc = UIntsToBytes(v); Array.Copy(enc, 0, result, i * 8, 8); } return result; } public static byte[] Decrypt(byte[] encrypted, byte[] key) { if (encrypted == null || encrypted.Length == 0 || encrypted.Length % 8 != 0) throw new ArgumentException("encrypted data length must be a multiple of 8"); if (key == null || key.Length != 16) throw new ArgumentException("key must be 16 bytes"); uint[] keyUint = BytesToUInts(key); int blockCount = encrypted.Length / 8; byte[] decrypted = new byte[encrypted.Length]; for (int i = 0; i < blockCount; i++) { byte[] block = new byte[8]; Array.Copy(encrypted, i * 8, block, 0, 8); uint[] v = BytesToUInts(block); TeaDecrypt(v, keyUint); byte[] dec = UIntsToBytes(v); Array.Copy(dec, 0, decrypted, i * 8, 8); } return Unpad(decrypted); } }这里需要特别注意加密分组数目的计算。padded.Length / 8是整数除法,只要padded长度保证是8的倍数,这个除法就没有余数问题。我在Pad方法里用(headAndDataLen + 7) / 8 * 8保证了这一点。
测试用的主程序可以这样写:
class Program { static void Main(string[] args) { string plain = "hello qq tea"; byte[] data = Encoding.UTF8.GetBytes(plain); byte[] key = Encoding.UTF8.GetBytes("0123456789abcdef"); byte[] encrypted = QQTEA.Encrypt(data, key); byte[] decrypted = QQTEA.Decrypt(encrypted, key); Console.WriteLine("原始字符串: " + plain); Console.WriteLine("加密后长度: " + encrypted.Length); Console.WriteLine("解密还原: " + Encoding.UTF8.GetString(decrypted)); Console.WriteLine("一致性: " + (plain == Encoding.UTF8.GetString(decrypted))); } }运行结果加密后长度为16字节(因为4字节长度头加12字节明文正好16字节,是8的倍数,不用补尾部),解密还原出原始字符串,一致性是True。
4. 测试与排查:怎么确认算法写对了
4.1 加密后再解密的一致性测试
算法写完之后,第一件事不是跑去接协议,而是用自测确认加解密能互相还原。我自己最常用的测试方式是:随机生成几组不同长度的明文,从1字节到100多字节都覆盖一遍,然后加密再解密,比对结果是否跟原明文完全一致。
这个测试能暴露大部分问题。比如分组长度不对,解密时数组越界或者丢数据;比如填充长度头写错,解密出来长度对不上;比如字节序转换出错,解密结果直接乱码。我写了一个小的批量测试方法,随机生成明文跑100遍,全通过才认为基本靠谱。
Random random = new Random(); for (int i = 0; i < 100; i++) { int len = random.Next(1, 200); byte[] testData = new byte[len]; random.NextBytes(testData); byte[] encrypted = QQTEA.Encrypt(testData, key); byte[] decrypted = QQTEA.Decrypt(encrypted, key); if (!decrypted.SequenceEqual(testData)) { Console.WriteLine($"第{i}组测试失败,长度{len}"); return; } } Console.WriteLine("全部100组随机数据测试通过");加密后的数据长度也有规律可循,可以用这个辅助判断。加密结果长度等于((原始长度 + 7) / 8 + 1) * 8,这里的加1是有个4字节长度头的含义。比如原始长度12,加密后长度是((12 + 7) / 8 + 1) * 8 = 16,跟实测一致。
4.2 解密乱码的排查思路
如果自测发现解密乱码,别急着怀疑算法,先按下面顺序排查。
第一步查密钥。密钥是否恰好16字节?QQ协议里不同场景用的密钥不同,如果用的是会话密钥,要确认会话密钥本身没有经过额外处理。密钥错一位,解密结果就是全盘乱码,这点跟很多分组密码一样。
第二步查填充结构。解密后先不要直接输出字符串,把前16字节打出来,十六进制看。正常情况下前4字节是一个数值,等于明文长度。如果这4字节解出来是个离谱的大数,比如几百万,那基本是加密侧填充规则和解密侧不一致,或者密钥错了。
第三步查字节序。看看转换函数里是不是统一用大端序。密文的二进制串看起来应该很均匀,如果解密前8字节转出的大端整数跟自己预期不一致,那就是字节序处理有遗漏。
我在实际排查时常用一个技巧:用一段已知明文和已知密钥,比如明文固定为QQ协议里常见的8字节结构,加密后把结果打出来跟参考实现的输出比对。只要密文一致,算法核心就对了。
4.3 长度异常与异常处理策略
解密时如果遇到长度头异常,常见表现有两种。一种是解密出来的前4字节是负数,这在用int直接接收位运算结果时会出现。C#里移位后的结果是int,如果把byte的0xFF左移24位,得到的是负数。所以我在Unpad里先判断len < 0,直接抛异常。
另一种是长度大于解密数据实际长度。明明解密出来只有16字节,长度头却写着50,这肯定是数据损坏或密钥错误。我在代码里做了判断,超过就抛InvalidOperationException,防止后续复制越界。
真实协议环境里,偶尔会遇到网络传输中数据被截断的情况。加密数据长度不是8的倍数时,我在Decrypt入口直接拒绝处理。有一些实现会尝试自动补齐尾部再解密,但我不建议这样做,因为加密侧不会产生非8倍数的密文,出现这种情况基本说明包本身不完整,勉强解密只会得到错误数据,还不如尽早报错。
5. 踩坑记录与实际应用建议
5.1 几个容易翻车的细节
写这个算法过程里,我踩过的坑能列出一小串,每个都是血泪教训。
第一个坑是C#的uint溢出。在默认的unchecked环境下没问题,但如果项目设置了checked,加法溢出直接抛异常。我后来干脆在加解密方法外层加了unchecked关键字,保证绝对不被项目全局设置干扰。
第二个坑是数组复制的边界。解密后Unpad时,原始数据从数组索引4开始,长度是len,如果目标数组长度不对,Array.Copy会抛异常。我花了很久才发现,在另一种实现里,他们用List<byte>来动态收集结果,绕过了这个问题,但效率略低。
第三个坑是Random频繁实例化。在并发环境下,多个线程各自new Random可能导致生成的随机数序列相同,因为Random默认基于时间种子,同一时刻创建的多个Random会给出一样的伪随机序列。加密包多的时候,这会导致不同请求的填充字节完全相同,虽然不影响解密,但密文模式会固定下来。后来我改成用Random.Shared或者一个静态的ThreadLocal 来解决。
5.2 密钥长度与密钥来源
QQ TEA的密钥固定是16字节。但实际协议里,密钥不一定是直接用原始字符串,很多情况下是经过MD5计算后取摘要。比如有些老版本用固定字符串做密钥,客户端和服务端各自把这个字符串MD5一下,得到16字节摘要作为TEA密钥。
我在C#里获取MD5摘要一般这么写:
using System.Security.Cryptography; using System.Text; string keySource = "some_string_key"; byte[] key = MD5.HashData(Encoding.UTF8.GetBytes(keySource));这里要注意编码。密钥源字符串用UTF-8还是GBK,取决于协议文档里定义的编码方式。QQ早期很多字符串用的是GBK,如果默认用UTF-8,算出来的MD5完全不同,加密结果也会对不上。
拿到的密钥可以保险起见用十六进制字符串打出来核对一下,跟参考实现比对。我第一次整合协议时,就是忽略了密钥来源的编码,导致加密虽然自洽但对接不了服务端,查了半天才发现是MD5原文编码错了。
5.3 把算法打包进C#项目的集成建议
这套TEA加解密类作为静态工具类使用非常方便。我建议单独放到一个名为QqCryptoToolkit的静态类里,跟协议解析、网络请求等模块解耦。对外只暴露Encrypt和Decrypt两个方法,内部细节全部私有,后续有调整只改内部,不影响其他模块。
性能方面,TEA本身极轻量,C#跑16轮加密一个8字节块,耗时基本在微秒级,完全不用担心。如果真的高频加密大量数据,可以考虑把加密循环里的数组分配优化掉,复用同一个buffer,不过一般协议场景用不到这么极致。
如果以后要对接不同版本的QQ协议,最好把填充规则做成可配置的策略。有的版本用4字节长度头,有的版本可能在长度头里还带其他标志位。至少要把Pad和Unpad抽成接口,别写死在TEA类里。我现在的做法是保留一个默认实现,后续遇到特殊版本时单独再写一套填充器,对现有代码零侵入。
5.4 关于安全性的一点看法
聊到加密,总有人问这个算法现在还能不能用、安不安全。从纯密码学的角度说,TEA这类老算法放到今天肯定不是最优先选择,很多年就有人提出针对简化轮数的攻击方法。但它在QQ协议这种封闭场景里使用,安全性主要不靠算法本身,而靠密钥的保密性和传输通道的保护。
我在做这个实现时,始终把它定位成协议兼容性技术,而不是通用安全方案。如果在自己的项目里想保护用户数据,我不会推荐直接用TEA,AES-GCM这类现代算法才是更好的选择。但如果你要做的是老协议兼容、数据包解析、或者纯粹出于学习目的研究分组密码原理,那写一遍TEA的收获非常大,能在很短的时间里帮你看懂Feistel结构、填充机制、端序转换这些密码学工程里绕不开的基础概念。
最后再分享一个小技巧,写这种底层算法类的时候,debug模式下多打几个十六进制日志很有用。加密前把明文、填充后数据、每组明文块都打出来;解密后把每步中间结果也打出来。排完错再把这些日志切到条件编译或者直接删掉,别留在线上代码里。我这次能快速定位问题,全靠这种方式,比单纯断点单步高效得多。