Artigo Técnico

Assinatura Pós-Quântica e EdDSA em PDF com HotPDF em Delphi

Um PDF com uma assinatura ML-DSA-65 pode passar na checagem da toolchain que o produziu e ainda assim voltar como algoritmo não suportado de um verifier que exige que o arquivo declare qual extensão define o algoritmo dele. A chave pública tem 1.952 bytes, exatamente o tamanho FIPS 204 para aquele conjunto de parâmetros. Os bytes da assinatura estão intactos e os byte ranges cobrem o arquivo inteiro. O que um verifier desses procura é uma entrada no Catalog do documento nomeando essa extensão, porque a ISO 32000-2 não lista ML-DSA de forma alguma

O lado da assinatura tem outra parede. Um token de hardware, um HSM na nuvem ou um cartão nacional de eID calcula uma assinatura sob pedido, mas nenhum deles entrega a chave privada ao seu aplicativo, então qualquer código de assinatura que espera carregar um arquivo PFX e assinar in-process simplesmente não tem nada para carregar. Os dois problemas são sobre onde uma decisão mora: a declaração de algoritmo mora no arquivo, e a chave mora em algum lugar que o seu código Delphi não alcança

Por que uma assinatura ML-DSA válida volta como não suportada?

O HotPDF verifica assinaturas CMS ML-DSA-44, ML-DSA-65 e ML-DSA-87 somente quando o documento carregado declara a extensão de desenvolvedor do HotPDF no Catalog dele. Sem essa declaração, o verifier retorna svUnsupportedAlgorithm para uma assinatura em reticulado, por mais sólida que seja a criptografia. O raciocínio é reprodutibilidade: um arquivo que usa um algoritmo que o padrão base não nomeia, e não diz de qual extensão depende, produz um veredito que outra implementação não tem base para repetir

No lado da produção, o THotPDF.EnsureMLDSAExtensions escreve /Extensions << /HotPDF << /BaseVersion /2.0 /ExtensionLevel 1 >> >> no Catalog e eleva o documento para PDF 2.0 se ele mirava uma versão mais antiga. No lado da leitura, o LoadedDocumentDeclaresMLDSAExtension retorna True só quando aquele dicionário está presente com /BaseVersion igual a 2.0 e /ExtensionLevel de pelo menos 1. O VerifyLoadedSignatureWithOptions combina os dois: recebe a flag AllowMLDSA do chamador e faz AND com o teste de declaração antes de qualquer verificação rodar, então ligar a flag num arquivo não declarado não muda nada

HotPDF condiciona a verificação de PDF ML-DSA atrás de uma declaração de extensão explícita no Catalog gravada por EnsureMLDSAExtensions antes de o documento ser salvo, e recusa algoritmos de lattice em arquivos não declarados mesmo quando AllowMLDSA está definido
HotPDF admite ML-DSA somente quando o Catalog ainda declara a extensão, o que mantém um veredito pós-quântico reproduzível, e chamar EnsureMLDSAExtensions antes de EndDoc coloca a declaração dentro do intervalo de bytes assinado em vez de depois dele
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'contract-pq.pdf';
    Pdf.StrictVersionLock := False;   // o padrão; deixa a chamada elevar o arquivo para PDF 2.0
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(50, 720, 0, 'Supply agreement 2026-114');
    Pdf.EnsureMLDSAExtensions;        // declare antes de qualquer assinatura cobrir o Catalog
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Dois detalhes nesse snippet pesam. A chamada tem que acontecer antes de o documento ser salvo e assinado, porque o Catalog fica dentro do intervalo de bytes assinado; remendá-lo depois é ou uma alteração não assinada num arquivo assinado, ou uma nova revisão incremental que qualquer validator vai listar como modificação. O segundo detalhe é o StrictVersionLock. Com o travamento ligado e o documento fixado abaixo do PDF 2.0, o EnsureMLDSAExtensions retorna sem gravar nada, já que uma extensão PDF 2.0 não pode ser declarada num arquivo 1.7. Se o seu gerador fixa versões por razões de arquivamento, confira Version = pdf20 antes de confiar na declaração

