Artigo Técnico

Dá para Assinar um PDF com SHA-3 no Delphi

Você troca uma rotina de assinatura em Delphi para SHA3-512 e o OpenSSL rejeita o resultado: o CMS declara SHA3-512, mas o atributo messageDigest dele guarda 32 bytes onde esse algoritmo produz 64. Sim, código Delphi pode assinar com SHA-3: o SignPadesForDigest do PDFium Component aceita SHA3-256, SHA3-384 ou SHA3-512 e escreve todo campo do CMS dependente do digest para essa única escolha, como a ISO/TS 32001 espera

O que a ISO/TS 32001 acrescenta a uma assinatura PDF 2.0

A ISO/TS 32001 é uma extensão curta da ISO 32000-2. Ela amplia o conjunto de funções hash que uma assinatura PDF pode declarar. A lista base fica na Tabela 260 da ISO 32000-2 §12.8.3.1, e para os subfilters baseados em CMS ela para em SHA-256, SHA-384, SHA-512 e RIPEMD-160, com SHA-1 deprecated. A especificação técnica acrescenta a família SHA-3 do FIPS 202. SHA-3 é uma sponge construction que não divide estrutura interna nenhuma com o SHA-2, e esse é todo o apelo para uma política de assinatura que quer uma segunda família de hash na prateleira caso o SHA-2 enfraqueça algum dia. No CMS cada variante é um object identifier sob o arco NIST. Os identificadores de algoritmo de assinatura combinados ficam bem ao lado deles:

TCmsDigestAlgorithmOID do digestSaídaRSA PKCS#1 v1.5ECDSA
cdaSha2562.16.840.1.101.3.4.2.132 bytes1.2.840.113549.1.1.111.2.840.10045.4.3.2
cdaSha3_2562.16.840.1.101.3.4.2.832 bytes2.16.840.1.101.3.4.3.142.16.840.1.101.3.4.3.10
cdaSha3_3842.16.840.1.101.3.4.2.948 bytes2.16.840.1.101.3.4.3.152.16.840.1.101.3.4.3.11
cdaSha3_5122.16.840.1.101.3.4.2.1064 bytes2.16.840.1.101.3.4.3.162.16.840.1.101.3.4.3.12

No PDFium Component a escolha inteira é uma enumeração, a TCmsDigestAlgorithm da unit FPdfCms. Deixe a chave escolher o membro. Uma curva NIST espera um digest da própria largura dela, então P-256 combina com SHA3-256, P-384 com SHA3-384 e P-521 com SHA3-512, e um validador que aplica a política de curvas e digests da ISO/TS 32002 marca qualquer outro par como inválido. RSA não tem regra assim. O argumento de força é curto de qualquer jeito: uma chave RSA-2048 entrega cerca de 112 bits de segurança e a RSA-3072 uns 128, então o SHA3-256 já ultrapassa a chave, enquanto SHA3-512 em uma chave RSA-2048 soma 32 bytes ao CMS e nada à segurança. Recomendamos SHA3-256 para RSA a menos que uma política escrita nomeie uma variante maior

Acrescimos SHA-3 da ISO/TS 32001 para assinaturas PDF a partir de Delphi: chaves EC combinam com o digest da mesma largura e RSA recebe SHA3-256 por padrão no PDFium Component
a família SHA-3 entra na Tabela 260 como segunda família de hash, curvas EC combinam com a largura correspondente, e SHA3-256 já ultrapassa uma chave RSA-2048

Cinco campos do CMS que mudam com o digest

