OpenSSL ECDSA签名与验证实战:从密钥生成到DER/raw格式避坑
2026/9/12 3:27:06 网站建设 项目流程

简介:面向 C/C++ 开发者和信息安全学习者,这套资料围绕 OpenSSL 库中的椭圆曲线数字签名算法(ECDSA)落地应用展开,提供从密钥生成、签名到验证的完整可运行示例,并配有 PDF 文档讲解原理,适合刚接触椭圆曲线密码学且希望快速上手的开发者。压缩包共 93 个文件,大小仅 5.17MB,核心文件包括 75 个 OpenSSL 头文件、3 个 cpp 源文件、3 个 vcxproj 工程文件与 1 个 sln 解决方案文件,此外还有 7 个编译好的 exe、2 个 lib 库、1 份 PDF 文档及配置文件,目录结构清晰,便于对照源码、库依赖和可执行程序进行学习。随包 PDF 从椭圆曲线密码学基础讲起,逐步说明 ECDSA 中私钥与公钥生成、消息摘要处理、(R,S)签名的计算与验证流程,并结合 OpenSSL 的 EC_KEY、ECDSA_sign 和 ECDSA_verify 等接口给出可直接复用的 C/C++ 代码,帮助读者同时理解数学原理与实际调用方法。已有 2527 人学习下载,若想了解 OpenSSL 中 ECDSA 的工程实现并独立完成签名验证实验,这份资料能提供很实用的参考;对于准备在项目中集成该算法的开发者,也可参考其中的接口调用与工程组织方式。 如果你打开OpenSSL的文档,准备写一段ECDSA签名与验证的代码,大概率会在EVP_DigestSignPEM_read_bio_PrivateKey、DER、raw这些词之间来回折腾。我最近在给一个设备激活码对接做基于OpenSSL库的ECDSA签名与验证,把可运行的代码和几处容易翻车的细节整理出来。这篇东西适合刚接触密码学接口、需要在业务系统里集成数字签名功能的后端开发,也适合正在排查“签名不一致”“验证失败”这类问题的同学参考。

1. 场景先行:为什么业务代码里要自己写ECDSA签名与验证

1.1 命令行能搞定的事,为什么还要写代码

很多人第一反应是:签名验证用openssl dgst -sha256 -sign private.pem不就行了?确实,一次性手工签名、导出证书、写个临时脚本,命令行完全够用。但真实业务里,签名数据往往是动态的:设备上报的激活码、客户端请求中的时间戳、软件包的版本信息,都是程序运行时才生成的。你不能让运维拿着命令行逐个签,更不可能在用户手机上执行shell命令,所以必须把签名和验证能力写进服务端或客户端程序里。

另一个场景是验证方拿到的不是文件,而是网络传输过来的字节流。比如设备端把序列号+时间戳签名后,服务端只收到一段base64字符串。这时候用OpenSSL库的API解析、验签,比反复拉起子进程调用命令行要可靠得多,性能和错误处理也都在掌握中。

1.2 ECDSA和RSA怎么选

选择ECDSA而不是RSA,最直接的原因是密钥短、签名短、性能好。256位的ECDSA密钥,安全强度大约相当于3072位RSA,签名长度只有70字节左右,特别适合物联网设备、移动端接口、二维码场景。如果对接方是传统金融老系统,对方只认RSA,那不要硬上ECDSA,互操作大于一切。

还有一个隐性优势:ECDSA的公钥可以直接从私钥推导出来,私钥结构也比RSA简单。但在密码学安全上,ECDSA对随机数的要求比RSA更敏感,随机数出问题会导致私钥泄露,这一点后面会专门讲。

1.3 为什么选OpenSSL而不是其他库

说实话,可选的密码学库不少,mbedTLS轻量、适合嵌入式,BoringSSL是谷歌的衍生版,NSS主要服务Firefox系。但OpenSSL依然是服务器环境里普及率最高、文档最多、踩坑经验最好搜的库。几乎每个Linux发行版都自带libssl-devopenssl-devel,部署时不需要额外带库,这对服务端项目很友好。

而且OpenSSL从1.1.0开始提供了一套统一的EVP高层接口,签名、加密、密钥生成都走同一套模式,学习成本比直接操作底层EC函数低很多。这篇代码尽量都用EVP接口,这样你在OpenSSL 1.1.1和3.x上都能跑,不会被某个废弃的低层API卡住。

2. 环境准备与版本陷阱:OpenSSL 3.x的安全策略如何影响签名

2.1 先搞清楚你手上是哪个版本

写代码之前,先看版本,不同版本的API和默认策略差很多。

openssl version -a

