ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于OpenSSL的ECDSA签名与验证:从密钥生成到工程落地

基于OpenSSL的ECDSA签名与验证:从密钥生成到工程落地 简介基于OpenSSL库的ECDSA签名与验证完整代码包面向C/C开发者及密码学学习者帮助理解椭圆曲线公钥算法的核心机制与应用实现。资源共93个文件包含75个OpenSSL头文件、3个C源文件、3个Visual Studio工程文件、编译好的exe工具以及PDF教程整体约5.17MB目录区分Debug与Release版本便于对照研究。目前已有2527人学习下载。通过阅读PDF并运行keygen、sign、verify三个程序可直观掌握私钥生成公钥、消息哈希签名及验签校验的完整流程源码和工程文件方便自行修改调试适合密码学初学者快速上手也可作为信息安全及相关方向课程设计、项目实践的参考资料。1. 项目背景与整体设计思路1.1 这个项目要解决什么问题我接到这个任务的时候手头刚好有一个需要做固件防篡改的需求。设备端和升级包之间需要互相确认身份防止中间人把恶意固件塞进来。翻了一圈方案RSA 2048 倒是能用但嵌入式设备那边MCU资源紧张RSA握手和验签的开销有点大密钥长度也占存储。于是就把目光放到了 ECDSA 上——基于OpenSSL库做ECDSA签名与验证配合完整的工程代码和文档顺手把整套流程沉淀成一个小项目。项目内核很简单生成一对椭圆曲线密钥用私钥对数据做签名公钥做验证。但实际落地的时候坑一点都不少。OpenSSL 的 API 跨版本差异、密钥格式转换、DER 编码的签名长度、哈希算法的选择、PEM 文件的管理每一项都值得单独讲一讲。这个项目面向的是有基础 C 语言功底的开发者或者正在做设备认证、软件完整性校验的嵌入式工程师。当然哪怕是刚接触 OpenSSL 的初学者只要照着下面的步骤走一遍也能跑通整个签名验证流程。1.2 为什么选 ECDSA 而不是 RSA先聊一下技术选型的逻辑。ECDSAElliptic Curve Digital Signature Algorithm和 RSA 都能做数字签名但底层的数学原理完全不同RSA 依赖大整数分解的困难性ECDSA 依赖椭圆曲线离散对数问题的困难性。正因为难解问题的性质不同同等级安全强度下 ECDSA 的密钥长度要短得多。安全强度RSA 密钥长度ECDSA 密钥长度签名尺寸DER 编码112 bits2048 bits224 bits64~72 bytes128 bits3072 bits256 bits70~72 bytes256 bits15360 bits521 bits139~142 bytes签名效率上ECDSA 的验证速度也比 RSA 快不少。RSA 验证需要做一次大指数模幂运算而 ECDSA 验证是两次点乘运算计算量差了一个量级。实际测下来在 Cortex-M4 这类主频不高的 MCU 上ECDSA P-256 验签耗时大约是 RSA 2048 的三分之一到四分之一。这个差距对电池供电、实时性要求高的设备来说非常关键。应用场景也很明确固件签名、Bootloader 安全启动、软件包完整性校验、TLS 证书签发、区块链交易签名。只要是验证数据没有被篡改且确认来源可信的地方ECDSA 都能用上。注意ECDSA 对随机数的质量要求极高。签名时如果随机数出现哪怕一个比特的偏差理论上就可能泄露私钥。2010 年 PlayStation 3 就是因为签名的随机数固定不变导致私钥被直接推算出来。生产环境务必使用 OpenSSL 默认的 CSPRNG或者硬件熵源。2. 环境准备与密钥管理2.1 OpenSSL 版本选择OpenSSL 目前的主流版本分两支1.1.1 和 3.x。3.0 开始引入了 provider 机制加解密、签名等操作默认走默认 providerAPI 层面大体兼容 1.1.1但有些细节有差异。我这里以 OpenSSL 3.0 为主讲解同时对 1.1.1 的差异点做提示。查一下你本机的版本openssl version -a如果输出OpenSSL 3.0.x或更高版本直接用我下面的代码没有问题。如果是 1.1.1也没关系核心的 EVP API 在 1.1.1 和 3.0 中都能用只需要注意EVP_MD_CTX_new()/EVP_MD_CTX_free()这两个函数是 1.1.0 之后才引入的老版本得用EVP_MD_CTX_create()/EVP_MD_CTX_destroy()。Windows 用户建议直接打开 OpenSSL 官网下载安装包选 Win64 版本。Linux 用户通过包管理器安装# Debian / Ubuntu sudo apt install libssl-dev openssl # CentOS / RHEL sudo yum install openssl-develmacOS 用户可以用 Homebrewbrew install openssl注意 Homebrew 安装的 OpenSSL 是 keg-only 的编译时需要手动指定头文件和库文件路径否则编译器可能找到系统自带的 LibreSSL。我当时在这里卡了半小时openssl version显示 LibreSSL 3.3.6一堆 API 编译报错最后用pkg-config显式指定路径才解决。2.2 生成和转换密钥密钥管理是整个签名流程的第一步也是最容易出问题的一步。ECDSA 密钥对包含曲线参数、私钥一个随机大整数 d、公钥椭圆曲线上的点 Q dG。OpenSSL 生成 P-256 私钥的命令如下# 生成 P-256 曲线的私钥 openssl ecparam -name prime256v1 -genkey -noout -out ecdsa_private.pem # 从私钥导出公钥 openssl ec -in ecdsa_private.pem -pubout -out ecdsa_public.pemprime256v1是 NIST P-256 曲线的 OpenSSL 名称在 TLS 和区块链生态里用得最广。如果需要更高级别的安全强度可以换成secp384r1或secp521r1。打开生成的私钥文件会看到类似这样的内容-----BEGIN EC PRIVATE KEY----- MHcCAQEEIBPb9JSL4M9ZJ1sW1L5nPpj2xUXaP0V4Y5k8tq3D8RZGoAoGCCqGSM49 AwEHoUQDQgAEoBJx2S1gXx8m2E7Qqg8qVc3v0H3C1XQ5wOZnBj2uY9lCzQ2xQXj ... -----END EC PRIVATE KEY-----有些场景需要 PKCS#8 格式或者需要把私钥转成 DER 二进制# 转 PKCS#8 格式很多库默认只认 PKCS#8 openssl pkcs8 -topk8 -nocrypt -in ecdsa_private.pem -out ecdsa_private_pkcs8.pem # 公钥转 DER 格式 openssl ec -in ecdsa_private.pem -pubout -outform DER -out ecdsa_public.der # 查看私钥明细 openssl ec -in ecdsa_private.pem -text -noout个人经验如果你的项目要对接 Java 或其他语言环境直接生成 PKCS#8 格式的私钥能省很多事。Java 的KeyFactory对 PKCS#1 的 EC 私钥支持不太好非要手动转格式浪费时间还容易踩编码的坑。2.3 PEM 与 DER 编码的选择PEM 是 Base64 编码的文本格式带 BEGIN/END 标记行优点是方便用文本编辑器查看、复制适合配置文件和调试场景。DER 是二进制格式体积更小适合固件存储和网络传输。一个常见误区很多人以为 PEM 就是纯文本可以在传输过程中随意加空格。实际上 OpenSSL 解析 PEM 对内容严格要求任何多余的字符都会导致PEM_read_bio_PrivateKey失败报bad base64 decode之类的错误。如果你是从配置中心读密钥注意把密钥内容先 trim 再传给 OpenSSL。3. 核心代码实现与文档整理3.1 签名流程的完整代码OpenSSL 的 EVP 接口把底层算法封装得很干净签名流程可以概括为初始化摘要上下文 → 设置签名密钥 → 更新待签名数据 → 签名输出。下面是一段可以直接编译运行的签名代码#include stdio.h #include stdlib.h #include string.h #include openssl/evp.h #include openssl/pem.h #include openssl/err.h #define SIG_MAX_LEN 512 static void handle_errors(void) { ERR_print_errors_fp(stderr); exit(EXIT_FAILURE); } int ecdsa_sign(const char *privkey_path, const unsigned char *data, size_t data_len, unsigned char **sig, size_t *sig_len) { EVP_MD_CTX *mdctx NULL; EVP_PKEY *pkey NULL; FILE *fp NULL; int ret 0; fp fopen(privkey_path, r); if (!fp) { perror(fopen); goto cleanup; } pkey PEM_read_PrivateKey(fp, NULL, NULL, NULL); fclose(fp); if (!pkey) handle_errors(); mdctx EVP_MD_CTX_new(); if (!mdctx) handle_errors(); /* 指定 ECDSA 算法和 SHA-256 摘要 */ if (EVP_DigestSignInit(mdctx, NULL, EVP_sha256(), NULL, pkey) ! 1) handle_errors(); /* 更新数据 */ if (EVP_DigestSignUpdate(mdctx, data, data_len) ! 1) handle_errors(); /* 先调用一次获取签名长度再真正的签名 */ if (EVP_DigestSignFinal(mdctx, NULL, sig_len) ! 1) handle_errors(); *sig (unsigned char *)malloc(*sig_len); if (!*sig) goto cleanup; if (EVP_DigestSignFinal(mdctx, *sig, sig_len) ! 1) handle_errors(); ret 1; cleanup: EVP_MD_CTX_free(mdctx); EVP_PKEY_free(pkey); return ret; } int main(void) { const char *msg Hello ECDSA! This is a test message.; unsigned char *sig NULL; size_t sig_len 0; int ret 0; ret ecdsa_sign(ecdsa_private.pem, (const unsigned char *)msg, strlen(msg), sig, sig_len); if (!ret) { fprintf(stderr, sign failed\n); return 1; } printf(signature length: %zu bytes\n, sig_len); printf(signature (hex): ); for (size_t i 0; i sig_len; i) printf(%02x, sig[i]); printf(\n); free(sig); return 0; }编译命令gcc -o ecdsa_sign ecdsa_sign.c -lssl -lcrypto代码里有一个关键细节EVP_DigestSignFinal第一次调用传入NULL作为签名缓冲区OpenSSL 会把需要的缓冲区长度写入sig_len。必须按这个两次调用的模式来写否则缓冲区长度不对第二次签名时会报EVP_DigestSignFinal长度错误。3.2 验证流程的完整代码验证流程和签名几乎对称#include stdio.h #include stdlib.h #include string.h #include openssl/evp.h #include openssl/pem.h #include openssl/err.h static void handle_errors(void) { ERR_print_errors_fp(stderr); exit(EXIT_FAILURE); } int ecdsa_verify(const char *pubkey_path, const unsigned char *data, size_t data_len, const unsigned char *sig, size_t sig_len) { EVP_MD_CTX *mdctx NULL; EVP_PKEY *pkey NULL; FILE *fp NULL; int ret 0; fp fopen(pubkey_path, r); if (!fp) { perror(fopen); return 0; } pkey PEM_read_PUBKEY(fp, NULL, NULL, NULL); fclose(fp); if (!pkey) handle_errors(); mdctx EVP_MD_CTX_new(); if (!mdctx) handle_errors(); if (EVP_DigestVerifyInit(mdctx, NULL, EVP_sha256(), NULL, pkey) ! 1) handle_errors(); if (EVP_DigestVerifyUpdate(mdctx, data, data_len) ! 1) handle_errors(); /* 验证签名: 返回 1 表示验证通过0 表示签名不匹配 0 表示出错 */ ret EVP_DigestVerifyFinal(mdctx, sig, sig_len); cleanup: EVP_MD_CTX_free(mdctx); EVP_PKEY_free(pkey); return ret; } int main(void) { const char *msg Hello ECDSA! This is a test message.; unsigned char sig[] { /* 这里填入签名 hex 字节 */ }; size_t sig_len 0; int ret 0; /* 实际使用时从文件读取签名 */ ret ecdsa_verify(ecdsa_public.pem, (const unsigned char *)msg, strlen(msg), sig, sig_len); if (ret 1) { printf(verify passed\n); } else if (ret 0) { printf(verify failed: signature mismatch\n); } else { printf(verify error\n); } return 0; }注意验证返回值的三态语义1表示通过0表示签名不匹配负数才是系统级错误。很多新手只判断ret 1但把 0 和负数混为一谈排查问题时很难定位是签名不对还是 OpenSSL 内部出错。3.3 签名格式的细节ECDSA 签名有几种编码方式。最常见的是 DER 编码格式为SEQUENCE { r INTEGER, s INTEGER }。P-256 曲线的 r 和 s 各 32 字节DER 编码后总长度一般是 70~72 字节不是固定的 64 字节。如果系统间需要传递签名要注意两点OpenSSL 的EVP_DigestSignFinal输出的是 DER 编码签名不是裸的 (r, s) 拼接。有些协议比如某些区块链要求以固定长度的裸 r||s 格式P-256 下各 32 字节共 64 字节传输签名。如果要从 DER 转裸格式需要用d2i_ECDSA_SIG解析出 BIGNUM再写入固定长度的字节数组。反过来从裸 r||s 构造 DER 签名用i2d_ECDSA_SIG。这个转换我在实际项目中经常用到因为 OpenSSL 的 DER 签名长度不固定而很多自定义协议喜欢定长字段省去长度前缀的解析。3.4 工程文档怎么组织代码之外文档也是这个项目交付的一部分。我建议按下面的结构组织仓库ecdsa-sign-project/ ├── include/ │ └── ecdsa_sign.h ├── src/ │ ├── ecdsa_sign.c │ ├── ecdsa_verify.c │ └── main.c ├── keys/ │ ├── ecdsa_private.pem │ └── ecdsa_public.pem ├── docs/ │ ├── API.md │ ├── 密钥管理指南.md │ └── 签名验证流程图.md ├── script/ │ ├── gen_keys.sh │ └── test_ecdsa.sh ├── Makefile └── README.mdREADME 部分一定要写清楚三件事环境依赖OpenSSL 版本、编译选项、如何生成密钥、如何运行测试。API 文档里列出每个函数的入参、出参、返回值和异常场景。流程图文档我建议用文本流程图加文字说明方便在 Git 里 diff也避免每次修改都得重新截图。4. 实测结果与避坑指南4.1 性能测试数据我在两台机器上跑过性能测试。一台是 x86 桌面端Intel i5-12400一台是 ARM 开发板Cortex-A551.8GHz。测试方法是对 1KB 数据进行签验签循环 1000 次取平均平台签名耗时验签耗时签名长度x86 i5-12400约 0.18 ms约 0.12 ms70~72 bytesCortex-A55 1.8GHz约 0.92 ms约 0.64 ms70~72 bytes补充一句ECDSA 签名比验签慢这和 RSA 相反。原因是签名需要一次标量乘法和一次模逆运算验签两步点乘可以共享部分计算所以更快。如果你的业务是多次签名、频繁验签ECDSA 在性能上的优势会更加明显。4.2 常见问题排查速查表总结一下我在实盘开发中踩过、以及帮别人排查过的典型问题问题现象根因解决方案编译时找不到openssl/evp.h头文件路径未指定或没装 dev 包apt install libssl-dev或编译加-I/usr/local/include运行时报PEM_read_PrivateKey返回 NULL私钥文件格式不对或密码回调未处理检查私钥是否为 PKCS#8 或 PKCS#1PEM_read_bio_PrivateKey需传密码EVP_DigestSignFinal第一次传入 NULL 后返回长度第二次报长度不足缓冲区分配过小签名长度上限参考EVP_PKEY_get_size(pkey)P-256 为 72 字节验签一直失败但密钥和数据都确认没改签名的编码格式不同DER vs 裸 r1.1.1 下编译报错3.0 下正常API 版本差异1.1.1 用EVP_MD_CTX_create/destroy或直接升级 OpenSSL 3.0固件里无法导入私钥私钥文件包含了加密信息或换行符有问题用openssl pkcs8 -topk8 -nocrypt生成无加密 PKCS#8读取时逐字节读入4.3 项目里额外处理的几个细节实际项目中我不会只把签名和验证封装成两个函数就完了还有几个细节值得注意。第一哈希算法选择的统一问题。ECDSA 本身不直接对数据进行签名而是对数据的哈希值签名。我在代码里固定用 SHA-256这没问题但如果你是和老系统对接一定要确认对方用的哈希算法。哈希算法的错配是最隐蔽的坑——签名方和验证方用的不是同一个哈希验签必然失败而查代码往往查不到问题。第二数据的分块处理。如果数据量很大比如几十 MB 的固件不要在内存里一次性 load 进来。EVP_DigestSignUpdate支持流式处理每次喂进去 16KB 或 64KB 分块内存占用是恒定的。我在固件签名工具里就是这么干的实测签名一个 32MB 的固件内存占用从 300MB 降到不到 10MB。第三密钥的权限管理。私钥文件在开发环境里可能无所谓但生产环境一定把私钥文件的权限设为 600chmod 600并考虑用硬件密钥管理模块HSM或安全芯片来存储。软件层的私钥提取是攻击者最容易下手的方向如果设备里存了明文的私钥文件再怎么加密传输都是白搭。4.4 后续扩展方向这个项目目前是单次签名、单次验证的版本。如果后续要往生产环境走可以朝三个方向扩展支持多种椭圆曲线参数做成可配置的模块避免曲线写死在代码里。增加签名数据的元信息头比如版本号、时间戳、公钥指纹防止重放攻击。把签名验证封装成独立的动态库用统一的回调接口对接不同平台方便 Bootloader、OTA 升级、证书验证等模块复用。我个人在实际操作中的一点体会是OpenSSL 的 API 看着简单但真正用起来细节都藏在函数返回值的语义里。每次编译之前先确认版本每次调用之前先查一下返回值到底有几种含义能省下大量排查时间。这个项目本身不难做成一个干净、可复用的模板之后后续所有需要 ECDSA 的工程都能直接拿过来改一改就上这大概就是沉淀文档和工具库的价值所在。本文还有配套的精品资源点击获取
返回列表