Trocar a função hash é a menor parte do trabalho. Uma assinatura CAdES detached nomeia o seu digest em cinco lugares, e um verificador estrito confere um contra o outro. Se qualquer um deles ainda diz SHA-256, a assinatura falha mesmo que a aritmética RSA ou ECDSA esteja correta:

  1. O digestAlgorithm no SignerInfo, espelhado no conjunto digestAlgorithms do SignedData, carrega o OID do digest, por exemplo 2.16.840.1.101.3.4.2.9 para SHA3-384
  2. O atributo assinado messageDigest (RFC 5652) guarda o hash dos dois trechos do /ByteRange, então o OCTET STRING dele tem 32, 48 ou 64 bytes
  3. O signatureAlgorithm nomeia o esquema combinado da tabela acima. RSASSA-PSS é a exceção: o OID dele continua id-RSASSA-PSS (1.2.840.113549.1.1.10) e o digest vai para os parâmetros
  4. Os parâmetros RSASSA-PSS repetem o digest duas vezes, como hashAlgorithm e como o hash do MGF1, e definem saltLength para o comprimento do digest. Deixe-os de fora e um verificador assume SHA-1, MGF1 com SHA-1 e um salt de 20 bytes, que é um algoritmo diferente; as regras de tagging do RSASSA-PSS-params do RFC 4055 importam aqui também
  5. O atributo assinado CMSAlgorithmProtection (RFC 6211) copia os identificadores de algoritmo de digest e de assinatura para os atributos assinados, onde ninguém pode trocá-los sem ser notado. Ele precisa casar com os campos 1 e 3 byte a byte

Um campo não muda de forma alguma. O ESSCertIDv2 dentro do atributo signing-certificate-v2 (RFC 5035) faz o hash do certificado do signatário com o hashAlgorithm próprio dele, que tem SHA-256 como padrão e é independente do digest do SignedData, então o PDFium Component o mantém em SHA-256 na codificação padrão. O erro que mais aparece em código CMS escrito à mão é o comprimento do messageDigest. Código escrito para SHA-256 tende a guardar o hash em um record fixo de 32 bytes e copiar esse record para o atributo. Empurre um resultado SHA3-512 por ele e o atributo recebe os primeiros 32 bytes enquanto o digestAlgorithm diz .10: a rejeição do OpenSSL da abertura. O mesmo erro também vive uma camada abaixo, onde o EVP_PKEY_sign do OpenSSL recebe o comprimento do digest como tbslen e o confere contra o message digest definido no contexto, então um signatário que sempre passa 32 recebe um erro de comprimento que nunca menciona SHA-3. O PDFium Component calcula o digest do documento a partir do algoritmo que o signatário resolvido declara e deriva os atributos, os parâmetros PSS e o atributo de proteção de algoritmo dessa mesma declaração, então nenhum campo pode se afastar dos outros

Os cinco campos do CMS que mudam com um digest SHA-3 em uma assinatura PAdES a partir de Delphi, conferidos cruzadamente por verificadores estritos conforme o PDFium Component os escreve
digestAlgorithm, messageDigest, signatureAlgorithm, os parâmetros PSS e o CMSAlgorithmProtection precisam nomear um digest, enquanto o ESSCertIDv2 mantém o seu próprio SHA-256

Assinando um PDF local com SignPadesForDigest

No componente TPdf os pontos de entrada SHA-3 são SignPadesForDigest(const FileName: string; const Options: TPadesSignOptions; DigestAlgorithm: TCmsDigestAlgorithm): Boolean e o seu gêmeo de stream SignPadesToStreamForDigest. Ambos recebem o mesmo record TPadesSignOptions que o SignPades, e o cdaSha256 produz a mesma assinatura que a sobrecarga existente, então um único caminho de código serve as duas famílias. No Windows um thumbprint SHA-1 seleciona um certificado no repositório MY do Current User. O backend OpenSSL nas outras plataformas assina com a chave e o certificado dados ao ConfigureSslSigningCredentials e recusa um thumbprint. Confira também a versão do documento. A ISO/TS 32001 estende o PDF 2.0, e a assinatura entra como uma atualização incremental que mantém o header original, então uma relying party que valida contra as regras do PDF 2.0 precisa de um arquivo 2.0 antes de você assinar