Uma chamada de verificação para EdDSA e ML-DSA

O VerifyLoadedSignatureWithOptions recebe um índice de assinatura, o stream de origem, um record THPDFCMSVerifyOptions e um out THPDFSignatureInfo. Comece o record pelo THPDFCMSVerifyOptions.Default, que deixa AllowMLDSA desligado, limita um contêiner CMS a 256 signers e restringe o aninhamento de countersignature a 8. Três campos importam para os algoritmos mais novos. O SignatureProvider deixa o seu próprio verifier responder primeiro. O OpenSSLLibraryPath nomeia o libcrypto OpenSSL 3 que executa a verificação Ed25519 e Ed448 em modo puro. O AllowMLDSA adere aos algoritmos em reticulado, sujeito ao teste de Catalog acima. O record também carrega campos de fixação de política de assinatura (RequireSignaturePolicy, ExpectedSignaturePolicyOID, ExpectedSignaturePolicyHash), que se aplicam a toda família de algoritmos

O caminho embutido se divide por algoritmo. Ed25519 e Ed448 passam pelo OpenSSL 3; sem caminho informado, o provedor de plataforma tenta libcrypto-3-x64.dll, libcrypto-3.dll e libcrypto.dll pela ordem de busca normal de DLL no Win64. ML-DSA passa pelo provedor de algoritmo ML-DSA do CNG do Windows, e num build do Windows que não o oferece o resultado é não suportado. Defina OpenSSLLibraryPath para uma cópia que você distribui junto ao executável. Deixá-lo vazio significa que o veredito depende de qual libcrypto outro instalador deixou no search path, e isso é algo difícil de explicar numa auditoria

O seu próprio SignatureProvider é consultado primeiro. Se ele retorna spsUnsupported, o provedor de plataforma embutido recebe o mesmo pedido, então um provedor que só conhece ML-DSA pode ficar na frente do tratamento EdDSA padrão sem reimplementá-lo

var
  Pdf: THotPDF;
  PQ: THPDFCallbackSignatureProvider;
  Opts: THPDFCMSVerifyOptions;
  Info: THPDFSignatureInfo;
  Status: THPDFSignatureVerifyStatus;
  Src: TFileStream;
begin
  PQ := THPDFCallbackSignatureProvider.Create(
    function(const Req: THPDFSignatureProviderRequest): THPDFSignatureProviderStatus
    begin
      if not (Req.Algorithm in [hsaMLDSA44, hsaMLDSA65, hsaMLDSA87]) then
        Exit(spsUnsupported);            // passe o EdDSA adiante para o provedor de plataforma
      // MyFips204Verify é o seu wrapper em volta de uma biblioteca ML-DSA;
      // Req.Input guarda os atributos assinados codificados em DER (modo puro)
      Result := MyFips204Verify(Req.AlgorithmOID, Req.PublicKey,
        Req.Input, Req.Signature);
    end);
  Pdf := THotPDF.Create(nil);
  Src := TFileStream.Create('contract-pq.pdf', fmOpenRead or fmShareDenyNone);
  try
    Pdf.LoadFromFile('contract-pq.pdf');
    Opts := THPDFCMSVerifyOptions.Default;
    Opts.SignatureProvider := PQ;
    Opts.OpenSSLLibraryPath := ExtractFilePath(ParamStr(0)) + 'libcrypto-3-x64.dll';
    Opts.AllowMLDSA := True;             // ainda faz AND com a declaração do Catalog
    Status := Pdf.VerifyLoadedSignatureWithOptions(0, Src, Opts, Info);
    if Status = svValid then
      Memo1.Lines.Add(string(Info.SignatureAlgorithm) + ' / ' +
        string(Info.SignatureAlgorithmOID));
  finally
    Src.Free;
    Pdf.Free;
    PQ.Free;                             // o record de opções não é dono dele
  end;
