跳到主要内容
ecdsa.com
← 全部文章

ECDSA 签名格式:DER 与原始格式(P1363)、high-S 及跨库陷阱

密钥没错,消息没错,算法也没错——验证却依然失败。多数情况下,只是双方对签名字节的排列方式理解不一致。本文从字节层面讲解 ECDSA 签名的两种编码,以及即使编码一致也会破坏互操作的 high-S 细节。

签名就是两个整数

无论传输中看到的是什么字节,从数学上说,ECDSA 签名只是一对正整数 (r, s),每个都介于 1 和 n − 1 之间,其中 n 是曲线的阶(对于 P-256 和 secp256k1,这个数接近 2²⁵⁶)。其余的一切都是序列化——而整个生态最终形成了两种互不兼容的序列化方式。两者都不包含对方所没有的信息;二者之间的转换是无损的,纯属机械操作。

格式 1:ASN.1 DER——源自 X.509 体系

OpenSSL、X.509 证书和 TLS 使用的格式来自 ASN.1 体系:这对整数被编码为一个 DER SEQUENCE,其中包含两个 INTEGER。下面是一个带注释的 P-256 示例:

DER 编码的 ECDSA 签名(带注释)
30 45                    SEQUENCE, 0x45 = 69 bytes follow
   02 21                 INTEGER, 0x21 = 33 bytes  (this is r)
      00 c8 7f 3b ...    33 bytes: leading 00 because r's top bit is set
   02 20                 INTEGER, 0x20 = 32 bytes  (this is s)
      6a 12 09 ...       32 bytes: no padding needed, top bit is clear

DER 的整数规则让这种格式稍显棘手:

  • 整数采用大端序和二进制补码表示,并且是最短长度:必须去掉前导零字节。
  • 但这些值都是正数——因此,如果第一个字节的最高位为 1(第一个字节 ≥ 0x80),就必须在前面补一个 0x00,以免该数被解读为负数。r 和 s 各自独立,可能带有、也可能不带这个填充字节。
  • 结果是一种变长编码:对于 P-256,通常为 70、71 或 72 字节(r 或 s 恰好以零字节开头时,偶尔会更短)。假定签名长度固定的代码是一个潜伏的 bug,只等某个不凑巧的随机值出现。
  • DER 是“唯一确定”(distinguished)的编码:每个值恰好只有一种合法的字节串。严格的解析器会拒绝非最短的整数、不合规范的填充或尾随的多余数据——比特币正是为了消除解析器之间的分歧,才把严格 DER 定为共识规则(BIP-66)。

格式 2:原始 r‖s——定长的 IEEE P1363 格式

另一种约定完全抛开 ASN.1:把 r 和 s 分别按大端序填充到恰好等于曲线坐标的长度,然后拼接起来。这种格式通常被称为 IEEE P1363 格式(得名于使用它的那项标准)、“raw”(原始)格式,或扁平(flat)r‖s:

曲线域元素长度原始签名长度
P-256 / secp256k132 字节64 字节
P-38448 字节96 字节
P-52166 字节132 字节

长度固定,没有标签,也没有填充规则——把数据从中间一分为二,就得到了两个整数。WebCrypto 输出的就是这种格式,RFC 7518 §3.4 也规定 JWS 必须使用它(因此 JWT 也是如此——实际影响见 JWT 选 ES256 还是 RS256),大多数区块链技术栈在内部处理的也是这种形式:以太坊签名是 65 字节的 r‖s‖v(原始整数对加一个恢复字节),而比特币如前所述,用严格 DER 编码交易签名。

这种失败是双向的,而且极为常见:把 64 字节的原始签名交给按 DER 解析的 API,会在 ASN.1 解析器那一步出错(首字节应为 0x30);而把约 71 字节的 DER 数据交给 WebCrypto 或 JWS 验证器,只是长度不对,验证会干脆地失败,报出笼统的“invalid signature”。两个技术栈不一致时,要显式转换——DER ⇄ 原始格式转换器可以在浏览器中双向转换,并显示解码出的 r 和 s。

high-S、low-S,以及有效签名为何会被拒绝

即使编码一致,还有一个细节会让跨技术栈的验证失败。ECDSA 有一种固有的对称性:如果 (r, s) 是有效签名,那么 (r, n − s) 也是同一密钥对同一消息的有效签名。任何持有签名的人都无需私钥就能把它翻转成另一种形式——签名字节具有延展性,而被签名的内容并没有。

