Pular para o conteúdo
ecdsa.com
← Todos os artigos

Formatos de assinatura ECDSA: DER vs bruto (P1363), high-S e armadilhas entre bibliotecas

A chave está certa, a mensagem está certa, o algoritmo está certo — e a verificação falha mesmo assim. Na maioria das vezes, os dois lados simplesmente discordam sobre como os bytes da assinatura estão organizados. Este é o guia, em nível de byte, das duas codificações de uma assinatura ECDSA e da sutileza do high-S, que quebra a interoperabilidade mesmo quando a codificação é a mesma.

Uma assinatura é feita de dois inteiros

Sejam quais forem os bytes que você vê trafegando pela rede, uma assinatura ECDSA é, matematicamente, só um par de inteiros positivos (r, s), cada um entre 1 e n − 1, em que n é a ordem da curva (um número próximo de 2²⁵⁶ na P-256 e na secp256k1). Todo o resto é serialização — e o ecossistema acabou adotando duas formas incompatíveis de fazê-la. Nenhuma das duas contém informação que falte à outra; converter entre elas não perde nada e é puramente mecânico.

Formato 1: ASN.1 DER — a linhagem do X.509

O formato que o OpenSSL, os certificados X.509 e o TLS usam vem do mundo ASN.1: o par é codificado como uma SEQUENCE DER de dois INTEGERs. Um exemplo de P-256 com anotações:

Assinatura ECDSA codificada em DER, com anotações
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

São as regras do DER para inteiros que tornam o formato um pouco traiçoeiro:

  • Os inteiros são big-endian, em complemento de dois e de tamanho mínimo: bytes zero à esquerda precisam ser removidos.
  • Mas os valores são positivos — então, se o bit mais significativo do primeiro byte estiver ligado (primeiro byte ≥ 0x80), é preciso acrescentar um único 0x00 na frente para que o número não seja lido como negativo. Tanto r quanto s podem ou não levar esse byte de padding, cada um de forma independente.
  • O resultado é uma codificação de tamanho variável: na P-256, normalmente 70, 71 ou 72 bytes (e, às vezes, menos, quando r ou s começa por acaso com bytes zero). Código que supõe um tamanho fixo de assinatura é um bug à espera do valor aleatório errado.
  • O DER é a codificação distinguished (o D da sigla): exatamente uma sequência de bytes válida para cada valor. Parsers rigorosos rejeitam inteiros não mínimos, padding fora da especificação ou lixo no final — o Bitcoin tornou o DER estrito uma regra de consenso (BIP-66) justamente para acabar com as divergências entre parsers.

Formato 2: r‖s bruto — IEEE P1363, o de tamanho fixo

A outra convenção abandona o ASN.1 por completo: complete r e s, cada um, até exatamente o tamanho da coordenada da curva, em big-endian, e concatene os dois. Esse formato costuma ser chamado de IEEE P1363 (por causa do padrão que o usava), “raw” (bruto) ou r‖s “flat” (plano):

CurvaTamanho do elemento do campoTamanho da assinatura bruta
P-256 / secp256k132 bytes64 bytes
P-38448 bytes96 bytes
P-52166 bytes132 bytes

Tamanho fixo, sem tags, sem regras de padding — divida o blob ao meio e você tem seus inteiros. É o formato que o WebCrypto emite, o formato que a RFC 7518 §3.4 exige para JWS (e, portanto, para JWTs — as consequências práticas estão em ES256 vs RS256 para JWTs) e a forma com que a maioria das stacks de blockchain trabalha internamente: as assinaturas do Ethereum são o r‖s‖v de 65 bytes (o par bruto mais um byte de recuperação), enquanto o Bitcoin, como dito acima, codifica as assinaturas de transação em DER estrito.

O modo de falha é simétrico e extremamente comum: uma assinatura bruta de 64 bytes entregue a uma API que espera DER morre no parser ASN.1 (era esperado 0x30), enquanto um blob DER de ~71 bytes entregue ao WebCrypto ou a um verificador JWS simplesmente tem o tamanho errado e falha de forma limpa, com um “invalid signature” genérico. Quando duas stacks divergem, converta explicitamente — o conversor DER ⇄ bruto faz a conversão nos dois sentidos, no navegador, e mostra r e s decodificados.

