Saltar al contenido
ecdsa.com
← Todos los artículos

Recuperación de clave pública ECDSA: recid, v y ecrecover

Una firma ECDSA más dos bits bastan para calcular la clave pública del firmante, y por eso las transacciones de Ethereum no llevan ninguna clave. Recuperamos claves a mano en Python y contrastamos el resultado con cast, el precompilado ecrecover, Bitcoin Core 31.1, viem y noble.

Respuesta breve. A partir de una firma ECDSA (r, s) y del hash del mensaje e, la clave pública del firmante es Q = r⁻¹(s·R − e·G), donde R es el punto de la curva cuya coordenada x es r. Hay hasta cuatro puntos así, de modo que el firmante adjunta un ID de recuperación: un bit para la paridad de la coordenada y de R y, en teoría, otro que indica si R.x se redujo módulo n. Ethereum lo codifica como v (27/28 en mensajes, chainId·2 + 35 + yParity en transacciones heredadas, yParity en transacciones tipadas) y Bitcoin, como el byte de encabezado de una firma compacta de 65 bytes (de 27 a 42). Con una entrada bien formada, la recuperación siempre devuelve alguna clave: solo es una verificación cuando comparas el resultado con la dirección o la clave que esperabas. El precompilado ecrecover de Ethereum no devuelve nada ante una entrada mal formada, cosa que Solidity convierte en address(0), y acepta tanto s como n − s.

Las matemáticas

Para firmar, ECDSA elige un nonce k, calcula R = kG, fija r = R.x mod n y s = k⁻¹(e + r·d) mod n. Al reordenar la segunda ecuación con Q = dG se obtiene

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

Todo lo que queda a la derecha se conoce salvo el propio R, y la firma solo contiene su coordenada x, reducida módulo n. Para reconstruir R, un verificador necesita:

  • La paridad de R.y. Para una x dada hay dos puntos, (x, y) y (x, p − y); uno de ellos tiene la y par.
  • Si R.x ≥ n. En secp256k1, el primo p del campo es un poco mayor que el orden del grupo n, así que una coordenada x en [n, p) se habría reducido. La probabilidad es (p − n)/p, unos 2⁻¹²⁷; en la práctica no ocurre nunca, y el v de Ethereum ni siquiera puede expresarlo.

Eso es el ID de recuperación: recid = 2·[R.x ≥ n] + (R.y mod 2), especificado en SEC 1 v2.0 §4.1.6 (SEC 1). Escrito en Python, sin nada oculto:

