签名就是两个整数
无论传输中看到的是什么字节,从数学上说,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 示例:
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 / secp256k1 | 32 字节 | 64 字节 |
| P-384 | 48 字节 | 96 字节 |
| P-521 | 66 字节 | 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) | DER | ECDSA 没有内置的原始格式模式;需在外部转换,或从 ECDSA_SIG 的 r 和 s 自行拼装 |
Node.js node:crypto | DER | sign/verify 的 dsaEncoding: "ieee-p1363" 选项(自 Node 12 LTS 系列起可用) |
WebCrypto crypto.subtle | 仅原始格式(P1363) | 不支持 DER——需在调用前后自行转换 |
Go crypto/ecdsa | 两者皆非:Sign/Verify 以 *big.Int 类型接收 r, s | DER 用 SignASN1 / VerifyASN1(Go 1.15+);原始格式用 FillBytes 构造 |
Python cryptography(pyca) | DER | decode_dss_signature / encode_dss_signature 辅助函数让你直接操作 r 和 s |
Python ecdsa 包 | 默认为原始格式 | sigencode_der / sigdecode_der 参数 |
| Java(JCA Signature) | DER(SHA256withECDSA) | 较新 JDK(9+)中的 SHA256withECDSAinP1363Format |
下面用代码演示其中几种。Node.js 可以用同一个密钥输出任一种编码:
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 生成定宽、左侧补零的原始格式两半:
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 格式签名,并提供了获取这两个整数的辅助函数:
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”往往原本就是有效的。