← All posts

Uma migração para ES256, três maneiras de estragar uma assinatura válida

Authagonal·July 20, 2026
jwtecdsaes256cryptovaultjwsdotnet

A migração de algoritmo em si correu bem. As três vidas da minha chave de assinatura de JWT conta essa história: RS256 para ES256 em um emissor em produção, as duas chaves publicadas no JWKS durante o período de sobreposição, um seletor de chave ativa que sai sozinho da chave RSA legada. Chaves, rotação, descoberta (todas as partes que parecem difíceis) funcionaram na primeira tentativa.

Esta é a história da parte que não correu bem. Em três ocasiões distintas durante essa migração, nosso servidor de autenticação emitiu um token cuja assinatura era criptograficamente válida (a matemática batia, a chave certa tinha assinado os bytes certos) e nenhum validador do planeta a aceitaria. Três camadas diferentes tinham estragado os bytes, cada uma à sua maneira, e cada falha parecia idêntica vista de fora: invalid signature. Não "formato errado", não "comprimento inesperado". Apenas: inválida.

Se você está encarando esse erro no meio de uma migração, pule para o guia de campo no final. O comprimento da assinatura decodificada aponta a camada culpada em quase todos os casos.

O RSA nos mimou: um inteiro, uma codificação

Eis o que ninguém conta antes de uma migração para ECDSA: o RSA nunca teve esse problema, então nenhum dos seus instintos se transfere.

Uma assinatura RSA é um único inteiro. O formato de transmissão é apenas esse inteiro, em big-endian, preenchido até o tamanho da chave: 256 bytes para RSA-2048, e existe essencialmente uma única forma de escrevê-lo. Assine com qualquer biblioteca, verifique com qualquer outra, e os bytes significam a mesma coisa.

Uma assinatura ECDSA é um par de inteiros, (r, s). Um par precisa de uma codificação, e o ecossistema convergiu para duas incompatíveis entre si:

  • ASN.1 DER: uma SEQUENCE de dois INTEGERs. Comprimento variável (tipicamente 70 a 72 bytes para P-256), sempre começando com o byte 0x30. É o que OpenSSL, X.509 e TLS falam.
  • R‖S bruto (IEEE P1363): r e s, cada um preenchido à esquerda até exatamente 32 bytes, concatenados. Sempre exatamente 64 bytes. É o que o JWS exige para ES256 (RFC 7518 §3.4).

Os dois codificam a mesma assinatura. Os dois estão "corretos". Entregue a um validador o formato que ele não espera e ele não diz "contêiner errado": ele decodifica inteiros sem sentido e reporta a única coisa que pode reportar, que a assinatura não verifica.

Cada camada entre a sua chave e a transmissão tem uma opinião sobre qual dos dois formatos ela fala. A nossa pilha tinha três camadas, e erramos as três, em sequência.

Armadilha um: o KMS responde em DER

Nossa chave privada vive no motor transit do HashiCorp Vault e nunca sai de lá: a aplicação envia bytes para serem assinados e recebe uma assinatura de volta. Quando trocamos a chave do transit de rsa-2048 para ecdsa-p256, a requisição de assinatura ainda carregava uma sobra da era RSA: signature_algorithm=pkcs1v15.

Isso é um parâmetro de padding do RSA. Não significa nada para uma chave ECDSA. O Vault não retornou erro. Ele ignorou o parâmetro irrelevante, assinou com a chave da curva e devolveu 200 com uma assinatura ECDSA perfeitamente válida, no seu marshaling padrão, que é DER.

Então o caminho feliz permaneceu verde de ponta a ponta: requisição aceita, assinatura devolvida, openssl dgst -verify a teria abençoado. E toda biblioteca de JWT rejeitou todos os tokens, porque, depois de decodificar o terceiro segmento em base64url, ela esperava 64 bytes de R‖S e encontrava 71 bytes começando com 0x30.

A correção é um único parâmetro de requisição: o endpoint de assinatura do transit do Vault aceita marshaling_algorithm=jws, que retorna R‖S bruto diretamente. Mas repare no formato da armadilha: um parâmetro que é errado para o tipo de chave e silenciosamente ignorado. A requisição parecia errada, estava errada e funcionou mesmo assim, para a definição errada de funcionar.

Armadilha dois: "é só concatenar os inteiros" falha em um token a cada 128

Se o seu KMS não consegue emitir R‖S nativamente, você vai se sentir tentado a converter o DER por conta própria: extrair os dois INTEGERs, concatenar, pronto. Esta é a mais traiçoeira das três armadilhas, porque ela está correta de forma intermitente.

Inteiros em DER têm comprimento mínimo e carregam sinal. Isso significa que:

  • um r que por acaso seja numericamente pequeno (byte mais alto zero) é codificado em 31 bytes ou menos, porque o DER remove zeros à esquerda;
  • um r cujo bit mais alto está ligado ganha um byte de sinal 0x00 e é codificado em 33 bytes.