uses
  System.SysUtils, System.Classes, System.IOUtils,
  PDFium, FPdfCms, FPdfCrypto, FPdfPades
  {$IFDEF MSWINDOWS}, FPdfCryptoWin{$ELSE}, FPdfCryptoSsl{$ENDIF};

function Sha3SigningAvailable: Boolean;
begin
{$IFDEF MSWINDOWS}
  Result := WinSha3DigestAvailable;   // provedores SHA-3 do CNG, Windows 11 24H2+
{$ELSE}
  Result := SslSha3SigningAvailable;  // False até as credenciais serem configuradas
{$ENDIF}
end;

procedure SignWithSha3(const SourceFile, SignedFile, Thumbprint: string;
  const KeyPassword: AnsiString; const Log: TProc<string>);
var
  Pdf: TPdf;
  Opts: TPadesSignOptions;
  Signed: TMemoryStream;
begin
{$IFNDEF MSWINDOWS}
  // O OpenSSL assina com material configurado explicitamente; sem chain neste esboço
  ConfigureSslSigningCredentials(
    TFile.ReadAllBytes('/etc/signing/signer-key.pem'),
    TFile.ReadAllBytes('/etc/signing/signer.der'), nil, KeyPassword);
{$ENDIF}
  if not Sha3SigningAvailable then
    raise Exception.Create('No SHA-3 capable signing backend on this machine');

  Opts := TPadesSignOptions.Default;          // B-B, RSASSA-PSS
{$IFDEF MSWINDOWS}
  Opts.CertificateThumbprint := Thumbprint;   // certificado no MY do Current User
{$ENDIF}
  Opts.Reason := 'Approved for release';

  Pdf := TPdf.Create(nil);
  Signed := TMemoryStream.Create;
  try
    Pdf.FileName := SourceFile;
    Pdf.Active := True;
    if Pdf.PdfVersion <> pv20 then
      Log(SourceFile + ' is not a PDF 2.0 file');
    try
      if Pdf.SignPadesToStreamForDigest(Signed, Opts, cdaSha3_256) then
        Signed.SaveToFile(SignedFile);
    except
      on E: EPadesCryptoUnavailable do
        Log('SHA-3 signing unavailable: ' + E.Message);
    end;
  finally
    Signed.Free;
    Pdf.Free;
  end;
end;

Quando o backend não consegue produzir SHA-3, a chamada levanta EPadesCryptoUnavailable em vez de recuar para SHA-256. Esse é o comportamento desejável. Alguém pediu SHA3-256 por um motivo, geralmente um documento de política da relying party, e uma assinatura SHA-256 produzida no lugar passaria em toda checagem do seu lado e seria barrada no deles, semanas depois, quando ninguém do seu time lembra mais qual máquina assinou. Capture a exceção onde você ainda consegue dizer ao operador qual host não tem o algoritmo

Onde o SHA-3 está disponível: CNG do Windows e OpenSSL

A disponibilidade pertence à máquina. No Windows o backend padrão é o CNG, e o CNG traz provedores de algoritmo SHA-3 a partir do Windows 11 24H2; em builds anteriores o BCryptOpenAlgorithmProvider para "SHA3-256" falha com STATUS_NOT_FOUND. O WinSha3DigestAvailable abre os provedores SHA3-256, SHA3-384 e SHA3-512 uma vez e guarda a resposta em cache, já que ela não pode mudar enquanto o processo roda. Em todo o resto assina o backend OpenSSL, e o que ele consegue fazer depende do libcrypto que encontrar:

  • O OpenSSL 3.x sempre exporta EVP_sha3_256, EVP_sha3_384 e EVP_sha3_512, então assinar com SHA-3 funciona em toda plataforma em que o backend carrega
  • O loader também aceita uma biblioteca da série 1.1 (libcrypto-1_1.dll, libcrypto.so.1.1). SHA-3 chegou no OpenSSL 1.1.1 e está ausente do 1.1.0, então em imagens Linux mais antigas o SslSha3SigningAvailable decide
  • O SslSha3SigningAvailable também exige credenciais de assinatura configuradas. Perguntado antes do ConfigureSslSigningCredentials, ele responde False mesmo quando o SslLibraryVersion reporta uma biblioteca 3.x perfeitamente boa. Isso parece um backend faltando. É só um problema de ordem
  • Um executável de 32 bits não consegue carregar libcrypto-3-x64.dll. Quando este backend foi trazido para cima, um build Win32 apontado para a DLL x64 reportava SslAvailable = False enquanto o SslMissingSymbols ecoava nada além do caminho da biblioteca, um sintoma que parece binding de símbolos quebrado; a causa era o bitness, e uma DLL correspondente ao executável resolveu

