Перейти к содержимому
ecdsa.com
Node.js · WebCrypto

ethers.js

ethers.js v6 — давно известная библиотека JavaScript для кошельков и контрактов Ethereum: 65-байтовые подписи r‖s‖v с v = 27/28, хеширование сообщений по EIP-191, nonce по RFC 6979 и безоговорочный отказ принимать подписи с high-S.
Лицензия
MIT
Проверенная версия
6.17.0 ·
Исходный код
github.com/ethers-io/ethers.js
Документация
docs.ethers.org/v6/

Совместимость вкратце

ECDSAEthereum и Bitcoin

EdDSA
none
Форматы открытых ключей
sec1-uncompressedsec1-compressedraw-xy

ethers умеет всё, что нужно приложению для работы с Ethereum (провайдеры, контракты, кодирование ABI, ENS), а большая часть кода на JavaScript подписывает данные классами Wallet и SigningKey из ethers. Арифметику на кривой библиотека берёт из @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, а подпись — с результатом RFC 6979 из @noble/curves, поэтому одинаковые ключ и сообщение всегда дают одинаковые байты. Чтобы подписать уже вычисленный хеш (дайджест EIP-712, хеш транзакции), используйте SigningKey.sign(): этот метод принимает ровно 32 байта и не добавляет префикс, а на любые другие данные отвечает ошибкой invalid digest length.

Библиотека терпима к структуре входных данных, но строга к их форме. verifyMessage() восстановил подписанта по подписям с v = 27/28 и с v = 0/1, а также по 64-байтовой компактной форме EIP-2098, но отклонил зеркальную подпись с high-S с ошибкой non-canonical s; use ._s, хотя математически она верна. Подписи от OpenSSL, облачных KMS или аппаратных модулей, которые не нормализуют s, нужно привести к low-S (s → n − s и смена v), прежде чем ethers их примет; проверка подписей Ethereum показывает, в какой форме записана подпись.

Большинство ошибок проверки при входе через кошелёк вообще не связаны с кривой: сообщение, которое отличается переводом строки или пробелом в конце; другой chain ID в домене EIP-712; кошелёк на смарт-контракте, подписи которого нужно проверять через ERC-1271, а не ecrecover. Типизированные данные разбирайте в инспекторе EIP-712, а полные сообщения для входа — в средстве проверки SIWE.

Пример подписывает сообщение для входа, восстанавливает адрес и показывает компактную форму. Сравнивайте восстановленные адреса, но никогда не байты подписей: у одной и той же подписи есть несколько допустимых кодировок.

Факты и проверки, на которых они основаны

Ничего из приведённого ниже не скопировано из документации. 20 октября 2026 г. проверка content/libraries/verify/ethers/run.sh запустила ethers.js 6.17.0 в среде Node.js 24.22.0; ethers depends on @noble/curves 1.2.0 в образе контейнера node:24.22.0-bookworm-slim; содержимое страницы должно совпадать с выводом проверки, а у каждого факта приведены соответствующие строки вывода.

  • Кодировка подписи ECDSA

    rsv-65

    signMessage() возвращает 65 байт в hex: r, s и байт восстановления v.

    Что записала проверка
    fact ecdsa-signature-encoding = rsv-65
  • Идентификатор восстановления v

    27-28

    В подписях, которые создаёт ethers, v равно 27 или 28.

    Что записала проверка
    fact recovery-v = 27-28
  • Префикс сообщения

    eip-191

    hashMessage() совпадает с keccak-256 от \x19Ethereum Signed Message:\n, десятичной длины сообщения в байтах и самого сообщения, вычисленным независимо с помощью @noble/hashes.

    Что записала проверка
    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
  • Nonce в ECDSA

    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

    verifyMessage() и Signature.from() отклоняют зеркальную подпись (r, n − s) с инвертированным v, выдавая ошибку 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() восстановил подписанта по 65-байтовым подписям с v = 27/28 или v = 0/1 и по 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 между сжатой и несжатой формой, а также принял 64-байтовое значение x‖y без префикса 0x04.

    Что записала проверка
    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

Библиотеки