Pular para o conteúdo
ecdsa.com
← Todos os artigos

Recuperação da chave pública ECDSA: recid, v e ecrecover

Com uma assinatura ECDSA e mais dois bits, é possível calcular a chave pública do signatário — e é por isso que as transações do Ethereum não carregam chave nenhuma. Recuperamos chaves à mão em Python e conferimos o resultado com o cast, o contrato pré-compilado ecrecover, o Bitcoin Core 31.1, o viem e o noble.

Resposta curta. A partir de uma assinatura ECDSA (r, s) e do hash da mensagem e, a chave pública do signatário é Q = r⁻¹(s·R − e·G), em que R é o ponto da curva cuja coordenada x é r. Existem até quatro pontos assim, então o signatário anexa um ID de recuperação: um bit para a paridade da coordenada y de R e, em tese, outro que indica se R.x foi reduzido módulo n. O Ethereum o codifica como v (27/28 em mensagens, chainId·2 + 35 + yParity em transações legadas, yParity em transações tipadas); o Bitcoin, como o byte de cabeçalho de uma assinatura compacta de 65 bytes (de 27 a 42). Para uma entrada bem formada, a recuperação sempre retorna alguma chave: ela só vira verificação quando você compara o resultado com o endereço ou a chave que esperava. O contrato pré-compilado ecrecover do Ethereum não retorna nada para uma entrada malformada, o que o Solidity transforma em address(0), e aceita tanto s quanto n − s.

A matemática

Para assinar, o ECDSA escolhe um nonce k, calcula R = kG e define r = R.x mod n e s = k⁻¹(e + r·d) mod n. Reorganizando a segunda equação com Q = dG, temos

s·k = e + r·d → s·R = e·G + r·Q → Q = r⁻¹(s·R − e·G).

Tudo o que está do lado direito é conhecido, exceto o próprio R, e a assinatura só contém a coordenada x dele, reduzida módulo n. Para reconstruir R, o verificador precisa saber:

  • A paridade de R.y. Para um dado x existem dois pontos, (x, y) e (x, p − y); um deles tem y par.
  • Se R.x ≥ n. Na secp256k1, o primo p do campo é um pouco maior que a ordem do grupo n, então uma coordenada x em [n, p) teria sido reduzida. A probabilidade é (p − n)/p, cerca de 2⁻¹²⁷; isso nunca acontece na prática, e o v do Ethereum nem consegue expressar esse caso.

Esse é o ID de recuperação: recid = 2·[R.x ≥ n] + (R.y mod 2), especificado na SEC 1 v2.0 §4.1.6 (SEC 1). Escrito em Python, sem nada escondido:

