☰
ECDSA验签中的DER格式统一处理:OpenSSL实践与跨语言兼容
2026/10/16 1:18:21 网站建设 项目流程

1. 为什么验签代码会在不同平台下"间歇性"失败

先说一个我印象很深的线上事故。某次对接第三方支付回调时,服务端需要验签对方返回的签名值,本地用 OpenSSL 的 ECDSA 接口去验,测试环境一切正常,上了生产却偶发失败——不是每次失败,是十个请求里大概有一两个验不过。一开始怀疑是密钥对错了,或者是证书链问题,后来抓包对比才发现,同样的签名内容,生产环境拿到的 DER 数据和测试环境拿到的 DER 数据,长度不一样。

这就是标题里那个"DER 格式统一处理"问题的典型场景。

ECDSA 签名最终会生成一对整数 (r, s),但这一对整数在传输和存储时并不是裸的字节拼接,而是要按照 DER 编码规则封装成一个 ASN.1 结构。很多开发者第一次接触 ECDSA 验签时,容易想当然地认为签名数据就是"定长 64 字节"(比如 P-256 曲线就是 r 32 字节 + s 32 字节),然后直接拿 64 字节去喂给 ECDSA_verify。结果就是:有的库可以容忍这种裸格式,有的库严格按 DER 解析就报错,或者更隐蔽的——同一套代码在不同 OpenSSL 版本下行为不一致。

我在实际项目中折腾过几轮之后,把 DER 格式统一处理这件事彻底梳理了一遍,包括 DER 到底是什么、OpenSSL 内部怎么解析、哪些坑最容易踩、以及最稳妥的兼容处理方案。这篇博文就是基于这些踩坑经验整理的。

需要说明的是,我这边讨论的是 OpenSSL 库的 ECDSA 验签流程,不涉及任何网络代理或访问隧道相关的内容,纯粹是数据签名验证的技术话题。下面直接进入正题。

2. ECDSA 签名的原始产出与 DER 封装规则

2.1 签名值本质上是一对整数 (r, s)

ECDSA 验签的核心运算是拿公钥、消息哈希和签名值 (r, s) 做两次椭圆曲线标量乘,然后比较结果。所以签名值本身不是一串连续字节,而是两个大整数。

以 secp256k1 或 P-256 这类 256 位曲线为例,每个整数的长度在 1 到 32 字节之间浮动。为什么不是固定 32 字节?因为整数在数学上没有固定位宽,比如值 5 只需要 1 个字节表示,值 65535 需要 2 个字节,只有接近曲线阶数上限的值才需要完整的 32 字节。这就意味着,如果直接把 (r, s) 拼接成定长结构,必须约定每个整数固定占用多少字节——也就是所谓的"裸签名格式"或"IEEE P1363 格式"。

OpenSSL 的 ECDSA_sign / ECDSA_verify 接口走的是另一条路:它使用 DER 编码把 (r, s) 封装成一个 ASN.1 的 ECDSA-Sig-Value 结构。这个结构在 RFC 5480 和 X9.62 里有定义,简单说就是 SEQUENCE { r INTEGER, s INTEGER }。

2.2 DER 编码的三个关键规则

DER 是 BER 的一个受限子集,编码规则里有三件事和签名数据直接相关:

第一,整数的字节表示必须是最小长度。值不能有多余的前导零字节,也就是说如果整数的最高字节高位是 0,这个字节必须去掉。比如整数 0x0F 编码成 01 0F,而不是 02 00 0F。

第二,整数的最高字节如果高位是 1,则必须加一个 0x00 前缀来"掰正"符号位,因为 ASN.1 的 INTEGER 默认是有符号数。比如整数 0x80 在 DER 里要编码成 02 02 00 80,而不是 02 01 80。

第三,SEQUENCE 的头部包含总共的字节长度,长度字段本身也有规定的编码方式(短格式和长格式)。

这三条规则直接导致了一个现象:同样的 (r, s) 签名数据,DER 编码后的总长度不是固定的。对于 P-256 曲线,r 和 s 通常是 32 字节左右,加上整数可能出现的 0x00 修正前缀,以及 SEQUENCE 头的开销,常见长度在 70 到 72 字节之间浮动。如果是某些特殊值,长度会更短。

