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:
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 único0x00para que el número no se lea como negativo.rys, 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
rosempieza 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»:
| Curva | Tamaño del elemento del campo | Tamaño de la firma en bruto |
|---|---|---|
| P-256 / secp256k1 | 32 bytes | 64 bytes |
| P-384 | 48 bytes | 96 bytes |
| P-521 | 66 bytes | 132 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 / API | Formato por defecto | El otro formato |
|---|---|---|
| OpenSSL (CLI y API EVP) | DER | No 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:crypto | DER | Opción dsaEncoding: "ieee-p1363" en sign/verify (disponible desde la línea LTS de Node 12) |
WebCrypto crypto.subtle | Solo en bruto (P1363) | No admite DER: convierte antes o después |
Go crypto/ecdsa | Ninguno: Sign/Verify reciben r, s como *big.Int | SignASN1 / VerifyASN1 para DER (Go 1.15+); el formato en bruto se construye con FillBytes |
Python cryptography (pyca) | DER | Las funciones auxiliares decode_dss_signature / encode_dss_signature dan acceso a r y s |
Paquete ecdsa de Python | En bruto por defecto | Argumentos 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:
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:
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:
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
0x30y 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
rysempieza 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 rellenarryspor 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
sconn/2y 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.