如果系统里没有开发头文件,Debian/Ubuntu执行apt install libssl-dev,CentOS/RHEL执行yum install openssl-devel。macOS用brew install openssl还需要把路径指到/opt/homebrew/opt/openssl。Windows下我建议直接用vcpkg安装openssl,别自己从源码编,编起来非常折磨,尤其是Perl环境和汇编工具链对不上时。

代码里也可以编译期确认版本:

#include <openssl/opensslv.h> printf("openssl version: %s\n", OPENSSL_VERSION_TEXT);

这是我踩过最不值的一个坑:本机OpenSSL 1.1.1开发调试正常,部署到客户机器上成了OpenSSL 3.x,某些函数编译是过了,运行行为却不一样。

2.2 3.x的默认安全级别会拒绝弱算法

OpenSSL 3.0引入了Provider机制,默认只加载defaultprovider,安全级别也比1.1.1更严格。一个典型影响是:在3.0里用默认配置做SHA1相关的签名会被拒绝,而1.1.1下可能只是告警。ECDSA本身不受影响,因为常用签名摘要都是SHA256以上,但如果你是从老项目里抄来的代码,签名摘要还是SHA1,就得注意了。

另一个和版本相关的点:OpenSSL 3.0推荐用EVP_PKEY_Q_keygen这种新接口,但EVP_PKEY_CTX老写法也没废弃。为了兼容1.1.1和3.x,我下面代码用的是EVP_PKEY_CTX,两边都能编译。如果你只在3.x上跑,可以换成更简洁的EVP_EC_gen("prime256v1")

2.3 为什么尽量用EVP接口而不是底层EC函数

OpenSSL里有一批以EC_KEYECDSA_do_sign开头的底层接口,功能直接,但有两个问题:一是从3.0开始它们默认走legacy provider,有些环境要显式加载才能用;二是它们绕过了EVP统一封装,换曲线、换算法时代码要重写。

EVP接口的思路是“算法无关”:你把EVP_PKEY当成一个抽象钥匙对象,签名时指定摘要算法,OpenSSL内部根据密钥类型自动走ECDSA逻辑。这样以后如果要从ECDSA切到Ed25519,签名/验证部分的调用代码几乎不用改,只改密钥生成部分的曲线参数就行。

3. ECDSA避不开的底层知识:曲线、r/s与DER编码

3.1 椭圆曲线公钥密码算法一句话

椭圆曲线密码的原理可以粗浅理解成一个“单向门”:有一个基点G,私钥是一个随机大整数d,公钥Q = d * G。给定G和d求Q非常快,但给定G和Q反推d,目前没有多项式时间算法。这个性质和RSA基于大整数分解的原理完全不同,所以密钥和签名的长度都短得多。

签名过程本身不涉及加密数据,而是对数据的哈希值做数学变换,生成一对整数(r, s)。验证方用公钥、哈希值、(r, s)做一次校验,通过则签名合法,不通过则数据可能被篡改或者签名方公钥不匹配。

3.2 常用曲线的选择

OpenSSL里曲线是通过NID来指定的,最常见的有这么几条:

曲线NID名称场景
NID_X9_62_prime256v1P-256 / secp256r1国际通用,兼容性最好
NID_secp256k1secp256k1比特币、区块链生态
NID_sm2SM2国内合规场景

我自己的建议是:除非对方明确要求,默认用P-256。它在WebAuthn、JWT、各种硬件安全模块里都是标配,OpenSSL和几乎所有语言库都支持。选曲线不是自己看着好看就行,必须和验签方对齐,否则公钥导过去对方解析不了。

3.3 签名结果到底长什么样:DER与raw格式

ECDSA签名本质就是(r, s)两个整数。但对外传输时有两种编码方式:

DER编码,也就是ASN.1 DER序列化,结构大致是SEQUENCE { INTEGER r, INTEGER s }。OpenSSL接口默认输出的就是DER,长度不固定,一般在70到72字节左右。Java的SHA256withECDSA、Go的ecdsa.SignASN1、Node的crypto.sign默认都是DER。

raw格式,有的文档里也叫r||s或plain格式,就是把r和s分别补齐到曲线字节长度再拼接。P-256的r和s各32字节,所以raw签名固定64字节。Android的某些签名接口、一些硬件安全模块、区块链协议喜欢用这种。

很多“签名不一致”“验签失败”的问题,根源就是把这两种格式混用了。后面我会给出转换思路。

4. 基于OpenSSL的ECDSA签名与验证完整代码走读

4.1 生成密钥对并导出PEM

下面这段生成P-256密钥对,并把私钥、公钥都写成PEM文件,方便后续测试使用。