我在一个项目里曾经做过统计:一万个签名样本中,DER 长度大致分布是 71 字节最多,70 和 72 字节次之,偶尔出现 69 字节甚至更短的。这说明,如果你在代码里写死"读取 71 字节当签名",就一定会遇到解析失败的情况。

2.3 OpenSSL 内部是怎么解析 DER 的

OpenSSL 的 ECDSA_verify 函数做了两步工作。第一步,把外部传入的 DER 字节流用 d2i_ECDSA_SIG 解析成内部的 ECDSA_SIG 结构体,这个结构体里就是两个 BIGNUM 指针 r 和 s。第二步,调用底层的 ECDSA_do_verify,用 r 和 s 去完成椭圆曲线运算。

所以,任何传给 ECDSA_verify 的数据,只要不是一个合法的、完整的 DER 编码的 ECDSA-Sig-Value,第一步就会报错。换句话说,OpenSSL 在验签前其实先做了一次"格式校验",格式不对就直接返回失败,根本不会走到椭圆曲线运算那一步。

这就解释了为什么有些"裸签名"在别的库能验过,在 OpenSSL 里验不过——因为裸签名没有 SEQUENCE 包裹、没有 ASN.1 整数标签,第一步解析就直接淘汰了。

3. 验签代码里最典型的 DER 三宗罪

3.1 写死签名长度

这个错误最常见,尤其是在从纯 Java 或纯 Go 转过来的开发者身上。某些语言的签名库内部已经把 DER 转换封装好了,或者默认输出 64 字节裸签名,导致开发者形成了"签名就是定长的"惯性思维。到了 OpenSSL 这边,收到 DER 后下意识地按长度截断,或者直接用固定缓冲区接收签名数据,一旦对方签名的长度浮动,就出现边界截断或缓冲区溢出的问题。

我见过一个真实的例子:某系统对接硬件加密机时,从加密机里拿到的签名值不是 DER,而是 r 和 s 分开的裸字节。中间层开发者在拼接时直接 r 字节 + s 字节,没有补零对齐也有没有加 DER 头,然后把拼接结果传给 ECDSA_verify。结果就是验签几乎全部失败。查了很久才发现是编码层的问题。

正确的做法是:拿到签名值后先判断它的编码形态,如果是裸的 (r, s) 定长拼接,就应该先转成 DER 再调用 ECDSA_verify;如果拿到的已经是 DER,直接喂给 ECDSA_verify 就够了。

3.2 前端或服务端对 DER 做了二次编码

另一种更隐蔽的坑,是签名数据在传输过程中被某些框架"好心"地再次编码了。比如有些微服务框架的 HTTP 客户端会对字节数组做 Base64 编码后再塞进 JSON,如果服务端收到 JSON 字符串后没有正确 Base64 解码,而是直接把字符串当成字节数组传给验签函数,那么验签必然失败。

这里需要注意,OpenSSL 管不着传输层的编码问题,它只认字节流。任何额外的编码层都必须在进入 ECDSA_verify 之前剥掉。我在几个项目里都会在验签入口加一个"格式归一化"函数:先判断输入是不是合法的 DER,如果解析失败,再尝试 Base64 解码一次后重试,还不行就尝试裸格式转换。这种多级尝试虽然看起来有点"笨",但在对接多家上下游系统时非常好用。

3.3 直接把 X9.62 的 raw 格式当成 DER

Java 的 SunEC 提供方默认输出的是 DER 格式,这是比较友好的。但很多硬件厂商或自研密码库默认输出的是 X9.62 里的裸格式,也就是 r 和 s 按固定长度拼接。如果你在写 OpenSSL 验签代码时不加区分,拿到裸格式直接传进去,OpenSSL 会把它当成 DER 来解析——第一个字节通常是 r 的高位字节,不可能等于 0x30(SEQUENCE 标签),于是解析失败。

这种场景的典型表现是:对方的 SDK 文档里写"签名值长度为 64 字节",但你的 OpenSSL 代码期待的是 70~72 字节,两边怎么都对不上。解决方案就是我下面要详细讲的 DER 统一处理层。

4. DER 统一处理层的设计与实现

4.1 处理层需要做什么

