跳到主要内容
ecdsa.com
WebCrypto · Node.js

WebCrypto (crypto.subtle)

内置于所有浏览器以及 Node.js、Deno 和 Bun 的 Web Cryptography API:在三条 NIST 曲线上提供 ECDSA,签名为原始 r‖s 格式;Ed25519 处处可用;后量子算法目前只有部分引擎支持。
许可证
W3C Software and Document License (specification); each engine ships under its own license
检查的版本
Node.js 24.22.0, Chromium 156.0.8078.4, Firefox 157.0, WebKit 27.2 ·
源代码
github.com/w3c/webcrypto
文档
w3c.github.io/webcrypto/

兼容性概览

ECDSAEdDSA

ECDSA 曲线
P-256P-384P-521
EdDSA
Ed25519
公钥格式
spki-derjwksec1-uncompressed
私钥格式
pkcs8-derjwk

crypto.subtle 是 Web 平台的标准密码学接口。同样的调用——generateKey、importKey、sign、verify、exportKey——在 Chromium、Firefox、WebKit 以及 Node.js 等服务端运行时中都能使用,因此对于必须在两端运行的代码,它是自然的选择。它只存在于安全上下文中:在普通 http 页面上,crypto.subtle 是 undefined。

规范把 ECDSA 签名格式固定为原始 r‖s,在 P-256 上为 64 字节,检查在全部四个引擎中得到的正是这种格式。它与 JWS 和 JWT 一致,因此 ES256 令牌无需转换;但它与 OpenSSL、Java 的 SHA256withECDSA、Go 的 SignASN1 和 node:crypto 都不一致,这些使用的都是 ASN.1 DER。把 DER 签名传给 verify() 时,它只返回 false 而不报错,因此这种不匹配看起来就像伪造的签名;可以用 DER ⇄ 原始格式转换器转换,或在签名验证工具中检查两种形式。

密钥是另一个常见的麻烦来源。importKey("spki") 接收 DER 字节,从不接收 PEM 文本;"raw" 接收 SEC1 点:Node.js、Chromium 和 Firefox 接受压缩形式,WebKit 则拒绝,因此可移植的代码应发送未压缩点。私钥只能以 PKCS#8 DER 或 JWK 格式导入——OpenSSL 默认写出的 SEC1 EC PRIVATE KEY 文件必须先转换为 PKCS#8,例如使用 JWK 转换器。

曲线列表只有 P-256、P-384 和 P-521:secp256k1 和 Brainpool 在所有引擎中都会抛出 NotSupportedError,因此比特币和以太坊密钥需要 @noble/curves 这样的库。Ed25519 在全部四个引擎中都能使用;Ed448 只能在 Node.js 中使用。后量子支持参差不齐:Node.js 24.22 实现了 ML-DSA 和 ML-KEM,但会给出实验性警告;Chromium 156 接受 ML-DSA 和 ML-KEM-768/1024,但不接受 ML-KEM-512;Firefox 157 和 WebKit 27.2 两者都不支持——依赖它们之前要先做特性检测,并可以在后量子签名工具中试用这些算法。

示例展示了可移植的写法:在算法对象中指明哈希算法,导入外部的 PEM 密钥时先把它解码为 DER,并记住 64 字节的输出必须先包装成 DER,OpenSSL 风格的验证方才能读取。

事实及其依据的检查