#include <stdio.h> #include <openssl/evp.h> #include <openssl/pem.h> #include <openssl/ec.h> #include <openssl/err.h> static void gen_keypair(const char *priv_file, const char *pub_file) { EVP_PKEY_CTX *pctx = NULL; EVP_PKEY *pkey = NULL; BIO *priv_bio = NULL, *pub_bio = NULL; pctx = EVP_PKEY_CTX_new_id(EVP_PKEY_EC, NULL); if (!pctx || EVP_PKEY_keygen_init(pctx) <= 0) { ERR_print_errors_fp(stderr); goto done; } if (EVP_PKEY_CTX_set_ec_paramgen_curve_nid(pctx, NID_X9_62_prime256v1) <= 0) { ERR_print_errors_fp(stderr); goto done; } if (EVP_PKEY_keygen(pctx, &pkey) <= 0) { ERR_print_errors_fp(stderr); goto done; } priv_bio = BIO_new_file(priv_file, "w"); if (!PEM_write_bio_PrivateKey(priv_bio, pkey, NULL, NULL, 0, NULL, NULL)) { ERR_print_errors_fp(stderr); goto done; } pub_bio = BIO_new_file(pub_file, "w"); if (!PEM_write_bio_PUBKEY(pub_bio, pkey)) { ERR_print_errors_fp(stderr); goto done; } done: BIO_free_all(priv_bio); BIO_free_all(pub_bio); EVP_PKEY_free(pkey); EVP_PKEY_CTX_free(pctx); }

注意PEM_write_bio_PrivateKey默认写的是PKCS#8格式,文件头是BEGIN PRIVATE KEY。如果你想写传统的BEGIN EC PRIVATE KEY,需要改用PEM_write_bio_ECPrivateKey并先把EVP_PKEY转成EC_KEY。大部分场景PKCS#8更通用,Java和Go都能直接读,不用纠结传统格式。

4.2 从PEM加载密钥

签名需要私钥,验证需要公钥。加载代码很直接:

static EVP_PKEY *load_key(const char *file, int is_private) { BIO *bio = BIO_new_file(file, "r"); EVP_PKEY *pkey = NULL; if (is_private) { pkey = PEM_read_bio_PrivateKey(bio, NULL, NULL, NULL); } else { pkey = PEM_read_bio_PUBKEY(bio, NULL, NULL, NULL); } BIO_free(bio); return pkey; }

如果加载结果为NULL,最可能的原因是文件内容不是PEM格式,或者密钥类型和函数预期不一致。比如用PEM_read_bio_EC_PUBKEY去读通用公钥格式,就会失败。统一用PEM_read_bio_PUBKEY最省心。

4.3 签名:EVP_DigestSign

