☰
mbedtls RSA签名验签与X.509证书固件校验Demo
2026/10/26 11:07:21 网站建设 项目流程

简介:这份面向嵌入式开发与信息安全学习者的mbedtls RSA签名验签数字证书示例工程,聚焦资源受限环境下的非对称签名与验签实践,适合具备C语言基础、希望理解数字证书底层机制的中级开发者,帮助把密码学算法落到可编译运行的工程中。压缩包共111个文件、约640KB,以28个头文件和25个C源文件为核心,涵盖RSA运算、大数处理、AES、熵源采集与X.509证书解析等模块;并配有cmake构建脚本、make文件及说明文本,另有obj、exe等编译产物,便于直接构建、运行和对照调试。目前已有504人学习下载。读者可完整梳理密钥对生成、SHA-256哈希、PKCS#1签名与公钥验签、证书公钥提取以及CMake工程链接的流程,源码按算法与证书处理模块分层组织,适合作为嵌入式密码学入门、课程实验与项目移植的参考样例。

1. 从一次固件验签失败说起:mbedtls 的 RSA 签名验签 demo 能给你什么

固件包在设备上验签失败,串口只吐出一个-0x4380,别的什么都没有——这是不少人第一次用 mbedtls 做 RSA 签名验签时的现场。这份 demo 把签名、验签和 X.509 数字证书解析串成一条能编译、能单步调试、能拆开修改的最小链路:构建机上用私钥对固件摘要签名,设备端只烧一份证书,启动时靠公钥把签名验回来。

它适合两类人。一类是做嵌入式、网关、IoT 固件升级的开发者,需要在 Cortex-M 上完成完整性校验,但又不想把 OpenSSL 那一大堆 API 放进固件;另一类是做插件、扩展包、配置下发的后端或客户端开发者,想用最小依赖验证 RSA 签名加证书的流程。demo 的价值不在代码量,而在于它把「密钥放哪台机器、用哪种填充、出错码对应哪一步」这些容易踩坑的地方写得很清楚。

2. 架构与选型:mbedtls 跑 RSA 需要的四层模块和一份裁剪清单

2.1 从 mbedtls_mpi 到 mbedtls_x509_crt 的四层结构

mbedtls 是分层库,不是一堆平铺的函数。理解这四层,调试时看到错误码才能立刻知道该往哪查。

最底层是mbedtls_mpi,负责大整数运算,RSA 的模幂、素数检验全建在它上面。往上一层是mbedtls_rsa_context,它是对 RSA 原语(加密、解密、签名、验签)的直接封装,参数是裸的模数N、私钥指数D、公钥指数E。再往上是mbedtls_pk_context抽象层,它把 RSA、EC、EdDSA 统一成一套pk_*接口,业务代码不需要知道底层是哪种算法。最上层是mbedtls_x509_crt,负责解析证书,解析完之后它内部就挂着一个pk_context,可以直接拿来验签。

哈希是独立的分支,走mbedtls_md_*。RSA 签名并不直接对原文做运算,它对「摘要加一层 DigestInfo 的 ASN.1 包装」做运算,所以mbedtls_pk_sign的参数里既要传md_alg,又要传已经算好的hash。这两个参数必须一致,传MBEDTLS_MD_SHA256却塞进去一个 MD5 的 16 字节摘要,就是后面最常见的-0x4380。

能力必须打开的宏关掉之后的典型症状
大数与模幂MBEDTLS_BIGNUM_C链接期一片mbedtls_mpi_*undefined
RSA 原语MBEDTLS_RSA_Cmbedtls_rsa_init找不到定义
pk 抽象层MBEDTLS_PK_C+MBEDTLS_PK_PARSE_Cmbedtls_pk_parse_key返回-0x3D00
PEM 文本密钥MBEDTLS_PEM_PARSE_C+MBEDTLS_BASE64_C只认 DER,读 PEM 直接报格式错
证书解析MBEDTLS_X509_USE_C+MBEDTLS_X509_CRT_PARSE_Ccrt.pk是空壳,验签必失败
随机源MBEDTLS_CTR_DRBG_C+MBEDTLS_ENTROPY_C签名报-0x003C