Uma frota Windows que ainda inclui máquinas pré-24H2 pode manter o CNG onde há SHA-3 e rotear o resto pelo OpenSSL com um signer provider. O IPadesSignerProvider tem dois métodos. O ResolveSslSignerForDigest na FPdfCryptoSsl retorna um signer cujo digest declarado, algoritmo de assinatura, comprimento de digest e salt PSS seguem todos o algoritmo pedido, e com um provider configurado o ResolvePadesSignerForDigest confere que o signer retornado declara o digest que a chamada pediu, levantando EPadesCrypto se não declarar. Um provider construído para SHA3-256 não pode carimbar um pedido SHA3-384. O provider é global ao processo, porém, então um servidor que assina em concorrência deve configurá-lo uma vez na inicialização em vez de por chamada como este esboço faz; o ConfigureSslLibraryPath aponta o loader para o libcrypto quando a DLL não está no path de busca

Fluxo de disponibilidade de assinatura SHA-3 em Delphi: provedores CNG do Windows a partir da 24H2, guardas de versão e bitness do OpenSSL, e o caminho de falha EPadesCryptoUnavailable no PDFium Component
a disponibilidade pertence à máquina, então a checagem pertence à inicialização: CNG onde há SHA-3, OpenSSL no resto, e nenhum fallback silencioso para SHA-256
uses
  System.SysUtils, System.Classes, System.IOUtils,
  PDFium, FPdfCms, FPdfCrypto, FPdfCryptoWin, FPdfCryptoSsl, FPdfPades;

type
  TOpenSslSha3Provider = class(TInterfacedObject, IPadesSignerProvider)
  private
    FDigest: TCmsDigestAlgorithm;
  public
    constructor Create(ADigest: TCmsDigestAlgorithm);
    function BackendName: string;
    function Resolve(const Options: TPadesSignerOptions): TPadesResolvedSigner;
  end;

constructor TOpenSslSha3Provider.Create(ADigest: TCmsDigestAlgorithm);
begin
  inherited Create;
  FDigest := ADigest;
end;

function TOpenSslSha3Provider.BackendName: string;
begin
  Result := 'OpenSSL';
end;

function TOpenSslSha3Provider.Resolve(
  const Options: TPadesSignerOptions): TPadesResolvedSigner;
begin
  Result := ResolveSslSignerForDigest(Options, FDigest);
end;

procedure SignSha3OnAnyWindows(Pdf: TPdf; Dest: TStream;
  const Opts: TPadesSignOptions; const KeyPassword: AnsiString);
var
  Provider: IPadesSignerProvider;
  Chain: TPadesChainCertArray;
  SslOpts: TPadesSignOptions;
begin
  if WinSha3DigestAvailable then
  begin
    Pdf.SignPadesToStreamForDigest(Dest, Opts, cdaSha3_256);   // caminho CNG
    Exit;
  end;

  Chain := nil;
  ConfigureSslSigningCredentials(
    TFile.ReadAllBytes('C:\Signing\signer-key.pem'),
    TFile.ReadAllBytes('C:\Signing\signer.der'), Chain, KeyPassword);
  if not SslSha3SigningAvailable then
    raise Exception.Create('No SHA-3 in loaded OpenSSL ' + SslLibraryVersion);

  SslOpts := Opts;
  SslOpts.CertificateThumbprint := '';   // o OpenSSL assina com o certificado configurado
  Provider := TOpenSslSha3Provider.Create(cdaSha3_256);
  ConfigurePadesSignerProvider(Provider);
  try
    Pdf.SignPadesToStreamForDigest(Dest, SslOpts, cdaSha3_256);
  finally
    ConfigurePadesSignerProvider(nil);
  end;