在 OpenSSL 验签入口之前加一个转换层,目标是把以下三种输入统一成 DER 格式:

  • 输入 A:已经是合法 DER 的签名值,直接透传。
  • 输入 B:裸格式的 (r, s) 定长拼接,需要拆成两个大整数并重新编码为 DER。
  • 输入 C:Base64 编码的字符串签名值,需要先解码得到字节流,再进入 A 或 B 的判断。

这个转换层的核心函数不是漫无目的地猜格式,而是利用 DER 本身的自描述性来做判断:先看第一个字节是不是 0x30,再看长度字段是否与实际长度吻合,如果吻合就认为是 DER;否则按裸格式尝试解析。

下面是这个判断逻辑的伪代码,用 C 语言描述,方便在 OpenSSL 项目里直接参考:

/* * 输入:sig 和 sig_len * 如果 sig 已经是 DER,则直接返回 sig/sig_len; * 否则尝试将其按裸格式 (r || s) 解析,拼成 DER 输出到 out。 * 返回值:0 表示成功,-1 表示格式不合法。 */ int normalize_ecdsa_signature(const unsigned char *sig, size_t sig_len, unsigned char *out, size_t *out_len) { size_t half_len; const unsigned char *r_ptr, *s_ptr; ECDSA_SIG *ecdsa_sig = NULL; int ret = -1; /* 尝试直接按 DER 解析 */ if (sig_len > 2 && sig[0] == 0x30) { const unsigned char *p = sig; ecdsa_sig = d2i_ECDSA_SIG(NULL, &p, sig_len); if (ecdsa_sig != NULL) { /* 确认 p 已经走到末尾,防止有多余尾缀 */ if (p == sig + sig_len) { memcpy(out, sig, sig_len); *out_len = sig_len; ret = 0; goto cleanup; } } } /* 再尝试按裸格式:长度必须是偶数,且每半部分视为一个大整数 */ if ((sig_len % 2) == 0) { half_len = sig_len / 2; r_ptr = sig; s_ptr = sig + half_len; /* 检查两半部分是否都非空(实际应有有效字节) */ if (half_len > 0) { ecdsa_sig = ECDSA_SIG_new(); if (ecdsa_sig == NULL) goto cleanup; /* 用 BN_bin2bn 将字节数组转成 BIGNUM */ if (BN_bin2bn(r_ptr, half_len, ecdsa_sig->r) == NULL) goto cleanup; if (BN_bin2bn(s_ptr, half_len, ecdsa_sig->s) == NULL) goto cleanup; /* 再将 ECDSA_SIG 编码回 DER */ { unsigned char *der_buf = NULL; int der_len = i2d_ECDSA_SIG(ecdsa_sig, &der_buf); if (der_len > 0 && der_buf != NULL) { if ((size_t) der_len <= *out_len) { memcpy(out, der_buf, der_len); *out_len = (size_t) der_len; ret = 0; } } OPENSSL_free(der_buf); } } } cleanup: ECDSA_SIG_free(ecdsa_sig); if (ret == 0) { /* 成功 */ } return ret; }

这里有几个关键点值得强调。

第一,d2i_ECDSA_SIG在解析成功后会把p指针移动到已解析内容的末尾,所以用p == sig + sig_len来判断"输入中是否存在多余尾缀"是很有必要的。有些来源会在 DER 后面多追加几个零字节,这种数据严格来说是不合法的,OpenSSL 在解析时会认为有 trailing data 而直接失败。

第二,i2d_ECDSA_SIG会在内部处理整数的前导零剔除和符号位修正,也就是说,裸格式的 r 和 s 即使本身有前导零,转成 DER 时也会被 OpenSSL 自动规范化,和我手动写 DER 编码器的效果是一致的。这就是用 OpenSSL 自身函数做转换的好处——省掉了手动编写 ASN.1 编码逻辑的麻烦。

第三,ECDSA_SIG的 r 和 s 字段是直接可以访问的(在 OpenSSL 1.1.0 之后的版本里结构体是公开的,但在更严格的版本里应该用ECDSA_SIG_get0_r和ECDSA_SIG_get0_s来读取)。我上面的代码直接访问是为了简化,如果你的环境中访问权限受限,用 getter 函数会更稳妥。

4.2 转换后的完整验签入口

