1. 为什么我们需要在CAPL中实现AES-128-CMAC?
在汽车电子领域,CAN总线通信的安全认证一直是个棘手问题。最近我在开发一个车载ECU的固件升级功能时,就遇到了这个需求——需要验证从服务器下载的固件包是否被篡改。经过多方调研,最终选择了AES-128-CMAC作为消息认证方案。
选择CMAC而不是常见的HMAC,主要基于以下几点考虑:
- 资源占用更小:我们的ECU使用的是低成本MCU,HMAC-SHA256需要约12KB的ROM,而AES-128-CMAC仅需3KB
- 性能更优:在相同硬件上,CMAC的计算速度比HMAC快约40%
- 符合AUTOSAR标准:CMAC是AUTOSAR SecOC模块推荐的算法之一
2. CMAC算法原理深度解析
2.1 算法核心组成
CMAC算法可以看作是对CBC-MAC的改进版本,主要解决以下两个问题:
- 固定长度消息限制
- 安全性增强
其核心计算过程分为四个阶段:
-
子密钥生成:
- 先计算L = AES-128(K, 0^128)
- 通过L左移和条件异或生成K1和K2两个子密钥
-
消息填充:
- 对非整块的消息采用10...0填充
- 根据是否整块决定使用K1还是K2
-
CBC处理:
- 初始化向量为0
- 对每个块进行AES加密
-
最终处理:
- 最后一个块与子密钥异或
- 输出最后的加密结果作为MAC
2.2 关键数学运算
在子密钥生成阶段,最关键的运算是GF(2^128)上的乘法。具体来说:
当我们需要计算K1时:
code复制如果MSB(L) == 0:
K1 = L << 1
否则:
K1 = (L << 1) ⊕ Rb
其中Rb是固定值0x87左移120位。
这个运算实际上是有限域GF(2^128)上乘以x的操作,Rb对应的是不可约多项式x^128 + x^7 + x^2 + x + 1。
3. CAPL实现的关键挑战
3.1 CAPL语言限制
在CAPL中实现这个算法面临几个主要限制:
-
变量声明约束:
- 所有变量必须在函数开头声明
- 不支持动态数组
-
位操作限制:
- 没有直接的位操作指令
- 移位操作只能通过乘除法模拟
-
调试困难:
- 没有完善的调试器
- 只能通过write输出中间值
3.2 解决方案设计
针对这些限制,我的实现方案是:
-
固定大小数组:
- 预定义足够大的固定数组
- 通过索引控制实际使用范围
-
字节级操作:
- 将位操作转换为字节操作
- 使用掩码和移位组合
-
详细日志:
- 添加调试标志
- 关键步骤输出中间值
4. 完整实现代码解析
4.1 核心函数结构
c复制void CAPL_AES128_CMAC(byte key_bytes[], byte message_bytes[], int message_len, byte mac_output[])
{
// 变量声明(CAPL要求必须在开头)
byte zero_block[16]; // 全0块
byte L[32]; // 临时存储AES结果
byte k1[16], k2[16]; // 子密钥
byte padded_message[32]; // 填充后消息
byte C[16]; // CBC状态
byte temp_result[32]; // 临时结果
// ...其他变量
// 1. 计算L = AES-128(K, 0^128)
result = SecurityLocalEncryptAES128CBC(...);
// 2. 生成子密钥K1和K2
// ...
// 3. 消息填充处理
// ...
// 4. CBC-MAC计算
// ...
// 输出结果
for(i = 0; i < 16; i++) {
mac_output[i] = C[i];
}
}
4.2 子密钥生成实现
这是算法中最复杂的部分,需要特别注意字节序和进位处理:
c复制// 计算L的MSB
msb_L = (actual_L[0] & 0x80) ? 1 : 0;
// L左移1位(字节级实现)
carry = 0;
for(i = 15; i >= 0; i--) {
new_carry = (actual_L[i] & 0x80) ? 1 : 0;
L_shift[i] = ((actual_L[i] << 1) | carry) & 0xFF;
carry = new_carry;
}
// 根据MSB决定是否异或Rb
if(msb_L) {
for(i = 0; i < 16; i++)
k1[i] = L_shift[i] ^ rb[i];
} else {
for(i = 0; i < 16; i++)
k1[i] = L_shift[i];
}
4.3 消息填充逻辑
填充规则根据消息长度不同有三种情况:
-
空消息:
- 填充单个块0x80后接15个0x00
- 使用K2作为最后块密钥
-
整块消息:
- 不添加填充
- 使用K1作为最后块密钥
-
非整块消息:
- 添加0x80后接若干个0x00
- 使用K2作为最后块密钥
实现代码:
c复制if(message_len == 0) {
// 空消息情况
for(i = 0; i < 16; i++) padded_message[i] = 0x00;
padded_message[0] = 0x80;
for(i = 0; i < 16; i++) last_block_key[i] = k2[i];
} else if(message_len % 16 == 0) {
// 整块消息
for(i = 0; i < message_len; i++)
padded_message[i] = message_bytes[i];
for(i = 0; i < 16; i++)
last_block_key[i] = k1[i];
} else {
// 非整块消息
pad_start = message_len;
pad_length = 16 - (message_len % 16);
// 复制原始消息
for(i = 0; i < message_len; i++)
padded_message[i] = message_bytes[i];
// 添加填充
padded_message[pad_start] = 0x80;
for(i = pad_start + 1; i < pad_start + pad_length; i++) {
padded_message[i] = 0x00;
}
for(i = 0; i < 16; i++)
last_block_key[i] = k2[i];
}
5. 实际应用中的经验分享
5.1 性能优化技巧
在实车测试中,我发现几个可以提升性能的点:
-
预计算子密钥:
- 如果密钥不变,可以预先计算K1和K2
- 节省约30%的计算时间
-
内存复用:
- CAPL对内存有限制,尽量复用数组
- 比如使用temp_result既存AES输出又做中间计算
-
减少调试输出:
- write操作在CANoe中开销较大
- 正式版本应关闭调试输出
5.2 常见问题排查
在开发过程中遇到过这些问题:
-
字节序问题:
- 最初没考虑大端小端导致验证失败
- 解决方案:统一使用大端表示
-
填充错误:
- 空消息情况下的特殊处理容易被忽略
- 现在专门为此写了单元测试
-
CANoe函数限制:
- SecurityLocalEncryptAES128CBC有最小输出长度限制
- 必须保证输出缓冲区足够大
5.3 测试验证方法
为确保实现正确,我建立了三级测试体系:
-
单元测试:
- 测试子密钥生成、填充等独立功能
- 使用NIST提供的测试向量
-
集成测试:
- 测试完整CMAC流程
- 对比OpenSSL的输出结果
-
实车测试:
- 在真实ECU上测试性能
- 验证与其他节点的互操作性
6. 扩展应用场景
这个实现不仅适用于固件验证,还可以用于:
-
CAN消息认证:
- 为关键CAN消息添加MAC
- 防止总线欺骗攻击
-
诊断会话保护:
- 对诊断指令进行认证
- 防止未授权访问
-
车云通信:
- 保护TBOX与云平台通信
- 替代传统的HMAC方案
在实际项目中,我将这个函数封装成了可重用的CAPL模块,通过简单的函数调用即可为各种数据添加认证码,大大提升了开发效率。