过去一段时间,我把AES CCM算法在Verilog里从零写了一遍,目标是让它能在FPGA上稳定跑起来,而不是只停留在功能仿真。AES CCM这个名字在无线局域网安全、物联网数据链路这些场景里出现频率很高,说白了就是一种既保机密又防篡改的认证加密方式。这次探索我分了四步走:先啃算法拆解,再写AES核心,接着做顶层状态机,最后用大量随机向量验证并上板。整个过程比较折腾,但也把很多文档里不会写的边界细节摸透了。如果你正准备在FPGA上做安全通信、传感器网关,或者只是想把CCM内部时序弄清,这篇记录应该能帮你省下不少时间。
1. 算法拆解:CCM为何把一次认证加密变成两次AES
1.1 两个机制一明一暗:CTR负责加密,CBC-MAC负责防篡改
先把概念说清楚。AES CCM并不是一个新的密码原语,它把两种已有模式组合在一起:
- CTR模式负责机密性。做法是生成一个个计数器块,用AES加密得到密钥流,再和明文按位异或,得到密文。
- CBC-MAC负责完整性校验。它把消息切成128位块,每个块和上一个块的密文状态异或后走一次AES,最后一个块的结果作为认证码。
麻烦在于,这两个过程用的都是AES加密方向,但要对同一份数据各跑一遍。也就是说,一方数据进来,至少要被AES核心消费两次。第一次算出MAC状态,第二次才能产生密文。用排队来比喻:CTR像是发号码牌的工作人员,CBC-MAC则是门口查证件的保安,两件事都得做,一个都不能少。
还有个容易被绕晕的点:解密方向到底要不要AES逆变换?不需要。CTR模式无论是加密还是解密,都只是用AES加密计数器块来生成密钥流,然后再异或回数据。MAC校验也是重新计算一次MAC然后比较。所以整个CCM模块只需要一个AES加密核心,不用去写InvSubBytes、InvMixColumns那一堆逆轮逻辑。这个认识让我后面省掉了将近一半工作量。
1.2 B0、A0和其他格式块:所有魔法都集中在块构造
CCM的输入参数包括密钥K、临时值Nonce、附加认证数据AAD,还有两个长度参数:认证标签长度M和消息长度字段字节数L。标准里对这些做了严格的块格式定义。
CBC-MAC的起点是B0块,一共16字节:
- 第0字节是Flags,包含保留位、Adata标志、认证长度编码和L编码;
- 后面跟着Nonce,通常为7到13字节;
- 最后是消息长度字段,用L字节的大端整数表示。
CTR部分同样有一个A0块,它和B0长得像,但Flags里只有L相关编码,没有认证长度和Adata标志。A0的作用是生成一个专门的密钥流片段,用来给最后的认证标签做掩码。后续A1、A2等计数器块逐次递增,对应每条明文块。
我记得第一次读到这些格式时最大的困惑,是不知道为什么要搞这么多“魔数”。实际写代码就明白了:B0块把消息长度、认证参数、附加数据标志全部揉进一个起始状态里,这样即使两条消息内容一样但长度字段不同,最终MAC结果也不一样,有效防止了长度歧义攻击。AAD部分则要单独构造一个B1块作为编码前缀,按AAD长度插入两字节或更多字节的表示,后面再跟真正的AAD内容。
1.3 认证与加密的先后顺序,以及MAC的长度掩码
完整流程的顺序是:
- 拼接B0块,进入CBC-MAC。
- 如果存在AAD,拼接B1块以及后续AAD数据,继续做CBC-MAC。
- 把明文消息按128位切块,每一块继续做CBC-MAC。
- 最后把CBC-MAC的结果和A0产生的密钥流做异或,只取高M字节作为认证标签Tag。
- 进入CTR阶段,对计数器A1、A2、A3...做AES加密,得到密钥流,和明文异或得到密文。
- 输出顺序是密文在前,认证标签在后。
这里有个隐藏细节:A1才是第一条密文对应的计数器,A0只服务于Tag掩码。不少人写CTR循环时会顺手从A0开始加密明文,结果标签会神秘错位。我在一开始实现时也犯过这个错,最后是靠对比测试向量才查出来。
2. AES-128内核的Verilog实现:先建稳定内核
2.1 状态矩阵排列与轮函数
AES内部把128位数据看成4x4字节矩阵,列优先存放。也就是说,输入的前4个字节是第一列,不是第一行。这个排列方式在做ShiftRows和MixColumns时要格外小心。
我用的顶层数据接口是扁平向量,例如state[127:0],其中state[7:0]对应字节0,state[15:8]对应字节1,以此类推。SubBytes阶段把每个字节送入S盒:
genvar i; generate for (i = 0; i < 16; i = i + 1) begin : sbox_map assign sbox_out[i*8 +: 8] = sbox_lut[state[i*8 +: 8]]; end endgenerateShiftRows要对四行分别做0、1、2、3个字节的循环左移。如果把state看成由四个32位字c0、c1、c2、c3组成,ShiftRows后的行关系是:row0不动,row1左移1字节,row2左移2字节,row3左移3字节。用扁平向量写的话,就是一组字节重排的assign语句,虽然看着繁琐,但没法用简单循环一笔带过。
MixColumns则是对每一列的四个字节做GF(2^8)有限域乘加。我封装了gmul2和gmul3两个函数,用异或和移位实现:
function automatic [7:0] gmul2(input [7:0] a); begin gmul2 = {a[6:0], 1'b0} ^ (a[7] ? 8'h1b : 8'h00); end endfunction function automatic [7:0] gmul3(input [7:0] a); begin gmul3 = gmul2(a) ^ a; end endfunction列混合的核心就是每列做c0, c1, c2, c3的线性变换:新c0是gmul2(c0) ^ gmul3(c1) ^ c2 ^ c3,其余按矩阵循环类推。这些函数用automatic声明,避免综合时弄出意外共享变量。
2.2 轮密钥扩展:固定会话密钥下的预计算策略
AES-128需要11组轮密钥,包括初始密钥和10轮扩展密钥。密钥扩展的核心是每轮从上一组密钥中取最后一个字,经过RotWord、SubWord、Rcon异或后,再逐字异或生成。
CCM典型场景是会话级固定密钥:一整包数据可能连续几百个块都用同一个密钥。在这种情况下,完全没有必要在每处理一个块时重新计算扩展密钥。我选择了在模块收到新密钥时执行一次扩展,把11组128位轮密钥存进寄存器数组,之后所有AES调用直接复用。这个改动让每个AES块的运转周期少了至少10个周期的密钥计算开销。
如果以后要做密钥频繁切换的设计,再改成“按轮边算边用”的模式也不难,只是状态机会多一组计数器。硬件设计就这点有意思,同一个功能换一个调度策略,面积和时延会差很多。
2.3 迭代架构还是流水线架构,我先选了串行
AES实现有一个经典选择题:把11轮摊开成流水线,还是用一套轮逻辑跑10个循环循环迭代。
流水线吞吐率高,但代价是S盒资源要复制10份甚至更多,LUT和BRAM占用剧增。我这次目标是先把CCM链路跑通,对吞吐率的要求不是极端高,所以选了一轮循环迭代方案:每一拍完成一个轮操作,10拍处理完数据块,加上初始AddRoundKey共11个有效周期。资源只消耗一份S盒和一份MixColumns逻辑,面积友好得多。
实测下来,这个串行AES核给顶层状态机带来的等待周期很明确:每块固定11拍。这对CCM的大状态机来说非常友好,不用额外判断AES走到了哪一轮,只要等aes_done拉高就能继续。
3. 顶层控制:CBC-MAC与CTR模式的状态机编排
3.1 输入输出接口协商
顶层模块没有走复杂的总线协议,我用了一组简单的valid/ready握手风格,同时用last指示一个包的最后一个128位数据。端口大致如下:
clk、rst_n:时钟和异步复位;key[127:0]、nonce[... :0]:密钥和临时值,包开始时采样;aad_data[127:0]、aad_valid、aad_last:附加认证数据输入流;msg_data[127:0]、msg_valid、msg_last:明文输入流;cipher_data[127:0]、cipher_valid、cipher_last:密文输出流;tag_out[127:0]、tag_valid:认证标签输出。
之所以把AAD和明文分开两个输入端口,是为了避免在外部用MUX反复切换。实际如果总线只有一个数据通道,把AAD和明文都在msg_data上串行输入也可以,状态机里加一个是否处于AAD阶段的标志即可。我在调试阶段发现,分开端口更容易定位是哪一段数据出错。
3.2 明文暂存BRAM:先认证后加密的必然代价
顶层控制有个绕不开的顺序问题:同样一份明文,要先做CBC-MAC,再做CTR加密。如果输入侧是流式的,你收到一个块时不立刻加密,还得缓存下来。我最初想过在MAC阶段请求外部重新送一次明文,但这样对端就要配合做一个“回放”机制,很不优雅。
最终方案是在模块内部放一个双口BRAM,把进来的明文块按顺序写进去。BRAM深度按照最大包长配置,一般通信场景下包长不超过几百字节,用几个BRAM足够。AAD则不需要缓存,因为在MAC处理到它之前,输入阶段已经把AAD收完了,之后可以从外部逻辑重放,或者干脆在输入阶段就把AAD的MAC异或状态算掉一部分。
说句实在话,这个BRAM是CCM硬件实现里绕不开的成本,谁告诉你不要缓存就能做,那多半只是把数据回放的矛盾踢给了上游。设计取舍时先把这个资源预算算进去。
3.3 FSM状态转移:从IDLE到TAG输出
顶层状态机是整个模块的大脑,我用了一段式写法,把状态转移和输出逻辑分开。核心状态如下:
| 状态 | 含义 | 进入条件 | 主要动作 |
|---|---|---|---|
| IDLE | 空闲 | 复位结束 | 等待start |
| MAC_B0 | 装载B0块 | 收到start | 把B0写入AES输入,启动AES |
| MAC_AAD | 处理AAD块 | MAC_B0完成且存在AAD | 拼接AAD数据做CBC-MAC |
| MAC_MSG | 处理明文块 | AAD处理完,或没有AAD时直接进入 | 从BRAM读出明文块,异或前级状态后做AES |
| MAC_FINAL | MAC收尾 | 所有明文块处理完 | 对A0做AES,得到Tag掩码,计算最终标签 |
| CTR_MSG | CTR加密 | MAC收尾完成后 | 从BRAM再读明文块,生成A1/A2...计数器做AES,异或输出密文 |
| OUTPUT_TAG | 输出标签 | CTR阶段最后一个块完成 | 输出Tag,回到IDLE |
MAC过程里,每个CBC-MAC块都要等待AES的aes_done信号。MAC_MSG状态下有一个固定的异或依赖:当前块的输入等于prev_mac_state XOR 当前明文块。因此无法在上一块AES还没结束时提前把下一块送进去。CTR阶段则相对自由,计数器块可以先算好,只是我在串行版本里没有做CTR预取,因为这个依赖不存在,纯属吞吐优化空间。
3.4 关键信号样例:握手和块边界
一个容易出的bug是last信号和块计数不同步。比如你声明一个包是3块,但实际输入时只拉了两个last,后续状态机就会在MAC阶段多等一个块。我的做法是同时维护一个块计数器block_cnt,在每个valid && ready处加一,last到来时检查它和包长配置是否一致,不一致就报错并回到IDLE。这在仿真阶段帮我抓到了不少输入侧激励没写对的问题。
CTR阶段的计数器生成采用如下逻辑:
assign ctr_block[127:0] = {nonce_field, current_l_bytes}; // 高字节保持nonce,低L字节自增模块化开发让这里很清楚:计数器块本身不需要走S盒前的复杂构造状态,只要在CTR_MSG状态根据当前序号生成新的A_i块,送入AES核即可。
4. 开销与坑:字节序、长度字段和标签生成的边界细节
4.1 字节序不一致带来的整包白干
这是我在整个项目里花时间最长的一个坑。用参考软件比对时,密文的前几块都对,但MAC永远错。查了很久后我把仿真里的B0块按十六进制逐字节打印出来,再和标准解算过程对照,发现Nonce的字节顺序颠倒了。
原因很具体:输入侧的总线是大端先到nonce[127:0]的最高字节,但标准算法要求把Nonce的第一个字节放在B0块紧随Flags之后的字节位置。如果不做重排,B0块看起来就完全不是同一个数值。这个错误在纯软件里几乎不会发生,因为软件数组天然按字节索引处理;但在硬件扁平向量里,位索引顺序需要你自己保证。
建议是开发早期就做一个小脚本,把B0、A0、A1这类的格式块专门输出成hex文件,用Golden Model逐字节比对。别一上来对比最终密文和标签,那只能告诉你错,不能告诉错在哪一环。
4.2 AAD长度前缀的编码规则
AAD在MAC阶段并不是直接拼原始字节,而是要先构造一个长度前缀。标准要求的规则是:
- 当AAD长度小于
0xFF00时,用两字节表示长度; - 当长度处在
0xFF00到0xFFFFFFFF之间时,先放一个0xFF字节,再跟四字节长度; - 长度再大的情况,放两个
0xFF字节再跟八字节长度。
模块里因为要通用,我把这段逻辑做成子模块,输入AAD字节长度,输出前缀字节和前缀总长度。实际无线局域网类协议里AAD都很短,两字节路径已经覆盖大多数情况,但代码还是保留了完整规则,免得以后换协议时又回来改状态机。
4.3 CBC-MAC要补位,CTR不补位
CBC-MAC阶段所有块都必须拼满16字节,不足的在末尾补零。B0、AAD块、明文块都一样。
CTR阶段则完全不同:密文长度和明文长度一致,如果最后一条明文剩余3字节,那就取对应密钥流的前3字节做异或,不需要把块补满,输出也只有3字节。这个差异一开始让我很困惑,因为两个阶段共用一个last信号。我在实现里分别维护mac_gap_cnt和ctr_gap_cnt两个计数变量,前者负责补零,后者负责截断。把这两个逻辑写在一起,几乎一定会出错。
4.4 标签截断与常数时间比较
认证标签长度M的可选值通常是4、6、8、10、12、14、16字节。即便支持这么多配置,底层算法仍是完整的16字节MAC结果,最终只截取前M字节。
Tag的计算是:
最终标签 = CBC-MAC输出的最后一个16字节块 XOR AES(A0)结果的steam m高M字节
在验证解密时,标签比较不能因为中间某字节不同就提前跳出,否则容易给功耗或时序侧信道留口子。硬件实现里最简单可靠的做法是把期望值和接收值逐字节异或或起来,最后检查归约结果是否为0:
always @(*) begin tag_error = 1'b0; for (i = 0; i < TAG_BYTES; i = i + 1) tag_error = tag_error | (exp_tag[i*8 +: 8] ^ recv_tag[i*8 +: 8]); end这种比较方式代码量很小,综合后逻辑也浅,适合放在最后输出级。
5. 仿真测试与上板实测:把向量跑到绿
5.1 黄金模型与大规模随机向量
没有可靠的参考实现,CCM这种多模式组合的模块几乎不可能靠肉眼看出对错。我的做法是写一个Python脚本,调用成熟密码学库生成标准CCM结果作为“黄金模型”。
脚本流程很简单:随机生成密钥、Nonce、AAD和长度不等的明文,计算加密后的密文和标签,把输入输出都写成hex文件。Verilog Testbench用$readmemh加载这些文件,逐条驱动DUT,再把结果写回文件,最后和Python侧比对。
用伪代码表示就是:
for i in range(5000): key = random_bytes(16) nonce = random_bytes(11) aad = random_bytes(random.randint(0, 64)) msg = random_bytes(random.randint(0, 512)) c, tag = reference_ccm_encrypt(key, nonce, aad, msg, taglen=12) write_vector(i, key, nonce, aad, msg, c, tag)重点是把“长度非16倍”、“AAD为空”、“AAD中段横跨多个块”、“明文恰好16字节”这些场景都覆盖进去。我后来发现的好几个边界bug,都是随机向量在小概率下撞出来的。
5.2 测试中最容易卡住的三类问题
第一类:握手信号不释放。某个状态里ready拉低后,外部数据继续保持,但由于状态机内部计数器没加,last永远等不到,仿真看似一直跑,实际卡死。解决方法是给状态机加一个全局空闲超时断言,超过预期周期数就打印状态和计数器。
第二类:计数器越界。CTR块的Nonce域和计数器域是拼在一起的,如果低位加满后不向上一位进位,Nonce域的最后一个字节会被错误改变。写自增逻辑时要注意进位只在低L字节范围。
第三类:MAC长度和消息长度混用。B0块最后的长度字段是消息长度,AAD前缀的长度字段是AAD长度。两个字段来自不同输入流,一旦在连线时接反,会导致短消息向量恰好过、长消息必挂。排查这类问题时,把每一块的构造值用仿真脚本单独抽象出来,比对成本会低很多。
最实用的排查链路是:先验证AES核本身,单独跑ECB测试向量,确认S盒和轮函数正确;再验证B0块构建和CBC-MAC小包,不进入CTR;最后才打开完整CCM链路。我见过有人一上来就调全流程,结果AES核、格式块、状态机三个地方同时错,最后只能全部推倒重查。
5.3 上板实测:面积、时序与吞吐率
在典型中低档FPGA上,这个不带CTR预取的串行版本资源占用大概是:LUT约2000到3000个,FF约800到1200个,BRAM用掉1个真双口加上若干S盒缓存,整体远远小于一个处理器核。时钟频率我稳定跑在120MHz左右,瓶颈主要在S盒组合路径。
周期消耗可以简单估算。设AES单个块要11拍,明文分成n块,AAD分成m块:
- MAC阶段要处理1个B0,m个AAD块,n个明文块;
- CTR阶段要处理n个明文块;
- 额外再算1次A0用于标签掩码。
总周期约等于(1 + m + n + n + 1) * 11 + 固定开销。比如256字节明文、AAD占2块,那么n=16,m=2,总周期约为(1 + 2 + 16 + 16 + 1) * 11 = 396拍,加上状态切换大约四百拍出头,在120MHz下处理一个256字节包大概3.4微秒。相较普通PC上纯软件跑同体积包,这个速度大约快一到一个数量级,关键是不占CPU,适合数据面卸载。
5.4 上板后的进一步调试
仿真通过不等于上板一次通过。我遇到的两个典型问题:一是时钟约束没有写完整,综合工具没有针对关键路径做约束,导致频率跑不到设计目标;二是BRAM输出级寄存器默认开启,读取时序多打了一拍,顶层AES核以为结果还没好,结果整个状态机慢了一轮。
上板调试强烈建议直接接调试逻辑分析器,把state、aes_done、ctrl_cnt这三组信号拉出来观察。看到状态机和预期节奏是否一致,比分页猜测快得多。
6. 几个让我少走弯路的设计决策
如果重新做一次这个模块,我会把这些决策再提前一点,写在这里给后来人参考。
第一,AES内核和CCM外围必须分两个阶段验证。AES核单独做ECB向量验证,只需要几十个周期就能跑完,问题定位清晰。我一开始直接让顶层电路跑完整CCM向量,错的时候不知道是轮密钥加错还是B0构建错,浪费了不少时间。
第二,字节序工具越早做越好。最好在写顶层模块的同时,写一个把B0、A0、A1等块转换成人可读hex格式的Python脚本。等最终密文对不上时,这个脚本能直接告诉你Nonce在B0里长什么样,问题往往一眼就看出来。
第三,明文暂存BRAM不要省。流式CCM必然要有地方缓存明文,否则MAC和CTR两个阶段根本无法共享同一份数据。提前在资源预算里算好这个BRAM,比临时抱佛脚改接口要稳得多。
第四,不要在一开始就追求CTR流水预取。串行版本能跑通之后,如果吞吐率不够,再把CTR计数器提前生成,做一个小型FIFO预取,收益非常明确。反过来如果在CCM整体还没验证时就加预取,调试复杂度会指数级上升。
最后,说一点我个人的体会。AES CCM在FPGA上实现,真正的难点不在AES轮函数本身,而在于模式组合的调度,以及那些繁琐的格式块细节。只要把算法拆成“MAC阶段”和“CTR阶段”两条独立路径,再用清晰的状态机把它们串起来,整个工程就成功了一大半。我常在处理完一包数据后在仿真脚本里顺手打印状态机步数,和估算的周期数对比,任何异常都能及早暴露。这个习惯,建议你也保留下来。