1. 项目缘起:为什么要在嵌入式项目中移植CMAC算法?
最近在做一个物联网网关的项目,涉及到与云端进行安全的数据通信。协议栈选用了MQTT over TLS,这本身没什么问题,用现成的mbedtls库就能搞定。但问题出在业务层的一个特定需求上:我们需要对某些关键的控制指令生成一个消息认证码,确保指令在传输过程中没有被篡改,并且能验证发送方的身份。这个需求听起来很像是HMAC(基于哈希的消息认证码)的典型场景,对吧?一开始我也是这么想的。
但在和云端对齐协议细节时,对方明确要求使用CMAC算法,并且指定了AES-128作为底层的分组密码。这就有点意思了。HMAC-SHA256用得好好的,为什么要换CMAC?深入一聊才明白,这其实是行业特定规范的要求。在某些对实时性和资源消耗极其敏感的领域,比如工业控制、车联网的某些指令,CMAC相比HMAC有几个潜在优势:它的计算过程更确定,与哈希算法相比,在某些硬件上可能有更优的加速支持,并且其输出长度固定为分组密码的分组大小(如AES是16字节),比HMAC-SHA256的32字节更短,对于带宽受限的无线传输来说,能节省一点是一点。
然而,当我打开手头这个基于STM32的工程,检查已有的mbedtls配置时,心里凉了半截。mbedtls_config.h里压根没开启MBEDTLS_CMAC_C这个宏。这意味着,尽管mbedtls作为一个功能丰富的密码库包含了CMAC的实现,但在默认的裁剪配置或我们之前为了节省Flash空间而做的精简配置中,它被禁用了。所以,“移植”这个词在这里的准确含义,其实是在我们的特定工程环境中,正确地启用、配置并集成mbedtls的CMAC模块,使其能够无缝地为我们所用,并确保其运行稳定、内存占用可控。这个过程,远不止是打开一个编译开关那么简单,涉及到依赖关系梳理、内存管理考量、API的正确使用以及性能测试,接下来我就把这趟“移植”之旅的完整过程和踩过的坑分享出来。
2. CMAC算法核心原理与mbedtls实现窥探
在动手修改代码之前,我觉得有必要先搞明白CMAC到底是怎么工作的,以及mbedtls是如何实现它的。这能帮助我们在后续配置和调试时,做到心中有数,而不是盲目地照搬。
CMAC,全称Cipher-based Message Authentication Code,它是一种基于对称分组密码(如AES、DES)来构造消息认证码的方案。你可以把它理解为一个“带密钥的校验和”,但比简单的CRC或MD5要安全得多,因为不知道密钥的人无法伪造有效的MAC。它的核心思想来自于早期的CBC-MAC,但解决了CBC-MAC在处理可变长度消息时的安全缺陷。
CMAC算法的计算过程可以概括为以下几个关键步骤,我们以最常用的AES-128-CMAC为例(即分组大小128位,16字节):
子密钥生成:首先,算法会根据输入的用户密钥K,推导出两个子密钥K1和K2。这个推导过程涉及对全零数据块进行AES加密,然后根据加密结果进行比特移位和与常数的异或操作。这两个子密钥是预计算的,只要密钥K不变,它们就可以被重复使用,是CMAC算法的“调味料”。
消息分组与填充:将待认证的消息M按16字节分组。如果最后一个分组是完整的16字节,则在其末尾异或子密钥K1;如果最后一个分组不足16字节,则先进行特定的填充(填充一个比特‘1’和若干比特‘0’),然后再异或子密钥K2。这一步确保了无论消息长度如何,最终处理的结构都是安全的。
CBC-MAC核心计算:用一个初始向量IV(通常为全零)开始,将处理后的消息分组依次进行AES加密,并将前一个密文分组与当前明文分组异或后再加密(即CBC模式)。最后一个分组的加密输出,取最左边的若干字节(通常为整个分组或指定长度),就得到了最终的CMAC值。
在mbedtls中,这些步骤被优雅地封装了起来。我们最需要打交道的结构体是mbedtls_cmac_context_t。这个结构体内部通常会包含:
- 一个底层密码的上下文(比如
mbedtls_cipher_context_t),用于执行AES加密。 - 存储生成的两个子密钥K1和K2。
- 可能还有内部缓冲区,用于处理未完成的分组数据。
对应的核心API包括:
mbedtls_cmac_init(): 初始化上下文。mbedtls_cmac_setkey(): 设置密钥,这个函数内部会完成上述的子密钥生成步骤。mbedtls_cmac_update(): 输入消息数据,可以多次调用。mbedtls_cmac_finish(): 结束计算,输出最终的CMAC值。mbedtls_cmac_free(): 释放上下文资源。
理解了这个流程,我们就能意识到,启用CMAC功能,不仅仅是要MBEDTLS_CMAC_C,它还强依赖底层的分组密码模块,例如MBEDTLS_AES_C。如果我们的工程里连AES都没启用,那CMAC就是无源之水。
3. 工程配置与依赖关系梳理:开启正确的“功能开关”
我的工程使用的是STM32CubeIDE,mbedtls以源码形式放在Middlewares/Third_Party/mbedtls目录下。移植的第一步,就是修改配置文件。通常,我们会有一个项目级的mbedtls_config.h,它可能复制自mbedtls/include/mbedtls/config.h,并在此基础上进行裁剪。
注意:千万不要直接修改mbedtls原生的
config.h文件,而应该使用项目中的副本或自定义配置头文件,并通过编译器-I选项指定其路径。这是保持库源码纯净、便于升级的好习惯。
首先,找到并确保以下核心宏被定义(取消注释或设置为1):
#define MBEDTLS_CMAC_C紧接着,检查其依赖项。根据mbedtls源码中的check_config.h文件(这是一个用于验证配置依赖的脚本,虽然不参与编译,但极具参考价值),MBEDTLS_CMAC_C依赖于:
#define MBEDTLS_AES_C // 或者其他的分组密码,如 MBEDTLS_DES_C, MBEDTLS_CAMELLIA_C 等,取决于你用哪种算法做CMAC。因为我们用的是AES-128,所以必须启用MBEDTLS_AES_C。顺藤摸瓜,AES可能又依赖于平台相关的熵源或随机数生成器(如果使用了一些特定模式),但在最基本的ECB/CBC/CMAC使用中,通常只需要AES的软件实现。为了保险起见,我建议同时检查:
#define MBEDTLS_CIPHER_C // 密码算法通用接口层,CMAC通过它来调用底层AES。 #define MBEDTLS_MD_C // 消息摘要通用接口层,虽然CMAC不直接属于MD,但某些配置或示例可能有关联。实际上,在mbedtls的模块化设计中,CMAC模块直接通过CIPHER模块来操作AES。所以MBEDTLS_CIPHER_C通常是必须的。
配置完成后,编译一下试试。果然,我遇到了第一个错误:undefined reference tombedtls_cipher_setkey''。这说明CIPHER模块确实被CMAC调用了,但我们的配置可能还没完全传递到编译链。仔细检查,发现MBEDTLS_CIPHER_C已经打开了,但问题可能出在链接阶段。确保你的编译单元(.c文件)正确包含了mbedtls/cmac.h头文件,并且在链接时,mbedtls库的相关源文件(如cmac.c,aes.c,cipher.c,cipher_wrap.c)都被正确编译并链接进了最终的可执行文件。
在CubeIDE或Makefile中,你需要确认Middlewares/Third_Party/mbedtls/library目录下的这些.c文件是否被添加到项目的源文件列表中。这是一个常见的坑:只修改了头文件配置,却忘了将对应的源文件加入编译。
4. 内存与资源考量:在有限的Flash和RAM中做选择
对于STM32F4这类资源有限的MCU,每增加一个功能模块都需要权衡。启用CMAC和AES会带来多大的空间开销?我做了个简单的对比测试。
在mbedtls_config.h中,我分别注释和取消注释相关宏,进行编译,观察生成的.map文件或IDE输出的尺寸信息:
- 基线配置:仅启用TLS客户端所需的最基本模块(如RSA、SHA256、随机数生成器等),不包含AES和CMAC。Flash占用约80KB。
- 启用AES:打开
MBEDTLS_AES_C和MBEDTLS_CIPHER_C。Flash占用增加了约8-10KB。这主要是AES的查表法实现(S盒等)和CIPHER通用框架的代码。 - 启用CMAC:在AES基础上再打开
MBEDTLS_CMAC_C。Flash占用额外增加约2-3KB。这个增量相对较小,因为CMAC的逻辑本身不复杂,大部分工作由底层的AES完成。
RAM方面,主要关注运行时堆栈和动态内存。mbedtls_cmac_context_t结构体本身的大小可以通过sizeof()查看,在我的平台上大约是100多字节。更重要的是,AES加密操作本身可能需要内部缓冲区。如果使用mbedtls提供的mbedtls_cipher_context_t,它内部会根据所选算法和模式分配内存。
提示:为了精确控制内存,尤其是在没有操作系统或使用静态内存池的嵌入式系统中,建议在初始化时明确指定内存分配函数。mbedtls允许通过
mbedtls_platform_set_calloc_free()来自定义。但更常见的做法是,直接使用库默认的(在禁用MBEDTLS_PLATFORM_MEMORY时,就是标准库的calloc和free),并确保你的堆空间足够。一个mbedtls_cmac_context_t加上一次AES操作的开销,通常不会超过1KB的临时RAM消耗。
如果你的资源极其紧张,还可以考虑是否启用AES的硬件加速。STM32F4系列具有Crypto硬件加速器,可以大幅提升AES运算速度并可能降低CPU负载。但这需要启用MBEDTLS_AES_ALT,并实现相应的硬件驱动层接口,这属于更深度的移植,本次项目由于时间关系,我暂时采用了软件实现,后续可以作为一个优化点。
5. 实战集成:从API调用到功能验证
配置和编译通过后,就到了实际的代码集成环节。我们的目标是在业务代码中,对一段给定的消息和密钥,生成其CMAC。
首先,包含必要的头文件:
#include "mbedtls/cmac.h" #include "mbedtls/cipher.h" // 可选,用于更底层的操作或错误码 #include <string.h> // for memcpy, memset然后,编写一个生成CMAC的示例函数:
/** * @brief 使用AES-128-CMAC生成消息认证码 * @param key: 16字节的AES密钥 * @param message: 待认证的消息 * @param msg_len: 消息长度 * @param mac: 输出缓冲区,至少16字节,用于存放生成的CMAC * @return 0成功,其他为mbedtls错误码 */ int generate_aes128_cmac(const unsigned char *key, const unsigned char *message, size_t msg_len, unsigned char *mac) { int ret = 0; mbedtls_cmac_context_t ctx; size_t mac_len = 16; // AES-128-CMAC输出16字节 // 1. 初始化上下文 mbedtls_cmac_init(&ctx); // 2. 设置密钥,指定使用AES-128算法 ret = mbedtls_cmac_setkey(&ctx, MBEDTLS_CIPHER_AES_128_ECB, key, 128); if (ret != 0) { printf("mbedtls_cmac_setkey failed! ret = -0x%04X\n", -ret); goto cleanup; } // 3. 输入消息数据 ret = mbedtls_cmac_update(&ctx, message, msg_len); if (ret != 0) { printf("mbedtls_cmac_update failed! ret = -0x%04X\n", -ret); goto cleanup; } // 4. 结束计算,获取MAC ret = mbedtls_cmac_finish(&ctx, mac, &mac_len); if (ret != 0) { printf("mbedtls_cmac_finish failed! ret = -0x%04X\n", -ret); goto cleanup; } // 理论上mac_len应该等于16,可以加个断言检查 // MBEDTLS_ASSERT(mac_len == 16); cleanup: // 5. 释放资源 mbedtls_cmac_free(&ctx); return ret; }这段代码看起来很简单,但有几个细节需要注意:
- 算法标识:
mbedtls_cmac_setkey的第二个参数是cipher_type。这里我们用了MBEDTLS_CIPHER_AES_128_ECB。注意,虽然CMAC内部使用的是CBC模式的思想,但设置密钥时指定的是底层的密码算法和密钥长度,模式参数(ECB)在这里可能被忽略或仅作为标识。查阅mbedtls源码确认,对于CMAC,它主要关心的是密码类型和密钥长度。使用MBEDTLS_CIPHER_AES_128_ECB是正确的。 - 密钥长度:第三个参数
keybits是128,对应AES-128。务必确保传入的key指针指向的数据确实是16字节。 - 错误处理:mbedtls的函数通常返回0表示成功,负数表示错误。错误码通常是一个负的十六进制数。通过
-ret可以将其转换为正数便于打印。在生产代码中,应该有更健壮的错误处理逻辑。 - 资源清理:无论成功与否,都必须调用
mbedtls_cmac_free来释放上下文内部可能申请的资源,防止内存泄漏。这是良好的编程习惯。
接下来,我们需要验证这个函数是否正确工作。我采用了“已知答案测试”的方法。从NIST的官方测试向量(可以在网上搜索“NIST CMAC test vectors”)中找一组AES-128-CMAC的测试数据。
例如,我使用了以下测试向量:
- 密钥K:
2b7e1516 28aed2a6 abf71588 09cf4f3c - 消息M: (空字符串)
- 预期CMAC:
bb1d6929 e9593728 7fa37d12 9b756746
编写一个测试函数:
void test_cmac_basic(void) { unsigned char key[16] = {0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f, 0x3c}; unsigned char message[1] = {0}; // 空消息 unsigned char expected_mac[16] = {0xbb, 0x1d, 0x69, 0x29, 0xe9, 0x59, 0x37, 0x28, 0x7f, 0xa3, 0x7d, 0x12, 0x9b, 0x75, 0x67, 0x46}; unsigned char calculated_mac[16] = {0}; int ret = generate_aes128_cmac(key, message, 0, calculated_mac); // 长度为0的空消息 if (ret == 0) { if (memcmp(calculated_mac, expected_mac, 16) == 0) { printf("CMAC test passed!\n"); } else { printf("CMAC test failed! Output mismatch.\n"); // 可以在这里打印出计算得到的mac进行对比 } } else { printf("CMAC calculation failed with error: %d\n", ret); } }将测试代码加入工程,在MCU上运行,通过串口查看输出。如果看到“CMAC test passed!”,那么恭喜你,最基本的CMAC功能已经移植成功了。我强烈建议多找几组测试向量(包括不同长度的消息)进行验证,确保边界条件(如刚好一个分组、超过一个分组等)下也能正确工作。
6. 踩坑与优化:实际应用中的经验之谈
在将CMAC集成到真实的数据上报和指令下发流程中时,我遇到了几个预料之外的问题,这里分享出来,希望能帮你避坑。
第一个坑:多线程/中断环境下的上下文复用。我的应用场景中,可能有多个任务或中断服务程序需要计算不同消息的CMAC。最初,我为了节省每次初始化和释放的开销,尝试定义一个全局的mbedtls_cmac_context_t变量,在不同地方重复使用。结果出现了计算错误,或者偶尔的异常。
原因分析:
mbedtls_cmac_context_t结构体内部是有状态的。在调用mbedtls_cmac_finish()之后,其内部状态已经改变,可能包含了最终的MAC值或中间数据。如果此时不经过mbedtls_cmac_free()和mbedtls_cmac_init()重置,直接用它为新的消息调用mbedtls_cmac_update(),会导致计算基于错误的状态进行。更危险的是,如果在计算过程中(即update之后,finish之前)被高优先级中断打断,而中断服务程序也使用了同一个全局上下文,那么状态会被彻底破坏。
解决方案:
- 为每个独立的CMAC计算会话使用独立的上下文。如果计算不频繁,最简单可靠的做法就是在栈上局部声明上下文,像上面的示例代码一样,让函数管理其生命周期。
- 如果出于性能考虑必须复用,则必须严格序列化访问。可以使用互斥锁(在RTOS中)或关中断(在裸机中)来保护整个CMAC计算序列(从
init或setkey到finish)。并且,在每次计算序列完成后,调用mbedtls_cmac_free(),然后在下一次计算前重新init和setkey。但这样做的开销可能并不比局部变量小多少,还增加了复杂性。实测下来,对于我们的应用频率,局部变量的方式完全可接受。
第二个坑:密钥管理的安全性与性能。我们的项目密钥是预先烧录在Flash中的。每次计算CMAC都需要调用mbedtls_cmac_setkey,这个函数内部会执行AES加密来生成子密钥K1和K2。这是一个相对耗时的操作(相比于update和finish)。
优化思路:如果同一个密钥需要用来计算大量消息的CMAC(例如,用同一个设备密钥认证所有上行消息),那么反复计算子密钥就是一种浪费。我们可以自己缓存子密钥吗?理论上可以,但需要理解mbedtls内部结构,不推荐。
一个更优雅的利用mbedtls自身机制的优化方法是:将上下文初始化、设置密钥的过程提前,在系统初始化时完成一次,然后将准备好的上下文保存起来备用。但如前所述,上下文不能直接用于并发计算。
我的折中方案是:创建一个“上下文模板”。在系统启动时,用设备密钥初始化一个CMAC上下文,并调用mbedtls_cmac_setkey。然后,深拷贝这个已设置好密钥的上下文。mbedtls没有提供直接的深拷贝函数,但我们可以通过观察其结构体定义(在cmac.h中)发现,它主要包含一个cipher_context_t和两个子密钥数组。我们可以自己实现一个拷贝函数,或者更简单地,将setkey所需的参数(算法类型、密钥)缓存起来,在每次需要时快速创建新的上下文并setkey。虽然setkey仍有开销,但避免了每次都从Flash读取和解析密钥的额外操作(如果密钥存储格式复杂的话)。
实际上,对于STM32F4,一次AES-128setkey的软件计算开销在微秒级,对于大多数物联网应用(秒级或分钟级的消息间隔)来说,这根本不是瓶颈。因此,我最终选择了最简单的“每次用时创建”模式,代码清晰,安全性也好(密钥材料在栈上停留时间短)。
第三个坑:输出MAC的长度与协议对齐。我们的云端协议规定,CMAC输出取前8字节(64位)作为认证码。而mbedtls_cmac_finish默认输出整个分组长度(16字节)。这需要我们在调用finish后,手动截取前8字节。
unsigned char full_mac[16]; size_t mac_len = 16; ret = mbedtls_cmac_finish(&ctx, full_mac, &mac_len); if (ret == 0) { memcpy(protocol_mac, full_mac, 8); // 协议规定的8字节MAC }务必和你的协议方确认好MAC长度。CMAC算法本身支持输出小于等于分组长度的任意比特长度,但mbedtls的finish函数似乎只输出完整字节(且是分组大小)。如果需要特定位数(如40位),可能需要对输出字节进行掩码操作。
7. 进阶话题:与TLS共存的配置与调试技巧
我们的工程中已经使用了mbedtls的TLS部分。现在又加入了CMAC模块,这可能会引入一些微妙的交互或配置冲突。
配置一致性检查:确保TLS和CMAC使用的密码套件没有底层冲突。例如,TLS如果使用了AES-256-GCM,那么MBEDTLS_AES_C肯定已经启用,并且可能还启用了MBEDTLS_GCM_C。我们的CMAC使用AES-128-ECB,这两者可以共存。但要注意,如果TLS配置为了极致精简,只使用了CHACHA20-POLY1305这种流密码,而没有启用AES,那么你单独为CMAC启用AES就会增加额外的代码体积。需要评估是否值得。
内存池冲突:有些深度定制的mbedtls移植,可能会使用静态内存池来替代动态内存分配。如果TLS和CMAC模块共享同一个内存池,需要确保池的大小足够容纳两者同时操作所需的内存。在我们的项目中,由于没有启用静态内存池(MBEDTLS_MEMORY_BUFFER_ALLOC_C未定义),所以不存在这个问题,使用的是系统堆。
调试技巧:当CMAC计算出现问题时,除了使用测试向量,还可以利用mbedtls的调试功能。在mbedtls_config.h中启用MBEDTLS_DEBUG_C,并在代码中调用mbedtls_debug_set_threshold(4)(或更高等级)。然后,在mbedtls_cmac_setkey、update、finish等函数调用前后,虽然CMAC模块本身可能没有太多调试输出,但底层的AES或CIPHER模块可能会打印出有价值的信息,帮助你判断是密钥设置错误,还是数据块处理问题。
另一种更直接的调试方法是“白盒对比”。在PC上使用OpenSSL命令行工具计算相同密钥和消息的CMAC,与嵌入式端的结果进行比对。OpenSSL命令示例:
# 假设密钥和消息是十六进制字符串 echo -n "这里是你的消息" | openssl aes-128-cbc -K `echo -n "你的16字节密钥十六进制字符串" | xxd -p` -iv 00000000000000000000000000000000 -nopad | tail -c 16 | xxd -p注意,OpenSSL的enc -aes-128-cbc命令在-nopad模式下,配合全零IV,其最后分组的输出与CMAC算法在概念上有相似之处,但严格来说并不直接等价于CMAC。最可靠的方法还是用Python的cryptography库或在线CMAC计算工具生成标准测试向量进行比对。
8. 总结与最终集成效果
经过上述步骤,我们成功地在基于STM32和mbedtls的嵌入式项目中“移植”并集成了AES-128-CMAC功能。回顾整个过程,关键点在于:
- 明确需求:确认必须使用CMAC而非HMAC。
- 理解依赖:知道CMAC依赖于CIPHER和AES(或其他分组密码)模块。
- 正确配置:在
mbedtls_config.h中精准地开启MBEDTLS_CMAC_C及其所有依赖宏,并确保对应源文件参与编译。 - 资源评估:了解增加的Flash/RAM开销,并在项目约束内做出选择。
- API熟练使用:掌握
init、setkey、update、finish、free这一套标准流程,并注意错误处理和上下文生命周期管理。 - 严格验证:使用NIST等权威测试向量进行验证,确保算法实现的正确性。
- 应对实际场景:处理好并发、密钥管理、MAC长度裁剪等工程细节。
最终,我将CMAC生成函数封装成一个独立的模块,提供了清晰的接口。在数据上报时,对报文关键字段进行CMAC计算,并将8字节的MAC附加在帧尾;在接收云端指令时,先校验CMAC,通过后才执行逻辑。整个机制上线后运行稳定,满足了项目的安全通信需求。
移植过程中,最深的体会是:在嵌入式环境下使用密码学库,三分靠理解算法,七分靠工程配置和细节把控。mbedtls的模块化设计给了我们很大的灵活性,但也要求我们对模块间的依赖关系有清晰的认知。希望这篇详细的记录,能帮助你在遇到类似需求时,少走一些弯路。