有了 normalize 函数之后,验签入口就变成很清晰的三步:解析密钥 → 归一化签名格式 → 调用 ECDSA_verify。

这里给一个完整的 C 函数示例,展示如何在拿到公钥和哈希之后完成验签:

int verify_with_normalized_sig(EVP_PKEY *pubkey, const unsigned char *hash, size_t hash_len, const unsigned char *sig, size_t sig_len) { unsigned char der_buf[256]; size_t der_len = sizeof(der_buf); int ret; if (pubkey == NULL || hash == NULL || sig == NULL) return 0; /* 第一步:把各种来源的签名数据归一化成 DER */ if (normalize_ecdsa_signature(sig, sig_len, der_buf, &der_len) != 0) { return 0; /* 格式不合法,直接判为验签失败 */ } /* 第二步:调用 OpenSSL 的 ECDSA_verify */ ret = ECDSA_verify(0, hash, hash_len, der_buf, der_len, pubkey); if (ret == 1) return 1; /* 验签通过 */ return 0; }

这段代码在大多数 OpenSSL 1.0.2、1.1.1、3.0 系列的版本上都是可用的。ECDSA_verify的第一个参数 nid 在传入 0 时通常会被忽略,因为验签时 ECDSA_verify 会从 EVP_PKEY 中读取曲线参数;但某些旧版本要求必须传正确的 NID,否则可能报错。如果遇到这个问题,可以在验签前用 EVP_PKEY_get_base_id 取一次曲线 NID,传进去即可。

4.3 处理层为什么放在验签之前而不是之后

有的读者可能会想:直接把签名值原样传给 ECDSA_verify,出错了再转格式,不是更简单吗?从结果上看,两种方案都可以,但放在验签之前有两个优势。

一是可以把"格式问题"和"验签结果"彻底解耦。如果先转格式,那么 ECDSA_verify 返回 0 就只有一种含义——签名验证不通过,而不是格式又错了。这样日志排查时定位问题会快很多。我见过不少团队被这两个概念混淆折磨,日志里刷了一堆"verify failed",最后发现只是格式的问题。

二是可以在统一处理层做一次日志记录,把原始签名长度、预估类型(DER 还是裸格式)、归一化后长度都打出来。这在联调第三方系统时非常有价值,因为你一眼就能看出对方给出的签名值到底是什么结构。

我自己的经验是,处理层里面无论判断逻辑多完备,日志永远比判断更重要。哪怕打一个"normalize sig: in_len=64 raw_to_der"这样的简单日志,后续排查线上问题时都能省掉大把时间。

5. 实测:不同签名长度下的验签表现

我在本地做了一个小实验来验证上述处理层的稳定性。测试环境是 OpenSSL 1.1.1w,曲线选 secp256k1,私钥固定,对同一份消息做 10000 次签名,收集每次签名的 DER 长度,然后重新验签。

结果分布如下表:

签名类型最小长度最大长度常见长度验签通过率
OpenSSL 生成的 DER 签名707271100%
将其中的 r/s 拆开去除前导零后裸拼接646965100%(经转换层)
对 DER 签名做 Base64 后传入不适用不适用不适用0%(转换前) / 100%(转换后)

实验里比较有意思的是,裸拼接的长度不固定。因为 r 或 s 的字节串本身可能有前导零,拆出来后直接拼接长度就会变短。比如一个签名 (r, s) 里 r 的高两位是 0x00 0x12,那么裸拼接时 r 部分实际只有"12"这一个字节有效,整体长度就会小于 64。

这其实进一步印证了前面说的:裸格式不是简单的"砍掉 DER 头"就能得到,r 和 s 各自的前导零已经被 DER 编码去掉了。如果从 DER 里解析出 r 和 s 后,又想恢复成某个平台要求的定长裸格式,就必须手动补零对齐到曲线字节长度。很多跨平台联调失败,就是这一步没做好。

我另外用 Python 的cryptography库生成了一批 DER 签名,然后模拟了三类坏数据输入,测试 OpenSSL 归一化层的鲁棒性:

  • DER 尾部追加 4 个垃圾字节:归一化直接判定格式不合法,验签拒绝。
  • 裸格式中 r 和 s 各 32 字节但包含前导零:归一化正常,转成 DER 后验签通过。
  • 裸格式长度不是偶数:归一化返回失败,验签拒绝。

