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
vdo 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:
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):
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:
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:
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 (
vtrocado entre 27 e 28,ssubstituído porn − 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 (
vfora de 27/28,s = 0) produz uma saída vazia. Oecrecoverdo Solidity a transforma emaddress(0); a documentação diz que ele retorna “zero em caso de erro”. Se o signatário esperado puder seraddress(0)em algum momento (um proprietário não definido, uma entrada ausente em um mapping), toda assinatura inválida passa. - Um
smodificado 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:
| Contexto | Codificação | Exemplo |
|---|---|---|
personal_sign, EIP-712, a maioria das carteiras | 65 bytes r ‖ s ‖ v, v = 27 + yParity | v = 27 ou 28 |
| Transação legada, EIP-155 | v = chainId·2 + 35 + yParity | cadeia 1: 37 ou 38 |
| Transações tipadas (EIP-2930, EIP-1559, EIP-4844, EIP-7702) | campo yParity separado, 0 ou 1 | 0 ou 1 |
| Assinatura compacta EIP-2098 | 64 bytes r ‖ yParityAndS, paridade no bit mais alto de s | bit 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 à parte | 0 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:
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)`);
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:
SIG=$($CLI signmessagewithprivkey "$WIF" "$MSG")
$CLI verifymessage "$ADDR" "$SIG" "$MSG"
python3 btc_recover.py "$MSG" "$SIG" # recover by hand, then build three variants
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:
"""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()}")
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
ecrecoverdá 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_signacrescenta o prefixo\x19Ethereum Signed Message:\ne o tamanho em decimal; a EIP-712 usa\x19\x01e 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
- SEC 1 v2.0: criptografia de curvas elípticas, §4.1.6 (operação de recuperação da chave pública)
- Ethereum: EIP-2, EIP-155, EIP-191, EIP-2098, Solidity: funções matemáticas e criptográficas
- ECDSA.sol do OpenZeppelin Contracts
- Bitcoin: BIP-137 (formato do cabeçalho da assinatura)
- Bibliotecas usadas: Foundry 1.8.5 (
cast,anvil), Bitcoin Core 31.1, viem 2.57.7, @noble/curves 2.4.0, pycryptodome 3.23.0