Um signatureValue ECDSA dentro de um container CMS é um DER SEQUENCE { INTEGER r, INTEGER s }. A função CNG do Windows BCryptVerifySignature não aceita nenhum dos dois: ela quer IEEE P1363 de largura fixa r || s, sem tags e sem lengths. O HotPDF, o componente VCL nativo de PDF para Delphi e C++Builder, converte entre os dois sob regras DER estritas antes de importar uma chave
A falha que isso evita é específica e desmoralizante. O Acrobat abre o documento e mostra um check verde. Seu próprio verificador, percorrendo os mesmos bytes, retorna inválido, ou o CNG devolve STATUS_INVALID_SIGNATURE sem mais explicações. Nada de errado com a assinatura. O que está errado é que cerca de setenta bytes de ASN.1 foram passados para uma API que esperava sessenta e quatro bytes de inteiro bruto, e a incompatibilidade é invisível a menos que você saiba procurar por ela
Por que o BCryptVerifySignature rejeita uma assinatura ECDSA válida?
Porque os dois lados da chamada falam codificações de assinatura diferentes, e nenhum dos dois anuncia isso. A ISO 32000-1 §12.8 diz que um signature dictionary carrega um blob CMS em /Contents; a RFC 5652 §5.3 diz que o signatureValue em cada SignerInfo é uma OCTET STRING cujo conteúdo é o que o algoritmo de assinatura definir. Para ECDSA esse conteúdo é a estrutura DER do SEC 1: uma SEQUENCE contendo dois INTEGERs. É de comprimento variável por design, porque r e s são inteiros e o DER remove octetos zero à esquerda dos inteiros
O IEEE P1363 assume a visão oposta. Ele define a assinatura como a concatenação das duas coordenadas, cada uma preenchida à esquerda com zeros até exatamente a largura em bytes do campo da curva. Uma assinatura P-256 sempre tem 64 bytes. Uma codificação DER da mesma assinatura normalmente tem 70 ou 71 bytes e pode variar de cerca de 8 a 72. Entregue a forma DER ao BCryptVerifySignature e só a checagem de comprimento já condena a chamada, e é por isso que o HotPDF normaliza antes de verificar, não depois
uses
HPDFECDSA;
// Converts a CMS signatureValue into the fixed-width form CNG expects.
// ARaw comes back as 64 bytes for P-256, 96 for P-384, 132 for P-521.
function ToP1363(const ADerSig: TBytes; ACurve: THPDFECDSACurve;
out ARaw: TBytes): Boolean;
begin
Result := HPDFECDSANormalizeSignature(ADerSig, ACurve, eseDER, ARaw);
end;
As regras DER que um parser de assinatura não pode relaxar
Toda rejeição listada aqui é uma rejeição que o HotPDF realiza deliberadamente, e cada uma fecha um caminho que um parser tolerante deixaria aberto. A tentação ao escrever um conversor é encontrar os dois nós INTEGER, copiar seu conteúdo, e seguir em frente. Isso funciona em entrada bem formada e aceita silenciosamente uma família de recodificações maleáveis em entrada hostil. Então HPDFECDSANormalizeSignature rejeita um inteiro negativo, ou seja, qualquer r ou s cujo primeiro octeto de conteúdo tenha o bit alto setado, porque um escalar ECDSA válido é positivo. Ele rejeita um valor inteiramente zero, já que r = 0 ou s = 0 nunca é uma assinatura legítima. Ele rejeita um octeto zero à esquerda redundante: a X.690 §8.3 permite exatamente um, e só quando o próximo octeto de outra forma seria lido como negativo, então um 00 seguido por um octeto abaixo de 0x80 é uma recodificação, não uma assinatura. Ele rejeita um cabeçalho de length não mínimo, porque a X.690 §10.1 exige a forma definite codificada no menor número de octetos possível, e um length de forma longa que poderia ter sido forma curta é uma sequência de bytes diferente carregando o mesmo significado. Ele rejeita um inteiro mais largo que o tamanho da coordenada da curva, já que esse valor não pode ser um elemento de campo. E ele rejeita qualquer nó residual depois de s, junto com uma SEQUENCE externa cujo comprimento total não é igual ao comprimento do blob inteiro
Essas duas últimas importam mais do que parecem. Bytes residuais depois da SEQUENCE são o truque clássico de maleabilidade de assinatura: anexe lixo, e um verificador tolerante ainda diz válido enquanto a sequência de bytes que ele validou não é a sequência de bytes que foi assinada. O mesmo instinto orienta o endurecimento de comprimento ASN.1 descrito na nota sobre parsing de PKCS#12, e é o mesmo instinto aqui. Em um caminho de verificação, uma estrutura aceita que nunca foi emitida por um signer conforme é um defeito, não uma cortesia
A largura da coordenada pertence à curva, não à assinatura
O HotPDF deriva a largura de saída do OID da curva nomeada, nunca do comprimento do DER que acabou de analisar. Essa é a segunda metade da conversão e a metade fácil de errar sutilmente. A RFC 5480 §2.1.1 identifica a curva nos parâmetros do SubjectPublicKeyInfo do certificado, e HPDFECDSACurveFromOID mapeia os três OIDs que o HotPDF suporta: 1.2.840.10045.3.1.7 para P-256, 1.3.132.0.34 para P-384, e 1.3.132.0.35 para P-521. HPDFECDSACoordinateSize então retorna 32, 48, ou 66 bytes, e o buffer P1363 é o dobro disso: 64, 96, ou 132. Cada inteiro decodificado é alinhado à direita dentro de sua metade, então um r curto é preenchido com zero à esquerda em vez de deslocado. P-521 é o que pega as pessoas, porque 521 bits são 65,125 bytes e arredondam para 66, dando uma assinatura de 132 bytes que nenhuma intuição de potência de dois teria previsto. A chave pública viaja junto como um ponto EC não comprimido conforme a RFC 5480 §2.2, que é 0x04 seguido de X e Y, então o HotPDF verifica que ela tem exatamente 1 + 2 * CoordinateSize bytes e começa com 0x04 antes de tocar no CNG
var
Digest, SigDER, PublicPoint: TBytes;
Curve: THPDFECDSACurve;
Res: THPDFECDSAVerifyResult;
begin
// secp256r1, taken from the certificate SubjectPublicKeyInfo parameters
Curve := HPDFECDSACurveFromOID('1.2.840.10045.3.1.7');
// PublicPoint must be $04 || X || Y, so 1 + 2 * 32 = 65 bytes for P-256
Res := HPDFECDSAVerifyDigest(Digest, SigDER, PublicPoint, Curve, eseDER);
case Res of
evrValid:
Memo1.Lines.Add('signature verifies');
evrInvalid:
Memo1.Lines.Add('signature does not match the digest');
evrMalformed:
Memo1.Lines.Add('DER encoding or public point rejected');
evrUnsupported:
Memo1.Lines.Add('curve or algorithm not supported here');
evrProviderUnavailable:
Memo1.Lines.Add('bcrypt.dll or the curve provider is missing');
evrProviderError:
Memo1.Lines.Add('CNG returned an unexpected status');
end;
end;
Note o último parâmetro. HPDFECDSAVerifyDigest também aceita eseP1363 para chamadores que já têm uma assinatura de largura fixa, vinda de um token de hardware ou de um serviço de assinatura remota que retorna r || s bruto. Esse caminho ainda impõe a checagem de comprimento e de não-zero em ambas as metades, então um buffer do tamanho certo cheio de zeros é recusado em vez de ser passado adiante ao provider
Por que o nome de algoritmo ECDSA genérico falha em Windows mais antigos?
Porque o nome genérico é mais novo que a base de implantação para a qual você está entregando. O CNG expõe um identificador de algoritmo ECDSA que infere a curva a partir da chave importada, e é a forma limpa de escrever esse código, mas o BCryptOpenAlgorithmProvider só tem garantia de resolvê-lo em versões mais recentes do Windows. Em uma máquina mais antiga a chamada de abertura falha, o handle do provider fica nulo, e toda verificação ECDSA na sua aplicação reporta não suportado em uma assinatura perfeitamente boa. O HotPDF evita esse precipício abrindo os identificadores por curva em vez disso. Ele resolve ECDSA_P256, ECDSA_P384, e ECDSA_P521 uma vez, guarda em cache um handle de provider por curva, e os fecha na finalização da unit. Cada verificação então faz só o trabalho barato: importa uma chave pública temporária a partir de um ECCPUBLICBLOB, chama BCryptVerifySignature, destrói a chave. Sem LoadLibrary repetido, sem GetProcAddress repetido, sem abrir e fechar provider por assinatura. A verificação em lote de algumas centenas de documentos sente a diferença, e um processo de serviço que de outra forma ficaria consumindo handles de provider sob carga também sente
Os códigos de resultado permanecem honestos sobre a distinção. evrProviderUnavailable significa que a máquina não conseguiu dar ao HotPDF um provider; evrInvalid significa que o CNG respondeu STATUS_INVALID_SIGNATURE. Colapsar essas duas em uma única falha é como um problema de implantação acaba sendo mal reportado como um documento forjado. A mesma separação entre falha de ambiente e falha criptográfica atravessa o tratamento de CNG e CAPI no lado de assinatura, coberto no artigo sobre assinatura via certificate store e byte order
Quem assinou isto? SignerIdentifier são duas coisas diferentes
A RFC 5652 §5.3 torna SignerIdentifier uma CHOICE, e um verificador que trata apenas um dos ramos vai verificar silenciosamente contra a chave errada. O primeiro ramo é issuerAndSerialNumber, uma SEQUENCE contendo o Name do issuer em DER bruto e o INTEGER serial, e comparar isso é uma comparação de bytes contra cada certificado no conjunto certificates do CMS. O segundo ramo é [0] subjectKeyIdentifier, uma OCTET STRING com tag implícita, e comparar isso exige escavar dentro do certificado em vez de comparar seus campos de cabeçalho
A escavação tem uma camada que surpreende as pessoas. O identificador de chave vive em uma extensão X.509v3, então o HotPDF percorre o campo [3] extensions do tbsCertificate, encontra a extensão cujo OID é 2.5.29.14, pula o BOOLEAN opcional de critical, e pega a OCTET STRING extnValue. Essa octet string não é o identificador. Conforme a RFC 5280 §4.2.1.2, seu conteúdo é ele mesmo DER, e o tipo KeyIdentifier é outra OCTET STRING, então você analisa uma segunda vez para chegar aos bytes de fato. Pare uma camada cedo demais e você compara um wrapper de 22 bytes contra um identificador de 20 bytes, nenhum certificado jamais corresponde, e o verificador recai na heurística seguinte que você escreveu, que é o verdadeiro perigo. Pegar o primeiro certificado do conjunto é um atalho tentador e está errado sempre que o CMS carrega uma cadeia, o que é a maior parte do tempo, porque a folha não é obrigada a vir primeiro. O HotPDF só aceita um certificado sem correspondência quando o container contém exatamente um; com múltiplos certificados presentes, uma correspondência exata de SignerIdentifier é obrigatória. Verificar um digest contra uma chave pública de CA intermediária não produz um erro amigável, produz um inválido confiante em um documento que está correto
var
Pdf: THotPDF;
Info: THPDFSignatureInfo;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('signed.pdf') > 0 then
for I := 0 to Pdf.GetLoadedSignatureFieldCount - 1 do
if Pdf.VerifyLoadedSignatureEx(I, Info) = svValid then
Memo1.Lines.Add(Format('%s: %s %s over %s, signer %s',
[String(Info.FieldName), String(Info.PublicKeyAlgorithm),
String(Info.CurveName), String(Info.HashAlgorithm),
String(Info.SignerName)]))
else
Memo1.Lines.Add(Format('%s: not valid', [String(Info.FieldName)]));
finally
Pdf.Free;
end;
end;
THPDFSignatureInfo.CurveName reporta P-256, P-384, ou P-521 para que um log de auditoria registre qual curva foi realmente usada em vez de só a palavra ECDSA. A engrenagem no nível de documento em torno dessa chamada, em particular como os segmentos de /ByteRange são hasheados e por que o digest precisa ser computado sobre o arquivo em vez da árvore de objetos analisada, é o assunto do artigo complementar sobre verificação de assinaturas digitais em PDF
O que isso não te dá
Um resultado verde de HPDFECDSAVerifyDigest responde só uma pergunta: esses bytes foram assinados pela chave privada correspondente a essa chave pública. Isso não diz nada sobre se essa chave pertence a alguém em quem você deveria confiar. Construir a cadeia até uma âncora de confiança, revogação via CRL ou OCSP, e checagens de política são trabalho separado, e qualquer produto que reporte uma assinatura válida sem eles está reportando menos do que o usuário assume. As datas de validade do certificado são expostas separadamente em THPDFSignatureInfo exatamente por esse motivo: uma assinatura pode verificar criptograficamente enquanto o certificado que a fez expirou há dois anos. O suporte de curva também é deliberadamente restrito. Três curvas primárias do NIST são tratadas, e uma assinatura sobre qualquer outra curva retorna não suportado em vez de um palpite. O caminho CNG é exclusivo do Windows, o que é a troca certa para um componente VCL mas vale mencionar antes de planejar um serviço multiplataforma em torno dele. E o rigor não é configurável: não há modo tolerante que aceite um comprimento DER não mínimo porque algum signer legado emitiu um assim. Se você encontrar tal arquivo em produção, a resposta honesta é registrá-lo e cobrar do produtor, não afrouxar o parser até o arquivo passar
O caminho de verificação ECDSA descrito aqui vem embutido no HotPDF Component padrão para Delphi e C++Builder, junto com os caminhos RSA PKCS#1 v1.5 e RSA-PSS e o registro completo de informações de assinatura; a página de produto traz a referência completa de assinatura digital