end;

Registre no log o Info.SignatureAlgorithmOID em vez de qualquer algoritmo que a sua configuração esperava. Para ML-DSA-65 é 2.16.840.1.101.3.4.3.18, e uma trilha de auditoria que registra o OID encontrado no arquivo responde depois a perguntas que um valor de configuração não responde. Se você não precisa de um provedor customizado de forma alguma, o overload mais curto VerifyLoadedSignature(SigIndex, Source, Info) monta as opções padrão e define AllowMLDSA a partir da declaração do Catalog para você

Regras de encoding que derrubam CMS ML-DSA e EdDSA produzidos por terceiros

As duas famílias de algoritmos são verificadas em modo puro sobre os atributos assinados completos codificados em DER, e um SignerInfo sem atributos assinados é reportado como não suportado. Para EdDSA, os parâmetros do AlgorithmIdentifier devem estar ausentes. Um erro comum em CMS feito à mão é fazer hash dos atributos assinados com SHA-512 ou SHAKE256 primeiro e assinar esse digest, o que transforma silenciosamente PureEdDSA numa variante prehash que nada no CMS declara. Para ML-DSA o verifier também confere o pareamento entre conjunto de parâmetros e message digest: ML-DSA-44 aceita SHA-256, SHA-384, SHA-512 ou SHAKE256, ML-DSA-65 precisa de SHA-384 ou mais forte, e ML-DSA-87 precisa de SHA-512 ou SHAKE256. Um pareamento errado aparece no Info.DigestConsistencyIssue, que é o primeiro campo a ler quando um arquivo de outra toolchain falha

O EdDSA tem uma história de registro própria. A ISO/TS 32002 é a extensão que registra Ed25519 e Ed448 para assinaturas PDF 2.0, e o EnsureISO32002Extensions adiciona a entrada ISO_ correspondente com ExtensionLevel 32002 ao Catalog. A verificação aceita EdDSA sem essa entrada, mas um arquivo que você produz ainda deve carregá-la, pelo mesmo motivo de reprodutibilidade da declaração ML-DSA

O que se pede a um provedor de assinatura

Um provedor de assinatura recebe um THPDFSignatureProviderRequest e retorna um THPDFSignatureProviderStatus mais, em caso de sucesso, os bytes da assinatura. O pedido carrega o algoritmo e o OID dele, o OID do algoritmo de digest, o comprimento do salt PSS, se o Input é uma mensagem (sikMessage) ou um digest pré-calculado (sikDigest), os bytes de entrada, o certificado do signer, um identificador de chave e um identificador de operação único da chamada. Esse é o vocabulário que um driver de token ou um serviço de assinatura já fala, e nada disso exige que a chave atravesse para o seu processo

Cada provedor também anuncia um conjunto de capacidades como spcSign, spcCancel, spcHardwareKey e spcRemoteKey. Várias implementações vêm na biblioteca:

  • THPDFCallbackSignatureProvider envolve funções anônimas para verify, sign e cancel, que é a rota mais curta de uma rotina de assinatura interna existente para uma assinatura PDF
  • THPDFRemoteSignatureProvider envolve uma função de transporte com limite de retentativas, um registro de cancelamento e tetos de tamanho de entrada e de assinatura, então um serviço remoto mal comportado não consegue empurrar uma resposta gigante para dentro do codificador CMS
  • THPDFPKCS11SignatureProvider toma emprestados uma sessão e um handle de chave privada que o seu código já abriu e autenticou. O HotPDF nunca vê o PIN e não gerencia a sessão do token; ele serializa chamadas C_Sign, antepõe o DigestInfo para RSA PKCS#1 v1.5, passa o comprimento real de salt para RSA-PSS por parâmetros de mecanismo, e converte um resultado cru de ECDSA r||s na forma DER que o CMS espera
  • THPDFWindowsKeyStorageSignatureProvider assina pelo armazenamento de chaves CNG do Windows, que cobre smart cards e chaves com respaldo de TPM, e pode exigir uma chave com respaldo de hardware