这张表是排查问题的第一现场。很多人遇到-0x3D00就去怀疑密钥文件坏了,其实多半是MBEDTLS_PEM_PARSE_C没开,库根本不认识-----BEGIN这一行。

2.2 裁剪清单:验签端能砍掉的三个模块

签名的机器和验签的设备,需要的宏集合不一样。签名需要私钥和随机数(用于盲化因子),验签只需要公钥做一次模幂,不需要任何随机源。这一刀砍下去,省的是 ROM 和 RAM,不是一点点。

/* verifier/mbedtls_config.h —— 只保留 RSA 验签所需的最小集合 */ #define MBEDTLS_BIGNUM_C #define MBEDTLS_ASN1_PARSE_C #define MBEDTLS_OID_C #define MBEDTLS_MD_C #define MBEDTLS_SHA256_C #define MBEDTLS_PK_C #define MBEDTLS_PK_PARSE_C #define MBEDTLS_RSA_C #define MBEDTLS_PEM_PARSE_C #define MBEDTLS_BASE64_C #define MBEDTLS_X509_USE_C #define MBEDTLS_X509_CRT_PARSE_C #define MBEDTLS_PLATFORM_C #define MBEDTLS_PLATFORM_MEMORY /* 接自己的 malloc/free,方便统计堆水位 */ #define MBEDTLS_ERROR_C /* mbedtls_strerror,调试期千万别关 */ /* 下面这三组是验签端主动砍掉的 */ /* #define MBEDTLS_ASN1_WRITE_C */ /* 只有构造签名数据时才需要 */ /* #define MBEDTLS_CTR_DRBG_C */ /* 验签不用随机数 */ /* #define MBEDTLS_ENTROPY_C */ /* 同上,连带硬件熵源适配都能省 */

这段配置的逻辑很直白:验签是一次确定性运算,输入是「摘要 + 签名 + 公钥」,输出是非 0 即失败的比较结果,中间没有任何需要随机性的步骤。MBEDTLS_ASN1_WRITE_C保留在签名端,是因为要把DigestInfo那段 ASN.1 前缀拼出来;验签端只需要解析,MBEDTLS_ASN1_PARSE_C就够了。MBEDTLS_ERROR_C会增加几 KB 的字符串表,但换来的是串口能打出人能看懂的错误名,调试期不关、量产再关,是我一贯的做法。

按-Os编到 Cortex-M4 上,这套配置的代码段大致在 40~55KB,RSA-2048 验签过程中的堆峰值在 2~4KB 之间,栈上主要开销是mbedtls_x509_crt结构本身。具体数字跟编译器版本、是否开 CRT 加速关系很大,用arm-none-eabi-size看自己的.text最准。

2.3 demo 的目录划分与两端的职责边界

这个 demo 最重要的一条设计原则是:签名和验签不在同一台机器上,私钥永远不进固件。目录结构直接体现这条边界。

rsa_demo/ ├── configs/ │ ├── mbedtls_config_signer.h # 构建机用,开 CTR_DRBG / ENTROPY │ └── mbedtls_config_verifier.h # 设备用,砍掉随机源 ├── keys/ │ ├── device_priv.pem # 只在构建机,禁止进仓库、禁止进固件 │ ├── device_pub.pem # 给不想拖证书的场景用 │ └── device_cert.pem # 自签证书,烧到设备 ├── signer/ │ ├── main.c # 读镜像 → 摘要 → 签名 → 写 .sig │ └── file_io.c ├── verifier/ │ ├── main.c # 上电验签流程 │ └── cert_blob.c # xxd 生成的证书字节数组 ├── port/ │ └── entropy_port.c # mbedtls_hardware_poll 适配 └── CMakeLists.txt

keys/下的私钥在真实工程里应该放在构建机的密钥服务或者 CI 的加密变量里,device_priv.pem这个文件本身就不该出现在代码仓库。设备侧只拿到device_cert.pem,而公钥是公开信息,泄露了也无所谓。这条边界如果一开始没划清,后面把私钥烧进固件再想改,就得做一次全量设备的密钥轮换,成本翻好几倍。

数据流是单向的:CI 出包时算一次 SHA-256,用私钥签名,把.sig和镜像一起打进升级包;设备收到包,先算镜像摘要,再拿本地证书里的公钥验签,通过才写 flash。整个链路里设备不需要网络、不需要密钥协商,是一个纯离线的完整性校验。