recover.py (fragmento): recuperación, Python 3.14.8 (aritmética de la curva como en 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")

Aquí nada es específico de secp256k1 salvo las constantes y el atajo de la raíz cuadrada: la recuperación también funciona en P-256. Es una convención de los ecosistemas de Bitcoin y Ethereum, no una propiedad de la curva; JOSE, TLS y WebAuthn, en cambio, transmiten o certifican las claves.

Ethereum: una firma de cast, recuperada a mano

El laboratorio generó una clave desechable con Foundry 1.8.5 y firmó un mensaje con la semántica de personal_sign (EIP-191):

eth.sh (fragmento): firmar, verificar con cast, recuperar en 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"

eth_recover.py divide los 65 bytes en r, s y v, prueba los cuatro ID de recuperación y después recupera la misma firma con un mensaje ligeramente distinto:

Salida (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

De aquí salen dos conclusiones. Un ID de recuperación equivocado da una clave válida distinta, no un error, así que un verificador que adivina v «recuperará» tan tranquilo la clave de otra persona. Y la misma firma sobre otro mensaje (ecdsa.org en lugar de ecdsa.com) recupera otra dirección más, igual de bien formada. La recuperación no tiene ningún modo de fallo ante datos erróneos; solo la comparación con un firmante esperado la convierte en una comprobación.

Qué devuelve ecrecover

Los contratos llaman al precompilado de la dirección 0x01 con 128 bytes: el hash y, a continuación, v, r y s como palabras de 32 bytes. El laboratorio le envió cinco entradas en un nodo local de anvil con 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
  • La firma gemela (v cambiado entre 27 y 28, s sustituido por n − s) recupera el mismo firmante. EIP-2 invalidó las firmas high-S en las transacciones, pero dejó el precompilado como estaba: «seguirá aceptando valores altos de s». Cualquier contrato que use los bytes de la firma como identificador, por ejemplo para marcar como gastado un vale firmado, admite una repetición con la gemela. Marca como usado el mensaje (un nonce o el resumen), nunca la firma.
  • Una entrada mal formada (v distinto de 27/28, s = 0) produce una salida vacía. El ecrecover de Solidity la convierte en address(0); su documentación dice que devuelve «cero en caso de error». Si el firmante esperado puede llegar a ser address(0) (un propietario sin asignar, una entrada que falta en un mapping), cualquier firma no válida pasa.
  • Un s modificado recupera una dirección de aspecto aleatorio, como en la prueba de Python.

La solución habitual para los tres casos es ECDSA.recover de OpenZeppelin (Contracts 5.6.1), que rechaza un s por encima de n/2 con ECDSAInvalidSignatureS, un resultado cero con ECDSAInvalidSignature y, en su sobrecarga para bytes, cualquier longitud distinta de 65 bytes. Las billeteras de contrato inteligente no tienen ninguna clave que recuperar: se verifican con ERC-1271, como se explica en Sign-In with Ethereum.

Las muchas formas de v

Dónde va el bit de recuperación depende de qué se firmó:

ContextoCodificaciónEjemplo
personal_sign, EIP-712, la mayoría de las billeteras65 bytes r ‖ s ‖ v, v = 27 + yParityv = 27 o 28
Transacción heredada, EIP-155v = chainId·2 + 35 + yParitycadena 1: 37 o 38
Transacciones tipadas (EIP-2930, EIP-1559, EIP-4844, EIP-7702)campo yParity aparte, 0 o 10 o 1
Firma compacta EIP-209864 bytes r ‖ yParityAndS, la paridad en el bit más alto de sbit más alto a 1: yParity 1
Firma de mensajes de Bitcoin (BIP-137)65 bytes header ‖ r ‖ s, header = 27 + recid + 0 (P2PKH, clave sin comprimir), 4 (P2PKH, clave comprimida), 8 (P2SH-P2WPKH) o 12 (P2WPKH)31 o 32 para una clave pública comprimida (P2PKH)
Bibliotecas de bajo nivel (libsecp256k1, noble)recid de 0 a 3, pasado por separado0 o 1

El laboratorio firmó con viem 2.57.7 y comprobó cómo tratan las variantes sus funciones de recuperación:

rec.mjs (fragmento): 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)`);
Salida (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

recoverMessageAddress de viem no acepta una cadena EIP-2098 de 64 bytes: conviértela antes con compactSignatureToSignature o recibirás invalid signature length. Un v fuera de lugar produce Invalid yParityOrV value. El v = 38 de la transacción heredada es 1·2 + 35 + 1. Para la transacción EIP-1559, viem indica v = 28 por comodidad, pero la transacción firmada en sí solo lleva yParity. web3.py, en Python, es igual de estricto con las longitudes: Expected 65, but got 64 bytes.

Un error real que surgió al preparar este laboratorio: invertir la paridad con v ^ 1 convierte 27 en 26 y 28 en 29, porque 27 es impar. Trabaja con yParity = v − 27 (o (v − 35) mod 2 en EIP-155) y vuelve a convertir solo en el borde.

Bitcoin: firmas compactas de mensajes

signmessage de Bitcoin Core produce el mismo tipo de firma, con un byte de encabezado delante. Con una clave desechable en regtest:

btc.sh (fragmento): 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
Salida (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

El resumen es un doble SHA-256 de "\x18Bitcoin Signed Message:\n", la longitud del mensaje y el mensaje. El encabezado 31 significa recid 0 y una clave pública comprimida; cambiarlo a 27 recupera el mismo punto, pero entonces se calcula el hash de la codificación sin comprimir y sale otra dirección, así que la verificación falla. Igual que ecrecover, la verificación de mensajes de Bitcoin Core aceptó la gemela high-S: low-S es una regla de retransmisión de transacciones, no una regla de mensajes. Para las direcciones SegWit y Taproot este formato se queda corto; Firma de mensajes en Bitcoin y BIP-322 trata el formato más reciente y las variantes de encabezado en las que no coinciden las billeteras.

Cómo añadir un ID de recuperación a las firmas de HSM y KMS

Los módulos de seguridad de hardware y los KMS en la nube firman un resumen y devuelven un (r, s) codificado en DER: sin ID de recuperación y sin ninguna garantía de low-S. Para usar una de esas claves en Ethereum, derivas su dirección una sola vez a partir de la clave pública y, después, en cada firma normalizas s y buscas la paridad que recupera tu propia clave. El laboratorio simula el HSM con una clave de pyca/cryptography que solo firma resúmenes ya 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' y después cast wallet verify (la primera de tres ejecuciones)
DER signature: 71 bytes, s is high
address 0x3bf48296556c8b52217d22066c7239c50f2102e6
recid 1
signature 0x052fbd197e707e99a4c38648b4a65fb674b15c93025878c55b3950f876bb5cf634aa76d1ffe274696a3d94ba225ae72ab73af4292a0681dc7262dc8ef9495d071c
Validation succeeded. Address 0x3bf48296556c8B52217D22066C7239C50F2102E6 signed this message.

El envoltorio Prehashed(hashes.SHA256()) solo le indica a la biblioteca que la entrada ya es un resumen de 32 bytes; el resumen en sí es Keccak-256, como exige Ethereum. Aquí es seguro probar las dos paridades porque el resultado se compara con una única dirección conocida. Fíjate en la longitud DER: 71 bytes en esta ejecución frente a 72 en otra. DER elimina los bytes cero iniciales y añade uno cuando el bit más alto está a 1, y por eso fallan los búferes de tamaño fijo (Formatos de firma ECDSA). El mismo procedimiento sirve para las claves ECC_SECG_P256K1 de AWS KMS; Google Cloud KMS ya devuelve low-S para secp256k1, pero tampoco da el ID de recuperación.

Cuándo la recuperación es la herramienta adecuada

La recuperación ahorra de 33 a 65 bytes por firma, y por eso Ethereum la usa en todas las transacciones. También cambia lo que demuestra una firma. Con una clave transmitida, el verificador comprueba «esta clave firmó este mensaje». Con la recuperación calcula «alguna clave firmó este mensaje» y después tiene que decidir si esa clave es aceptable, normalmente derivando una dirección y comparándola con una lista de permitidos, con un propietario almacenado o con el from que declara el usuario. El código que se salta la comparación no verifica nada.

Fuera de las cadenas de bloques, rara vez hay motivo para usarla. Los protocolos que pueden transportar un ID de clave o un certificado (JWT kid, TLS, X.509) deberían hacerlo, y así el verificador busca exactamente una clave en lugar de fiarse de lo que produzcan las matemáticas.

Errores frecuentes

  • Recuperar sin comparar. Registrar «firma válida» en el log cuando ecrecover tiene éxito. Toda firma bien formada recupera algo.
  • Aceptar address(0) como firmante, directamente o a través de una variable sin inicializar.
  • Protección contra repetición basada en los bytes de la firma. La gemela high-S es una segunda firma, igual de válida, del mismo mensaje.
  • Adivinar v. Probar las dos paridades y aceptar la que recupere «una» clave convierte la verificación en la búsqueda de cualquier clave; prueba las dos solo cuando después compares con una única dirección esperada.
  • Calcular el hash de lo que no es. personal_sign añade el prefijo \x19Ethereum Signed Message:\n y la longitud en decimal; EIP-712 usa \x19\x01 y dos hashes de estructuras. Recuperar con el resumen equivocado da una dirección equivocada sin ningún error. Las diferencias están en EIP-191 frente a EIP-712.
  • Formatos que no coinciden entre firmas de 65 bytes, firmas EIP-2098 de 64 bytes y firmas DER. Convierte de forma explícita en la frontera y, para la salida de HSM y KMS, añade el ID de recuperación como se muestra más arriba.

Fuentes