O formato JWS não quer nenhuma das duas coisas. Ele quer cada valor preenchido à esquerda até exatamente 32 bytes, sempre. Uma concatenação ingênua dos inteiros do DER produz assinaturas de 63, 64, 65 ou 66 bytes dependendo do sorteio, e só as de 64 bytes verificam. Trate o byte de sinal mas esqueça o preenchimento e você fica com um conversor que falha sempre que r ou s começa com um byte zero: cada um é um evento de 1 em 256, então cerca de um token a cada 128 sai curto.

Essa versão passa no seu teste de fumaça, passa na revisão de código, vai para produção e então falha um login por hora, sem nenhum padrão visível. Nós escapamos dela fazendo o Vault executar a conversão (marshaling_algorithm=jws de novo: deixe o formato a cargo de quem é dono da operação de assinatura), mas a mesma armadilha tem uma variante puramente local em .NET: ECDsa.SignData/VerifyData aceitam um DSASignatureFormat, e cantos diferentes do framework usam padrões diferentes; o mundo de SignedXml e certificados pensa em Rfc3279DerSequence, enquanto as APIs brutas de ECDsa usam P1363 por padrão. Nosso caminho local de verificação agora diz DSASignatureFormat.IeeeP1363FixedFieldConcatenation explicitamente. Depois desta migração, não deixamos nenhuma API de assinatura escolher o formato por padrão, em lugar nenhum.

Armadilha três: a correção mudou o dialeto de base64 debaixo de nós

O Vault embrulha cada assinatura em um envelope: vault:v1:<encoded-bytes>. Com o marshaling padrão em DER, a parte codificada é base64 padrão, e nosso cliente obedientemente chamava Convert.FromBase64String sobre ela.

Mude para marshaling_algorithm=jws e o Vault (de forma razoável, seguindo a especificação que agora fala) codifica a porção da assinatura em base64url: o alfabeto -/_, sem preenchimento. Uma flag de requisição tinha mudado duas coisas ao mesmo tempo: o formato de bytes que pedimos e a codificação de texto em que ele chegava.

Convert.FromBase64String quer base64 padrão com preenchimento. Uma assinatura de 64 bytes são 86 caracteres em base64url (que não é múltiplo de quatro), então a decodificação lançava uma FormatException em cada token, sem exceção. De certa forma, esta foi a mais gentil das três falhas: pelo menos ela quebrou, ruidosamente, na exata linha que estava errada, em vez de produzir bytes de aparência válida que falhavam silenciosamente em outro lugar. A correção é decodificar (e, na ida e volta da verificação, recodificar) com um codec de base64url.

Repare na ironia: a assinatura finalmente estava no formato de bytes correto, e agora era a camada de texto que a estragava. Três codificações, empilhadas: a codificação do par de inteiros, o preenchimento de campo fixo e a codificação de caracteres. Cada uma delas, sozinha, capaz de transformar uma assinatura válida em um token rejeitado.

O guia de campo: o comprimento aponta o culpado

Quando um JWT falha na validação no meio de uma migração de algoritmo, decodifique em base64url o terceiro segmento do token e olhe o que você tem:

Assinatura decodificada Qual camada a estragou
Exatamente 64 bytes O formato de transmissão está certo, procure em outro lugar (kid divergente, chave errada no JWKS, fixação de alg)
70 a 72 bytes, primeiro byte 0x30 O DER vazou: o KMS ou a biblioteca de criptografia está emitindo ASN.1
63 ou 65 bytes, e só alguns tokens falham Uma conversão DER→bruto feita à mão, sem preenchimento de campo fixo
A decodificação lança exceção ou produz lixo Incompatibilidade entre base64 e base64url uma camada abaixo

E uma verificação cruzada que separa bugs de formato de bugs de chave em segundos: verifique a mesma assinatura com o OpenSSL, que fala DER. Se o OpenSSL diz válida enquanto toda biblioteca de JWT diz inválida, você tem um problema de codificação. Se ambos dizem inválida, você tem um problema de chave.

A lição

"Criptograficamente válida" é uma propriedade da matemática. "Verificável" é uma propriedade de cada fronteira de codificação entre a matemática e o consumidor, e nossa assinatura cruzou quatro delas: o marshaling do KMS, seu envelope de transporte, a decodificação do nosso cliente e o formato de transmissão do JWS. Três dessas fronteiras tinham opiniões, e cada uma discordou da vizinha exatamente uma vez.

A moral sobre testes é aquela que de fato nos fez mudar de processo: nunca prove um caminho de assinatura verificando com o seu próprio código. Nosso caminho de assinatura e nosso caminho de verificação compartilhavam todas essas suposições, então as idas e voltas de assinar e verificar passavam enquanto todo consumidor real nos rejeitava. O teste que finalmente cravou os três bugs emite um token real, valida esse token com a biblioteca de JWT padrão da plataforma contra o JWKS publicado (as suposições de outra pessoa, não as nossas) e então afirma uma coisa brutalmente específica por cima: a assinatura decodificada tem exatamente 64 bytes. A matemática cuida de si mesma. São os bytes que você precisa vigiar.