end;

Como um signer remoto ou HSM fica sabendo do digest?

Assinatura remota divide o trabalho em dois. O PreparePadesRemoteSignature escreve um PDF com um slot /Contents vazio mais um TPadesRemoteSigningRequest, e o guardião da chave devolve um CMS detached mais tarde, muitas vezes de outro processo ou máquina, pela sessão de preparo e conclusão usada para chaves HSM e de nuvem. No lado do preparo você define o TPadesRemoteSignOptions.DigestAlgorithm, digamos para cdaSha3_384, e o request registra a escolha no TPadesRemoteSigningRequest.DigestAlgorithm ao lado do DocumentDigest, o valor que o messageDigest do CMS precisa carregar. A partir daí o request é a autoridade. O CompletePadesRemoteSignature recalcula o digest do /ByteRange com o algoritmo guardado no request e nunca mais consulta as opções; de outro modo, uma mudança de configuração entre preparo e conclusão julgaria um request antigo por um algoritmo novo. Um serviço de assinatura construído sobre o componente deve pegar o algoritmo do mesmo lugar:

uses
  System.SysUtils, System.Classes,
  FPdfCms, FPdfCrypto, FPdfCryptoSsl, FPdfPades,
  FPdfPadesRemote;   // liga a implementação Enterprise

function SignRemoteRequest(const RequestFile: string;
  Padding: TPadesPaddingScheme): TBytes;
var
  Stream: TFileStream;
  Request: TPadesRemoteSigningRequest;
  SignerOpts: TPadesSignerOptions;
  Resolved: TPadesResolvedSigner;
begin
  Stream := TFileStream.Create(RequestFile, fmOpenRead or fmShareDenyWrite);
  try
    Request := LoadPadesRemoteSigningRequest(Stream);
  finally
    Stream.Free;
  end;

  SignerOpts := TPadesSignerOptions.Default;   // thumbprint vazio para o OpenSSL
  SignerOpts.PaddingScheme := Padding;
  // O request decide o digest; a configuração deste serviço não
  Resolved := ResolveSslSignerForDigest(SignerOpts, Request.DigestAlgorithm);
  Result := BuildSignedDataEx(Request.DocumentDigest, Resolved.Signer,
    Resolved.SignerCertDer, Resolved.ChainCertDer, TCmsBuildOptions.Default);
end;

Dentro do request, o comprimento do digest é a única evidência do algoritmo. Ele faz as vezes de fingerprint. Antes de ler qualquer stream, o CompletePadesRemoteSignature compara Length(DocumentDigest) com CmsDigestByteLength(DigestAlgorithm), então um digest truncado ou um algoritmo renomeado falha nesse ponto e o stream de destino nunca é tocado. O comprimento sozinho não distingue SHA3-256 de SHA-256, ambos 32 bytes, e é por isso que o valor do digest também é comparado com o messageDigest dentro do CMS devolvido. O PDF preparado, em contraste, é byte-idêntico para SHA-256 e SHA3-384: o placeholder guarda só números do /ByteRange e nenhum digest. Arquivos idênticos são fáceis de serem lidos como se a configuração estivesse sendo ignorada. Assinatura remota vem na edição Enterprise pela unit FPdfPadesRemote

A variante raramente é a decisão difícil; para uma chave RSA em geral é cdaSha3_256, e o que falha em produção é um host cujo CNG ou libcrypto não consegue produzi-la, e é por isso que a checagem de disponibilidade pertence à inicialização da aplicação em qualquer deployment do PDFium Component para Delphi