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:
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 único0x00na frente para que o número não seja lido como negativo. Tantorquantospodem 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
rouscomeç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):
| Curva | Tamanho do elemento do campo | Tamanho da assinatura bruta |
|---|---|---|
| P-256 / secp256k1 | 32 bytes | 64 bytes |
| P-384 | 48 bytes | 96 bytes |
| P-521 | 66 bytes | 132 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 / API | Formato padrão | O outro formato |
|---|---|---|
| OpenSSL (CLI e API EVP) | DER | Sem modo bruto embutido para ECDSA; converta externamente ou monte a partir de r e s do ECDSA_SIG |
Node.js node:crypto | DER | Opção dsaEncoding: "ieee-p1363" em sign/verify (disponível desde a linha LTS do Node 12) |
WebCrypto crypto.subtle | Só bruto (P1363) | Sem suporte a DER — converta antes/depois |
Go crypto/ecdsa | Nenhum dos dois: Sign/Verify recebem r, s como *big.Int | SignASN1 / VerifyASN1 para DER (Go 1.15+); monte o bruto com FillBytes |
Python cryptography (pyca) | DER | As funções auxiliares decode_dss_signature / encode_dss_signature expõem r e s |
Pacote Python ecdsa | Bruto por padrão | Argumentos 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:
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:
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:
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
0x30e 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:
rescomeç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 completarrescom 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
scomn/2e 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.