3. 密钥与证书准备:三条 openssl 命令产出的文件怎么塞进固件

3.1 生成密钥、自签证书与一份纯公钥

调试阶段最省事的做法是用 openssl 一次性把材料备齐,别自己在代码里生成密钥——嵌入式上生成 2048 位 RSA 密钥慢到让人怀疑人生,而且没必要。

# 1. 私钥:2048 位,输出 PKCS#8 格式(默认就是) openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \ -out device_priv.pem # 2. 自签证书,有效期 10 年,主题随便写,验签不校验证书链 openssl req -new -x509 -key device_priv.pem -sha256 -days 3650 \ -subj "/CN=fw-sign-demo" -out device_cert.pem # 3. 导出一份纯公钥,给「不想拖证书」的场景用 openssl rsa -in device_priv.pem -pubout -out device_pub.pem # 4. 先用命令行自测一遍,确认密钥本身没问题 openssl dgst -sha256 -sign device_priv.pem -out fw.bin.sig fw.bin openssl dgst -sha256 -verify device_pub.pem -signature fw.bin.sig fw.bin

第 4 步非常关键。它能帮你把「密钥材料本身有问题」和「mbedtls 调用方式有问题」这两类故障彻底分开。命令行验得过、mbedtls 验不过,问题一定在代码;命令行都验不过,那就是密钥或者摘要算法对不上,跟 mbedtls 一点关系都没有。这一步能省掉大量无效排查。

-days 3650和-subj里的内容对验签结果没有影响,因为 demo 只做签名验证,不做证书链校验和有效期检查。但如果后面要上完整的证书链验证,mbedtls_x509_crt_verify会检查not_before/not_after,设备 RTC 不准就会报证书过期,那就是另一个问题了。

3.2 PKCS#1 与 PKCS#8、PEM 与 DER 的取舍

同一份密钥有四种常见落盘方式,选错了mbedtls_pk_parse_key会直接返回格式错误。

格式文件头mbedtls 支持情况建议
PKCS#1 PEMBEGIN RSA PRIVATE KEY2.x/3.x 都支持老工程常见,能用
PKCS#8 PEMBEGIN PRIVATE KEY2.x/3.x 都支持推荐,openssl 默认输出
加密 PKCS#8BEGIN ENCRYPTED PRIVATE KEY需要MBEDTLS_PKCS5_C设备上别用,解不开还得带口令
DER 二进制无文件头,直接是 ASN.1都支持省掉 base64 解码,体积更小

设备端我一般优先选 DER。PEM 是 base64 文本,解析时要走MBEDTLS_BASE64_C,凭空多几 KB 代码,而且数组里还得留结尾的\0。证书本身用 PEM 也行,因为只有一份,体积差不了多少;私钥这种绝不该出现在设备上的东西,格式根本无所谓。

需要注意的是,mbedtls 3.x 的mbedtls_pk_parse_key多了两个参数:f_rng和p_rng。这是为了支持加密私钥的解密。即便你传的是未加密的 PEM,参数也得填,通常直接给NULL, NULL就行——但如果传的密钥是加密的,而这两个是NULL,会返回MBEDTLS_ERR_PK_PASSWORD_REQUIRED。

3.3 把 PEM 变成 C 数组,注意结尾那个\0

设备上没有文件系统,证书一般是以字节数组的形式编进固件。最省事的转换工具是xxd。

xxd -i device_cert.pem > verifier/cert_blob.c # 产出形如: # unsigned char device_cert_pem[] = { 0x2d, 0x2d, 0x2d, 0x2d, 0x2d, ... }; # unsigned int device_cert_pem_len = 1234;

xxd -i生成的数组长度不包含结尾的\0。而mbedtls_x509_crt_parse对 PEM 输入的要求是buflen必须把结尾的空字符算进去,否则解析会失败。这个坑非常隐蔽,因为数组内容看着完全正确,只是长度差了 1。