以下内容均非抄自文档。检查脚本 content/libraries/verify/webcrypto/run.sh 于2026年10月20日在容器镜像 node:24.22.0-bookworm-slim 中运行了 WebCrypto (crypto.subtle) Node.js 24.22.0, Chromium 156.0.8078.4, Firefox 157.0, WebKit 27.2;本页内容必须与它的输出一致,每条事实都列出了对应的输出行。

  • ECDSA 签名编码

    raw

    在 Node.js、Chromium、Firefox 和 WebKit 中,sign() 在 P-256 上都返回恰好 64 字节的原始 r‖s;规范没有定义 DER 选项。

    检查记录
    fact ecdsa-signature-encoding = raw
  • 签名时的 low-S

    either

    签名没有经过规范化:每个引擎生成的 64 个签名中都出现了 high-S 值。

    检查记录
    fact ecdsa-low-s-sign = either
  • 验证时的 high-S

    accepts-high-s

    每个引擎都接受了把 s 替换为 n − s 的签名。

    检查记录
    fact ecdsa-low-s-verify = accepts-high-s
  • ECDSA 的 nonce

    random

    每个引擎都使用随机 nonce——对同一数据签名两次会得到不同的字节,也没有确定性选项。

    检查记录
    fact ecdsa-nonce = random
  • 签名调用对哪些内容做哈希

    hashes-message

    sign() 用算法对象中指定的哈希算法对数据计算哈希;在一个独立实现中,每个引擎的签名都能用消息的 SHA-256 验证通过。无法对预先计算好的摘要签名。

    检查记录
    fact ecdsa-prehash = hashes-message
  • 格式错误的 ECDSA 签名

    returns-false

    在全部四个引擎中,DER 签名、63 字节的签名和 3 字节的数据块都让 verify() 返回 false;不会抛出任何异常。

    检查记录
    fact ecdsa-malformed-signature = returns-false
    evidence ecdsa-malformed-signature: Node.js: DER false, 63 bytes false, 3 bytes false
    evidence ecdsa-malformed-signature: Chromium: DER false, 63 bytes false, 3 bytes false
    evidence ecdsa-malformed-signature: Firefox: DER false, 63 bytes false, 3 bytes false
    evidence ecdsa-malformed-signature: WebKit: DER false, 63 bytes false, 3 bytes false
  • ECDSA 曲线

    P-256P-384P-521

    仅支持 P-256、P-384 和 P-521;secp256k1、K-256 和 brainpoolP256r1 在每个引擎中都抛出 NotSupportedError。

    检查记录
    fact ecdsa-curves = P-256, P-384, P-521
    evidence ecdsa-curves: Node.js: P-256 true; P-384 true; P-521 true; secp256k1 NotSupportedError; K-256 NotSupportedError; brainpoolP256r1 NotSupportedError
    evidence ecdsa-curves: Chromium: P-256 true; P-384 true; P-521 true; secp256k1 NotSupportedError; K-256 NotSupportedError; brainpoolP256r1 NotSupportedError
    evidence ecdsa-curves: Firefox: P-256 true; P-384 true; P-521 true; secp256k1 NotSupportedError; K-256 NotSupportedError; brainpoolP256r1 NotSupportedError
    evidence ecdsa-curves: WebKit: P-256 true; P-384 true; P-521 true; secp256k1 NotSupportedError; K-256 NotSupportedError; brainpoolP256r1 NotSupportedError
  • EdDSA

    Ed25519

    Ed25519 在全部四个引擎中都能签名和验证。

    检查记录
    fact eddsa = Ed25519
  • 各引擎的 Ed448 支持情况

    Node.js

    Ed448 只能在 Node.js 中使用,且 Node.js 将其标记为实验性功能;Chromium、Firefox 和 WebKit 都拒绝了它。

    检查记录
    fact ed448-engines = Node.js
  • 后量子算法

    none

    目前还没有哪种后量子算法能在全部四个引擎中使用,因此可移植的代码无法依赖其中任何一种。

    检查记录
    fact pqc = none
    evidence pqc: Node.js: ML-DSA-44 true; ML-DSA-65 true; ML-DSA-87 true; ML-KEM-512 true; ML-KEM-768 true; ML-KEM-1024 true
    evidence pqc: Chromium: ML-DSA-44 true; ML-DSA-65 true; ML-DSA-87 true; ML-KEM-512 NotSupportedError; ML-KEM-768 true; ML-KEM-1024 true
    evidence pqc: Firefox: ML-DSA-44 NotSupportedError; ML-DSA-65 NotSupportedError; ML-DSA-87 NotSupportedError; ML-KEM-512 NotSupportedError; ML-KEM-768 NotSupportedError; ML-KEM-1024 NotSupportedError
    evidence pqc: WebKit: ML-DSA-44 NotSupportedError; ML-DSA-65 NotSupportedError; ML-DSA-87 NotSupportedError; ML-KEM-512 TypeError; ML-KEM-768 TypeError; ML-KEM-1024 TypeError
  • 各引擎的 ML-DSA 支持情况

    Node.jsChromium

    ML-DSA-44、ML-DSA-65 和 ML-DSA-87 在 Node.js(实验性)和 Chromium 156 中签名与验证均成功;Firefox 和 WebKit 抛出 NotSupportedError。

    检查记录
    fact ml-dsa-engines = Node.js, Chromium
  • 各引擎的 ML-KEM 支持情况

    Node.jsChromium

    使用 ML-KEM-768 的 encapsulateBits 和 decapsulateBits 在 Node.js 和 Chromium 156 中正常工作;Chromium 拒绝了 ML-KEM-512,Firefox 和 WebKit 则完全不支持 ML-KEM。

    检查记录
    fact ml-kem-engines = Node.js, Chromium
  • 公钥格式

    spki-derjwksec1-uncompressed

    每个引擎都能导入 DER 字节形式的 SPKI、JWK 和 SEC1 未压缩点("raw");PEM 文本和不带 0x04 前缀的 64 字节 x‖y 会抛出 DataError。

    检查记录
    fact public-key-formats = spki-der, jwk, sec1-uncompressed
    evidence public-key-formats: Node.js: spki-pem DataError; spki-der true; jwk true; sec1-uncompressed true; sec1-compressed true; raw-xy DataError
    evidence public-key-formats: Chromium: spki-pem DataError; spki-der true; jwk true; sec1-uncompressed true; sec1-compressed true; raw-xy DataError
    evidence public-key-formats: Firefox: spki-pem DataError; spki-der true; jwk true; sec1-uncompressed true; sec1-compressed true; raw-xy DataError
    evidence public-key-formats: WebKit: spki-pem DataError; spki-der true; jwk true; sec1-uncompressed true; sec1-compressed DataError; raw-xy DataError
  • 各引擎的压缩点支持情况

    Node.jsChromiumFirefox

    33 字节的压缩点可以在 Node.js、Chromium 和 Firefox 中以 "raw" 导入,WebKit 则抛出 DataError。

    检查记录
    fact compressed-point-engines = Node.js, Chromium, Firefox
  • 私钥格式

    pkcs8-derjwk

    私钥只能以 PKCS#8 DER 或 JWK 格式导入;单独的 32 字节标量被所有引擎拒绝。

    检查记录
    fact private-key-formats = pkcs8-der, jwk
    evidence private-key-formats: Node.js: pkcs8-der true; jwk true; raw-scalar SyntaxError
    evidence private-key-formats: Chromium: pkcs8-der true; jwk true; raw-scalar SyntaxError
    evidence private-key-formats: Firefox: pkcs8-der true; jwk true; raw-scalar DataError
    evidence private-key-formats: WebKit: pkcs8-der true; jwk true; raw-scalar SyntaxError

