ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

TEA填充算法C#实现及QQ协议应用解析

TEA填充算法C#实现及QQ协议应用解析 TEA填充算法这五个字做QQ协议分析的人肯定不陌生。说得直白一点早期QQ在加密网络数据之前需要先把任意长度的明文“规整”成TEA算法能处理的8字节整数倍加密后再发出去接收方解密之后再根据填充规则把原始数据准确还原回来。我最近用C#把这一整套逻辑重新实现了一遍从填充、分组加密到解密、反填充全部没有依赖任何第三方库。这篇文章就把完整代码、关键原理和实操里踩过的坑一起整理出来给正在研究QQ协议、需要兼容老系统或者单纯对TEA感兴趣的同学一份可以直接抄走的参考。1. 为什么QQ协议会用到TEA填充1.1 TEA这个老算法为什么值得研究TEA的全称是Tiny Encryption Algorithm1994年由剑桥大学的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。举个例子明文长度是8padLen就是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模式有关但整体来说性能完全不是瓶颈。如果要在高吞吐场景下使用可以考虑三个优化方向第一用Spanbyte代替数组拷贝减少内存分配第二把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本身不难难的是把边界条件和字节序处理干净。希望这篇文章能把你的路铺平一点。
返回列表