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

Formatos de firma ECDSA: DER frente a raw (P1363), high-S y trampas entre bibliotecas

La clave es correcta, el mensaje es correcto, el algoritmo es correcto… y la verificación sigue fallando. La mayoría de las veces, las dos partes simplemente no coinciden en cómo se ordenan los bytes de la firma. Esta es la guía, a nivel de bytes, de las dos codificaciones de una firma ECDSA y del matiz de high-S que rompe la interoperabilidad incluso cuando la codificación coincide.

Una firma son dos enteros

Sean cuales sean los bytes que veas en la red, una firma ECDSA es, matemáticamente, solo un par de enteros positivos (r, s), cada uno entre 1 y n − 1, donde n es el orden de la curva (un número cercano a 2²⁵⁶ en P-256 y secp256k1). Todo lo demás es serialización, y el ecosistema se quedó con dos formas incompatibles de hacerla. Ninguna contiene información que le falte a la otra; la conversión entre ellas no pierde nada y es puramente mecánica.

Formato 1: ASN.1 DER, el linaje de X.509

El formato que usan OpenSSL, los certificados X.509 y TLS procede del mundo ASN.1: el par se codifica como una SEQUENCE DER de dos INTEGER. Un ejemplo anotado con P-256:

Firma ECDSA codificada en DER, anotada
30 45                    SEQUENCE, 0x45 = 69 bytes follow
   02 21                 INTEGER, 0x21 = 33 bytes  (this is r)
      00 c8 7f 3b ...    33 bytes: leading 00 because r's top bit is set
   02 20                 INTEGER, 0x20 = 32 bytes  (this is s)
      6a 12 09 ...       32 bytes: no padding needed, top bit is clear

Las reglas de DER para los enteros son lo que complica un poco el formato:

  • Los enteros van en big-endian, en complemento a dos y con longitud mínima: hay que quitar los bytes cero iniciales.
  • Pero los valores son positivos, así que, si el bit más significativo del primer byte está a 1 (primer byte ≥ 0x80), hay que anteponer un único 0x00 para que el número no se lea como negativo. r y s, cada uno por su cuenta, pueden llevar o no este byte de relleno.
  • El resultado es una codificación de longitud variable: en P-256, normalmente de 70, 71 o 72 bytes (y alguna vez más corta, cuando r o s empieza por bytes cero). El código que da por hecha una longitud de firma fija es un fallo a la espera del valor aleatorio equivocado.
  • DER es la codificación distinguida: exactamente una cadena de bytes válida por cada valor. Los analizadores estrictos rechazan los enteros no mínimos, el relleno fuera de la especificación o la basura al final; Bitcoin convirtió el DER estricto en regla de consenso (BIP-66) precisamente para zanjar los desacuerdos entre analizadores.

Formato 2: r‖s en bruto (IEEE P1363), el de longitud fija

La otra convención prescinde por completo de ASN.1: rellena r y s hasta exactamente el tamaño de coordenada de la curva, en big-endian, y los concatena. A menudo se le llama formato IEEE P1363 (por el estándar que lo usaba), «raw» (en bruto) o r‖s «plano»:

CurvaTamaño del elemento del campoTamaño de la firma en bruto
P-256 / secp256k132 bytes64 bytes
P-38448 bytes96 bytes
P-52166 bytes132 bytes

Longitud fija, sin etiquetas, sin reglas de relleno: parte el bloque de bytes por la mitad y ya tienes los enteros. Es el formato que emite WebCrypto, el que el RFC 7518 §3.4 exige para JWS (y, por tanto, para los JWT; las consecuencias prácticas se tratan en ES256 frente a RS256 para JWT) y la forma con la que trabajan internamente la mayoría de las pilas de cadenas de bloques: las firmas de Ethereum son el r‖s‖v de 65 bytes (el par en bruto más un byte de recuperación), mientras que Bitcoin, como se indicó antes, codifica las firmas de transacciones en DER estricto.

El modo de fallo es simétrico y muy común: una firma en bruto de 64 bytes entregada a una API que espera DER muere en el analizador ASN.1 (que espera 0x30), mientras que un bloque DER de ~71 bytes entregado a WebCrypto o a un verificador de JWS simplemente tiene la longitud equivocada y falla limpiamente con un genérico «invalid signature». Cuando dos pilas no coinciden, convierte de forma explícita: el conversor DER ⇄ en bruto lo hace en ambos sentidos en el navegador y muestra los r y s decodificados.

High-S, low-S y por qué se rechaza una firma válida

Incluso con codificaciones que coinciden, hay otro matiz que rompe la verificación entre pilas distintas. ECDSA tiene una simetría inherente: si (r, s) es una firma válida, entonces (r, n − s) también es una firma válida del mismo mensaje con la misma clave. Cualquiera que tenga una firma puede pasarla a la otra forma sin la clave privada: los bytes de la firma son maleables, aunque el contenido firmado no lo sea.