High-S, low-S e por que uma assinatura válida é rejeitada

Mesmo com codificações iguais, mais uma sutileza quebra a verificação entre stacks diferentes. O ECDSA tem uma simetria inerente: se (r, s) é uma assinatura válida, então (r, n − s) também é uma assinatura válida para a mesma mensagem com a mesma chave. Qualquer pessoa que tenha uma assinatura pode convertê-la na outra forma sem a chave privada — os bytes da assinatura são maleáveis, embora o conteúdo assinado não seja.

Para protocolos que identificam dados pelo hash dos bytes das assinaturas — os IDs de transação do Bitcoin são o exemplo canônico —, essa maleabilidade por terceiros é um problema real. A correção é a forma canônica low-S: exigir s ≤ n/2 e, caso contrário, normalizar trocando s por n − s. O Bitcoin adotou o low-S como regra de retransmissão (proposta na BIP-62 e especificada na BIP-146), e a verificação da libsecp256k1 só aceita assinaturas low-S, a menos que quem a chama normalize antes. Algumas bibliotecas focadas em segurança de outros ecossistemas também produzem low-S por padrão e podem ser configuradas para rejeitar high-S na verificação.

Quem fala o quê: padrões das bibliotecas

Biblioteca / APIFormato padrãoO outro formato
OpenSSL (CLI e API EVP)DERSem modo bruto embutido para ECDSA; converta externamente ou monte a partir de r e s do ECDSA_SIG
Node.js node:cryptoDEROpção dsaEncoding: "ieee-p1363" em sign/verify (disponível desde a linha LTS do Node 12)
WebCrypto crypto.subtleSó bruto (P1363)Sem suporte a DER — converta antes/depois
Go crypto/ecdsaNenhum dos dois: Sign/Verify recebem r, s como *big.IntSignASN1 / VerifyASN1 para DER (Go 1.15+); monte o bruto com FillBytes
Python cryptography (pyca)DERAs funções auxiliares decode_dss_signature / encode_dss_signature expõem r e s
Pacote Python ecdsaBruto por padrãoArgumentos sigencode_der / sigdecode_der
Java (JCA Signature)DER (SHA256withECDSA)SHA256withECDSAinP1363Format em JDKs modernos (9+)

Alguns deles em código. O Node.js consegue emitir qualquer uma das duas codificações com a mesma chave:

node — uma chave, os dois 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

O Go deixa explícitos os inteiros que estão por baixo, o que também torna a conversão óbvia — repare no FillBytes para as metades brutas de largura fixa, completadas com zeros:

go — saída DER, saída bruta
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:])

O pyca cryptography, do Python, assina em DER, com funções auxiliares para chegar aos inteiros:

python — de DER para bruto com 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

Um roteiro de depuração

Quando uma assinatura ECDSA se recusa a ser verificada entre dois sistemas, percorra esta lista na ordem:

  • Teste de tamanho. Exatamente 64/96/132 bytes → bruto. Começa com 0x30 e tem um byte de tamanho plausível → DER. Qualquer outra coisa: decodificação errada de uma camada externa (base64, hex) ou um copiar e colar truncado.
  • Formatos compatíveis. A API que verifica espera o que o signatário produziu? Converta com o conversor DER ⇄ bruto em vez de assinar de novo.
  • Zeros à esquerda. Se assinaturas brutas falham de forma intermitente (mais ou menos 1 vez em 128: r e s começam, cada um, com um byte zero em cerca de 1 vez em 256), desconfie de uma conversão que descartou bytes zero à esquerda em vez de completar r e s com zeros à esquerda até a largura fixa.
  • High-S. Se as falhas são intermitentes, em cerca de 50% dos casos, e um dos lados envolve uma blockchain ou uma stack reforçada em segurança, compare s com n/2 e normalize.
  • Todo o resto — hash errado, curva errada, hash do hash — pertence à camada do algoritmo, e não à do formato; para esse contexto, comece por ECDSA vs RSA.

O resumo animador: DER vs bruto é puro encanamento. Nenhuma criptografia está envolvida na conversão, nada muda na chave e, quando os dois lados concordam sobre a disposição dos bytes — e sobre o low-S, onde isso importa —, o “invalid signature” que custou uma tarde costuma se revelar uma assinatura válida desde o início.