Artigo Técnico

Verificação de Assinaturas PDF ECDSA em Delphi: DER para P1363

Um signatureValue ECDSA dentro de um contentor CMS é um DER SEQUENCE { INTEGER r, INTEGER s }. A função Windows CNG BCryptVerifySignature não aceita nenhum dos dois: quer IEEE P1363 de largura fixa r || s, sem tags e sem comprimentos. 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 isto evita é específica e desmoralizante. O Acrobat abre o documento e mostra um visto verde. O seu próprio verificador, a percorrer os mesmos bytes, devolve inválido, ou o CNG devolve STATUS_INVALID_SIGNATURE sem mais explicação. Não há nada de errado com a assinatura. O que está errado é que cerca de setenta bytes de ASN.1 foram passados a uma API que esperava sessenta e quatro bytes de inteiro em bruto, e a incompatibilidade é invisível a menos que se saiba onde procurar

Porque é que o BCryptVerifySignature rejeita uma assinatura ECDSA válida?

Porque os dois lados da chamada falam codificações de assinatura diferentes, e nenhum deles o anuncia. O ISO 32000-1 §12.8 diz que um dicionário de assinatura transporta um blob CMS em /Contents; o 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 SEC 1: uma SEQUENCE com dois INTEGERs. Tem comprimento variável por design, porque r e s são inteiros e o DER retira os octetos zero à esquerda dos inteiros

O IEEE P1363 assume a visão oposta. Define a assinatura como a concatenação das duas coordenadas, cada uma preenchida à esquerda com zeros até exatamente à largura em bytes do campo da curva. Uma assinatura P-256 tem sempre 64 bytes. Uma codificação DER da mesma assinatura tem normalmente 70 ou 71 bytes e pode ir de cerca de 8 a 72. Entregue a forma DER ao BCryptVerifySignature e só a verificação de comprimento já condena a chamada, e é por isso que o HotPDF normaliza antes de verificar, e 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 analisador de assinaturas não pode relaxar

Cada rejeição aqui listada é uma rejeição que o HotPDF executa deliberadamente, e cada uma fecha um caminho que um analisador tolerante deixaria aberto. A tentação ao escrever um conversor é encontrar os dois nós INTEGER, copiar o seu conteúdo, e seguir em frente. Isso funciona em entradas bem formadas e aceita silenciosamente uma família de recodificações maleáveis em entradas hostis. Por isso HPDFECDSANormalizeSignature rejeita um inteiro negativo, ou seja qualquer r ou s cujo primeiro octeto de conteúdo tenha o bit alto ativo, porque um escalar ECDSA válido é positivo. Rejeita um valor inteiramente zero, já que r = 0 ou s = 0 nunca é uma assinatura legítima. Rejeita um octeto zero à esquerda redundante: o X.690 §8.3 permite exatamente um, e só quando o octeto seguinte de outra forma se leria como negativo, pelo que um 00 seguido de um octeto abaixo de 0x80 é uma recodificação, não uma assinatura. Rejeita um cabeçalho de comprimento não mínimo, porque o X.690 §10.1 exige a forma definida codificada no menor número de octetos possível, e um comprimento de forma longa que podia ter sido forma curta é uma cadeia de bytes diferente a transportar o mesmo significado. Rejeita um inteiro mais largo do que o tamanho de coordenada da curva, já que esse valor não pode ser um elemento de campo. E rejeita qualquer nó a seguir a s, juntamente com uma SEQUENCE exterior cujo comprimento total não seja igual ao comprimento de todo o blob

Estes dois últimos importam mais do que parece. Bytes finais depois da SEQUENCE são o truque clássico de maleabilidade de assinatura: acrescente lixo, e um verificador tolerante continua a dizer válido enquanto a cadeia de bytes que validou não é a cadeia de bytes que foi assinada. O mesmo instinto orienta o reforço de comprimentos ASN.1 descrito na nota sobre a análise de PKCS#12, e é o mesmo instinto aqui. Num caminho de verificação, uma estrutura aceite que nunca foi emitida por um signatário 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. Esta é a segunda metade da conversão e a metade fácil de errar de forma subtil. O RFC 5480 §2.1.1 identifica a curva nos parâmetros 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 devolve então 32, 48 ou 66 bytes, e o buffer P1363 é o dobro disso: 64, 96 ou 132. Cada inteiro descodificado é alinhado à direita na sua metade, pelo que um r curto é preenchido com zeros à esquerda em vez de deslocado. O P-521 é o que apanha 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 ao lado como um ponto EC não comprimido segundo o RFC 5480 §2.2, que é 0x04 seguido de X e Y, pelo que o HotPDF verifica que 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á possuem uma assinatura de largura fixa, vinda de um token de hardware ou de um serviço de assinatura remoto que devolve r || s em bruto. Esse caminho ainda impõe a verificação de comprimento e de não-zero em ambas as metades, pelo que um buffer do tamanho certo cheio de zeros é recusado em vez de passado ao fornecedor

Porque é que o nome de algoritmo ECDSA genérico falha em Windows mais antigos?