用法

用 P-256 签名,把 PEM 公钥解码为 DER 后导入,然后验证——签名是 64 字节的原始形式。
// The same code runs in browsers, Deno, Bun and Node.js (globalThis.crypto).
const { subtle } = globalThis.crypto;
const curve = { name: "ECDSA", namedCurve: "P-256" };
const ecdsa = { name: "ECDSA", hash: "SHA-256" };

const { privateKey, publicKey } = await subtle.generateKey(curve, true, ["sign", "verify"]);
const data = new TextEncoder().encode("order #1042 approved");
const signature = new Uint8Array(await subtle.sign(ecdsa, privateKey, data));

// A PEM public key from another system: importKey("spki") wants the DER bytes,
// so strip the armour and base64-decode first. PEM text itself is a DataError.
const spki = new Uint8Array(await subtle.exportKey("spki", publicKey));
const pem = `-----BEGIN PUBLIC KEY-----\n${btoa(String.fromCharCode(...spki))}\n-----END PUBLIC KEY-----`;
const der = Uint8Array.from(atob(pem.replace(/-----[^-]+-----|\s/g, "")), (c) => c.charCodeAt(0));
const imported = await subtle.importKey("spki", der, curve, false, ["verify"]);

// Always 64 bytes on P-256: raw r||s, never DER. Convert before handing it to OpenSSL or Java.
console.log("signature bytes:", signature.length);
console.log("verified:", await subtle.verify(ecdsa, imported, signature, data));

代码片段测试工具会在容器镜像 node:24.22.0-bookworm-slim 中原样运行每个示例,只要输出不匹配,运行就会失败。

命令
node usage.mjs
输出必须匹配(正则表达式)
signature bytes: 64\nverified: true

库