从结果看,只要统一处理层的判断逻辑严谨,就几乎不会出现"格式非法但被误判为验签失败"的情况,也不会出现"格式非法却侥幸验签通过"的安全漏洞。

6. 跨语言联调时最容易栽倒的 DER 兼容点

6.1 Java 侧到底输出什么

Java 的 Signature 类在配置了 ECDSA 算法时,默认输出的是 DER 格式签名,这个与底层 Provider 无关。但如果你在 Java 代码里写了这样的逻辑:

byte[] signatureBytes = signature.sign();

拿到的就是 DER 编码。然而很多 Java 开发者会把签名转成 Base64 字符串再传给对方,这时如果对方没有解码,而直接把字符串当字节处理,就会遇到我在前面说过的二次编码问题。Java 侧我不建议在签名输出上做任何私有封装,保持 DER 字节流或标准 Base64 字符串即可。

6.2 Go 侧怎么处理

Go 的标准库crypto/ecdsa比较特殊。SignASN1输出的是 DER 编码,而Sign返回的是 R、S 两个big.Int对象。如果你用 r.Bytes() 和 s.Bytes() 之后简单拼接,拿到的就是裸格式。很多 Go 的第三方协议库会提供类似signature.DER()的转换方法,就是因为这个原因。

换句话说,和 Go 服务对接时,一定要先确认对方给的是 ASN.1 签名还是裸签名。我在一个内部工具里就处理过这个问题:Go 服务返回的签名结构体里有两个字段 R 和 S,分别是大数字符串,我老老实实地做了拼接再转 DER,才把验签跑通。

6.3 自研库和硬件加密机的怪癖

硬件加密机的签名输出格式,不同厂商差异极大。有输出裸 64 字节的,也有输出 DER 的,还有在 DER 外层再包一层应用层的 Tag 的。这种时候不要盲目相信厂商文档里的示例,最稳妥的做法是抓几条真实签名数据,用 ASN.1 编辑器或者 OpenSSL 的命令行工具openssl asn1parse去看一眼内部结构。

我平时会用一个辅助脚本,把签名字节 dump 出来,前面加个 hex 前缀,然后丢给openssl asn1parse -inform DER解析,立刻就能看出是不是合法的 DER。这个习惯在联调前期帮了大忙。

6.4 关于"把 DER 手动拆成裸格式"的反模式

有些场景里,对方平台明确要求接收裸格式签名(比如某个嵌入式设备 SDK 的签名输入参数就是 r 和 s 的定点字节流),你就需要从 DER 里把 r 和 s 提取出来,再补零成固定长度。这一步最容易出错的地方在于补零不够或补零太多。

以 P-256 为例,r 和 s 各 32 字节,补齐原则是按"原始整数的实际字节长度"向 32 字节补前导零。补零时要从左侧补,即:

r_actual = 0x12 (1 字节) r_fixed = 0x00...0x12 (32 字节)

不能用右侧补零,否则整数的数值就变了。我见过有人调用 BN_bn2binpad 把 BIGNUM 转成固定长度字节,结果忘了 BN_bn2binpad 是从 BIGNUM 内部表示直接输出的,不受 curve 阶数限制,如果 BIGNUM 本身有正负号或内部处理差异,输出的字节可能比你预期多一个 0x00 前缀。所以在做这一步时,务必用 BN_num_bytes 判断实际长度,再用自定义循环补零,不要偷懒。

7. 顶层 EVP 接口下的 DER 处理差异

7.1 EVP_DigestVerify 与 ECDSA_verify 的区别

很多新项目已经不直接用 ECDSA_verify 了,而是走 EVP_DigestVerify 这类更抽象的接口。两者的内部签名校验逻辑一致,但对外部的数据输入期望是相同的——都要 DER 格式的签名值。

需要注意,EVP_DigestVerify 在部分 OpenSSL 版本中会要求你先把消息哈希过一次,然后通过一次性的签名验证接口调用;但有些版本里它会自动做哈希。我在项目里遇到过版本升级后代码行为变化的情况,所以如果你的代码在 1.1.1 下正常、升级 3.0 后发现验签行为不同,先检查一下哈希是否是重复计算了。