En los protocolos que identifican los datos calculando el hash de los bytes de las firmas (los ID de transacción de Bitcoin son el ejemplo canónico), esta maleabilidad por parte de terceros es un problema real. La solución es la forma canónica low-S: exigir s ≤ n/2 y, si no se cumple, normalizar sustituyendo s por n − s. Bitcoin adoptó low-S como regla de retransmisión (propuesta en BIP-62 y especificada en BIP-146), y la verificación de libsecp256k1 solo acepta firmas low-S, salvo que quien la llama normalice antes. En otros ecosistemas, algunas bibliotecas centradas en la seguridad también producen low-S por defecto y se pueden configurar para rechazar high-S al verificar.

Quién habla qué: los valores por defecto de las bibliotecas

Biblioteca / APIFormato por defectoEl otro formato
OpenSSL (CLI y API EVP)DERNo tiene un modo en bruto integrado para ECDSA; convierte externamente o monta la firma a partir de los r y s de ECDSA_SIG
Node.js node:cryptoDEROpción dsaEncoding: "ieee-p1363" en sign/verify (disponible desde la línea LTS de Node 12)
WebCrypto crypto.subtleSolo en bruto (P1363)No admite DER: convierte antes o después
Go crypto/ecdsaNinguno: Sign/Verify reciben r, s como *big.IntSignASN1 / VerifyASN1 para DER (Go 1.15+); el formato en bruto se construye con FillBytes
Python cryptography (pyca)DERLas funciones auxiliares decode_dss_signature / encode_dss_signature dan acceso a r y s
Paquete ecdsa de PythonEn bruto por defectoArgumentos sigencode_der / sigdecode_der
Java (JCA Signature)DER (SHA256withECDSA)SHA256withECDSAinP1363Format en los JDK modernos (9+)

Algunos de ellos, en código. Node.js puede emitir cualquiera de las dos codificaciones con la misma clave:

node: una clave, los dos formatos
import { generateKeyPairSync, sign } from "node:crypto";

const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
const msg = Buffer.from("same signature, two encodings");

const der = sign("sha256", msg, privateKey);
const raw = sign("sha256", msg, { key: privateKey, dsaEncoding: "ieee-p1363" });

console.log(der.length); // 70..72 (varies per signature)
console.log(raw.length); // always 64

Go hace explícitos los enteros subyacentes, lo que también vuelve evidente la conversión; fíjate en FillBytes para las mitades en bruto de ancho fijo, rellenas con ceros:

go: salida DER y salida en bruto
der, err := ecdsa.SignASN1(rand.Reader, priv, digest) // ASN.1 DER
if err != nil { return err }

r, s, err := ecdsa.Sign(rand.Reader, priv, digest)    // the integers themselves
if err != nil { return err }
raw := make([]byte, 64)
r.FillBytes(raw[:32])  // zero-pads on the left — critical for short values
s.FillBytes(raw[32:])

En Python, pyca cryptography firma en DER y ofrece funciones auxiliares para llegar a los enteros:

python: de DER a r‖s en bruto con pyca/cryptography
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature

der_sig = private_key.sign(message, ec.ECDSA(hashes.SHA256()))  # DER bytes

r, s = decode_dss_signature(der_sig)
raw_sig = r.to_bytes(32, "big") + s.to_bytes(32, "big")          # 64-byte P1363

Lista de comprobación para depurar

Cuando una firma ECDSA se niega a verificarse entre dos sistemas, recorre esta lista en orden:

  • Prueba de longitud. Exactamente 64/96/132 bytes → en bruto. Empieza por 0x30 y tiene un byte de longitud plausible → DER. Cualquier otra cosa: una decodificación incorrecta de una capa externa (base64, hex) o un copiar y pegar truncado.
  • Coincidencia de formato. ¿La API que verifica espera lo que produjo el firmante? Convierte con el conversor DER ⇄ en bruto en lugar de volver a firmar.
  • Relleno con ceros. Si las firmas en bruto fallan de forma intermitente (aproximadamente 1 vez de cada 128: cada uno de r y s empieza por un byte cero más o menos 1 vez de cada 256), sospecha de una conversión que eliminó los bytes cero iniciales en lugar de rellenar r y s por la izquierda hasta el ancho fijo.
  • High-S. Si los fallos son intermitentes, en torno al 50 %, y uno de los lados es una cadena de bloques o una pila con seguridad reforzada, compara s con n/2 y normaliza.
  • Todo lo demás (hash equivocado, curva equivocada, calcular el hash del hash) pertenece a la capa del algoritmo, no a la del formato; para ese contexto, empieza por ECDSA frente a RSA.

La conclusión alentadora: DER frente a raw es pura fontanería. La conversión no implica criptografía alguna, nada de la clave cambia y, una vez que ambos lados se ponen de acuerdo en la disposición de los bytes (y en low-S, donde importa), la firma que daba «invalid signature» y te costó una tarde suele resultar válida desde el principio.