对于通过对签名字节做哈希来标识数据的协议——比特币交易 ID 就是典型例子——这种第三方延展性是个实实在在的问题。解决办法是规范的 low-S 形式:要求 s ≤ n/2,否则用 n − s 替换 s 进行规范化。比特币把 low-S 定为中继规则(由 BIP-62 提出,在 BIP-146 中规定),libsecp256k1 的验证只接受 low-S 签名,除非调用方先做规范化。其他生态中一些注重安全的库也默认生成 low-S 签名,并且可以配置为在验证时拒绝 high-S。

谁用哪种格式:各库的默认设置

库 / API默认格式另一种格式
OpenSSL(CLI 和 EVP API)DERECDSA 没有内置的原始格式模式;需在外部转换,或从 ECDSA_SIG 的 r 和 s 自行拼装
Node.js node:cryptoDERsign/verify 的 dsaEncoding: "ieee-p1363" 选项(自 Node 12 LTS 系列起可用)
WebCrypto crypto.subtle仅原始格式(P1363)不支持 DER——需在调用前后自行转换
Go crypto/ecdsa两者皆非:Sign/Verify 以 *big.Int 类型接收 r, sDER 用 SignASN1 / VerifyASN1(Go 1.15+);原始格式用 FillBytes 构造
Python cryptography(pyca)DERdecode_dss_signature / encode_dss_signature 辅助函数让你直接操作 r 和 s
Python ecdsa 包默认为原始格式sigencode_der / sigdecode_der 参数
Java(JCA Signature)DER(SHA256withECDSA)较新 JDK(9+)中的 SHA256withECDSAinP1363Format

下面用代码演示其中几种。Node.js 可以用同一个密钥输出任一种编码:

node——同一个密钥,两种格式
import { generateKeyPairSync, sign } from "node:crypto";

const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
const msg = Buffer.from("same signature, two encodings");

const der = sign("sha256", msg, privateKey);
const raw = sign("sha256", msg, { key: privateKey, dsaEncoding: "ieee-p1363" });

console.log(der.length); // 70..72 (varies per signature)
console.log(raw.length); // always 64

Go 把底层的整数显式暴露出来,这也让转换一目了然——注意用 FillBytes 生成定宽、左侧补零的原始格式两半:

go——输出 DER,输出原始格式
der, err := ecdsa.SignASN1(rand.Reader, priv, digest) // ASN.1 DER
if err != nil { return err }

r, s, err := ecdsa.Sign(rand.Reader, priv, digest)    // the integers themselves
if err != nil { return err }
raw := make([]byte, 64)
r.FillBytes(raw[:32])  // zero-pads on the left — critical for short values
s.FillBytes(raw[32:])

Python 的 pyca cryptography 以 DER 格式签名,并提供了获取这两个整数的辅助函数:

python——用 pyca/cryptography 把 DER 转为原始格式
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature

der_sig = private_key.sign(message, ec.ECDSA(hashes.SHA256()))  # DER bytes

r, s = decode_dss_signature(der_sig)
raw_sig = r.to_bytes(32, "big") + s.to_bytes(32, "big")          # 64-byte P1363

调试清单

当 ECDSA 签名在两个系统之间验证不通过时,按顺序逐项检查:

  • 长度检查:恰好 64/96/132 字节 → 原始格式。以 0x30 开头且长度字节合理 → DER。其他情况:外层编码(base64、hex)解码错误,或复制粘贴时被截断。
  • 格式匹配:验证端 API 要求的格式与签名端生成的格式是否一致?用 DER ⇄ 原始格式转换器转换,而不是重新签名。
  • 补零:如果原始签名间歇性地验证失败(大约每 128 次出现 1 次:r 和 s 各自约每 256 次有 1 次以零字节开头),就要怀疑转换时丢掉了前导零字节,而没有把 r 和 s 左侧补零到固定宽度。
  • high-S:如果失败是间歇性的,比例约为 50%,并且其中一方涉及区块链或经过安全加固的技术栈,就把 s 与 n/2 比较并进行规范化。
  • 其他问题——哈希错误、曲线错误、对哈希再做哈希——属于算法层而不是格式层;相关背景可以从 ECDSA 与 RSA 读起。

令人宽慰的结论是:DER 与原始格式之争纯属数据格式的对接问题。转换中不涉及任何密码学运算,密钥也没有任何变化;一旦双方在字节布局上达成一致——在需要的场合,也在 low-S 上达成一致——那个耗掉你一下午的“invalid signature”往往原本就是有效的。