O provedor PKCS#11 recebe digest como entrada, então cobre RSA PKCS#1 v1.5, RSA-PSS e ECDSA. Ed25519 e Ed448 assinam a mensagem completa de atributos assinados, e esses passam pelo provedor callback ou remoto, em que o seu código entrega Req.Input ao token ou serviço como mensagem

Provedores de assinatura plugáveis do HotPDF trocam um record de requisição simples e retornam um status de seis valores, mostrado para as implementações callback, remota e PKCS#11, enquanto a chave privada nunca entra no processo Delphi
Os provedores callback, remoto e PKCS#11 falam o mesmo record de requisição e respondem com o mesmo status de seis valores, e é isso que permite a tokens de hardware, serviços HSM remotos e cartões nacionais de eID continuarem guardando suas chaves em vez de exportá-las
var
  Provider: THPDFRemoteSignatureProvider;
  Opts: THPDFCMSSignOptions;
  CertDER: TBytes;
  InStream, OutStream: TFileStream;
begin
  CertDER := TFile.ReadAllBytes('signer.cer');   // System.IOUtils; certificado DER da chave remota
  Provider := THPDFRemoteSignatureProvider.Create(
    function(const Req: THPDFSignatureProviderRequest; Attempt: Integer;
      out Signature: TBytes): THPDFSignatureProviderStatus
    begin
      // PostToSigningService é o seu cliente HTTP próprio: mapeie timeouts e 5xx para
      // spsProviderError, uma aprovação recusada para spsCancelled, uma chave desconhecida para spsUnsupported
      Result := PostToSigningService(Req.KeyIdentifier, Req.AlgorithmOID,
        Req.Input, Signature);
    end,
    3,          // RetryLimit: até quatro tentativas no total
    1048576,    // MaxInputBytes
    65536);     // MaxSignatureBytes
  Opts := HPDFCMSDefaultOptions(palBaseline_B_B);
  Opts.SignatureScheme := cmsRSAPSS;
  Opts.DigestAlgorithm := cmsdaSHA256;
  Opts.PSSSaltLength := 32;
  // contract-unsigned.pdf foi salvo com um placeholder do AddSignedSignatureField
  InStream := TFileStream.Create('contract-unsigned.pdf', fmOpenRead or fmShareDenyWrite);
  OutStream := TFileStream.Create('contract-signed.pdf', fmCreate);
  try
    HPDFCMSSignPDFStreamWithProvider(InStream, OutStream, CertDER,
      Provider, 'contracts-2026', Opts);
  finally
    OutStream.Free;
    InStream.Free;
    Provider.Free;
  end;
end;

Seis status, e o que cada um deveria disparar

O THPDFSignatureProviderStatus tem seis valores porque a reação certa difere para cada um. spsInvalid é uma falha criptográfica e pertence a um log de segurança. spsUnsupported significa que o provedor não implementa o algoritmo, o que é um problema de implantação. spsMalformed significa que o pedido ou a resposta tinha a forma errada. spsProviderError é uma falha de transporte ou de dispositivo que pode dar certo na retentativa. spsCancelled significa que uma pessoa recusou o prompt do token, e repetir só a irrita

O provedor remoto embute essa política. Ele só repete em spsProviderError, confere o registro de cancelamento antes de cada tentativa, rejeita entrada vazia ou gigante como spsMalformed antes de chamar o transporte, e rebaixa uma resposta "válida" com assinatura vazia ou gigante para spsMalformed. No lado da verificação, a resposta de um provedor mapeia para o status de verificação: spsInvalid vira svSignatureInvalid, spsMalformed vira svMalformed, spsProviderError e spsCancelled viram ambos svCryptoProviderError, e spsUnsupported vira svUnsupportedAlgorithm depois que o provedor de plataforma também recusou o pedido

