1. 认识ZUC:4G空口加密背后的核心引擎
1.1 你的手机每一秒都在跑这套算法
如果你调试过4G空口协议栈,或者做过终端侧的协议一致性测试,大概率见过两个编号:128-EEA3和128-EIA3。这是3GPP LTE标准里正式采用的加密与完整性保护算法,而它们底层的核心引擎,就是今天要聊的ZUC祖冲之算法。
这句话不是包装,是字面意思。你的手机在4G网络下打电话、刷网页、传消息,空口链路上的信令和业务数据,很大概率就是用ZUC生成的密钥流做过异或加密的。我在调一个模拟基站项目时,为了确认加密链路是否真的生效,把ZUC的软件实现从头到尾捋了一遍,期间踩了不少坑。这篇文章把我整理出来的配置要点和实操流程分享出来,照着走一遍,你也能在几分钟内把一个能用的ZUC密钥流生成模块跑起来,并且确认它输出的结果是对的。
理解ZUC不需要特别高深的数学背景。你只要清楚一个核心概念:它是个流密码。流密码的工作方式有点像“一次性密码本”——用密钥和初始向量生成一串无穷无尽的伪随机密钥流,然后把明文和密钥流逐字节异或,得到密文。反过来,接收端用同样的密钥和初始向量生成同样的密钥流,再异或一次,明文就还原了。ZUC负责的,就是“生成那串密钥流”这件事。
适合来看这篇文章的人主要有两类。一类是做4G/5G协议栈开发、基站或终端测试的工程师,你需要把加密算法跑起来做联调;另一类是密码学方向的学生或安全研究人员,你手头可能有一个ZUC的数学描述,但想吃透它的工程细节。这篇文章不绕弯子,直接给思路、给代码骨架、给避坑清单。
1.2 为什么4G选择了流密码而不是分组密码
你可能会有个疑问:AES在4G里也在用,ZUC和它是什么关系?
4G LTE的数据保护体系里,加密算法和完整性算法成对出现。128-EEA1和128-EIA1基于AES,128-EEA2和128-EIA2基于SNOW 3G,128-EEA3和128-EIA3则基于ZUC。网络和终端在建立安全上下文的时候,会通过协商机制选一组用。三种算法并存的局面,既是为了避免单一算法被攻破后的全盘风险,也照顾了不同阵营的产业需求。
那为什么有了AES这种成熟的分组密码,还要单独搞一个流密码?关键在“匹配场景”四个字上。
空口的数据是连续流式的,一个子帧一个子帧往下走,分组密码需要对每个块做多轮迭代运算,硬件实现时面积大、时延高。流密码天然是逐比特/逐字节处理的,生成密钥流的电路可以做到非常紧凑,延迟也低,很适合终端这种对功耗和面积敏感的嵌入式环境。ZUC的设计目标就是:在资源受限的平台上,用较低的成本达到足够高的安全强度和吞吐量。
另外还有一个容易被忽略的点:完整性保护。EEA是加密,EIA是完整性,两者的工作模式不同,但都建立在ZUC的密钥流生成能力之上。所以只要你把ZUC的核心模块跑通,往上接一层模式逻辑,两个算法都能出。这也是为什么我说搞懂ZUC的值钱之处,不在于背下几个API,而在于理解那套密钥流是怎么被精密地“挤”出来的。
2. 算法内部怎么转:LFSR、比特重组与F函数逐一拆解
2.1 三个模块的分工与协作
ZUC的结构一眼看过去有点吓人,拆开其实就三个部分:线性反馈移位寄存器LFSR、比特重组BR、非线性函数F。
用一条流水线来打比方:LFSR相当于原料仓库,里面存着16个31比特的寄存器状态,每拍向前滚动一次,输出一批“半成品”;比特重组是个搬运工,它从仓库特定的货架上取货,把31比特的数据重新拼装成4个32比特的“标准件”;非线性函数F是精加工车间,拿其中两个标准件加上内部记忆状态做非线性搅动,输出一个32比特的成品,同时更新自己的状态。
整条流水线每个时钟周期出一轮结果。初始化阶段,F的成品W不直接对外输出,而是被回灌到LFSR的反馈回路里,用来把初始状态“搅匀”;进入工作模式后,W才作为真正的密钥流输出。这个设计思路在密码学上很常见:先让系统充分混合,把所有可预测的规律性抹掉,再开始对外干活。
三个模块之间的关系不是简单的串联。F函数的新状态会参与下一轮计算,LFSR的反馈公式在初始化模式和工作模式下也不一样。所以实现的时候必须严格区分阶段,这是后面要重点强调的坑。
2.2 LFSR的初始化和反馈逻辑
LFSR是ZUC的状态主体,16个寄存器单元s0到s15,每个单元保存31比特数据。这里为什么是31比特而不是32比特,很多人第一次看会愣住,其实是个很聪明的设计:31是素数,选取一个本原多项式作为反馈连接,能保证输出序列的周期尽可能长,也就是让密钥流不容易在短时间内重复。同时,用模2^31-1的加法运算,可以避开一些代数结构上的弱点,增加分析难度。
初始化的时候,要把128比特的密钥key和128比特的初始向量IV装进这16个单元里。标准的装载方式是:每个寄存器单元由三段拼接而成,分别是key的一个字节、一段固定常数D、以及IV的一个字节。也就是说,s_i = key[i] || D[i] || iv[i]。这里的D常数是标准文档里写死的15比特固定值,用来保证初始状态不会落入全零等危险区域。
装载完后,R1和R2清零,然后进入32轮的初始化模式运算。这个阶段每个周期做什么?LFSR先走一步反馈,BR取出4个32比特字,F函数计算出一轮结果W。在初始化模式下,W不输出,而是被拆成两部分,与LFSR的反馈值一起模加回s16,再整体移位。这样相当于让密码系统的所有状态都充分混合了32拍,把key和IV的统计规律彻底打散。
工作模式就简单多了:LFSR照常反馈,但W不再回灌,而是直接作为密钥流输出。这里有个很隐蔽的细节,初始化模式的反馈公式里包含了W,也就是非线性项参与反馈;工作模式的反馈公式则是纯线性的。这两条公式在标准文档里分开给出,我见过不少实现把它们混写,结果就是前32轮状态全对、从第32轮开始输出全错,非常折磨人。
2.3 比特重组与非线性函数F
LFSR里是31比特的单元,但ZUC对外要输出32比特的字,中间那道转换工序就由比特重组BR完成。
BR从16个寄存器单元里挑出8个特定位置,通过拼接高位和低位的方式,生成4个32比特字X0、X1、X2、X3。具体选哪些位置不是随意的,而是经过分析选择的,目的是让密钥流的输出位与LFSR状态之间不要有简单的线性对应关系。X0和X1进入F函数参与搅动,X2和X3则直接参与最终密钥流的输出。也就是说,密钥流不是F输出的W单独决定的,还混合了一部分LFSR的当前状态,这一设计能抵御某些针对状态恢复的攻击。
F函数内部包含两个S盒S0和S1,两个线性变换L1和L2,以及两个32比特的记忆单元R1和R2。每一轮,F拿X0、X1、X2进来,先算一个中间量,再分别更新R1和R2,最后输出W。S盒的查表操作是唯一的非线性来源,密码强度主要靠它撑着;L1和L2是线性扩散,负责把S盒输出的效果快速扩散到整个32比特字上。
懂硬件设计的人看到这种结构会觉得很眼熟:S盒加线性变换再加记忆单元,本质上是一条带反馈的替换-扩散网络。它的优势在于,每轮只需几次查表和几次加减异或运算,非常适合硬件流水线实现。这个F函数的输出W在初始化阶段回灌给LFSR,本身也是一种反馈控制,确保系统不会因为纯线性反馈而退化。
3. 实战配置:手写最小ZUC实现并跑通加密链路
3.1 准备工作与工程结构
我建议第一次动手时选C语言。原因很直接:4G协议栈、基站模拟器、终端协议栈基本都是C/C++的生态,你写完的代码能直接扔进工程里跑。如果想快速验证逻辑,也可以用Python先搭一个教学版,但最终落地还是要回到C。
工程结构不用复杂,四个文件就够了:
- zuc.h:对外接口和上下文结构体声明
- zuc.c:ZUC核心逻辑实现
- sbox.c:S0、S1两个256项32位查表的定义
- test_zuc.c:测试入口,加载一个测试向量并输出密钥流
S盒表不用自己算,标准文档附录里就有完整数据,或者从主流开源密码库里抄一份,注意核对来源即可。这里强调一点:S盒是32比特输入、32比特输出的查表,很多人会误以为是AES那种8进8出的S盒,实际不是。查表方式也不同,ZUC的S0、S1每个都是256个32比特值的表,先查出结果再做线性变换。
构建命令也简单,如果工程目录下有了五个源文件,一条命令就能编出测试程序:
gcc -O2 -o test_zuc zuc.c sbox.c test_zuc.c -Wall -Wextra3.2 核心代码框架与逐步实现
我把实现拆成几个函数,分别是上下文初始化、LFSR反馈、比特重组、F函数、以及密钥流生成。这样每一块都能独立测试,出问题也好定位。
上下文结构体定义如下:
#include <stdint.h> typedef struct { uint32_t lfsr[16]; uint32_t R1; uint32_t R2; int gen_mode; // 0: 初始化模式, 1: 工作模式 } zuc_ctx; void zuc_init(zuc_ctx *ctx, const uint8_t *key, const uint8_t *iv); void zuc_generate_keystream(zuc_ctx *ctx, uint32_t *keystream, int num_words);zuc_init负责装载key和IV、清零R1R2、然后跑32轮初始化模式。LFSR装载时,把每个字节分别拼进对应寄存器单元,这个字节序问题在后面专门讲。
LFSR反馈运算的核心是计算一个新的s16。初始化模式和工作模式的区别就体现在这里。用一个switch分支来区分两套反馈公式,是我在工程里比较推荐的做法,比一个公式硬扛到底直观得多。
static uint32_t lfsr_next(zuc_ctx *ctx, uint32_t w) { uint32_t *s = ctx->lfsr; uint32_t s16; uint32_t v = s[0]; // 按标准计算线性反馈值:对s0、s2、s5、s3、s1做移位异或 uint32_t linear = ( ((v << 31) >> 31) ^ ((s[1] << 31) >> 31) ^ ((s[2] >> 29) & 0x7) ^ ((s[3] >> 28) & 0xF) ^ ((s[4] << 3) & 0x7F8) // 后面还有几项,按标准逐一补齐 ); // 简化示意:实际完整实现需按照标准文档公式逐项展开 if (ctx->gen_mode == 0) { // 初始化模式:W参与反馈 s16 = (linear + w) & 0x7FFFFFFF; if (s16 == 0) s16 = 0x7FFFFFFF; // 还需要再加上高位截断项,标准文档中有完整公式 } else { // 工作模式:纯线性反馈 s16 = linear; } // 整体移位 for (int i = 0; i < 15; i++) { s[i] = s[i + 1]; } s[15] = s16; return s16; }这段代码我故意没写全,因为完整的线性反馈组合项比较多,手敲容易错,直接抄标准文档附录里的公式最稳妥。接下来是比特重组和F函数:
static void bit_reorganization(zuc_ctx *ctx, uint32_t *x0, uint32_t *x1, uint32_t *x2, uint32_t *x3) { uint32_t *s = ctx->lfsr; *x0 = ((s[15] & 0x7FFF8000) << 1) | (s[14] & 0xFFFF); *x1 = ((s[11] & 0x7FFF8000) << 1) | (s[9] & 0xFFFF); *x2 = ((s[7] & 0x7FFF8000) << 1) | (s[5] & 0xFFFF); *x3 = ((s[3] & 0x7FFF8000) << 1) | (s[1] & 0xFFFF); } static uint32_t zuc_f(zuc_ctx *ctx, uint32_t x0, uint32_t x1, uint32_t x2) { uint32_t w = ((x0 ^ ctx->R1) + ctx->R2) ^ x2; uint32_t tmp = (x0 ^ ctx->R1) + ctx->R2; uint32_t newR1 = sbox0((tmp << 16) | (tmp >> 16)) ^ sbox1((tmp & 0xFFFF) ^ (tmp >> 16)); // 标准里的线性变换L1和L2各自有固定的异或移位组合 uint32_t newR2 = sbox0((w << 16) | (w >> 16)) ^ sbox1((w & 0xFFFF) ^ (w >> 16)); ctx->R1 = newR1; ctx->R2 = newR2; return w; }这里我把S盒的上层调用方式写了个大概。标准文档里S0、S1的输入是一整个32比特字,但查表之前要先做线性变换L1或L2,再把结果拆成两个16比特半字分头去查,最后异或合并。直接拿32比特去查是不对的。很多半路出家的实现错在把这一层简化成了“查一次表”,导致输出和标准对不上。
3.3 测试向量验证方法
代码写完,第一件事不是急着加密数据,而是先做测试向量验证。ZUC标准在3GPP TS 35.222的附录里给出了一组测试向量:给定一组key和IV,预期输出的密钥流是什么,文档里直接写了十六进制串。
验证的步骤非常机械:
- 准备一个全零的key和一个全零的IV;
- 初始化上下文,跑出前若干个32比特密钥流字;
- 和标准文档里的预期值逐字节对比。
我的经验是:只要前4个32比特字完全一致,后面基本就不会有大问题。因为ZUC的扩散性很好,哪怕你某一位装错,第4个字之后就会面目全非。所以可以写个循环,只打印前16个字,贴到文档旁边一行行对。
如果对不上,先别怀疑标准错了——十有八九是你自己的实现问题。最常见的三处:字节序、模运算0值处理、初始化模式和工作模式的反馈公式。下面专门开一节说。
4. 避坑指南:ZUC实践中最容易踩的3类错误
4.1 字节序和位序:最容易出错的地方
我在第一次实现ZUC时,在测试向量这一步卡了整整一天。后来发现罪魁祸首是字节序:key和IV装载进LFSR时,标准文档里定义的是“字节按序号依次填入寄存器单元”,而工程实现里有些人图省事,直接把key的4个字节当成一个32比特整数写进寄存器,高位低位一颠倒,输出的密钥流就完全不对了。
更隐蔽的是位序问题。标准文档在定义寄存器和输出字的时候,会明确bit0是最低位还是最高位。ZUC的设计里,LFSR寄存器单元的高位部分和低位部分有各自的语义,BR拼接时用的是“取这个单元的高17位”和“取那个单元的低16位”这种写法。你要是用C语言的位域去定义,必须确认编译器对位域的内存布局和你预期一致,否则又是个隐蔽坑。
我的建议是:不要试图在脑海里把整个位序逻辑转一圈。直接把标准文档里的拼接表达式逐字翻译成位运算,每一个括号的移位方向和掩码都对着原文检查两遍。宁可写得啰嗦一点,也别用玄妙的位操作简写。这个习惯能帮你省下大量排查时间。
4.2 模2^31-1的0值陷阱
ZUC的LFSR反馈运算用到了模2^31-1的加法。模2^31-1加法和普通加法有一个区别:当两数相加结果的低31位恰好是0时,严格意义下这个值应该等于2^31-1,而不是0。因为2^31-1在模2^31-1体系下和0是等价的。
这个“0等于全1”的规则,是ZUC实现里最经典的一个坑。如果你在反馈计算里直接做加法和掩码,没有判断结果为0的情况并把值替换成0x7FFFFFFF,那么LFSR的某一个寄存器单元就会变成0。一旦某个状态变成0,后续的线性反馈可能让整个状态退化,密钥流质量急剧下降,甚至输出出现周期性重复。
在调试时,我建议在LFSR更新函数里加一个临时断言:每个周期结束后检查16个寄存器单元是否有任意一个等于0x00000000。如果出现0,就一定是你漏了归一化处理。正常实现下,由于D常数的设置,初始化之后的LFSR状态不会出现0值,这个断言能帮你快速锁定问题。
4.3 初始化模式与工作模式的切换
第三个高频坑出现在模式切换上。ZUC的初始化过程需要运行32轮,这32轮里F函数的输出W要回灌到LFSR的反馈回路。从第33轮开始,W不再回灌,而是直接作为密钥流输出。
有些实现图省事,把所有轮次都套用同一个反馈公式,这样做的结果是:前32轮状态可能看起来没什么异常,但第33轮开始的输出和标准答案完全不同。你排查时只看前几字反而发现不了,非得把输出序列拉长到第5个字之后才能看出问题。
我的经验是:在代码里显式区分状态,而不是用轮次数硬编码。定义一个gen_mode字段,初始化阶段设成0,32轮跑完后置为1。LFSR反馈函数里直接读这个字段,分支处理。这样不仅逻辑清楚,以后如果要支持“密钥更新但上下文复用”这类场景,切换也更自然。
5. 工程化落地:性能优化与协议栈集成要点
5.1 软件实现的吞吐优化思路
如果你只是测试,逐轮输出密钥流完全够用。但真到项目里,ZUC的吞吐能力决定了加密模块能不能跟上数据面的速率。
实际优化有几个方向。第一,查表开销是最大的一块,S0、S1两张表总共2KB,在现代CPU里能稳稳放进L1缓存,所以热点数据要保证访问局部性,千万别在函数里频繁传大结构体。第二,F函数的计算链可以全部展开成无分支的位运算序列,避免条件跳转导致流水线停顿。第三,密钥流的生产和消费解耦——不要调用一次生成一个32比特字,而是开辟一块缓冲区,一次性生成上千个字的密钥流,每次异或时直接从缓冲区取,缓冲区用完了再触发新一轮生成。
我在一个模拟项目里用过这样的做法:初始化时一次性生成4KB的密钥流缓冲,数据到达时直接异或。实测下来单核吞吐能达到每秒几百兆比特,满足大多数模拟场景足够了。如果你的目标环境更受限,比如跑在某个嵌入式软核上,那就要考虑对LFSR的反馈链路做并行展开,这里就不展开了。
还有一点值得提:ZUC在硬件上实现时,核心延迟可以做到非常低,因为LFSR和F函数都是典型的一拍完成逻辑。也正因为如此,很多基站侧的实现会倾向于用硬件加速,把CPU省下来做协议处理。软件实现则更适合模拟器、测试仪表和教学平台。
5.2 与协议栈集成时的关键参数
算法本身跑通只是第一步,真正接进4G协议栈时,你还要处理好密钥流不被重复使用的安全问题。这部分很容易被忽略,但它直接关系加密强度。
ZUC作为流密码,最忌讳的是同一个密钥流加密两个不同的消息。一旦出现这种“密钥流重用”,攻击者把两段密文异或,就能消掉密钥流,直接得到两段明文的异或结果,明文信息就会泄露。
协议栈通过一组参数来避免这个问题:COUNT、BEARER和DIRECTION。COUNT是计数器,每条消息递增;BEARER标识无线承载;DIRECTION区分上下行。ZUC的初始向量IV就是由这些参数拼接、扩展而来的。所以在工程集成时,你要做的是保证每个数据包在调用ZUC生成密钥流之前,IV都按标准公式正确组装好,而不是在初始化时固定一个IV然后一辈子不复用。
另外,密钥本身由网络侧在安全模式命令里下发给终端,协商过程属于上层信令。作为算法模块的调用方,你只需要接收已经协商好的128比特密钥,然后配合COUNT、BEARER、DIRECTION组出IV,调用初始化函数即可。这个边界要清晰,别把密钥管理逻辑也塞进算法模块里。
我自己第一次完整跑通ZUC时,其实不是一次成功的。标准文档里那段初始化模式的反馈公式我看漏了一行,导致前32轮结果和参考实现完全对不上,排查到深夜才发现是漏了一个移位项。所以我的建议是:先别急着写大框架,把标准附录里的测试向量当成你的对照基线,跑一步对一步,这样能省下大量时间。ZUC的结构其实非常工程化,吃透它之后,你再去看128-EEA3和128-EIA3的工作模式也会很快。以后真到了项目里,当你看到空口数据被正确解出、完整性校验逐条通过的时候,你会有种“这算法是我亲手揉出来的”踏实感。