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

ethers.js

ethers.js v6,老牌的以太坊钱包与合约 JavaScript 库:65 字节的 r‖s‖v 签名(v = 27/28),EIP-191 消息哈希,RFC 6979 nonce,并坚决拒绝 high-S 签名。
许可证
MIT
检查的版本
6.17.0 ·
源代码
github.com/ethers-io/ethers.js
文档
docs.ethers.org/v6/

兼容性概览

ECDSA以太坊与比特币

ECDSA 曲线
secp256k1
EdDSA
none
公钥格式
sec1-uncompressedsec1-compressedraw-xy

应用与以太坊交互所需的一切——提供者、合约、ABI 编解码、ENS——ethers 都已涵盖,而大多数 JavaScript 代码正是用它的 Wallet 和 SigningKey 类来签名的。曲线运算来自 @noble/curves;ethers 6.17.0 锁定了它的 1.2.0 版本。

wallet.signMessage() 实现了 personal_sign:它在消息前加上 \x19Ethereum Signed Message:\n 和消息长度(EIP-191),用 keccak-256 计算哈希,并返回 65 字节的 r ‖ s ‖ v,其中 v = 27 或 28。本次检查用一个独立的 keccak 实现核对了摘要,并用 @noble/curves 的 RFC 6979 输出核对了签名,因此同一密钥和同一消息总是得到相同的字节。要对已经算好的哈希签名——例如 EIP-712 摘要或交易哈希——应使用 SigningKey.sign(),它只接受恰好 32 字节的输入,且不添加前缀;其他输入都会报错 invalid digest length。

ethers 对输入的布局很宽松,对规范形式却很严格。verifyMessage() 能从 v = 27/28、v = 0/1 以及 64 字节的 EIP-2098 紧凑形式中恢复出签名者,但拒绝了镜像得到的 high-S 签名,报错 non-canonical s; use ._s——尽管它在数学上是有效的。由不对 s 做规范化的 OpenSSL、云 KMS 或硬件模块生成的签名,必须先转换为 low-S(s → n − s,并翻转 v),ethers 才会接受;以太坊签名验证工具可以显示签名属于哪种形式。

钱包登录中的大多数验证问题与曲线毫无关系:消息相差一个换行符或末尾空格,EIP-712 域中的链 ID 不同,或者是智能合约钱包,其签名需要用 ERC-1271 而不是 ecrecover 验证。用 EIP-712 查看器查看类型化数据,用 SIWE 验证工具检查完整的登录消息。

示例对一条登录消息签名、恢复出地址并展示紧凑形式;比较时应比较恢复出的地址,切勿比较签名字节,因为同一个签名有多种有效编码。

事实及其依据的检查

以下内容均非抄自文档。检查脚本 content/libraries/verify/ethers/run.sh 于2026年10月20日在容器镜像 node:24.22.0-bookworm-slim 中运行了 ethers.js 6.17.0(运行时为 Node.js 24.22.0; ethers depends on @noble/curves 1.2.0);本页内容必须与它的输出一致,每条事实都列出了对应的输出行。

  • ECDSA 签名编码

    rsv-65

    signMessage() 以十六进制返回 65 字节:r、s 和恢复字节 v。

    检查记录
    fact ecdsa-signature-encoding = rsv-65
  • 恢复 ID v

    27-28

    ethers 生成的签名中,v 为 27 或 28。

    检查记录
    fact recovery-v = 27-28
  • 消息前缀

    eip-191

    hashMessage() 的结果与用 @noble/hashes 独立计算的 keccak-256 一致,计算对象是 \x19Ethereum Signed Message:\n、十进制的字节长度和消息本身。

    检查记录
    fact message-prefix = eip-191
  • 默认哈希

    keccak256

    消息签名使用 keccak-256,而不是 SHA-256。

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

    hashes-message-or-digest

    signMessage() 对加上 EIP-191 前缀的消息计算哈希;SigningKey.sign() 对 32 字节的摘要原样签名,对该摘要生成的签名与前者完全相同。

    检查记录
    fact ecdsa-prehash = hashes-message-or-digest
    evidence ecdsa-prehash: SigningKey.sign over the EIP-191 digest equals signMessage: true; a 2-byte input: invalid digest length
    evidence ecdsa-prehash: recoverAddress(digest, signature) returns the signer: true
  • ECDSA 的 nonce

    rfc6979

    RFC 6979:签名与 @noble/curves 对同一摘要生成的确定性签名一致。

    检查记录
    fact ecdsa-nonce = rfc6979
  • 签名时的 low-S

    low-s

    64 个消息签名中没有 high-S 值。

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

    rejects-high-s

    翻转 v 后的镜像签名 (r, n − s) 会被 verifyMessage() 和 Signature.from() 拒绝,报错 non-canonical s; use ._s。

    检查记录
    fact ecdsa-low-s-verify = rejects-high-s
    evidence ecdsa-low-s-verify: verifyMessage with (r, n - s, flipped v): non-canonical s; use ._s
    evidence ecdsa-low-s-verify: Signature.from on the high-S twin: non-canonical s; use ._s
  • 接受的签名形式

    v-27-28v-0-1eip-2098

    verifyMessage() 能从 v = 27/28 或 v = 0/1 的 65 字节签名以及 64 字节的 EIP-2098 紧凑编码中恢复出签名者。

    检查记录
    fact signature-input-shapes = v-27-28, v-0-1, eip-2098
    evidence signature-input-shapes: 65-byte v 27/28: recovers the signer
    evidence signature-input-shapes: 65-byte v 0/1: recovers the signer
    evidence signature-input-shapes: 64-byte EIP-2098: recovers the signer
  • ECDSA 曲线

    secp256k1

    仅支持 secp256k1。

    检查记录
    fact ecdsa-curves = secp256k1
  • EdDSA

    none

    不支持 EdDSA。

    检查记录
    fact eddsa = none
  • 后量子算法

    none

    不支持后量子算法。

    检查记录
    fact pqc = none
  • 公钥格式

    sec1-uncompressedsec1-compressedraw-xy

    SigningKey.computePublicKey() 能在 SEC1 压缩点与未压缩点之间转换,还接受不带 0x04 前缀的 64 字节 x‖y。

    检查记录
    fact public-key-formats = sec1-uncompressed, sec1-compressed, raw-xy
    evidence public-key-formats: 64-byte x||y: true

用法

按 personal_sign 语义对登录消息签名,恢复出签名者,并展示 65 字节形式和 EIP-2098 形式。
import { Signature, Wallet, verifyMessage } from "ethers";

const wallet = Wallet.createRandom();
const message = "Sign in to example.com\nNonce: 8f2c1a";

// personal_sign: EIP-191 prefix + keccak256, RFC 6979 nonce, low-S, v = 27 or 28.
const signature = await wallet.signMessage(message);
const { v, compactSerialized } = Signature.from(signature);

// Recover the address and compare it; never compare signature bytes.
const signer = verifyMessage(message, signature);

console.log("signature bytes:", (signature.length - 2) / 2, "v:", v);
console.log("signer matches:", signer === wallet.address);
console.log("EIP-2098 compact bytes:", (compactSerialized.length - 2) / 2);

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

环境准备
npm init -y >/dev/null && npm install --no-audit --no-fund ethers@6.17.0
命令
node usage.mjs
输出必须匹配(正则表达式)
signature bytes: 65 v: 2[78]\nsigner matches: true\nEIP-2098 compact bytes: 64

库