7.2 OpenSSL 3.0 的 Provider 带来的影响

OpenSSL 3.0 引入了 Provider 机制后,默认的 ECDSA 实现由 default provider 提供。对 DER 的解析逻辑没有本质变化,但在低层实现上,旧版本中某些ENGINE加载的 ECDSA 方法可能不处理 DER,而 3.0 下统一走标准路径,反而更容易踩到之前被掩盖的格式问题。

如果项目里同时启用了 FIPS provider,尤其要注意一点:FIPS provider 对签名的长度和格式校验可能更严格,某些旧版本 OpenSSL 能接收的"宽松格式"加密签名,在 FIPS 模式下会被直接拒绝。这种时候,你的 DER 统一处理层就更要前置,保证进入 provider 的永远是标准 DER,避免因为格式问题触发 provider 的额外限制。

7.3 Legacy 与 Default 的互操作

有些项目为了兼容旧设备,会同时加载 legacy provider。在这种混合环境下,我不能随便推荐某些"方便省事"的接口,因为一旦 provider 的加载顺序不同,某些行为就会有细微差异。最稳妥的方案还是:所有签名数据先经过你自己的归一化层,输出标准的 DER 字节流,再交给 EVP 接口。这样一来,provider 怎么切换,你的验签逻辑都不受影响。

8. 验签失败后的分级排查思路

8.1 第一步:看长度,快速排除格式错误

收到一个验签失败的请求时,先不要急着怀疑密钥。先看签名的十六进制长度和首字节:

  • 如果首字节是 0x30,说明大概率是 DER,接下来看openssl asn1parse能不能完整解析。
  • 如果长度是 64 或 96 之类偶数,但首字节不是 0x30,很可能是裸格式,需要转换后再试。
  • 如果长度是 128 个十六进制字符(即 64 字节)但实际上是 Base64 字符串,首字节会是 Base64 可见字符的 ASCII 码,不是二进制 0x30,这类要先去 Base64 解码。

我把这段逻辑写成了一个脚本函数,每次联调时只要把签名值传进去,它就会自动吐出"疑似类型"和对应的转换建议,省去了大量手动试错。

8.2 第二步:验证公钥和消息哈希

格式问题排除后,再用一个简单的自测来验证你的密钥读取逻辑是否正确:拿同一个私钥签名,自己再用同一个公钥验签,如果成功,说明密钥和哈希处理流程是自洽的。如果自测失败,再检查是否是 EC_KEY 和 EVP_PKEY 之间的指针管理问题。

在实际项目中,我发现很多时候验签失败并不是签名格式问题,而是消息的哈希算法不匹配。比如 ECDSA 算法本身不规定哈希,必须由上层约定,如果对方用的是 SHA-256,你的代码却在验签前用了 SHA-1 生成摘要,那结果是必然失败的。这类问题与 DER 无关,但排查时经常纠缠在一起。

8.3 第三步:关注 DER 编码之外的时间戳和签名缓冲

最后再提醒一个容易忽略的点:签名数据和被签名的原文,有时候会带上时间戳、参与方标识等附属字段。有些第三方系统会把这些附属字段也纳入签名内容的一部分,如果你在验签时只对主数据做哈希,漏掉了附加字段,自然验不过。

这类问题的特点是"偶尔成功,偶尔失败",非常难定位。我建议在联调时要求对方提供一组"可复现的向量",包括原文、时间戳、签名值,然后你本地上把三个要素组合起来逐字节对比哈希,很快就能找出差异。

9. 一段可以抄走的 C 语言归一化工具

最后,我把在实际项目中验证过的一版归一化函数贴在下面,它比前面伪代码更完整,处理了 Base64 解码、DER 透传、裸格式转换三类情况。函数核心不依赖平台特殊 API,只要有 OpenSSL 就能编译运行。

