mbedtls RSA签名验签与X.509证书校验实战
发布时间:2026/10/26 2:43:09 锦皓数字建站

简介面向嵌入式开发与信息安全学习者的 mbedtls RSA 签名验签示例工程聚焦数字证书场景下非对称加密的实际落地。压缩包共111个文件、约640KB以c与h源码为核心辅以obj中间文件、cmake构建脚本和txt说明等内容涉及RSA密钥对生成、SHA-256摘要、PKCS#1签名与验证、X.509证书解析以及CMake工程组织能帮助读者理解资源受限环境中公钥与身份信息的绑定关系、证书颁发机构签名带来的可信保障以及证书公钥提取和签名校验流程。已有504人学习适合用作密码学课程实验、嵌入式安全模块预研和项目集成参考工程结构完整便于对照源码梳理签名验签链路的模块划分、依赖配置和排错思路也适合作为从零搭建轻量级安全示例的起点降低理解非对称加密工程化的门槛。1. 从一条验签返回码说起这个 mbedtls RSA 签名验签 demo 到底在做什么上周有人拿着一块板子来问授权文件校验一直失败串口只打出一行mbedtls_pk_verify returned -0x4380再往下就没日志了。这个 mbedtls RSA 签名验签 demo 就是为这类场景准备的从 PEM 读私钥、对数据做 SHA-256、按 PKCS#1 v1.5 填签名再从数字证书里取公钥把签名验回来整条链路在 C 代码里手动走一遍。适合两类人一是做固件授权、License 校验、OTA 包完整性检查的嵌入式开发二是想搞明白数字证书验签到底在验什么、不愿只调 openssl 命令行的后端同学。它不解决业务问题但换平台、换密钥长度、换填充方式时你能清楚知道该改哪一行。2. 工程结构与技术选型一个能塞进固件的密码学底座这个 demo 的骨架非常小小到第一次看会觉得就这但它刻意保持了这种形状把密码学调用、证书处理、熵源适配切成三块每一块都能单独替换。之所以这样切是因为在真实项目里出问题的往往不是 RSA 算法本身而是熵源在你的板子上取不到、证书格式和你以为的不一样、链接时少了一个符号。分层之后每一类问题都能被定位到具体文件而不用在一坨main()里翻。2.1 目录长什么样源码、证书、第三方库三块分开先看结构这份 demo 的目录组织大概是这个样子rsa_sign_demo/ ├── CMakeLists.txt # 顶层构建把 mbedtls 作为子目录引入 ├── include/ │ ├── rsa_sign.h # 签名/验签对外接口调用方只认这个头 │ └── cert_util.h # 证书加载、公钥提取、信任链校验 ├── src/ │ ├── main.c # 命令行入口演示 sign/verify 两个子命令 │ ├── rsa_sign.c # 哈希 pk_sign / pk_verify 的封装 │ ├── cert_util.c # x509_crt_parse_file、crt.pk 复用 │ └── entropy_port.c # 熵源适配层移植时只改这个文件 ├── certs/ │ ├── server_key.pem # 2048 位 RSA 私钥PKCS#1 传统格式 │ ├── server_cert.pem # 对应自签证书 │ └── ca_cert.pem # 演示信任链用的根证书 ├── tests/ │ └── test_sign_verify.c # 正常、篡改、错密钥三种用例 └── third_party/ └── mbedtls/ # 以子模块或解压源码的形式放进来include/里的头文件是对外契约src/里是实现certs/是运行时资源third_party/是依赖源码。把 mbedtls 源码直接放进仓库而不是依赖系统包在嵌入式里几乎是默认操作交叉编译时目标板上通常没有libmbedcrypto.so静态编进去省掉一堆动态库路径问题。entropy_port.c单独拎出来是个关键设计mbedtls 默认的熵源在 Linux 上走/dev/urandom在裸机或 RTOS 上根本不存在把它隔离成一个文件换平台时只改这一处其余代码一行不动。certs/目录里的文件是运行时按路径读取的mbedtls_pk_parse_keyfile和mbedtls_x509_crt_parse_file都接受文件路径。如果你的目标环境没有文件系统比如裸机 Flash这一层要换成从 Flash 地址读 buffer用mbedtls_pk_parse_key和mbedtls_x509_crt_parse这两个接受内存指针的版本。这是移植时第一个要动的地方提前留好抽象接口能省很多返工。2.2 技术选型为什么是 mbedtls 而不是别的在 C 生态里做 RSA 签名验签可选的东西其实不多横向比较一下会更清楚这个 demo 为什么这么选方案体积典型裁剪后依赖证书解析适用场景mbedtls60KB~200KB无纯 C99内置 X.509MCU、RTOS、资源受限设备openssl数 MB 起libc、部分平台依赖内置功能最全服务器、桌面只做 RSA 的轻量库20KB 左右无通常没有只需要裸 RSA不碰证书自己按 RFC 实现看人无要自己写 ASN.1教学不建议上生产mbedtls 的位置很微妙它比纯 RSA 库多了完整的 X.509 解析和 PKCS#1 填充又比 openssl 小了整整一个数量级。对于设备要验证一个带数字证书的授权文件这种需求它刚好够用且不会把 Flash 撑爆。真正让它在嵌入式站稳脚跟的是可裁剪性。mbedtls 的每个功能模块都由mbedtls_config.h里的宏控制关掉不需要的模块链接器会把对应代码整个丢掉。一个只做 SHA-256 RSA-2048 X.509 证书解析的配置编译出来通常在 100KB 上下RAM 占用取决于你给 RSA 上下文留的空间2KB 到 8KB 都能跑起来。这个 demo 就是按最小可用集配置的没有 TLS、没有 ECDSA、没有 PKCS#12。选型时有个容易忽略的点mbedtls 的 API 在 2.x 和 3.x 之间做过一次不兼容调整函数名从mbedtls_xxx_free改成了mbedtls_xxx_cleanup部分参数顺序也变过。你在网上抄到的示例代码大概率是 2.x 的直接贴进 3.x 工程会编译报错。这个 demo 里的注释会标出差异点自己二次开发时也要先确认手上是哪个大版本别小看这一步我见过有人在这上面耗了半天。2.3 配置宏与构建编译不过先查这几个开关mbedtls 的配置全部集中在mbedtls_config.h2.x 叫config.h这个 demo 需要打开的最小集合是这样/* mbedtls_config.h 中必须打开的宏顺序按依赖关系排 */ #define MBEDTLS_BIGNUM_C /* 大数运算RSA 的地基关掉就没有 RSA */ #define MBEDTLS_OID_C /* ASN.1 OID 解析证书里到处都是 OID */ #define MBEDTLS_ASN1_PARSE_C /* ASN.1 解析读密钥和证书都要 */ #define MBEDTLS_ASN1_WRITE_C /* ASN.1 写生成签名结构时用 */ #define MBEDTLS_PK_C /* 公钥抽象层pk_sign/pk_verify 在这 */ #define MBEDTLS_PK_PARSE_C /* 解析 PEM/DER 格式的密钥 */ #define MBEDTLS_RSA_C /* RSA 算法本体 */ #define MBEDTLS_PKCS1_V15 /* PKCS#1 v1.5 填充本 demo 默认 */ // #define MBEDTLS_PKCS1_V21 /* PSS 填充改成 PSS 时再打开 */ #define MBEDTLS_MD_C /* 哈希抽象层 */ #define MBEDTLS_SHA256_C /* SHA-256 具体实现 */ #define MBEDTLS_X509_CRT_PARSE_C /* X.509 证书解析 */ #define MBEDTLS_X509_USE_C /* 证书基础结构CRT_PARSE 依赖它 */ #define MBEDTLS_CTR_DRBG_C /* 确定性随机数发生器PSS 和密钥生成要用 */ #define MBEDTLS_ENTROPY_C /* 熵收集给 DRBG 提供种子 */ #define MBEDTLS_PEM_PARSE_C /* 读 PEM 格式的证书和私钥 */ #define MBEDTLS_BASE64_C /* PEM 本质是 Base64没有它 PEM 解析必挂 */每个宏背后都是一段被条件编译包起来的代码少开一个的表现通常是链接期报undefined reference to mbedtls_xxx而不是编译报错。MBEDTLS_BASE64_C是最容易漏的一个它的名字看起来和 RSA 没关系但 PEM 文件就是 Base64 包了一层漏掉它MBEDTLS_PEM_PARSE_C会直接失效。如果不想动官方源码里的配置文件用MBEDTLS_CONFIG_FILE宏指向自己的配置头保持在third_party之外管理cmake_minimum_required(VERSION 3.16) project(rsa_sign_demo C) set(CMAKE_C_STANDARD 99) # 指向自定义配置避免修改 third_party 里的原始文件 add_definitions(-DMBEDTLS_CONFIG_FILEmbedtls_user_config.h) include_directories(${CMAKE_CURRENT_SOURCE_DIR}/config) # mbedtls 作为静态库一起编进来目标板上不需要预装动态库 set(ENABLE_TESTING OFF CACHE BOOL FORCE) set(ENABLE_PROGRAMS OFF CACHE BOOL FORCE) add_subdirectory(third_party/mbedtls) add_executable(rsa_demo src/main.c src/rsa_sign.c src/cert_util.c src/entropy_port.c) target_include_directories(rsa_demo PRIVATE include) target_link_libraries(rsa_demo PRIVATE mbedtls mbedx509 mbedcrypto)注意ENABLE_PROGRAMS和ENABLE_TESTING这两个选项官方 CMake 默认会把自带的一堆示例程序和测试编出来交叉编译时这些程序大部分过不了白白拖慢构建。关掉之后只留三个库目标mbedcrypto算法、mbedx509证书、mbedtlsTLS 层本 demo 其实用不到但mbedx509的 CMake 依赖会带上。链接顺序也有讲究mbedtls依赖mbedx509依赖mbedcrypto顺序反了在部分链接器上会报符号找不到。编译命令很朴素mkdir -p build cd build cmake .. -DCMAKE_BUILD_TYPERelease make -j4 # 产物./rsa_demo以及 libmbedcrypto.a / libmbedx509.a / libmbedtls.a跑通这一步之后./rsa_demo sign certs/server_key.pem和./rsa_demo verify certs/server_cert.pem就能看到签名和验签的输出了。如果卡在编译阶段九成是配置宏漏开或者 mbedtls 版本 API 对不上先把错误信息里的符号名拿去搜对应的宏比盲目加-lmbedtls有用得多。3. 核心链路拆解从密钥对到一次成功的验签这条链路一共四个环节生成密钥和证书、对数据做哈希、用私钥签名、用证书里的公钥验签。每个环节都有几个必须做对的选择做错了不会报错只会让验签静默失败——这也是这类问题最难查的地方。下面按执行顺序拆。3.1 密钥与证书准备openssl 生成mbedtls 读取密钥和证书不需要用 mbedtls 生成实际项目里通常由服务端或构建流水线用 openssl 生成好随固件一起烧进去。demo 里用的是自签名证书两条命令搞定# 1. 生成 2048 位 RSA 私钥输出 PKCS#1 传统格式BEGIN RSA PRIVATE KEY openssl genrsa -out certs/server_key.pem 2048 # 2. 用私钥签发自签证书有效期 365 天摘要算法 SHA-256 openssl req -new -x509 -key certs/server_key.pem \ -out certs/server_cert.pem -days 365 -sha256 \ -subj /CCN/ODemo/CNdemo.localgenrsa默认输出的是 PKCS#1 格式文件头是-----BEGIN RSA PRIVATE KEY-----。如果你加-traditional或者用新版 openssl 3.x可能会拿到 PKCS#8 格式-----BEGIN PRIVATE KEY-----。这两种格式 mbedtls 都能读但 PKCS#8 需要额外打开MBEDTLS_PK_PARSE_C里的对应分支有些裁剪过的配置会漏掉表现就是解析返回MBEDTLS_ERR_PK_KEY_INVALID_FORMAT。保守起见自己生成时明确用 PKCS#1省一类问题。-sha256这个参数别省。openssl 老版本默认用 SHA-1 签证书而很多 mbedtls 配置里默认不开 SHA-1MBEDTLS_SHA1_C结果就是证书能解析、签名能生成但证书自身的签名校验过不去报错信息还很含糊。明确指定 SHA-256 之后两边就对齐了。证书里的CN字段在 demo 里只是占位真正影响验签的是公钥本身和签名算法。但如果你要用mbedtls_x509_crt_verify做完整信任链校验CN和 SAN 扩展会被检查取决于你传的cn参数所以还是按实际用途填写别留空。3.2 签名链路哈希、填充、私钥运算三步RSA 签名不是用私钥加密原文这个误解害过不少人。真实流程是三步先对原文做哈希再把哈希值按 PKCS#1 规定封装成DigestInfo结构最后用私钥对这个结构做模幂运算。mbedtls 的mbedtls_pk_sign把后两步都封进去了你只需要把哈希值传给它。/* 对一段内存数据做 SHA-256 签名输出写到 sig 缓冲区 */ int rsa_sha256_sign(const unsigned char *msg, size_t msg_len, const char *key_path, unsigned char *sig, size_t sig_size, size_t *sig_len) { mbedtls_pk_context pk; unsigned char hash[32]; /* SHA-256 固定 32 字节 */ int ret; mbedtls_pk_init(pk); /* 从 PEM 文件加载私钥第二个参数是密码无密码传 NULL */ ret mbedtls_pk_parse_keyfile(pk, key_path, NULL); if (ret ! 0) { goto exit; /* 通常是文件路径错或格式不支持 */ } /* 第一步对原文做哈希结果写进 hash 缓冲区 */ ret mbedtls_md(mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), msg, msg_len, hash); if (ret ! 0) { goto exit; } /* 第二、三步pk_sign 内部会加 DigestInfo 前缀并做 RSA 私钥运算 */ ret mbedtls_pk_sign(pk, MBEDTLS_MD_SHA256, /* 决定 DigestInfo 里的算法标识 */ hash, sizeof(hash), /* 传的是哈希不是原文 */ sig, sig_size, sig_len, mbedtls_ctr_drbg_random, g_ctr_drbg); exit: mbedtls_pk_cleanup(pk); /* 2.x 里叫 mbedtls_pk_free */ return ret; }mbedtls_pk_sign的第二个参数md_alg是关键。它不只是告诉库这个哈希是用什么算的PKCS#1 v1.5 在封装时会把这个算法标识编码进DigestInfo验签方会用它来核对。如果签名时传MBEDTLS_MD_SHA256、验签时传MBEDTLS_MD_SHA1两边算出来的DigestInfo不一样验签必然失败而返回码只有一个笼统的MBEDTLS_ERR_RSA_VERIFY_FAILED。sig_size是缓冲区容量sig_len是实际写入长度RSA-2048 的签名固定是 256 字节。传之前一定要确认缓冲区够大mbedtls_pk_sign在容量不足时返回MBEDTLS_ERR_PK_BUFFER_TOO_SMALL不会帮你扩容。顺手提一句 2.x 和 3.x 的差异2.x 的mbedtls_pk_sign只有一个sig_len指针参数既是入参也是出参3.x 拆成了sig_size和sig_len两个从 2.x 代码迁移过来时这里会编译不过改一下就好。填充方式的选择也值得说清楚这个表在选型时会用到填充方案确定性签名前是否需随机数兼容性说明PKCS#1 v1.5是否最广同一数据多次签名结果完全一致便于比对PSS (v2.1)否是较新系统每次签名结果不同安全性更高demo 默认走 v1.5因为它确定性调试时两次运行结果一致容易判断是不是自己写错了。换成 PSS 只要把宏MBEDTLS_PKCS1_V21打开、调用时传MBEDTLS_RSA_PKCS_V21但这时候随机数源必须可用否则会返回MBEDTLS_ERR_RSA_RNG_FAILED这也是下一章要重点讲的熵源问题。3.3 验签链路为什么必须先算哈希再验验签不是用公钥解出原文然后比对而是用公钥解出DigestInfo取出里面的哈希和你自己算的哈希比对。mbedtls 把前半段封进了mbedtls_pk_verify后半段的比对也在里面做了你只需要提供原文算出的哈希和签名值。/* 用证书里的公钥验签返回 0 表示通过 */ int rsa_sha256_verify(const unsigned char *msg, size_t msg_len, const char *cert_path, const unsigned char *sig, size_t sig_len) { mbedtls_x509_crt crt; unsigned char hash[32]; int ret; mbedtls_x509_crt_init(crt); /* 证书解析crt.pk 里已经包含了可用的公钥上下文 */ ret mbedtls_x509_crt_parse_file(crt, cert_path); if (ret ! 0) { goto exit; /* 负数绝对值是失败条数见 4.4 节 */ } ret mbedtls_md(mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), msg, msg_len, hash); if (ret ! 0) { goto exit; } /* 直接复用证书解析出的公钥不用再单独解析一遍 */ ret mbedtls_pk_verify(crt.pk, MBEDTLS_MD_SHA256, hash, sizeof(hash), sig, sig_len); exit: mbedtls_x509_crt_free(crt); return ret; }crt.pk这个字段是mbedtls_pk_context类型证书解析成功之后它就已经是一个可用的公钥上下文了直接拿来验签不需要再mbedtls_pk_parse_public_key。初学者常犯的错误是解析完证书又去单独解析 PEM 公钥文件多此一举还容易因为格式不一致出问题。这里有个细节验签时传给mbedtls_pk_verify的md_alg必须和签名时一致hash和hash_len也必须是同一算法算出来的。hash_len传错比如把 32 写成 20不会立刻报错而是进到 RSA 运算内部才失败返回码同样含糊。调试时最有效的手段是把签名侧和验签侧算出的哈希用十六进制打印出来逐字节比对两边一致再往下查 RSA 层能省掉大量猜测。3.4 证书链校验自签证书和真实信任链的差别上面那段验签只验证了签名是这个公钥对应的私钥产生的但没有验证这个公钥本身可不可信。攻击者完全可以自己生成一对密钥和一张自签证书签出来的东西照样能过mbedtls_pk_verify。要防这个得走证书链校验/* 校验证书链ca 是预置的根证书crt 是待验证的证书 */ int verify_cert_chain(const char *crt_path, const char *ca_path) { mbedtls_x509_crt crt, ca; uint32_t flags 0; int ret; mbedtls_x509_crt_init(crt); mbedtls_x509_crt_init(ca); ret mbedtls_x509_crt_parse_file(ca, ca_path); if (ret ! 0) goto exit; ret mbedtls_x509_crt_parse_file(crt, crt_path); if (ret ! 0) goto exit; /* 第三个参数是中间证书链自签场景传 NULL */ ret mbedtls_x509_crt_verify(crt, ca, NULL, NULL, flags, NULL, NULL); if (ret ! 0) { /* flags 里是具体哪一项没过过期、域名不符、签发者未知等 */ printf(verify failed, flags0x%08X\n, (unsigned)flags); } exit: mbedtls_x509_crt_free(crt); mbedtls_x509_crt_free(ca); return ret; }flags这个出参是排查问题的钥匙。MBEDTLS_X509_BADCERT_EXPIRED表示证书过期MBEDTLS_X509_BADCERT_NOT_TRUSTED表示签发者不在信任链里MBEDTLS_X509_BADCERT_CN_MISMATCH表示 CN 和预期不符。demo 里的自签证书需要把它自己也作为 CA 传进去否则永远报NOT_TRUSTED。实际项目里的顺序通常是先做证书链校验、拿到flags 0再用crt.pk做数据验签。两步分开做的好处是失败时能区分是证书不可信还是数据被篡改这两种情况在日志里必须能分开不然线上排查只能靠运气。4. 关键代码逐段讲几个函数把整条链路串起来上一章讲的是流程这一章讲实现。这几个函数看着不长但每一行都有它的道理尤其是资源清理和错误处理部分写漏了在 PC 上跑没事跑到板子上连续调用几百次就开始出玄学问题。4.1 熵源与 DRBG随机数初始化不能省mbedtls 的随机数分两层熵源负责从系统收集真实随机性Linux 上是/dev/urandom板子上可能是硬件 RNG 或者 ADC 噪声DRBG确定性随机数发生器用熵源做种子之后按需产生随机字节。整条链路的初始化只需要做一次放在程序启动阶段/* 全局上下文整个进程复用一份别在函数里反复初始化 */ static mbedtls_ctr_drbg_context g_ctr_drbg; static mbedtls_entropy_context g_entropy; int crypto_rng_init(void) { /* personalization string参与种子衍生换个值就得到不同的随机序列 */ const char *pers rsa_sign_demo; int ret; mbedtls_ctr_drbg_init(g_ctr_drbg); mbedtls_entropy_init(g_entropy); ret mbedtls_ctr_drbg_seed(g_ctr_drbg, mbedtls_entropy_func, /* 熵源回调 */ g_entropy, /* 回调的上下文 */ (const unsigned char *)pers, strlen(pers)); if (ret ! 0) { /* 常见返回 MBEDTLS_ERR_CTR_DRBG_ENTROPY_SOURCE_FAILED */ return ret; } return 0; }mbedtls_entropy_func是个函数指针签名是int (*)(void *ctx, unsigned char *buf, size_t len)。如果你的平台没有/dev/urandom就在entropy_port.c里实现一个同签名的函数把硬件 RNG 的输出填进buf然后把这里的mbedtls_entropy_func换掉。这一步不做PSS 签名必挂RSA 密钥生成也必挂。pers这个参数很容易被当成摆设。它的作用是在种子衍生时混入一段固定字符串使得同样的熵源输入在不同应用里产生不同的随机序列。多进程环境或者同一块板子上跑多个应用时传不同的pers能避免随机数序列撞车。demo 里随便填了个字符串真实项目建议填应用名加版本号。还有一点mbedtls_ctr_drbg_seed之后DRBG 会自动做一次重播种策略配置默认阈值是 10000 次请求。这个值在长时间运行的服务里可能需要调但在设备端基本不用管。4.2 mbedtls_pk_sign一次完整调用里的六个参数把签名函数单独拎出来逐参数看是因为这六个参数每一个错了都会导致签名成功但验签失败这种最难查的问题ret mbedtls_pk_sign(pk, /* 1. 私钥上下文 */ MBEDTLS_MD_SHA256, /* 2. 摘要算法标识 */ hash, sizeof(hash), /* 3. 哈希值 长度 */ sig, sig_size, sig_len, /* 4. 输出缓冲区 容量 实际长度 */ mbedtls_ctr_drbg_random,/* 5. 随机数回调 */ g_ctr_drbg); /* 6. 随机数上下文 */第 2 个参数决定DigestInfo里的算法 OID必须和验签方一致。第 3 个参数传的是哈希值本身不是原文长度必须是该摘要算法的输出长度SHA-256 是 32SHA-1 是 20。第 4 个参数里sig_size是容量RSA-2048 需要 256 字节缓冲区开了 128 字节就会返回MBEDTLS_ERR_PK_BUFFER_TOO_SMALL。第 5、6 个参数在 v1.5 填充下其实用不到但函数签名要求必须传可以传NULL不过为了将来切 PSS 方便demo 里一直传着有效的 DRBG。有个常被误解的点mbedtls_pk_sign内部已经帮你做了哈希。如果你把原文直接当hash传进去、长度传原文长度函数会返回MBEDTLS_ERR_PK_BAD_INPUT_DATA因为长度和摘要算法不匹配。但如果你传的是MBEDTLS_MD_NONE加任意长度数据它就不加DigestInfo直接做原始 RSA 运算这时候签名是能签的只是验签方如果按标准流程走必然失败。这个行为差异是很多跨语言互操作问题的根源。返回值的处理也要注意。mbedtls_pk_sign返回 0 表示成功负数表示失败正数在 mbedtls 里基本不用。失败时不要只看返回值mbedtls_pk_context里的状态可能已经变了必须走mbedtls_pk_cleanup释放然后再重试不要在同一份上下文上反复调。4.3 mbedtls_pk_verify返回码怎么读mbedtls_pk_verify的返回码比签名侧更值得研究因为验签失败的场景多。常用的几个返回码含义通常原因0验签通过—MBEDTLS_ERR_RSA_VERIFY_FAILED签名不匹配数据被改、哈希算法不一致、签名被截断MBEDTLS_ERR_RSA_INVALID_PADDING填充格式非法签名值被篡改或用了错误的填充模式MBEDTLS_ERR_PK_SIG_LEN_MISMATCH签名长度不符签名缓冲区传短了或数据本身被截断MBEDTLS_ERR_PK_BAD_INPUT_DATA入参非法哈希长度和算法不匹配、空指针这些码的数值都是负数日志里以十六进制出现时不好辨认建议封装一个映射函数把返回码转成字符串再打日志。这个习惯能救很多时间尤其是在只有串口输出的设备上一个符号名比一串十六进制有用得多。验签成功只代表数据和签名匹配且公钥正确不代表这个公钥可信。这两件事必须分开判断混在一起就是安全漏洞。demo 的main.c里把这两步分开调用输出也分开打印就是为了让这个边界清晰。4.4 证书加载与公钥提取parse 的返回值是个坑mbedtls_x509_crt_parse系列的返回值语义和其他函数不一样。它不是0 成功、负数失败而是负数表示解析失败的证书条数正数表示成功解析的条数只有返回 0 或者正数才算没出错。这个设计在解析证书链一个 PEM 文件里多张证书时有用但只看返回值小于 0就判断失败会漏掉一种情况文件里有 3 张证书2 张成功 1 张失败返回 -1。/* 一次解析整个 PEM 文件可能包含多张证书 */ ret mbedtls_x509_crt_parse_file(crt, certs/server_cert.pem); if (ret 0) { /* 注意这里判断的是 0不是 ! 0 */ printf(parse failed: -0x%04X\n, (unsigned)-ret); return ret; } if (ret 0) { /* 部分证书解析失败但至少有一张可用按需决定是否继续 */ printf(warning: %d cert(s) failed to parse\n, ret); }解析成功之后crt.pk就是第一张证书的公钥。如果文件里有多张crt.next指向下一张可以遍历。提取公钥用于验签时通常取第一张也就是终端实体证书。还有一个容易踩的点mbedtls_x509_crt_parse接受内存 buffer 的版本要求 buffer 长度包含结尾的\0因为它内部按 C 字符串处理 PEM 内容。很多人从文件读出来只读了st_size字节没读那个\0结果解析在最后一行失败返回MBEDTLS_ERR_X509_INVALID_FORMAT但文件用文本编辑器打开看完全正常。这个坑在从文件系统读证书时几乎必踩一次。5. 避坑与排查验签失败之后我查了什么这一章列的五个问题都是实际遇到过、并且当时查了很久的。每个都按现象 → 原因 → 解决写照着排查能省不少时间。5.1 现象签名返回 0验签返回MBEDTLS_ERR_RSA_VERIFY_FAILED原因九成是哈希算法对不上。签名时传MBEDTLS_MD_SHA256验签时传了MBEDTLS_MD_SHA1或者两边都对但数据在处理过程中被改过——比如签名时传的是文件内容验签时传的是去掉末尾换行符的内容或者反过来多读了一个\0。解决在签名和验签两侧都把hash数组用十六进制打印出来逐字节比对。一致说明问题在 RSA 层填充模式、密钥对是否配对不一致就往上查数据处理逻辑。这个方法看着笨但比猜快得多。5.2 现象证书解析返回MBEDTLS_ERR_X509_INVALID_FORMAT文件本身没问题原因用mbedtls_x509_crt_parse从内存解析时buffer 长度没包含结尾的\0。PEM 解析器按字符串处理读到 buffer 末尾还没找到\0就会判定格式错误。用mbedtls_x509_crt_parse_file不会有这个问题因为它内部自己处理了。解决读文件时多读一个字节或者直接把strlen(buf) 1作为长度传进去。顺带检查 PEM 文件是不是被 Windows 编辑器改成了 CRLF 换行虽然 mbedtls 一般能容忍但某些裁剪版本对\r的处理不一致统一转成 LF 更保险。5.3 现象跑一段时间后崩溃栈回溯指向mbedtls_pk_verify原因mbedtls_pk_context或mbedtls_x509_crt没初始化就用了。这两个结构体不是 POD内部有指针和状态标记mbedtls_xxx_init负责把它们置零漏掉这一步结构体里是栈上的随机值函数一进去就按非法指针解引用。解决所有 mbedtls 上下文结构体在声明之后立刻init出错路径上也要走cleanup。用 goto 统一清理是 C 里最省心的写法demo 里每个函数都是这个模式。另外注意cleanup之后不要再用这个上下文重复 init 之前必须先 cleanup 一次。5.4 现象把签名结果用printf(%s)打出来比对长度总是对不上原因RSA 签名是二进制数据256 字节里出现0x00的概率不低。用%s打印会在第一个0x00处截断strlen算出来的长度也偏小看起来像是签名被截断了实际数据是完整的。解决打印二进制数据一律用十六进制逐字节输出或者 Base64 编码后打印。存文件、走网络传输、写数据库都一样二进制数据不能当字符串处理。这个坑不止在签名上哈希值、密钥、IV 全都适用。5.5 现象喂一段较长的数据去签名返回MBEDTLS_ERR_RSA_BAD_INPUT_DATA原因直接把原文当hash参数传给了mbedtls_pk_sign长度超过了摘要算法的输出长度32 字节或者用了MBEDTLS_MD_NONE加超长数据做原始 RSA 运算。RSA-2048 一次能处理的数据量上限是 245 字节256 减去 PKCS#1 v1.5 的填充开销超了必然失败。解决先哈希再签名这是标准流程。如果确实需要签超长数据正确做法是哈希或者用 Merkle 树分块哈希而不是直接塞给 RSA。反过来验签也一样先对原文哈希再传进去不要试图把原文直接传给mbedtls_pk_verify。6. 二次开发把 demo 改成能用的签名模块demo 是单文件命令行真要用起来一般要做三件事把接口封装成不依赖文件路径的形式、把填充模式做成可配置、把签名和验签的互操作性用 openssl 交叉验证一遍。先说封装。原始接口直接传文件路径在嵌入式上很别扭因为证书和密钥通常烧在 Flash 固定地址没有文件系统。比较实用的做法是加一层内存接口把路径版本和 buffer 版本都暴露出去/* 统一的调用参数路径和内存二选一另一个置 NULL */ typedef struct { const char *key_path; /* 私钥文件路径可为 NULL */ const unsigned char *key_buf; /* 私钥内存数据可为 NULL */ size_t key_len; mbedtls_md_type_t md_alg; /* 摘要算法SHA-256 / SHA-1 */ int padding; /* MBEDTLS_RSA_PKCS_V15 / V21 */ } sign_param_t; int sign_init(const sign_param_t *p); int sign_data(const unsigned char *msg, size_t len, unsigned char *sig, size_t *sig_len); int verify_data(const unsigned char *msg, size_t len, const unsigned char *sig, size_t sig_len);sign_init里根据key_path是否为 NULL 决定走mbedtls_pk_parse_keyfile还是mbedtls_pk_parse_key上下文存在模块内的静态变量里sign_data直接复用避免每次都解析一遍私钥。解析 2048 位 RSA 私钥在 MCU 上大概要几十毫秒放在高频调用的路径上很伤。填充模式改成可配置只需要改两处sign_init里根据padding设置mbedtls_rsa_set_padding签名和验签时把MBEDTLS_MD_SHA256换成mbedtls_md_info_from_type的返回。换成 PSS 之后mbedtls_pk_sign的随机数回调必须传有效的 DRBG传 NULL 会直接返回MBEDTLS_ERR_RSA_RNG_FAILED。互操作性验证这一步别省。用 openssl 命令行对同一份数据签名再拿你的代码验或者反过来能快速确认填充模式和 DigestInfo 编码是否符合标准# 用私钥对文件做 SHA-256 签名输出二进制 openssl dgst -sha256 -sign certs/server_key.pem -out sig.bin data.bin # 用公钥验签输出 Verified OK 表示通过 openssl dgst -sha256 -verify (openssl x509 -in certs/server_cert.pem -pubkey -noout) \ -signature sig.bin data.bin # 把二进制签名转成十六进制方便和 C 代码的输出对比 xxd -p sig.bin | tr -d \n两边对不上的时候先看DigestInfo的十六进制前缀。标准 SHA-256 的 DigestInfo 前缀固定是3031300d060960864801650304020105000420如果你的输出前面多了或少了几个字节问题就在md_alg参数上不在 RSA 运算本身。最后提一句性能。RSA-2048 私钥运算在普通 Cortex-M4 上大概几十毫秒验签公钥运算在几毫秒差距很大。如果设备端只做验签完全可以把私钥留在服务端设备端只烧公钥或证书既省算力又省密钥泄露风险。这个取舍在做方案时就要定下来别等到最后才发现设备扛不住签名耗时。从那以后我每次接一个新的 mbedtls 工程第一件事都是把mbedtls_pk_verify的返回码映射成符号名打出来第二件事是在代码里写清注释标明填充模式是 v1.5 还是 PSS、摘要算法是哪个。这两条做到了后面九成的验签问题能在五分钟内定位。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。