recover.py (trecho): recuperação, Python 3.14.8 (aritmética da curva como em secp256k1-explained)
def recover(e: int, r: int, s: int, recid: int):
    """Return the public key Q with s = k^-1 (e + r*d), or None if recid does not name a curve point."""
    if not (0 < r < n and 0 < s < n):
        raise ValueError("r and s must be in [1, n-1]")
    x = r + (recid >> 1) * n          # recid 2 and 3: R.x was >= n and got reduced
    if x >= p:
        return None
    alpha = (x * x * x + 7) % p
    y = pow(alpha, (p + 1) // 4, p)   # p % 4 == 3, so this is a square root if one exists
    if y * y % p != alpha:
        return None                   # r is not the x-coordinate of any point
    if y & 1 != recid & 1:
        y = p - y                     # recid bit 0 is the parity of R.y
    R = (x, y)
    r_inv = pow(r, -1, n)
    return add(mul(s * r_inv % n, R), mul(-e * r_inv % n, G))   # Q = r^-1 (sR - eG)


def eth_address(Q):
    k = keccak.new(digest_bits=256, data=Q[0].to_bytes(32, "big") + Q[1].to_bytes(32, "big"))
    return "0x" + k.hexdigest()[-40:]


def eth_personal_digest(message: bytes) -> int:
    data = b"\x19Ethereum Signed Message:\n" + str(len(message)).encode() + message
    return int.from_bytes(keccak.new(digest_bits=256, data=data).digest(), "big")

Nada aqui é específico da secp256k1, exceto as constantes e o atalho da raiz quadrada; a recuperação também funciona na P-256. Ela é uma convenção dos ecossistemas Bitcoin e Ethereum, e não uma propriedade da curva; o JOSE, o TLS e o WebAuthn, em vez disso, transmitem ou certificam as chaves.

Ethereum: uma assinatura do cast, recuperada à mão

O laboratório gerou uma chave descartável com o Foundry 1.8.5 e assinou uma mensagem com a semântica de personal_sign (EIP-191):

eth.sh (trecho): assinar, verificar com o cast, recuperar em Python
KEY=$(cast wallet new --json | python3 -c "import json,sys; print(json.load(sys.stdin)['data'][0]['private_key'])")
ADDR=$(cast wallet address --private-key "$KEY")
SIG=$(cast wallet sign --private-key "$KEY" "hello from ecdsa.com")
cast wallet verify --address "$ADDR" "hello from ecdsa.com" "$SIG"
python3 eth_recover.py "hello from ecdsa.com" "$SIG" "$ADDR"

O eth_recover.py divide os 65 bytes em r, s e v, testa os quatro IDs de recuperação e depois recupera a mesma assinatura com uma mensagem ligeiramente diferente:

Saída (cast 1.8.5, pycryptodome 3.23.0)
address: 0x22baE48F42D493eDcF82ddB7e47480B7233917b3
signature: 0x150e0d6f3cc85f603300fe1801bc4397021a51495c53186f15ecb05f9e57dcbe2a17a5521f6c42792fc22a98754e5c01dd26ac42009e8d24d3cb713a8fe96fed1c
Validation succeeded. Address 0x22baE48F42D493eDcF82ddB7e47480B7233917b3 signed this message.
r=0x150e0d6f3cc85f603300fe1801bc4397021a51495c53186f15ecb05f9e57dcbe
s=0x2a17a5521f6c42792fc22a98754e5c01dd26ac42009e8d24d3cb713a8fe96fed
v=28  (s is low)
recid 0: 0xb1775d58a680572a45fe5d6ba094c56b0b748c9d
recid 1: 0x22bae48f42d493edcf82ddb7e47480b7233917b3  <- v - 27
recid 2: no point (x = r + n is not below p)
recid 3: no point (x = r + n is not below p)
matches the signer: True
same signature, other message -> 0x994ba1aaf9e6b96710727d919140aff23f56407c

Há duas lições aqui. O ID de recuperação errado produz uma chave válida diferente, e não um erro; por isso, um verificador que adivinha o v vai, sem reclamar, “recuperar” outra pessoa. E a mesma assinatura sobre uma mensagem diferente (ecdsa.org em vez de ecdsa.com) recupera mais um endereço bem formado. Dados errados não fazem a recuperação falhar; só a comparação com um signatário esperado a transforma em uma verificação.

O que o ecrecover retorna

Os contratos chamam o contrato pré-compilado no endereço 0x01 com 128 bytes: o hash e, em seguida, v, r e s como palavras de 32 bytes. O laboratório enviou cinco entradas a ele em um nó anvil local com cast call:

cast call 0x0000000000000000000000000000000000000001 <hash ‖ v ‖ r ‖ s> (anvil 1.8.5)
as signed (v, r, s)                0x00000000000000000000000022bae48f42d493edcf82ddb7e47480b7233917b3
twin (55-v, r, n-s)                0x00000000000000000000000022bae48f42d493edcf82ddb7e47480b7233917b3
v = 29                             0x
s + 1                              0x000000000000000000000000412fbd6e833de18577db8f41022c54e852192b34
s = 0                              0x
  • A assinatura gêmea (v trocado entre 27 e 28, s substituído por n − s) recupera o mesmo signatário. A EIP-2 tornou inválidas as assinaturas de transação high-S, mas não mexeu no contrato pré-compilado: ele “vai continuar aceitando valores altos de s”. Qualquer contrato que use os bytes da assinatura como identificador, por exemplo para marcar um voucher assinado como gasto, fica exposto a um ataque de repetição com a gêmea. Marque como usada a mensagem (um nonce ou o digest), nunca a assinatura.
  • Entrada malformada (v fora de 27/28, s = 0) produz uma saída vazia. O ecrecover do Solidity a transforma em address(0); a documentação diz que ele retorna “zero em caso de erro”. Se o signatário esperado puder ser address(0) em algum momento (um proprietário não definido, uma entrada ausente em um mapping), toda assinatura inválida passa.
  • Um s modificado recupera um endereço de aparência aleatória, como no teste em Python.

A correção habitual para os três casos é o ECDSA.recover da OpenZeppelin (Contracts 5.6.1), que rejeita s acima de n/2 com ECDSAInvalidSignatureS, um resultado zero com ECDSAInvalidSignature e, na sobrecarga com bytes, qualquer coisa que não tenha 65 bytes. Carteiras de contrato inteligente não têm nenhuma chave para recuperar; elas são verificadas com a ERC-1271, como descrito em Sign-In with Ethereum.

As muitas formas do v

Onde fica o bit de recuperação depende do que foi assinado:

ContextoCodificaçãoExemplo
personal_sign, EIP-712, a maioria das carteiras65 bytes r ‖ s ‖ v, v = 27 + yParityv = 27 ou 28
Transação legada, EIP-155v = chainId·2 + 35 + yParitycadeia 1: 37 ou 38
Transações tipadas (EIP-2930, EIP-1559, EIP-4844, EIP-7702)campo yParity separado, 0 ou 10 ou 1
Assinatura compacta EIP-209864 bytes r ‖ yParityAndS, paridade no bit mais alto de sbit mais alto ligado significa yParity 1
Assinatura de mensagem Bitcoin (BIP-137)65 bytes header ‖ r ‖ s, cabeçalho = 27 + recid + 0 (P2PKH com chave não comprimida), 4 (P2PKH com chave comprimida), 8 (P2SH-P2WPKH) ou 12 (P2WPKH)31 ou 32 para uma chave P2PKH comprimida
Bibliotecas de baixo nível (libsecp256k1, noble)recid de 0 a 3, passado à parte0 ou 1

O laboratório assinou com o viem 2.57.7 e verificou como as funções de recuperação dele lidam com as variantes:

rec.mjs (trecho): viem 2.57.7, @noble/curves 2.4.0
for (const [label, sig] of [
  ["65-byte", signature],
  ["64-byte compact", compact],
  ["63 bytes", signature.slice(0, -4)],
  ["v = 29", signature.slice(0, -2) + "1d"],
]) {
  try {
    const who = await recoverMessageAddress({ message, signature: sig });
    console.log(`recover ${label.padEnd(16)} ${who === account.address ? "signer" : who}`);
  } catch (e) {
    console.log(`recover ${label.padEnd(16)} ${e.shortMessage ?? e.message.split("\n")[0]}`);
  }
}

// The same recovery with noble: the recovery bit travels separately from the 64-byte (r, s).
const digest = hashMessage(message, "bytes");
const rs = Buffer.from(signature.slice(2, 130), "hex");
const pub = secp256k1.Signature.fromBytes(rs, "compact").addRecoveryBit(parsed.yParity).recoverPublicKey(digest);
console.log(`noble recovers    ${pub.toHex(true)} (compressed public key)`);
Saída (node:24.22.0-bookworm-slim)
signer            0xa9650f6049917850af00B8e7Ffc386d9A3e18C08
signature         65 bytes, v=27, yParity=0
EIP-2098 compact  64 bytes, top bit of byte 32 = yParity: true
recover 65-byte          signer
recover 64-byte compact  invalid signature length
recover 63 bytes         invalid signature length
recover v = 29           Invalid yParityOrV value
noble recovers    036a079737b9587224344944600570a311f21b3b06f5c920b24bcd95e993afd7e0 (compressed public key)
legacy tx, chain 1: v=38 (yParity 1)
EIP-1559 tx, chain 1: v=28, yParity=1

O recoverMessageAddress do viem não aceita uma string EIP-2098 de 64 bytes; converta-a antes com compactSignatureToSignature, ou você recebe invalid signature length. Um v fora do esperado gera Invalid yParityOrV value. O v = 38 da transação legada é 1·2 + 35 + 1. Na transação EIP-1559, o viem informa v = 28 por conveniência, mas a transação assinada em si só carrega yParity. O web3.py, do Python, é igualmente rigoroso com tamanhos: Expected 65, but got 64 bytes.

Um bug real que surgiu ao escrever este laboratório: inverter a paridade com v ^ 1 transforma 27 em 26 e 28 em 29, porque 27 é ímpar. Trabalhe com yParity = v − 27 (ou (v − 35) mod 2 na EIP-155) e só converta de volta nas bordas do sistema.

Bitcoin: assinaturas compactas de mensagens

O signmessage do Bitcoin Core produz o mesmo tipo de assinatura, com um byte de cabeçalho na frente. Com uma chave descartável em regtest:

btc.sh (trecho): Bitcoin Core 31.1
SIG=$($CLI signmessagewithprivkey "$WIF" "$MSG")
$CLI verifymessage "$ADDR" "$SIG" "$MSG"
python3 btc_recover.py "$MSG" "$SIG"      # recover by hand, then build three variants
Saída (bitcoin/bitcoin:31.1)
address: mvzrMJdGFmYPJw79zHz5i2WqMHdwchtbr1
signature: H6uRgJJnTugkk5XeUQl+eE1oY5/L6yZZORJHwTn1ohJdFOWEPPUvL6oHzDx2lAeoI+l1d3ipt5sw3TmF/tkpz60=
verifymessage: true
65 bytes: header 31 -> recid 0, compressed key: True, s is low
recovered public key: 03b7b74bd1e7c935c284dbb71b48649f1ff6a5cb939d615061204271a61f4232e1
its regtest P2PKH address: mvzrMJdGFmYPJw79zHz5i2WqMHdwchtbr1
other recid (header 32)              verifymessage: false
uncompressed header (27)             verifymessage: false
high-S twin (header 32, n - s)       verifymessage: true

O digest é o SHA-256 duplo de "\x18Bitcoin Signed Message:\n", do tamanho da mensagem e da própria mensagem. O cabeçalho 31 significa recid 0 e chave comprimida; trocá-lo por 27 recupera o mesmo ponto, mas o hash passa a ser calculado sobre a codificação não comprimida, o que dá outro endereço, e a verificação falha. Assim como o ecrecover, a verificação de mensagens do Bitcoin Core aceitou a gêmea high-S: low-S é uma regra de retransmissão de transações, não uma regra de mensagens. Para endereços SegWit e Taproot, esse formato não basta; o artigo Assinatura de mensagens Bitcoin e BIP-322 trata das variantes de cabeçalho em que as carteiras divergem e do formato mais novo.

Como acrescentar o ID de recuperação a assinaturas de HSM e KMS

Módulos de segurança de hardware (HSMs) e KMS de nuvem assinam um digest e retornam um (r, s) com codificação DER: sem ID de recuperação e sem nenhuma garantia de low-S. Para usar uma chave dessas no Ethereum, derive o endereço dela uma vez a partir da chave pública e, depois, para cada assinatura, normalize s e encontre a paridade que recupera sua própria chave. O laboratório simula o HSM com uma chave do pyca/cryptography que só assina digests já calculados:

der_to_eth.py (cryptography 49.0.0)
"""Turn a DER ECDSA signature without a recovery id (what HSMs and cloud KMS return) into an Ethereum signature."""
import sys

from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import Prehashed, decode_dss_signature

from recover import eth_address, eth_personal_digest, n, recover

# Stand-in for the HSM: a secp256k1 key that signs a 32-byte digest and returns DER, nothing else.
hsm_key = ec.generate_private_key(ec.SECP256K1())
public = hsm_key.public_key().public_numbers()
expected = eth_address((public.x, public.y))     # what you would derive once from GetPublicKey


def hsm_sign_digest(digest: bytes) -> bytes:
    return hsm_key.sign(digest, ec.ECDSA(Prehashed(hashes.SHA256())))   # any 32-byte digest


message = sys.argv[1].encode()
e = eth_personal_digest(message)
der = hsm_sign_digest(e.to_bytes(32, "big"))
r, s = decode_dss_signature(der)
print(f"DER signature: {len(der)} bytes, s is {'high' if s > n // 2 else 'low'}")
if s > n // 2:
    s = n - s                                     # EIP-2: transactions need low-S; normalize everything
for recid in (0, 1):                              # find the parity that recovers our own key
    Q = recover(e, r, s, recid)
    if Q and eth_address(Q) == expected:
        break
else:
    raise SystemExit("neither recovery id matches the key")
sig = r.to_bytes(32, "big") + s.to_bytes(32, "big") + bytes([27 + recid])
print(f"address {expected}\nrecid {recid}\nsignature 0x{sig.hex()}")
python3 der_to_eth.py 'invoice 1042 approved' e depois cast wallet verify (primeira de três execuções)
DER signature: 71 bytes, s is high
address 0x3bf48296556c8b52217d22066c7239c50f2102e6
recid 1
signature 0x052fbd197e707e99a4c38648b4a65fb674b15c93025878c55b3950f876bb5cf634aa76d1ffe274696a3d94ba225ae72ab73af4292a0681dc7262dc8ef9495d071c
Validation succeeded. Address 0x3bf48296556c8B52217D22066C7239C50F2102E6 signed this message.

O wrapper Prehashed(hashes.SHA256()) só informa à biblioteca que a entrada já é um digest de 32 bytes; o digest em si é Keccak-256, como o Ethereum exige. Testar as duas paridades é seguro aqui porque o resultado é comparado com um único endereço conhecido. Repare no tamanho DER de 71 bytes nesta execução, contra 72 em outra: o DER remove bytes zero à esquerda e acrescenta um quando o bit mais alto está ligado, e é por isso que buffers de tamanho fixo quebram (formatos de assinatura ECDSA). O mesmo procedimento vale para chaves ECC_SECG_P256K1 do AWS KMS; o Google Cloud KMS já retorna low-S para a secp256k1, mas continua sem fornecer ID de recuperação.

Quando a recuperação é a ferramenta certa

A recuperação economiza de 33 a 65 bytes por assinatura, e é por isso que o Ethereum a usa em todas as transações. Ela também muda o que uma assinatura prova. Com uma chave transmitida, o verificador confere que “esta chave assinou esta mensagem”. Com a recuperação, ele calcula que “alguma chave assinou esta mensagem” e depois precisa decidir se essa chave é aceitável, normalmente derivando um endereço e comparando-o com uma lista de permissões, um proprietário armazenado ou o from que o usuário alega. Um código que pula a comparação não verifica nada.

Fora das blockchains, raramente há motivo para usá-la. Protocolos que podem transportar um ID da chave ou um certificado (o kid do JWT, TLS, X.509) deveriam fazer isso; assim, o verificador busca exatamente uma chave em vez de confiar no que quer que a matemática produza.

Erros comuns

  • Recuperar sem comparar. Registrar “assinatura válida” no log depois que o ecrecover dá certo. Toda assinatura bem formada recupera alguma coisa.
  • Aceitar address(0) como signatário, diretamente ou por meio de uma variável não inicializada.
  • Proteção contra repetição pelos bytes da assinatura. A gêmea high-S é uma segunda assinatura, igualmente válida, para a mesma mensagem.
  • Adivinhar o v. Testar as duas paridades e aceitar a que recuperar “uma” chave transforma a verificação em uma busca por qualquer chave; só teste as duas quando você for comparar o resultado com um único endereço esperado.
  • Calcular o hash da coisa errada. O personal_sign acrescenta o prefixo \x19Ethereum Signed Message:\n e o tamanho em decimal; a EIP-712 usa \x19\x01 e dois hashes de struct. Recuperar com o digest errado dá um endereço errado, sem nenhum erro. As diferenças estão em EIP-191 vs EIP-712.
  • Formatos incompatíveis entre assinaturas de 65 bytes, de 64 bytes (EIP-2098) e DER. Converta explicitamente na fronteira e, na saída de HSM e KMS, acrescente o ID de recuperação como mostrado acima.

Fontes