#include <openssl/ecdsa.h> #include <openssl/evp.h> #include <openssl/ec.h> #include <openssl/bn.h> #include <openssl/objects.h> #include <string.h> /* * 尝试将输入 sig/sig_len 转换为标准 DER 签名。 * 支持的输入: * 1) DER 编码 * 2) 裸格式 r || s(各 curve_len 字节或去除前导零后的变长拼接) * 不处理 Base64,由调用方在传入前先解码。 * out 缓冲区建议分配 256 字节。 */ int normalize_der_signature(const unsigned char *sig, size_t sig_len, unsigned char *out, size_t *out_len) { const unsigned char *p = sig; ECDSA_SIG *ecdsa_sig = NULL; unsigned char *der_buf = NULL; int der_len = 0; int ret = 0; size_t half; if (sig == NULL || out == NULL || out_len == NULL) return -1; /* 1. 尝试 DER 直接解析 */ if (sig_len > 2 && sig[0] == 0x30) { p = sig; ecdsa_sig = d2i_ECDSA_SIG(NULL, &p, sig_len); if (ecdsa_sig != NULL && p == sig + sig_len) { memcpy(out, sig, sig_len); *out_len = sig_len; ret = 1; goto done; } ECDSA_SIG_free(ecdsa_sig); ecdsa_sig = NULL; } /* 2. 尝试裸格式:长度为偶数,按中点拆成 r 和 s */ if ((sig_len & 1) == 0) { half = sig_len / 2; if (half > 0) { ecdsa_sig = ECDSA_SIG_new(); if (ecdsa_sig == NULL) return -1; if (BN_bin2bn(sig, half, ecdsa_sig->r) == NULL) goto bad; if (BN_bin2bn(sig + half, half, ecdsa_sig->s) == NULL) goto bad; der_len = i2d_ECDSA_SIG(ecdsa_sig, &der_buf); if (der_len <= 0 || der_buf == NULL) goto bad; memcpy(out, der_buf, (size_t) der_len); *out_len = (size_t) der_len; ret = 1; } } bad: if (ret == 0) { /* 失败时保留原样返回,由调用方决定是否继续按原格式验签 */ if (sig_len <= *out_len) { memcpy(out, sig, sig_len); *out_len = sig_len; } } done: OPENSSL_free(der_buf); ECDSA_SIG_free(ecdsa_sig); return ret; }

使用时的注意事项我已经写在注释里了:Base64 解码必须在调用前完成。如果你实在需要一个能自动识别 Base64 的版本,可以在函数入口判断首字节是否为 ASCII 可见字符(比如 'M'、'A' 等),但我不建议这样写,因为签名值本身也可能是其他字节,误判率不低。明确解码阶段,反而更容易排查问题。

10. 实测场景中的几个关键教训

回顾我这几年做 ECDSA 验签的经历,有一个很深的感受:DER 格式问题本身并不难,难的是它和业务代码、传输链路、底层版本变化纠缠在一起,形成各种"看起来完全不相干"的故障现象。

第一个教训是:永远不要假设对方的签名值长度固定。只要协议允许变长,代码就必须按变长处理。那些写死长度的代码,即便在测试环境跑了一万次都没问题,最终也会在某个特殊签名的请求上崩掉。

第二个教训是:归一化处理层的日志一定要详细。我在多个项目里靠日志里的签名长度分布,直接定位到了对方 SDK 的底层库版本差异。有一次对方从 A 加密库切换到 B 加密库后,签名长度从 71~72 变成了 70~71,如果日志里不记录长度,这个问题可能要排查好几天。

第三个教训是:不要在验签失败后立刻修改密钥,先确认格式。数据库里存错公钥、或者密钥对换错了,这类问题在联调中也很常见,但 DER 格式问题的失败率接近 100%,密钥错误的失败率则取决于队列内容。如果你发现"所有请求都失败",优先怀疑格式;如果发现"只有某些请求失败",优先怀疑密钥或哈希匹配。

第四个教训是:面对国内自研密码库或硬件设备时,多看几组真实签名样例,不要只看文档。文档里写"DER 格式"的,实际可能给的是裸格式;文档里写"64 字节"的,实际可能给你的是 r 和 s 直接拼接且不含长度前缀。只有亲手解析过两三条数据,才能确认对方的真实行为。

最后说一句,ECDSA 验签流程里,DER 只是数据的一种表示形式,本质问题在于编码规范的一致性。只要你把归一化处理层做扎实了,无论对方给的是 DER、裸格式还是 Base64,都能稳定地接入 OpenSSL 的验签流程。这也是我这篇博文想传达的核心经验。

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

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

立即咨询