Porque o nome genérico é mais recente do que a base de implantação para a qual está a distribuir. O CNG expõe um identificador de algoritmo ECDSA que infere a curva a partir da chave importada, e é a forma limpa de escrever este código, mas o BCryptOpenAlgorithmProvider só tem garantia de o resolver em versões mais recentes do Windows. Numa máquina mais antiga a chamada de abertura falha, o handle de fornecedor fica nulo, e toda a verificação ECDSA na sua aplicação reporta não suportado numa assinatura perfeitamente boa. O HotPDF evita o precipício abrindo antes os identificadores por curva. Resolve ECDSA_P256, ECDSA_P384 e ECDSA_P521 uma vez, guarda em cache um handle de fornecedor por curva, e fecha-os na finalização da unit. Cada verificação faz então apenas o trabalho barato: importar uma chave pública temporária a partir de um ECCPUBLICBLOB, chamar BCryptVerifySignature, destruir a chave. Sem LoadLibrary repetido, sem GetProcAddress repetido, sem abertura e fecho de fornecedor por assinatura. A verificação em lote de algumas centenas de documentos sente a diferença, e também a sente um processo de serviço que de outra forma iria consumir handles de fornecedor sob carga

Os códigos de resultado mantêm-se honestos quanto à distinção. evrProviderUnavailable significa que a máquina não conseguiu dar ao HotPDF um fornecedor; evrInvalid significa que o CNG respondeu STATUS_INVALID_SIGNATURE. Colapsar essas duas coisas numa única falha é como um problema de implantação é 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 do lado da assinatura, coberto no artigo sobre assinatura com armazém de certificados e ordem de bytes

Que certificado assinou isto? SignerIdentifier são duas coisas diferentes

O RFC 5652 §5.3 faz de SignerIdentifier um CHOICE, e um verificador que trate apenas um dos ramos vai verificar silenciosamente contra a chave errada. O primeiro ramo é issuerAndSerialNumber, uma SEQUENCE com o Name do emissor em DER em bruto e o INTEGER de série, e fazer a correspondência é 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 fazer a correspondência exige escavar dentro do certificado em vez de comparar os seus campos de cabeçalho

A escavação tem uma camada que surpreende as pessoas. O identificador de chave vive numa extensão X.509v3, pelo que o HotPDF percorre o campo [3] extensions do tbsCertificate, encontra a extensão cujo OID é 2.5.29.14, salta o BOOLEAN opcional de crítica, e retira a OCTET STRING extnValue. Essa cadeia de octetos não é o identificador. Segundo o RFC 5280 §4.2.1.2 o seu conteúdo é ele próprio DER, e o tipo KeyIdentifier é outra OCTET STRING, pelo que se analisa uma segunda vez para chegar aos bytes reais. Pare uma camada demasiado cedo e compara um invólucro de 22 bytes contra um identificador de 20 bytes, nenhum certificado alguma vez corresponde, e o verificador recorre à heurística que se escreveu a seguir, que é o verdadeiro perigo. Pegar no primeiro certificado do conjunto é um atalho tentador e está errado sempre que o CMS transporta uma cadeia, o que é a maioria das vezes, porque a folha não é obrigada a vir primeiro. O HotPDF só aceita um certificado sem correspondência quando o contentor tem exatamente um; com vários certificados presentes, uma correspondência exata de SignerIdentifier é obrigatória. Verificar um digest contra a chave pública de uma CA intermédia não produz um erro amigável, produz um inválido confiante num documento que está bem

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 registo de auditoria guarde qual a curva efetivamente usada em vez de apenas a palavra ECDSA. A canalização ao nível do documento à volta desta chamada, em particular como os segmentos de /ByteRange são sujeitos a hash e porque é que o digest tem de ser calculado sobre o ficheiro e não sobre a árvore de objetos analisada, é o tema do artigo complementar sobre a verificação de assinaturas digitais de PDF

O que isto não lhe dá

Um resultado verde de HPDFECDSAVerifyDigest responde a uma única pergunta: estes bytes foram assinados pela chave privada correspondente a esta chave pública. Não diz nada sobre se essa chave pertence a alguém em quem deva confiar. A construção da cadeia até uma âncora de confiança, a revogação através de CRL ou OCSP, e as verificações de política são trabalho separado, e qualquer produto que reporte uma assinatura válida sem eles está a reportar menos do que o utilizador assume. As datas de validade do certificado são disponibilizadas em separado em THPDFSignatureInfo exatamente por essa razão: uma assinatura pode verificar-se criptograficamente enquanto o certificado que a fez expirou há dois anos. O suporte de curvas também é deliberadamente estreito. Três curvas primárias do NIST são tratadas, e uma assinatura sobre qualquer outra curva devolve 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 a pena declarar antes de planear um serviço multiplataforma à volta dele. E o rigor não é configurável: não existe um modo tolerante que aceite um comprimento DER não mínimo só porque algum signatário legado emitiu um. Se encontrar tal ficheiro em produção, a resposta honesta é registá-lo e ir atrás do produtor, não alargar o analisador até o ficheiro passar

O caminho de verificação ECDSA aqui descrito faz parte do HotPDF Component padrão para Delphi e C++Builder, a par dos caminhos RSA PKCS#1 v1.5 e RSA-PSS e do registo completo de informação de assinatura; a página de produto contém a referência completa de assinatura digital