Pular para o conteúdo
ecdsa.com
Node.js · WebCrypto

ethers.js

O ethers.js v6, a biblioteca JavaScript de longa data para carteiras e contratos Ethereum: assinaturas r‖s‖v de 65 bytes com v = 27/28, hash de mensagens EIP-191, nonces RFC 6979 e recusa categórica de assinaturas high-S.
Licença
MIT
Versão verificada
6.17.0 ·
Código-fonte
github.com/ethers-io/ethers.js
Documentação
docs.ethers.org/v6/

Visão geral da compatibilidade

ECDSAEthereum e Bitcoin

Curvas ECDSA
secp256k1
EdDSA
none
Formatos de chave pública
sec1-uncompressedsec1-compressedraw-xy

O ethers cobre tudo de que uma aplicação precisa para se comunicar com o Ethereum — provedores, contratos, codificação ABI, ENS —, e suas classes Wallet e SigningKey são o que a maior parte do código JavaScript usa para assinar. A aritmética da curva vem da @noble/curves; o ethers 6.17.0 fixa a versão 1.2.0 dela.

wallet.signMessage() implementa personal_sign: adiciona à mensagem o prefixo \x19Ethereum Signed Message:\n e o comprimento dela (EIP-191), calcula o hash com keccak-256 e retorna 65 bytes de r ‖ s ‖ v com v = 27 ou 28. O teste conferiu o digest com uma implementação independente de keccak e a assinatura com a saída RFC 6979 da @noble/curves, de modo que a mesma chave e a mesma mensagem sempre geram os mesmos bytes. Para assinar um hash que você já calculou — um digest EIP-712, o hash de uma transação —, use SigningKey.sign(), que recebe exatamente 32 bytes e não adiciona prefixo; qualquer outra entrada falha com invalid digest length.

Na entrada, o ethers é tolerante quanto ao formato e rigoroso quanto à forma. verifyMessage() recuperou o signatário a partir de v = 27/28, de v = 0/1 e da forma compacta EIP-2098 de 64 bytes, mas recusou a assinatura high-S espelhada com non-canonical s; use ._s — embora ela seja matematicamente válida. Assinaturas produzidas pelo OpenSSL, por KMS de nuvem ou por módulos de hardware que não normalizam o s precisam ser convertidas para low-S (s → n − s, com o v invertido) antes que o ethers as aceite; o verificador de assinaturas Ethereum mostra em que forma uma assinatura está.

A maioria dos bugs de verificação em logins com carteira nem tem a ver com a curva: uma mensagem que difere por uma quebra de linha ou por um espaço no final, um ID da cadeia diferente no domínio EIP-712 ou uma carteira de contrato inteligente cujas assinaturas exigem ERC-1271 em vez de ecrecover. Inspecione dados tipados com o inspetor de EIP-712 e mensagens de login completas com o verificador de SIWE.

O exemplo assina uma mensagem de login, recupera o endereço e mostra a forma compacta; compare os endereços recuperados, nunca os bytes da assinatura, porque a mesma assinatura tem várias codificações válidas.

Os fatos e as verificações por trás deles

Nada abaixo foi copiado da documentação. A verificação content/libraries/verify/ethers/run.sh executou ethers.js 6.17.0 em Node.js 24.22.0; ethers depends on @noble/curves 1.2.0 na imagem de contêiner node:24.22.0-bookworm-slim em 20 de outubro de 2026; a página precisa corresponder ao que a verificação imprimiu, e cada fato lista essas linhas.

  • Codificação da assinatura ECDSA

    rsv-65

    signMessage() retorna 65 bytes em hex: r, s e o byte de recuperação v.

    Registrado pela verificação
    fact ecdsa-signature-encoding = rsv-65
  • ID de recuperação v

    27-28

    Nas assinaturas que o ethers produz, v é 27 ou 28.

    Registrado pela verificação
    fact recovery-v = 27-28
  • Prefixo da mensagem

    eip-191

    hashMessage() é igual ao keccak-256 de \x19Ethereum Signed Message:\n, do comprimento em bytes em decimal e da mensagem, calculado de forma independente com @noble/hashes.

    Registrado pela verificação
    fact message-prefix = eip-191
  • Hash padrão

    keccak256

    As assinaturas de mensagens usam keccak-256, não SHA-256.

    Registrado pela verificação
    fact ecdsa-default-hash = keccak256
  • Entrada do hash na chamada de assinatura

    hashes-message-or-digest

    signMessage() calcula o hash da mensagem com o prefixo EIP-191; SigningKey.sign() assina um digest de 32 bytes sem alterá-lo e produziu a mesma assinatura para esse digest.

    Registrado pela verificação
    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 do ECDSA

    rfc6979

    RFC 6979: a assinatura coincidiu com a assinatura determinística da @noble/curves sobre o mesmo digest.

    Registrado pela verificação
    fact ecdsa-nonce = rfc6979
  • Low-S ao assinar

    low-s

    Nenhum valor high-S em 64 assinaturas de mensagens.

    Registrado pela verificação
    fact ecdsa-low-s-sign = low-s
  • High-S ao verificar

    rejects-high-s

    A assinatura espelhada (r, n − s) com o v invertido é recusada por verifyMessage() e Signature.from() com non-canonical s; use ._s.

    Registrado pela verificação
    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
  • Formatos de assinatura aceitos

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

    verifyMessage() recuperou o signatário a partir de assinaturas de 65 bytes com v = 27/28 ou v = 0/1 e da codificação compacta EIP-2098 de 64 bytes.

    Registrado pela verificação
    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
  • Curvas ECDSA

    secp256k1

    Apenas secp256k1.

    Registrado pela verificação
    fact ecdsa-curves = secp256k1
  • EdDSA

    none

    Sem EdDSA.

    Registrado pela verificação
    fact eddsa = none
  • Algoritmos pós-quânticos

    none

    Sem algoritmos pós-quânticos.

    Registrado pela verificação
    fact pqc = none
  • Formatos de chave pública

    sec1-uncompressedsec1-compressedraw-xy

    SigningKey.computePublicKey() converteu pontos SEC1 entre a forma comprimida e a forma não comprimida e também aceitou um x‖y de 64 bytes sem o prefixo 0x04.

    Registrado pela verificação
    fact public-key-formats = sec1-uncompressed, sec1-compressed, raw-xy
    evidence public-key-formats: 64-byte x||y: true

Uso

Assine uma mensagem de login com a semântica de personal_sign, recupere o signatário e mostre as formas de 65 bytes e 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);

O executor de trechos de código roda cada exemplo literalmente na imagem de contêiner node:24.22.0-bookworm-slim, e a execução falha se a saída não corresponder.

Configuração
npm init -y >/dev/null && npm install --no-audit --no-fund ethers@6.17.0
Comando
node usage.mjs
A saída deve corresponder a (expressão regular)
signature bytes: 65 v: 2[78]\nsigner matches: true\nEIP-2098 compact bytes: 64

Bibliotecas