/* verifier/cert_blob.c 里补一个包装,把长度问题一次性解决 */ static int load_device_cert(mbedtls_x509_crt *crt) { /* 静态缓冲,避免每次调用都在栈上开一份 */ static unsigned char pem_buf[sizeof(device_cert_pem) + 1]; static int inited = 0; if (!inited) { memcpy(pem_buf, device_cert_pem, sizeof(device_cert_pem)); pem_buf[sizeof(device_cert_pem)] = '\0'; /* 补上结尾空字符 */ inited = 1; } /* 此时 buflen = sizeof(device_cert_pem) + 1,包含 '\0' */ return mbedtls_x509_crt_parse(crt, pem_buf, sizeof(pem_buf)); }

这段代码做了两件事。第一,把xxd数组复制到一个多出一字节的静态缓冲区,并在末尾补'\0',这样传给mbedtls_x509_crt_parse的长度就是「有效字符数 + 1」,符合 PEM 分支的要求。第二,用static加一次性初始化,避免每次验签都在栈上开一份证书大小的缓冲——如果证书是 1.2KB,放在一个 2KB 栈的任务里,第二次调用就会栈溢出。

如果改成 DER 格式,这一段可以简化成直接把数组和长度传进去,不需要补\0,也不需要MBEDTLS_PEM_PARSE_C和MBEDTLS_BASE64_C。量产版本我更倾向 DER。

3.4 parse_key 与 parse_public_key 的差别

加载密钥有两个函数,别用混。

/* 签名端:加载私钥,3.x 需要传 rng 参数(未加密密钥可传 NULL) */ ret = mbedtls_pk_parse_key(&pk, key_buf, key_len, NULL, 0, mbedtls_ctr_drbg_random, &ctr_drbg); /* 验签端:加载纯公钥,不需要任何随机源 */ ret = mbedtls_pk_parse_public_key(&pk, pub_buf, pub_len);

mbedtls_pk_parse_key能读私钥也能读公钥,mbedtls_pk_parse_public_key只读公钥。验签端用后者,代码更小,也不会因为误传私钥而埋下安全隐患。返回值的语义也不一样:-0x3D00是格式不对,-0x3C00是需要口令,-0x3F80是内存分配失败,这三个错误码对应的处理动作完全不同,不要一律当成「密钥文件坏了」。

4. 签名与验签链路:mbedtls_pk_sign 与 mbedtls_pk_verify 逐行拆

4.1 签名端:随机源、上下文与一次完整的签名调用

签名端跑在构建机上,资源不受限,可以把完整流程写清楚。

/* signer/main.c —— 对一段内存数据做 RSA-2048 + SHA-256 签名 */ #include "mbedtls/pk.h" #include "mbedtls/md.h" #include "mbedtls/ctr_drbg.h" #include "mbedtls/entropy.h" #define SIG_BUF_MAX 512 /* 2048 位只需 256,留余量给 3072/4096 位密钥 */ int rsa_sign(const unsigned char *key_buf, size_t key_len, const unsigned char *data, size_t data_len, unsigned char *sig, size_t *sig_len) { int ret; mbedtls_pk_context pk; mbedtls_entropy_context entropy; mbedtls_ctr_drbg_context ctr_drbg; unsigned char hash[32]; /* SHA-256 固定 32 字节 */ const char *pers = "rsa_sign_demo"; mbedtls_pk_init(&pk); mbedtls_entropy_init(&entropy); mbedtls_ctr_drbg_init(&ctr_drbg); /* 1. 熵源喂给 CTR_DRBG,签名盲化因子从这里取 */ ret = mbedtls_ctr_drbg_seed(&ctr_drbg, mbedtls_entropy_func, &entropy, (const unsigned char *)pers, strlen(pers)); if (ret != 0) goto exit; /* 2. 加载私钥;3.x 的后两个参数是 rng,2.x 没有 */ ret = mbedtls_pk_parse_key(&pk, key_buf, key_len, NULL, 0, mbedtls_ctr_drbg_random, &ctr_drbg); if (ret != 0) goto exit; /* 3. 先算摘要,原文多长都只算这一次 */ ret = mbedtls_md(mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), data, data_len, hash); if (ret != 0) goto exit; /* 4. 签名:md_alg 必须和上一步算摘要用的算法一致 */ *sig_len = SIG_BUF_MAX; ret = mbedtls_pk_sign(&pk, MBEDTLS_MD_SHA256, hash, sizeof(hash), sig, SIG_BUF_MAX, sig_len, mbedtls_ctr_drbg_random, &ctr_drbg); exit: mbedtls_pk_free(&pk); mbedtls_ctr_drbg_free(&ctr_drbg); mbedtls_entropy_free(&entropy); return ret; }