static int sign_data(EVP_PKEY *key, const unsigned char *msg, size_t msg_len, unsigned char **sig, size_t *sig_len) { EVP_MD_CTX *mdctx = EVP_MD_CTX_new(); int ok = 0; if (EVP_DigestSignInit(mdctx, NULL, EVP_sha256(), NULL, key) <= 0) { ERR_print_errors_fp(stderr); goto done; } // 第一次调用传入NULL,得到签名最大长度 if (EVP_DigestSign(mdctx, NULL, sig_len, msg, msg_len) <= 0) { ERR_print_errors_fp(stderr); goto done; } *sig = OPENSSL_malloc(*sig_len); if (!*sig) { goto done; } if (EVP_DigestSign(mdctx, *sig, sig_len, msg, msg_len) <= 0) { ERR_print_errors_fp(stderr); goto done; } ok = 1; done: EVP_MD_CTX_free(mdctx); return ok; }

有一点要解释:EVP_DigestSign是“一步到位”的接口,内部会自动处理SHA256摘要、ECDSA签名。两次调用是为了先问长度再填数据,这是OpenSSL的常见用法。如果你用的是EVP_DigestSignUpdate+EVP_DigestSignFinal,那是处理流式数据用的,功能等价,但这段代码更简洁。

4.4 验证:EVP_DigestVerify

static int verify_data(EVP_PKEY *key, const unsigned char *msg, size_t msg_len, const unsigned char *sig, size_t sig_len) { EVP_MD_CTX *mdctx = EVP_MD_CTX_new(); int ret = 0; if (EVP_DigestVerifyInit(mdctx, NULL, EVP_sha256(), NULL, key) <= 0) { ERR_print_errors_fp(stderr); goto done; } ret = EVP_DigestVerify(mdctx, sig, sig_len, msg, msg_len); done: EVP_MD_CTX_free(mdctx); return ret; }

EVP_DigestVerify的返回值很关键:返回1表示签名有效,返回0表示签名无效(可能数据被篡改、公钥不匹配、签名格式错误),返回小于0表示出现了内部错误。很多人只判断if (ret == 1),这没问题,但排查时最好把0和负数分开打印,信息量完全不同。

4.5 把它们串起来

签名方持私钥,对"device_id=abc&timestamp=1700000000"签名,输出DER签名;验证方持公钥,对同样的消息验签。测试时可以先改消息里的一个字节,再看验签结果从1变成0,这是验证逻辑正确性的最快方法。

完整工程里,建议用CMake或Makefile管理编译,链接时要加上-lcrypto。一段最简单的Makefile:

CC=gcc CFLAGS=-Wall -O2 LIBS=-lcrypto all: ecdsa_demo ecdsa_demo: main.c $(CC) $(CFLAGS) -o $@ $< $(LIBS) clean: rm -f ecdsa_demo

5. 实测高频翻车点:编码混淆、哈希不一致与排查方法

5.1 DER和raw签名格式互转

这绝对是我见别人踩过最多、自己也踩过的坑。OpenSSL输出的DER签名,和Android/Java某些接口返回的64字节raw签名,内容不兼容。对方拿你DER签名去验,直接返回“bad signature”。

最通用的转换思路是解析DER:

#include <openssl/ec.h> #include <openssl/objects.h> static int der_to_raw(const unsigned char *der, size_t der_len, unsigned char *raw, size_t *raw_len) { const unsigned char *p = der; const unsigned char *end = der + der_len; ECDSA_SIG *sig = d2i_ECDSA_SIG(NULL, &p, der_len); if (!sig) return 0; const BIGNUM *r = NULL, *s = NULL; ECDSA_SIG_get0(sig, &r, &s); size_t field_len = 32; // P-256 BN_bn2binpad(r, raw, field_len); BN_bn2binpad(s, raw + field_len, field_len); *raw_len = field_len * 2; ECDSA_SIG_free(sig); return 1; }

反向raw转DER,可以用ECDSA_SIG_new()ECDSA_SIG_set0(),再i2d_ECDSA_SIG导出。注意BN_bn2binpad会把BIGNUM补齐到固定长度,避免r或s前导零导致长度对不齐。

当你和某个端联调前,第一件事就是要问清楚对方默认签名格式是DER还是raw,不要猜。签名格式不统一,算法再正确也白搭。

5.2 哈希算法不一致,验证永远失败

ECDSA签名本质是对消息的哈希值做签名,签名方用SHA256,验证方也必须用SHA256。示例代码里Init时传的都是EVP_sha256(),如果签名时用SHA384、验证时用SHA256,EVP_DigestVerify会稳定返回0,不会报错,但就是验不过。

这种问题隐蔽在:代码是从别的项目里复制来的,签名部分和验证部分分别在两个服务里,各自用了不同的摘要算法。排查时把两端的算法参数打出来对比,一眼就能发现。另一个细节是有些国密场景要求SM3摘要,那就要用EVP_sm3(),曲线也得换成SM2,不能只改摘要。

5.3 OpenSSL错误排查三板斧

遇到签名验证问题,先把错误队列打出来:

ERR_print_errors_fp(stderr);

这一句比任何调试日志都管用。常见错误包括:bad signature,通常是签名格式或公钥不匹配;unknown curve,是密钥文件里的曲线NID当前OpenSSL不支持;wrong public key type,是加载密钥时用错了读取函数。

还有一个很多人忽略的问题:验证方拿到的公钥PEM,可能是传统格式BEGIN EC PUBLIC KEY,也可能是通用格式BEGIN PUBLIC KEY。如果你固定用PEM_read_bio_PUBKEY去读传统格式,会读到NULL。稳妥做法是先按PUBKEY读,失败再按EC_PUBKEY读,两个函数都试试,不要一根筋。

5.4 随机数安全,这不是代码细节而是命根子

ECDSA的数学结构决定了:如果两次签名使用了同一个随机数k,攻击者可以直接通过两个签名反推出私钥。这就是“随机数重用导致私钥泄露”的经典攻击。业务代码里严禁自己生成k传入签名函数,OpenSSL内部会用安全随机数生成器处理,你不要插手。

在嵌入式环境或者启动阶段熵源不足时,签名可能变慢甚至失败。这时先检查系统的熵源是否正常,而不是绕过安全随机数机制。我在真实设备上遇到过因为/dev/urandom不可读导致签名一直失败的情况,查了半天才发现是部署环境的系统服务被裁剪得太狠。

5.5 最后再分享一个实际体会

你对接的每一端都可能对格式有自己的理解。最稳妥的方法是写一个小的互操作测试用例:双方约定好同一份消息、同一对密钥,A方签出来的DER签名和raw签名都留一份,B方分别验一遍,并把验证结果截图或日志留存。这个动作能避免上线后才发现两边“各自都对,但就是验不过”的尴尬。我在实际项目里靠这个办法,至少省了三个通宵。

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

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

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

立即咨询