ARTICLE DETAIL

资讯详情

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

OpenSSL ECDSA签名与验证实战:从密钥生成到DER/raw格式避坑

OpenSSL ECDSA签名与验证实战:从密钥生成到DER/raw格式避坑 简介面向 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_DigestSign、PEM_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-dev或openssl-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-devCentOS/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_KEY、ECDSA_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_DigestSignstatic 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_DigestSignUpdateEVP_DigestSignFinal那是处理流式数据用的功能等价但这段代码更简洁。4.4 验证EVP_DigestVerifystatic 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_idabctimestamp1700000000签名输出DER签名验证方持公钥对同样的消息验签。测试时可以先改消息里的一个字节再看验签结果从1变成0这是验证逻辑正确性的最快方法。完整工程里建议用CMake或Makefile管理编译链接时要加上-lcrypto。一段最简单的MakefileCCgcc CFLAGS-Wall -O2 LIBS-lcrypto all: ecdsa_demo ecdsa_demo: main.c $(CC) $(CFLAGS) -o $ $ $(LIBS) clean: rm -f ecdsa_demo5. 实测高频翻车点编码混淆、哈希不一致与排查方法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、验证时用SHA256EVP_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方分别验一遍并把验证结果截图或日志留存。这个动作能避免上线后才发现两边“各自都对但就是验不过”的尴尬。我在实际项目里靠这个办法至少省了三个通宵。本文还有配套的精品资源点击获取
返回列表