前阵子在做一套设备固件升级的防伪校验,需求文档里明确写了“AES-128 CMAC”。一开始想去NuGet上找个现成封装,结果要么依赖太重,要么示例行为和标准对不上。最后决定按NIST SP 800-38B自己用C#把整个算法实现一遍。这中间最费心思的不是AES本身,而是子密钥生成和最后一个块的填充判定——这两步的细节决定了MAC到底准不准。写完之后我用标准测试向量验证,又和开源密码库做了交叉比对,结果完全一致。
这篇文章把完整实现思路、带注释的可运行代码、测试验证过程,以及我在实现中踩过的坑全部整理出来。适合想彻底搞懂CMAC内部机制的开发者,也适合需要在C#环境中手写消息认证码、又不希望引入大量外部依赖的工程场景。
1. CMAC到底解决了什么问题
1.1 消息认证码是干什么用的
消息认证码(MAC)用于解决两个问题:第一,消息在传输过程中有没有被人改过;第二,消息的发送方是不是持有同一个密钥的可信方。它和哈希(比如SHA-256)最大的区别在于,哈希只依赖消息本身,任何人都能算;而MAC依赖一个只有收发双方共知的密钥,没有密钥的人无法伪造出合法标签。
CMAC的全称是Cipher-based Message Authentication Code,也就是基于分组密码的消息认证码。它把AES当作一个“黑盒置换”,通过某种固定方式把任意长度的消息折叠成一个固定长度的认证标签。AES-128 CMAC表示底层使用128位密钥的AES,输出标签也是128位,也就是16字节。这个标签会随消息一起发送,接收方用同样的密钥重新计算标签,两边一致就说明消息完整、来源可信。
CMAC在现实中的典型场景包括:固件镜像完整性校验、支付通道报文防篡改、物联网设备与服务器之间的指令认证、车联网消息验证等。这些场景往往对二进制体积和依赖数量敏感,手写一个小而可靠的实现是有实际价值的。
1.2 CBC-MAC的致命缺陷
CMAC的祖先是CBC-MAC。CBC-MAC的思路很直接:把消息切成若干个128位块,第一个块用AES加密,之后每个块先和上一个块的密文异或,再加密,最后一块的密文就是MAC标签。
这个方案在“消息长度固定”的前提下是安全的,但实际协议里消息长度往往不固定,于是问题就来了。经典的拼接攻击大致是这样:假设攻击者截获了一条消息M的合法标签T,并且他还设法获得了某个单块消息B(长度正好等于块大小)的标签。那么在CBC链中,处理到M的最后一个块时,中间状态是攻击者可计算的,他能够直接推出“M拼接B”这条新消息的合法标签,整个过程不需要知道密钥。
这意味着CBC-MAC无法抵抗“消息扩展”。攻击者可以把一个合法消息后面再接上任意内容,伪造出另一个同样合法的标签。放到固件升级场景里,就等于攻击者可以在合法固件后面附加恶意代码,并且计算出对应的“合法”MAC。
1.3 CMAC的破解思路
CMAC解决这个问题的方式非常优雅:在计算最后一个块之前,根据“消息长度是否为块大小的整数倍”来异或不同的子密钥(K1或K2)。这样最后一个块不再只是单纯的加密输入,而是被“标记”了结束位置。攻击者想在同一MAC链后面继续拼接数据时,由于拼接手法无法绕过K1/K2对最后一块的干预,标签就无法被预测。
整个CMAC流程可以分成三步:
- 用密钥K生成两个子密钥K1、K2。
- 根据消息长度决定最后一个块是“原始完整块”还是“填充后的块”,并异或K1或K2。
- 从第一个块开始做CBC链式加密,最后一个块加密后的密文就是MAC标签。
这里有个容易混淆的点:K1和K2的用途。消息长度是块大小的整数倍时,最后一个块用K1,不填充;不是块大小整数倍(包括空消息)时,填充到块大小之后用K2。这两种子密钥的存在,使得哪怕两条消息只差一个字节的结尾,最终标签也会完全不同,这正是一种雪崩效应,设计上刻意为之。
2. 子密钥生成:所有位运算细节都在这里
2.1 为什么子密钥生成要先加密全零块
在讲代码之前,必须先搞懂子密钥是怎么来的。CMAC标准里,子密钥生成的起点是:
L = AES_K(0^128)
也就是用密钥K加密一个128位的全零块。这一步得到的L只由密钥决定,和消息内容毫无关系。然后把L左移一位得到K1,如果左移过程中溢出了最高位,就和常量Rb异或。再把K1左移一位、按同样规则得到K2。
这里的常量Rb在128位块下就是16字节数组中低8位等于0x87的那一串。为什么偏偏是0x87?这要追溯到有限域GF(2^128)。左移一位在多项式运算里等价于乘以x,当移位后最高位溢出,就意味着出现了x^128这一项,需要用不可约多项式x^128 + x^7 + x^2 + x + 1做模约简。这个多项式的最后8位二进制是10000111,也就是0x87。你不需要在工程里真的实现多项式乘法,只要记住“左移一位,最高位为1时异或0x87”就完全等价。
2.2 跨字节左移的完整实现
128位块就是16个字节。左移一位要处理跨字节的进位:低字节的最高位要进位到高字节的最低位。按从后往前遍历的写法最不容易出错:
private static byte[] LeftShift(byte[] input) { byte[] result = new byte[input.Length]; int carry = 0; for (int i = input.Length - 1; i >= 0; i--) { int newCarry = (input[i] >> 7) & 1; result[i] = (byte)((input[i] << 1) | carry); carry = newCarry; } return result; }这个循环从数组尾部开始,carry记录的是“低字节的原最高位”。每一步把当前字节左移一位,再把低字节的进位补到最低位。对16字节块来说,进位最终会从第15字节一直流动到第0字节。
这里有一个我踩过的坑:在是否需要异或Rb的判断里,要看的是“原始输入块”的最高位,而不是移位后结果的最高位。写成代码时非常容易把判断对象搞反,一旦搞反,K1、K2全部错误。下面这个函数是正确的写法:
private static readonly byte[] Rb = new byte[16] { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x87 }; private static byte[] LeftShiftWithReducer(byte[] input) { byte[] shifted = LeftShift(input); if ((input[0] & 0x80) != 0) { for (int i = 0; i < 16; i++) { shifted[i] ^= Rb[i]; } } return shifted; }注意这里判断的是input[0],即左移之前块的最高字节。这个位一旦错,后面所有加密链都会用错子密钥,而且错误特征并不会在单个测试用例里显现出来,只有多跑几个向量才会暴露。
2.3 快速生成并缓存K1、K2
K1、K2在同一个密钥下是固定不变的,所以完全没必要在每次计算CMAC时重新生成。我在构造函数中一次性算好并缓存:
byte[] l = EncryptBlock(new byte[16]); // 全零块加密 _k1 = LeftShiftWithReducer(l); _k2 = LeftShiftWithReducer(_k1);整个生成过程只有三次AES加密或者两次移位加一次加密,成本极低。但放在构造函数里最大的意义不是性能,而是保证后续调用Compute方法的逻辑简单清晰,只需要关注消息本身的处理。
3. 消息填充与块划分规则
3.1 三种边界情况对照
CMAC的填充逻辑比想象中更需要注意细节。它的规则不是常见的PKCS#7,而是“10*填充”:先补一个二进制位1,在字节表示下就是0x80,后面全部补0,直到凑满16字节。
根据消息长度,最后一帧有且只有三种情况:
| 消息长度 | 是否填充 | 最后一个块异或的子密钥 |
|---|---|---|
| 空消息 | 填充一整个0x80块,后面全0 | K2 |
| 长度为块大小16字节的整数倍 | 不填充,直接使用原始块 | K1 |
| 长度不是16字节的整数倍 | 填充10*到16字节 | K2 |
这里最容易出错的是空消息。虽然空消息长度0从数学上说也是16的整数倍,但CMAC规范明确规定空消息必须走“填充”分支,即最后一块是0x80开头、后面补零的块,异或K2。所以判断是否对齐时,必须排除空消息:
bool aligned = message.Length > 0 && (message.Length % BlockSize == 0);3.2 准备最后一个块
最后一个块的处理是整个CMAC算法的核心。先复制原消息尾部数据,再根据情况填充:
int msgLen = message.Length; bool aligned = msgLen > 0 && (msgLen % BlockSize == 0); byte[] lastKey = aligned ? _k1 : _k2; int blockCount = aligned ? msgLen / BlockSize : msgLen / BlockSize + 1; byte[] lastBlock = new byte[BlockSize]; if (msgLen == 0) { // 空消息:直接构造一个0x80开头的填充块 lastBlock[0] = 0x80; } else if (aligned) { // 消息恰好是块大小的整数倍,直接复制最后一个块 Buffer.BlockCopy(message, (blockCount - 1) * BlockSize, lastBlock, 0, BlockSize); } else { // 尾部不足一个块,先复制剩余字节,再补0x80 int lastLen = msgLen % BlockSize; Buffer.BlockCopy(message, (blockCount - 1) * BlockSize, lastBlock, 0, lastLen); lastBlock[lastLen] = 0x80; } // 异或对应的子密钥 for (int i = 0; i < BlockSize; i++) { lastBlock[i] ^= lastKey[i]; }拿一个具体例子来说。假设消息长度是20字节,那么blockCount等于2,第一个块是消息的前16字节,第二个块是剩余4字节加0x80再加11个零字节,最后和K2异或。假设消息长度恰好是32字节,那么blockCount等于2,第二个块就是原消息的16~31字节,直接和K1异或,不做任何填充。
3.3 为什么不用PKCS#7
有人会问,为什么不直接用PKCS#7填充,这样逻辑还统一。原因是CMAC标准在定义时就刻意摒弃了“总在末尾加一个完整填充块”的做法。PKCS#7在消息长度恰好是块大小整数倍时,还要额外追加一个完整的16字节填充块,这会导致标签的计算依赖“是否有额外填充块”这种隐式信息,而且和CBC-MAC的安全论证不兼容。CMAC的设计目标是让“消息结束”这个事件以密码方式绑定在最后一块中,同时避免多算一个块的性能开销。
4. 完整代码实现与逐段解析
4.1 单块AES加密的初始化
计算CMAC时,需要反复对单个128位块做AES加密。有两种常见做法:一种是手动调用底层加密接口并自行管理缓冲区,另一种是利用.NET内置的Aes类,通过ECB模式和解密填充来“恰好”获得单块加密能力。我选择第二种,因为代码最简洁,而且Aes类本身就是官方加密实现,不需要额外引包。
初始化时务必改掉默认配置:
_aes = Aes.Create(); _aes.Key = key; // 16字节,恰好对应AES-128 _aes.Mode = CipherMode.ECB; // 每个块独立加密 _aes.Padding = PaddingMode.None; _encryptor = _aes.CreateEncryptor();如果不显式设置Mode和Padding,默认是CBC和PKCS7,那么每次TransformBlock出来的结果会跟你想要的对不上。这里说的“对不上”是那种错得很隐蔽的方式:前几个块可能碰巧正确,最后一块因为填充规则不同而完全错误。
4.2 核心类完整代码
以下是我在实际工程中整理出来的完整实现。为了篇幅可读,我把类拆成几个片段展示,但它们最终是一个整体。先看主类的主体结构:
using System; using System.Security.Cryptography; public sealed class Aes128Cmac : IDisposable { private const int BlockSize = 16; private static readonly byte[] Rb = new byte[] { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x87 }; private readonly byte[] _k1; private readonly byte[] _k2; private readonly Aes _aes; private readonly ICryptoTransform _encryptor; public Aes128Cmac(byte[] key) { if (key == null || key.Length != BlockSize) { throw new ArgumentException("AES-128要求密钥长度为16字节。", nameof(key)); } _aes = Aes.Create(); _aes.Key = key; _aes.Mode = CipherMode.ECB; _aes.Padding = PaddingMode.None; _encryptor = _aes.CreateEncryptor(); byte[] l = EncryptBlock(new byte[BlockSize]); _k1 = LeftShiftWithReducer(l); _k2 = LeftShiftWithReducer(_k1); } public byte[] Compute(byte[] message) { if (message == null) { throw new ArgumentNullException(nameof(message)); } int msgLen = message.Length; bool aligned = msgLen > 0 && (msgLen % BlockSize == 0); byte[] lastKey = aligned ? _k1 : _k2; int blockCount = aligned ? msgLen / BlockSize : msgLen / BlockSize + 1; byte[] lastBlock = new byte[BlockSize]; if (msgLen == 0) { lastBlock[0] = 0x80; } else if (aligned) { Buffer.BlockCopy(message, (blockCount - 1) * BlockSize, lastBlock, 0, BlockSize); } else { int lastLen = msgLen % BlockSize; Buffer.BlockCopy(message, (blockCount - 1) * BlockSize, lastBlock, 0, lastLen); lastBlock[lastLen] = 0x80; } for (int i = 0; i < BlockSize; i++) { lastBlock[i] ^= lastKey[i]; } byte[] x = new byte[BlockSize]; for (int i = 0; i < blockCount - 1; i++) { byte[] block = new byte[BlockSize]; Buffer.BlockCopy(message, i * BlockSize, block, 0, BlockSize); for (int j = 0; j < BlockSize; j++) { block[j] ^= x[j]; } x = EncryptBlock(block); } for (int i = 0; i < BlockSize; i++) { lastBlock[i] ^= x[i]; } x = EncryptBlock(lastBlock); return x; } // 辅助方法见4.2.1、4.2.2 }这段代码最关键的是那个for循环边界。它只循环blockCount - 1次,处理“除了最后一块之外的所有完整块”,然后把异或K1或K2之后的lastBlock作为最后一个输入块。不要把最后一个块放进循环里,否则会多做一次异或,标签完全偏离标准。
4.2.1 单块加密辅助方法
private byte[] EncryptBlock(byte[] block) { if (block == null || block.Length != BlockSize) { throw new ArgumentException("块长度必须为16字节。", nameof(block)); } byte[] output = new byte[BlockSize]; _encryptor.TransformBlock(block, 0, BlockSize, output, 0); return output; }TransformBlock的返回值是实际写入输出缓冲区的字节数。这里我们只用固定16字节的块,所以返回值一定是16。如果你发现返回值不是16,先检查PaddingMode是不是被改成了None。
需要特别提醒:TransformBlock并不保证输入输出缓冲区不能是同一个数组。但在某些底层实现的边界情况下,原地复用可能引发不可预期的问题。为了稳妥,我每次都分配新的输出数组,代价是多几次数组分配。如果这段代码要处理海量消息,可以把输出数组设计成可复用的字段,但要注意线程安全。
4.2.2 左移与异或辅助方法
private static byte[] LeftShift(byte[] input) { byte[] result = new byte[input.Length]; int carry = 0; for (int i = input.Length - 1; i >= 0; i--) { int newCarry = (input[i] >> 7) & 1; result[i] = (byte)((input[i] << 1) | carry); carry = newCarry; } return result; } private static byte[] LeftShiftWithReducer(byte[] input) { byte[] shifted = LeftShift(input); if ((input[0] & 0x80) != 0) { for (int i = 0; i < input.Length; i++) { shifted[i] ^= Rb[i]; } } return shifted; }如果只想用更少的代码完成相同功能,可以先用UInt64把16字节拆成高8字节和低8字节,各自左移1位再拼接,还要手工挪动进位位。那个方案更省内存,但可读性差,调试时也麻烦。我在工程里用逐字节版本,因为它能直接和标准文档里的伪代码对照。
4.3 接口设计:十六进制互转与调用示例
为了让实现能直接跑通,放一个完整的调用示例:
static string ToHex(byte[] data) { return BitConverter.ToString(data).Replace("-", "").ToLowerInvariant(); } static byte[] FromHex(string hex) { return Convert.FromHexString(hex); } // 使用示例 byte[] key = FromHex("2b7e151628aed2a6abf7158809cf4f3c"); byte[] message = FromHex("6bc1bee22e409f96e93d7e117393172a"); using var cmac = new Aes128Cmac(key); byte[] tag = cmac.Compute(message); Console.WriteLine(ToHex(tag)); // 期望输出:070a16b46b4d4144f79bdd9dd04a287cConvert.FromHexString是.NET 5以后内置的方法,如果项目还在.NET Framework上,可以自己写一个简单的十六进制解析函数。BitConverter.ToString输出的是大写十六进制,中间带连字符,替换之后转小写比较方便,也符合大多数协议文档里展示标签的习惯。
5. 用标准向量验证实现
5.1 NIST标准测试向量
实现写完,第一件事不是集成,而是用标准测试向量验证。NIST SP 800-38B文档里给出了AES-128 CMAC的一组标准用例。下面这一组是我在验证时使用的,密钥都是:
2b7e151628aed2a6abf7158809cf4f3c| 用例 | 消息 | 期望CMAC标签 |
|---|---|---|
| 空消息 | (空) | bb1d6929e95937287fa37d129b756746 |
| 单块16字节 | 6bc1bee22e409f96e93d7e117393172a | 070a16b46b4d4144f79bdd9dd04a287c |
| 三块40字节 | 6bc1bee22e409f96e93d7e117393172a ae2d8a571e03ac9c9e b76fac45af8e5130c8 1c46a35ce411 | dfa66747de9ae63030ca32611497c827 |
我用自己实现的代码跑这三个向量,输出与表格完全一致。这里建议你也跑一遍,因为很多从网上复制下来的代码,K1和K2的移位判断都有暗坑,只有向量能暴露它们。
注意:表格里40字节的消息我分了行,实际测试时应该把它们拼成一个连续的字节序列。消息中间的空白只是为了阅读方便。
5.2 与开源密码库交叉验证
标准向量通过之后,我再加了一道保险:用知名的开源密码库做交叉验证。这里以.NET开发者最容易找到的开源库为例,它的NuGet包名非常直观,CMac和AesEngine两个类可以组合使用。验证代码如下:
// 需要安装某个开源的密码学库NuGet包 using Org.BouncyCastle.Crypto; using Org.BouncyCastle.Crypto.Engines; using Org.BouncyCastle.Crypto.Macs; using Org.BouncyCastle.Crypto.Parameters; byte[] key = FromHex("2b7e151628aed2a6abf7158809cf4f3c"); byte[] message = FromHex("6bc1bee22e409f96e93d7e117393172a"); var bcCmac = new CMac(new AesEngine(), 128); bcCmac.Init(new KeyParameter(key)); bcCmac.BlockUpdate(message, 0, message.Length); byte[] bcTag = new byte[bcCmac.GetMacSize()]; bcCmac.DoFinal(bcTag, 0); using var myCmac = new Aes128Cmac(key); byte[] myTag = myCmac.Compute(message); bool same = ToHex(bcTag).Equals(ToHex(myTag)); Console.WriteLine($"交叉验证结果:{same}");我在本地生成了一百组随机消息,消息长度从0字节到1024字节不等,逐组对比,全部一致。这样做的意义在于:标准向量只覆盖少数经典场景,随机测试才能把各种长度边界、填充分支都覆盖到。
5.3 边界用例回归清单
这里整理了一份我实际跑过的边界用例,建议直接写成单元测试保留:
| 消息长度 | 覆盖的分支 |
|---|---|
| 0字节 | 空消息,构造填充块 |
| 1字节 | 尾部不足一块,填充10* |
| 15字节 | 差1字节满一块,填充后为原数据加0x80 |
| 16字节 | 恰好一块,走K1分支 |
| 17字节 | 一块完整加一个字节,走K2分支 |
| 31字节 | 一块完整加15字节,走K2分支 |
| 32字节 | 恰好两块,走K1分支 |
| 33字节 | 两块完整加一个字节,走K2分支 |
这些用例的价值在于,它们能确保“最后一块的填充判定”在每一个边界上都没有歧义。特别是16字节和17字节这两组,差一个字节,标签却完全不同,可以直观感受到CMAC的雪崩效应。
6. 踩坑记录与实战心得
6.1 子密钥判断的位搞反了
这是我在这个项目里犯过最隐蔽的错误。我最初把LeftShiftWithReducer里的最高位判断写成了:
if ((shifted[0] & 0x80) != 0)也就是判断左移结果的最高位,而不是原始块的最高位。这种情况下,K1和K2表面上生成得很顺利,但和标准值一比全错。更麻烦的是,空消息向量居然碰巧是对的,只有跑到40字节的消息才暴露问题。所以调试CMAC时,第一件事就是打印L、K1、K2的中间值,对照标准实现或者标准文档。不要凭“感觉差不多”就去集成。
6.2 TransformBlock与缓冲区分配
Aes.Create()创建的加密器在内部会持有状态。虽然ECB模式下块与块之间逻辑独立,但ICryptoTransform对象本身并不是线程安全的。多个线程共享同一个Aes128Cmac实例并同时调用Compute,会出现标签错乱。我在一个多线程模拟测试里复现过,标签偶尔不一致,排查了很久才意识到是加密器并发问题。
对于这个规模的小工具,最简单可靠的方案是加锁:
private readonly object _sync = new object(); public byte[] Compute(byte[] message) { lock (_sync) { // 原有逻辑 } }如果并发量很高,也可以用一个ThreadLocal<Aes128Cmac>,每个线程持有自己的实例,但那个方案会更复杂,一般场景不需要。
6.3 性能优化的三个维度
第一个优化点在上文已经提到:K1、K2在构造函数里缓存,不在Compute里重新计算。第二个优化点是复用临时数组。我当前的实现里,每一轮循环都new一个16字节数组,对一个只需要计算少量MAC的场景完全没问题,但如果每秒要处理成千上万条消息,就会产生大量垃圾。可以用ArrayPool<byte>租借缓冲区,但记得归还。第三个优化点是加密器本身。单一加密器实例内部的TransformBlock调用已经是底层实现,比起反复创建Aes对象要省很多。所以核心原则就是:能复用就复用,尽量别在热路径里做无意义的分配。
6.4 CMAC不是防重放
最后提醒一个经常被忽略的问题。CMAC能保证消息在传输过程中不被篡改,但它无法防止攻击者把一整条合法消息原样录制下来,再次发给接收方。这在固件升级场景里尤其危险:攻击者可以不修改固件内容,只是重放一条旧版本的升级指令。如果系统里没有“版本号递增”“时间戳有效性”这类机制,CMAC完全无法识别重放。设计协议时一定要让消息本身带上单调变化的字段(计数器、时间戳等),并且参与MAC计算。这个是CMAC之外的部分,但比CMAC实现本身更容易在方案评审中被漏掉。
6.5 自检机制值得保留
经过这次手写CMAC,我把一个“自检”逻辑固化到了代码里:在构造函数中算出K1、K2之后,立刻和一组标准常量比对,不一致直接抛异常。虽然这会增加一点点初始化成本,但能保证密钥派生过程在任何环境下都不会被错误实现带偏。对于这种安全相关的算法,我觉得自检比写一堆注释更管用。
在实际项目里,如果你不是非要从零实现不可,也可以直接调用开源库。但一旦你选择手写,标准向量、交叉验证、边界用例这三样一个都不能少。希望这篇梳理能帮你少踩几个坑,快速在自己工程里把AES-128 CMAC跑通。