Una migración a ES256, tres formas de destrozar una firma válida
La migración del algoritmo en sí salió bien. Las tres vidas de mi clave de firma JWT cuenta esa historia: de RS256 a ES256 en un emisor en producción, ambas claves publicadas en el JWKS durante el solapamiento, un selector de clave activa que se rota solo fuera de la clave RSA heredada. Claves, rotación, descubrimiento: todas las partes que suenan difíciles funcionaron a la primera.
Esta es la historia de la parte que no. En tres ocasiones distintas durante esa migración, nuestro servidor de autenticación emitió un token cuya firma era criptográficamente válida (las matemáticas cuadraban, la clave correcta había firmado los bytes correctos) y ningún validador del mundo la aceptaría. Tres capas distintas habían destrozado los bytes cada una a su manera, y cada fallo se veía idéntico desde fuera: invalid signature. No "formato incorrecto", ni "longitud inesperada". Solo: inválida.
Si estás mirando ese error en plena migración, salta a la guía de campo del final. La longitud de la firma decodificada delata la capa culpable casi siempre.
RSA nos malacostumbró: un entero, una codificación
Esto es lo que nadie te cuenta antes de una migración a ECDSA: RSA nunca tuvo este problema, así que ninguna de tus intuiciones se transfiere.
Una firma RSA es un único entero. El formato de cable es simplemente ese entero, en big-endian, rellenado hasta el tamaño de la clave: 256 bytes para RSA-2048, y en esencia solo hay una manera de escribirlo. Firma con cualquier biblioteca, verifica con cualquier otra, y los bytes significan lo mismo.
Una firma ECDSA es un par de enteros, (r, s). Un par necesita una codificación, y el ecosistema se decantó por dos incompatibles:
- ASN.1 DER: una
SEQUENCEde dosINTEGER. Longitud variable (normalmente 70 a 72 bytes para P-256), siempre empezando por el byte0x30. Esto es lo que hablan OpenSSL, X.509 y TLS. - R‖S en bruto (IEEE P1363):
rysrellenados por la izquierda hasta exactamente 32 bytes cada uno y concatenados. Siempre exactamente 64 bytes. Esto es lo que JWS exige para ES256 (RFC 7518 §3.4).
Ambas codifican la misma firma. Ambas son "correctas". Dale a un validador la que no espera y no dirá "contenedor incorrecto": decodifica enteros basura e informa de lo único que puede informar: la firma no verifica.
Cada capa entre tu clave y el cable tiene una opinión sobre cuál de las dos habla. Nosotros teníamos tres capas, y nos equivocamos en las tres, una tras otra.
Primer disparo al pie: el KMS responde en DER
Nuestra clave privada vive en el motor transit de HashiCorp Vault y nunca sale de él: la aplicación envía bytes para firmar y recibe una firma de vuelta. Cuando cambiamos la clave transit de rsa-2048 a ecdsa-p256, la petición de firma todavía llevaba un resto de la era RSA: signature_algorithm=pkcs1v15.
Ese es un parámetro de relleno de RSA. No significa nada para una clave ECDSA. Vault no dio error. Ignoró el parámetro irrelevante, firmó con la clave de curva y devolvió 200 con una firma ECDSA perfectamente válida, en su marshaling por defecto, que es DER.
Así que el camino feliz siguió en verde de extremo a extremo: petición aceptada, firma devuelta, openssl dgst -verify la habría bendecido. Y todas las bibliotecas de JWT rechazaron todos los tokens, porque tras decodificar en base64url el tercer segmento esperaban 64 bytes de R‖S y encontraban 71 bytes que empezaban por 0x30.
La solución es un parámetro de la petición: el endpoint de firma de transit de Vault acepta marshaling_algorithm=jws, que devuelve R‖S en bruto directamente. Pero fíjate en la forma de la trampa: un parámetro que es incorrecto para el tipo de clave y que se ignora en silencio. La petición parecía incorrecta, era incorrecta, y aun así funcionó, según la definición equivocada de funcionar.
Segundo disparo al pie: "concatena los enteros y ya" falla un token de cada 128
Si tu KMS no puede emitir R‖S de forma nativa, te verás tentado a convertir el DER tú mismo: extraes los dos INTEGER, concatenas, listo. Este es el disparo al pie más traicionero de los tres, porque es correcto de forma intermitente.
Los enteros DER son de longitud mínima y con signo. Eso significa que:
- una
rque resulte ser numéricamente pequeña (byte alto a cero) se codifica en 31 bytes o menos, porque DER elimina los ceros iniciales; - una
rcon el bit alto activado adquiere un byte de signo0x00y se codifica en 33 bytes.
El formato JWS no quiere ninguna de las dos cosas. Quiere cada valor rellenado por la izquierda hasta exactamente 32 bytes, siempre. Una concatenación ingenua de los enteros DER produce firmas de 63, 64, 65 o 66 bytes según toque, y solo las de 64 bytes verifican. Gestiona el byte de signo pero olvida el relleno y te queda un conversor que falla siempre que r o s empieza por un byte cero: cada caso es un evento de 1 entre 256, así que aproximadamente un token de cada 128 sale corto.
Esa versión pasa tu prueba de humo, pasa la revisión de código, se despliega, y luego falla un inicio de sesión cada hora en producción sin ningún patrón visible. Nosotros lo esquivamos haciendo que Vault hiciera la conversión (marshaling_algorithm=jws otra vez: que la parte que posee la operación de firma posea también el formato), pero la misma trampa tiene una variante puramente local en .NET: ECDsa.SignData/VerifyData aceptan un DSASignatureFormat, y distintos rincones del framework usan valores por defecto distintos: el mundo de SignedXml y certificados piensa en Rfc3279DerSequence, mientras que las API ECDsa en bruto usan P1363 por defecto. Nuestra ruta de verificación local ahora indica DSASignatureFormat.IeeeP1363FixedFieldConcatenation de forma explícita. Después de esta migración, no dejamos que ninguna API de firmas use su formato por defecto, en ningún sitio.
Tercer disparo al pie: la solución cambió el dialecto de base64 por debajo
Vault envuelve cada firma en un sobre: vault:v1:<encoded-bytes>. Con el marshaling DER por defecto, la parte codificada es base64 estándar, y nuestro cliente llamaba obedientemente a Convert.FromBase64String sobre ella.
Cambia a marshaling_algorithm=jws y Vault (razonablemente, según la especificación que ahora habla) codifica la porción de la firma en base64url: el alfabeto -/_, sin relleno. Un solo flag de la petición había cambiado dos cosas a la vez: el formato de bytes que pedíamos y la codificación de texto en la que llegaba.
Convert.FromBase64String quiere base64 estándar con relleno. Una firma de 64 bytes son 86 caracteres de base64url, que no es múltiplo de cuatro, así que la decodificación lanzaba una FormatException en absolutamente todos los tokens. En cierto modo este fue el más amable de los tres fallos: al menos reventaba, ruidosamente, en la línea exacta que estaba mal, en vez de producir bytes de aspecto válido que fallaban en silencio en otro sitio. La solución es decodificar (y, en el viaje de vuelta de la verificación, recodificar) con un códec de base64url.
Cuenta la ironía: la firma por fin estaba en el formato de bytes correcto, y ahora era la capa de texto la que la destrozaba. Tres codificaciones, apiladas: la codificación del par de enteros, el relleno de campo fijo y la codificación de caracteres. Cada una capaz por sí sola de convertir una firma válida en un token rechazado.
La guía de campo: la longitud delata al culpable
Cuando un JWT falla la validación en plena migración de algoritmo, decodifica en base64url el tercer segmento del token y mira lo que tienes:
| Firma decodificada | Qué capa la destrozó |
|---|---|
| Exactamente 64 bytes | El formato de cable es correcto: busca en otra parte (kid que no coincide, clave equivocada en el JWKS, fijación de alg) |
70 a 72 bytes, primer byte 0x30 |
Se ha colado DER: el KMS o la biblioteca criptográfica está emitiendo ASN.1 |
| 63 o 65 bytes, y solo fallan algunos tokens | Una conversión DER a bruto hecha a mano sin relleno de campo fijo |
| La decodificación lanza una excepción o produce basura | Desajuste entre base64 y base64url una capa más abajo |
Y una comprobación cruzada que separa los bugs de formato de los bugs de clave en segundos: verifica la misma firma con OpenSSL, que habla DER. Si OpenSSL dice válida mientras todas las bibliotecas de JWT dicen inválida, tienes un problema de codificación. Si ambos dicen inválida, tienes un problema de clave.
La lección
"Criptográficamente válida" es una propiedad de las matemáticas. "Verificable" es una propiedad de cada frontera de codificación entre las matemáticas y el consumidor, y nuestra firma cruzó cuatro: el marshaling del KMS, su sobre de transporte, la decodificación de nuestro cliente y el formato de cable de JWS. Tres de esas fronteras tenían opiniones, y cada una discrepó de su vecina exactamente una vez.
La moraleja sobre pruebas es aquella por la que de verdad cambiamos el proceso: nunca demuestres una ruta de firma verificando con tu propio código. Nuestra ruta de firma y nuestra ruta de verificación compartían todas y cada una de estas suposiciones, así que los ciclos de firmar y verificar pasaban mientras todos los consumidores reales nos rechazaban. La prueba que por fin cazó los tres bugs emite un token real, lo valida con la biblioteca JWT estándar de la plataforma contra el JWKS publicado (las suposiciones de otro, no las nuestras) y luego afirma encima una cosa brutalmente específica: la firma decodificada tiene exactamente 64 bytes. Las matemáticas se cuidan solas. Son los bytes lo que hay que vigilar.