Se você escreve um provedor, resista a mapear toda condição inesperada para spsInvalid. Isso transforma uma DLL ausente ou um token de serviço expirado num relato de que a assinatura do cliente é forjada, e alguém vai ter que explicar isso ao cliente. Retorne spsInvalid só quando a matemática de fato falhou. Para assinar, retorne spsValid só junto de uma assinatura não vazia; os dois pontos de entrada levantam EHPDFCMSError quando um provedor retorna sucesso sem bytes

Onde a assinatura do provedor pousa no arquivo

Duas funções conectam um provedor a bytes PDF reais. O HPDFCMSBuildSignedDataWithProvider constrói CMS desanexado a partir de um digest de documento que você calculou em outro lugar; o comprimento do digest tem que bater com Options.DigestAlgorithm, então uma configuração SHA-384 espera 48 bytes e rejeita um digest SHA-256 de 32 bytes com exceção. O HPDFCMSSignPDFStreamWithProvider assina o placeholder que o AddSignedSignatureField dispôs, faz hash das duas janelas do /ByteRange, pede ao provedor a assinatura sobre os atributos assinados, e remenda o resultado hexadecimal no /Contents. Se o CMS cresce além do placeholder, a chamada levanta exceção e restaura o placeholder não assinado no stream de saída em vez de truncar qualquer coisa

O layout é o mesmo que o caminho de assinatura baseado em PFX grava: duas faixas que pulam a janela hexadecimal da assinatura. O verifier cobra essa forma de todo arquivo, conferindo que o vão entre as faixas contém exatamente a string /Contents desta assinatura e nada mais, e reportando svInvalidByteRange caso contrário. Um documento assinado por um token PKCS#11 por isso verifica com o mesmo código de verificação de assinatura que um assinado a partir de um arquivo PFX. As regras de perfil que ficam acima da escolha de algoritmo são cobertas em assinaturas baseline PAdES em Delphi, e as armadilhas de encoding ECDSA com que a conversão PKCS#11 lida estão em verificação CMS ECDSA e formatos de assinatura P1363

Dois pontos de entrada de assinatura de provedor do HotPDF, HPDFCMSBuildSignedDataWithProvider para um digest computado externamente e HPDFCMSSignPDFStreamWithProvider para um placeholder disposto, convergem em um layout CMS único que preserva as faixas /ByteRange que os validadores verificam primeiro
Qualquer um dos pontos de entrada grava a mesma forma assinada de duas faixas cobertas ao redor da janela hexadecimal, então um documento assinado por provider chega a todo validador exatamente como um assinado a partir de um arquivo PFX

Implantando assinaturas pós-quânticas sem abandonar leitores

Um leitor de PDF que não implementa ML-DSA reporta tal assinatura como inverificável, e a pessoa olhando o documento vê um aviso em vez de um visto verde. Isso torna a migração um problema de cronograma. Mantenha RSA ou ECDSA como a assinatura que os validators de hoje vão julgar. Onde uma política exige evidência resistente a quânticos, declare a extensão com o EnsureMLDSAExtensions e adicione uma segunda assinatura produzida por um signer capaz de ML-DSA ao lado da clássica. Essa assinatura vem do seu próprio signer FIPS 204, já que o construtor CMS do HotPDF não cria assinaturas ML-DSA; a parte da biblioteca é verificá-la. Mude a assinatura primária só quando os sistemas que consomem seus documentos puderem verificá-la

Nesse plano o HotPDF cobre as duas pontas que você controla a partir do Delphi. Os construtores CMS dele assinam com RSA PKCS#1 v1.5, RSA-PSS, ECDSA, Ed25519 e Ed448 por qualquer dos provedores acima, e o verifier dele confere ML-DSA, EdDSA e os algoritmos clássicos numa única chamada, com o OID de fato encontrado registrado no resultado. O HotPDF é um componente VCL de PDF nativo para Delphi e C++Builder que não precisa de runtime PDF externo, então o código de assinatura e verificação compila dentro do seu executável. Edições, o conjunto de recursos de assinatura e um download de avaliação estão na página do componente HotPDF para Delphi