逐个参数说明。&pk是加载好私钥的上下文;MBEDTLS_MD_SHA256告诉库用哪种 DigestInfo 前缀去包装摘要;hash和sizeof(hash)是已经算好的摘要,长度必须严格等于该摘要算法的输出长度;sig是输出缓冲,SIG_BUF_MAX是缓冲容量(3.x 新增的参数,防越界);sig_len是传入传出参数,函数返回时被写成实际签名长度;最后两个是随机数回调,签名时的盲化因子由它提供,这是防侧信道攻击的必要步骤,不能传NULL。

hash这个缓冲区是栈上的 32 字节,很安全。但如果你把data传成整包固件(比如 8MB),mbedtls_md会一次性算完,构建机上没问题,设备上就得改成流式mbedtls_md_starts/update/finish,一边读 flash 一边喂。

4.2 验签端:从证书里直接取公钥

验签端最省事的写法是直接把证书解析出来,用里面的pk字段。

/* verifier/main.c —— 用证书里的公钥验证签名 */ int rsa_verify(const unsigned char *cert_pem, size_t cert_len_with_nul, const unsigned char *data, size_t data_len, const unsigned char *sig, size_t sig_len) { int ret; mbedtls_x509_crt crt; unsigned char hash[32]; mbedtls_x509_crt_init(&crt); /* 1. 解析证书,cert_len_with_nul 必须包含结尾 '\0' */ ret = mbedtls_x509_crt_parse(&crt, cert_pem, cert_len_with_nul); if (ret != 0) goto exit; /* 2. 算摘要,算法必须和签名端完全一致 */ ret = mbedtls_md(mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), data, data_len, hash); if (ret != 0) goto exit; /* 3. crt.pk 就是证书里解出来的公钥上下文,不需要再 parse */ ret = mbedtls_pk_verify(&crt.pk, MBEDTLS_MD_SHA256, hash, sizeof(hash), sig, sig_len); exit: mbedtls_x509_crt_free(&crt); /* 必须释放,内部有多个堆分配 */ return ret; }

mbedtls_x509_crt_parse支持一次传多张证书(PEM 里连着写多段BEGIN CERTIFICATE会解析成链表),这里只传一张,crt.pk就直接指向第一张证书的公钥。如果证书是 DER 格式,把MBEDTLS_PEM_PARSE_C关掉也一样能走通,只是长度参数不需要 +1。

mbedtls_pk_verify的返回值语义要记牢:0 表示验签通过,MBEDTLS_ERR_RSA_VERIFY_FAILED(-0x4380)表示签名和内容对不上,其他负值表示环境或参数有问题。第三类错误和「签名不匹配」必须分开处理——前者说明代码有 bug 或者配置没开,不能当成「固件被篡改」上报,否则线上会天天报安全告警却查不出原因。

4.3 PSS 与 v1.5 的切换点

PKCS#1 v1.5 是默认填充,mbedtls_pk_sign/mbedtls_pk_verify走的都是它。如果对端要求 PSS(新的协议规范里越来越常见),签名和验签都要换 API。

/* 签名端:改用 mbedtls_rsa_set_padding,或者直接用 pk_sign_ext */ mbedtls_pk_rsassa_pss_options pss_opts = { .mgf1_hash_id = MBEDTLS_MD_SHA256, .expected_salt_len = MBEDTLS_RSA_SALT_LEN_ANY, }; /* 验签端:普通 verify 换成 verify_ext,多传一个 options 结构 */ ret = mbedtls_pk_verify_ext(MBEDTLS_PK_RSASSA_PSS, &pss_opts, &crt.pk, MBEDTLS_MD_SHA256, hash, sizeof(hash), sig, sig_len);

mgf1_hash_id是掩码生成函数用的哈希,通常和主摘要算法保持一致;expected_salt_len表示接受任意长度的盐,如果对端固定了盐长(比如 32),这里就填 32。PSS 和 v1.5 在字节层面完全不同,一端用 PSS、另一端用 v1.5,验签必然失败,而且错误码还是-0x4380,

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询