Artigo Técnico

Por que PDFs assinados AES-256 carregam devagar em Delphi

O HotPDF carrega PDFs assinados e criptografados com AES-256 em Delphi muito mais rápido do que costumava, porque três custos separados foram removidos do caminho de carregamento: parsing de tokens sem buffer do stream de origem, uma segunda rodada da checagem de senha do Algoritmo 2.A da ISO 32000-2 durante a descriptografia, e um loop interno AES-128-CBC lento dentro do hardened hash do R6. Em alvos Delphi, a correção do R6 sozinha derrubou o carregamento de um documento de cerca de 66 ms para 16 ms

Nada disso aparecia nos pequenos arquivos de teste que a maioria das suítes usa. Uma amostra sem assinatura e sem criptografia de um ou dois kilobytes abre em centésimos de segundo não importa o que o parser faça. A encrenca começa com os documentos que os clientes realmente mandam: um contrato com uma assinatura CMS embutida, cerca de 35 KB, protegido com senha de revisão 6. Essa combinação liga todos os caminhos de código caros de uma vez, e cada um deles tinha sua própria razão para ser mais lento do que precisava ser. Este artigo passa por eles na ordem em que foram encontrados, junto com os erros de cache-key que apareceram quando os resultados de verificação de assinaturas começaram a ser memoizados

Por que um PDF assinado de 34 KB leva 170 ms para carregar?

Um PDF assinado de 34 KB levava 170 ms para carregar porque o parser lia um THandleStream bare diretamente, e cada byte de token virava sua própria chamada de sistema ReadFile. Um tokenizer PDF avança um byte por vez enquanto procura delimitadores, então um handle stream sem buffer converte uma varredura barata em memória em milhares de transições de kernel

Os detalhes explicam por que o bug sobreviveu. O THotPDF.LoadFromFile já envolvia sua origem de arquivo em um buffer de leitura interno havia muito tempo, e a sonda AutoDecrypt já havia sido corrigida pela mesma razão depois de passar 1,4 s re-consultando o tamanho de um arquivo de 56 MB. Documentos assinados e criptografados seguem uma rota diferente, porém. Para garantir que leituras preguiçosas de objetos e inspeção de assinaturas sempre vejam a mesma revisão imutável, mesmo depois de o chamador liberar seu próprio stream, o THotPDF.LoadFromStream tira um snapshot da origem e o analisa uma segunda vez. Essa segunda análise, junto com o load de reuso usado para revisões autenticadas, lia o handle do snapshot sem buffer na frente. Ambos agora passam pelo mesmo leitor bufferizado interno que o caminho de arquivo usa

Pipeline de carregamento de PDF criptografado AES-256 assinado do HotPDF em Delphi: stream de snapshot, análise estrutural bufferizada, pré-varredura do Algoritmo 2.A, descriptografia única reusando a chave recuperada, e um hardened hash R6 que chama AES-128-CBC nativo, derrubando um carregamento de 66 ms para 16 ms
As três correções se empilham em um caminho: o buffering remove milhares de chamadas de sistema, a chave pré-varrida é reusada sob uma flag de escopo, e o loop interno R6 alcança o backend CBC nativo

Esse buffer é interno, então você nunca o cria você mesmo. O que você controla é o stream que entrega ao LoadFromStream, e o HotPDF lê esse stream como está durante a primeira passada estrutural. Se os dados já estão em memória, passe um TMemoryStream. Se estão no disco, chame o LoadFromFile, ou no Delphi 10.1 Berlin e posteriores dê ao loader um TBufferedFileStream em vez de um TFileStream cru:

uses
  System.SysUtils, System.Classes, HPDFDoc;

function LoadEncryptedFromBytes(const FileName: string;
  const Password: AnsiString): THotPDF;
var
  Mem: TMemoryStream;
begin
  Result := THotPDF.Create(nil);
  Mem := TMemoryStream.Create;
  try
    Mem.LoadFromFile(FileName);   // uma leitura em bloco em vez de leituras por token
    Mem.Position := 0;
    // LoadFromStream devolve a contagem de páginas, 0 quando o documento não carregou
    if Result.LoadFromStream(Mem, Password) < 1 then
      raise Exception.Create('Could not load ' + FileName);
  except
    Result.Free;
    Mem.Free;
    raise;
  end;
  Mem.Free;
end;

Liberar o memory stream logo depois do load é seguro para entrada assinada e criptografada, porque o componente já guarda seu próprio snapshot dos bytes. Guarde o arquivo ou buffer original de qualquer forma se pretende verificar assinaturas depois, já que a verificação faz o hash dos segmentos brutos /ByteRange do arquivo em vez do modelo de objetos descriptografado

Medindo carregamento e verificação como fases separadas

Tempo de load e tempo de verificação de assinatura devem ser medidos separadamente, porque falham de formas diferentes e são consertados em lugares diferentes. A fase de load cobre parsing, checagem de senha e descriptografia. A fase de verificação refaz o hash das faixas de bytes assinadas e confere a assinatura CMS. Se você juntar os dois em um único número, uma regressão em qualquer um se esconde dentro do outro

O TStopwatch da RTL, de System.Diagnostics, basta para isso. O harness abaixo carrega com a sobrecarga de senha do THotPDF.LoadFromFile e então verifica a primeira assinatura pela sobrecarga TStream do VerifyLoadedSignature. A sobrecarga de stream é a escolha certa para um arquivo criptografado, porque o caminho de load com senha não guarda um nome de arquivo de origem para as sobrecargas sem parâmetro reabrirem

uses
  System.SysUtils, System.Classes, System.Diagnostics,
  HPDFDoc, HPDFSignVerify;

procedure TimeSignedEncryptedLoad(const FileName: string;
  const Password: AnsiString);
var
  Pdf: THotPDF;
  Src: TFileStream;
  Info: THPDFSignatureInfo;
  Status: THPDFSignatureVerifyStatus;
  Watch: TStopwatch;
  Pages: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Watch := TStopwatch.StartNew;
    Pages := Pdf.LoadFromFile(FileName, Password);
    Writeln(Format('Load + decrypt: %d ms, %d pages, encrypted=%s',
      [Watch.ElapsedMilliseconds, Pages, BoolToStr(Pdf.IsEncrypted, True)]));
    if (Pages < 1) or (Pdf.GetLoadedSignatureFieldCount = 0) then
      Exit;

    Src := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
    try
      Watch := TStopwatch.StartNew;
      Status := Pdf.VerifyLoadedSignature(0, Src, Info);
      Writeln(Format('Verify signature 0: %d ms, status %d, signer %s',
        [Watch.ElapsedMilliseconds, Ord(Status), string(Info.SignerName)]));
    finally
      Src.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

Rode várias vezes e ignore a primeira rodada. A primeira chamada paga custos de configuração de uma vez só, incluindo construir as tabelas AES e rodar seu self-test, e o cache de disco distorce a primeira leitura também. O que você procura é o número de regime permanente para um arquivo real de cliente, medido contra uma cópia sem assinatura e sem criptografia do mesmo documento

Por que a senha AES-256 era derivada duas vezes?

O HotPDF derivava a chave de arquivo AES-256 duas vezes porque dois estágios do mesmo load cada um rodava a checagem completa de senha por conta própria. Antes da análise estrutural, o RecoverReloadKeysFromStreamRanges pré-varre os bytes brutos para que contêineres de object stream criptografados possam ser descriptografados enquanto os filtros são decodificados. Essa pré-varredura já executa as checagens do Algoritmo 2.A da ISO 32000-2 §7.6.4.3.3, validando a senha contra os salts de usuário e de dono e recuperando a chave de criptografia do arquivo. O DecryptLoadedDocument então ignorava esse resultado e fazia a derivação inteira de novo

Para revisão 4 e mais antigas, a duplicata custava pouco mais que um loop MD5. Para revisão 6, o Algoritmo 2.A chama o hardened hash do Algoritmo 2.B, então a duplicata dobrava a parte mais cara de abrir o arquivo. Reusar a chave recuperada é simples. Reusá-la com segurança é a parte mais difícil, porque uma chave em cache que sobrevive à própria checagem de senha vira um bypass de autenticação

A correção limita o reuso com uma flag de escopo que é verdadeira apenas entre a pré-varredura e a chamada de descriptografia dentro das sobrecargas de senha do LoadFromFile e do LoadFromStream, e é limpa em um bloco finally. A flag só é ligada quando a pré-varredura realmente validou a senha. Uma chamada direta ao DecryptLoadedDocument depois de um load simples sempre roda a derivação completa, porque aquela senha nunca foi pré-varrida. Sem essa restrição, uma chave de reload obsoleta de uma abertura bem-sucedida anterior poderia descriptografar o documento silenciosamente para um chamador posterior que fornecesse a senha errada, e esse é um bug bem pior que um load lento

Onde o hardened hash do R6 gasta seu tempo?

O hardened hash do R6 gasta quase todo o seu tempo em criptografia AES-128-CBC. O Algoritmo 2.B da ISO 32000-2 §7.6.4.3.4 repete uma rodada pelo menos 64 vezes. Cada rodada constrói um buffer de 64 cópias da senha, do hash atual e, no lado do dono, da string /U, criptografa esse buffer com AES-128-CBC usando os primeiros 16 bytes do hash como chave e os 16 seguintes como IV, e escolhe SHA-256, SHA-384 ou SHA-512 para o próximo hash a partir dos primeiros 16 bytes do ciphertext lidos como número módulo 3. Os hashes são curtos. Os buffers sendo criptografados têm vários kilobytes por rodada

Diagrama do loop do hardened hash do Algoritmo 2.B R6 do HotPDF para Delphi: cada uma de pelo menos 64 rodadas constrói um buffer de 64 cópias da senha, do hash e da string /U, o criptografa com AES-128-CBC usando o hash como chave e IV, e escolhe SHA-256, SHA-384 ou SHA-512 a partir dos primeiros bytes do ciphertext
O custo do R6 mora na criptografia do buffer em AES-128-CBC, não nos digests, e é por isso que uma rotina CBC nativa dentro do PDF20HashDanceR6 derruba o load de um documento inteiro de cerca de 66 ms para 16 ms

A implementação desse loop no HotPDF, o PDF20HashDanceR6, costumava criptografar o buffer um bloco por vez por meio de um AES de referência orientado a bytes. Agora ele primeiro tenta uma rotina CBC nativa sem padding, que em alvos Delphi alcança o backend C AES linkado. Com essa mudança, o load de um documento inteiro caiu de cerca de 66 ms para 16 ms. Se a rotina nativa está indisponível ou devolve um comprimento que não casa, o código recua para o loop histórico de blocos, e esse loop pode por sua vez usar um bloco AES-128 dirigido por tabelas cujas T-tables são geradas a partir do S-box existente. A cifra é a mesma em todos os casos, então a chave derivada é byte-idêntica independentemente de qual caminho a computou

Este artigo é sobre velocidade. Acertar a regra de terminação do Algoritmo 2.B é um problema de correção separado, e um off-by-one ali rejeita senhas corretas apenas de vez em quando. O lado de configuração de R5 versus R6 está coberto no guia de configuração de criptografia de PDF AES-256 com o HotPDF

Quatro maneiras de um bloco AES rápido mentir para você

Um bloco AES dirigido por tabelas pode produzir ciphertext errado e ainda passar no próprio self-test. Cada uma das quatro armadilhas abaixo apareceu enquanto o caminho rápido era construído, e duas delas passaram pelo self-test como ele foi desenhado inicialmente

  • Reentrada do self-test. A rotina de prontidão liga sua flag de inicializado antes de rodar o self-test, então uma chamada reentrante feita durante o teste toma o caminho de referência. A primeira versão do teste portanto comparava a implementação de referência com ela mesma. A correção é um bloco rápido bruto sem guarda que o self-test chama diretamente, para o caminho de tabelas ser o código que de fato é testado
  • Layouts de tabela trocados. Trocar a ordem de bytes de duas das quatro tabelas de rodada (Te1 e Te3) produz ciphertext errado que o self-test reentrante não tinha como notar. O que o pegou foi um programa de sonda independente que imprimia o ciphertext do FIPS-197 Apêndice C.1 69c4e0d86a7b0430d8cdb78070b4c55a para a chave 000102…0f e o plaintext 00112233…ff. O self-test agora exige tanto aquele vetor quanto igualdade de bytes com o bloco de referência antes de confiar no caminho rápido
  • Checagens de range e overflow do FPC. Sob Free Pascal com -Cr e -Co, conversões explícitas Byte() estilo Delphi e aritmética de deslocamento de largura de byte levantam exceções de range ou overflow. Índices de tabela e a duplicação GF(2^8) agora são computados na largura inteira nativa e mascarados com and $FF
  • Miscompilação do inliner do FPC. Helpers pequenos de load e store big-endian que recebem parâmetros PByte eram compilados em access violations quando marcados inline sob FPC. Remover o inline os consertou, e o custo da chamada é desprezível ao lado das consultas às tabelas

A mesma experiência está por trás da ligação final. Roteiar todo bloco AES-128 da biblioteca pelas tabelas rápidas causou um access violation determinístico no FPC durante um load candidato de publicação, então essa mudança foi revertida. O design publicado é conservador: CBC nativo primeiro, um ramo explícito de tabela rápida dentro do loop R6, e o bloco de referência em todo o resto. A lição geral é que código criptográfico novo que precisa compilar sob vários compiladores precisa de uma sonda de known-answer independente rodando em cada compilador alvo. Uma falha de teste em nível de DLL não consegue dizer se você tem uma tabela errada ou um crash. Para mais sobre o lado Free Pascal, veja as notas sobre os backends Deflate e AES do HotPDF sob Free Pascal

Como cachear a verificação de assinaturas sem cachear a resposta errada?

Um cache de verificação de assinaturas só é seguro se sua chave cobrir toda entrada que pode mudar o veredito, incluindo entradas que vivem fora das faixas de bytes assinadas. A camada de documento headless do HotPDF memoiza resultados de verificação CMS por hash dos bytes do CMS, dos bytes do dicionário de assinatura no gap do /Contents, do conteúdo das faixas cobertas, de uma impressão digital de política, dos orçamentos de verificação, do tamanho total da origem e de um intervalo de tempo de um minuto. Três dessas entradas foram adicionadas apenas depois de um bug mostrar que faltavam

Entradas da chave de cache de verificação de assinaturas do HotPDF para Delphi: bytes do CMS, o dicionário de assinatura no gap /Contents, conteúdo das faixas cobertas, impressão digital de política, orçamentos, tamanho total da origem e um intervalo de tempo de um minuto são hasheados juntos antes de um veredito CMS ser memoizado
Um veredito só pode ser reusado quando toda entrada que poderia mudá-lo está na chave - o tamanho da origem importa porque uma revisão base e sua atualização incremental podem compartilhar faixas cobertas idênticas

Arquivos de certificado confiáveis são indexados por conteúdo, não por caminho. A camada de operações grava o mesmo material PEM em um nome de arquivo temporário fresco para cada comando, então uma chave baseada em caminho nunca casava e o cache nunca acertava. O tamanho total da origem precisou fazer parte da chave porque o UnsignedTrailingBytes reportado depende dele: uma revisão base e uma atualização incremental posterior podem compartilhar exatamente o mesmo conteúdo coberto tendo comprimentos finais diferentes, e um cache indexado só pelas faixas entregaria o veredito da revisão base ao arquivo atualizado. O contexto do que esses bytes finais significam está no guia do HotPDF para verificar assinaturas digitais PDF em Delphi

O terceiro bug era uma armadilha da linguagem Pascal, não um erro de design. Rotinas de hash recebem um buffer const sem tipo, e passar uma variável de array dinâmico a um parâmetro sem tipo passa o endereço da referência do array, não os dados do array:

function Checksum(const Data; Count: Integer): Cardinal;
var
  P: PByte;
  I: Integer;
begin
  Result := 0;
  P := @Data;
  for I := 0 to Count - 1 do
  begin
    Result := Result + P^;
    Inc(P);
  end;
end;

procedure DigestCMS(const CMS: TBytes);
begin
  // Errado: lê a referência do array e o que vier depois dela na stack.
  // Uma leitura de 16 KB a partir daí escapa da página de stack e levanta um
  // access violation, e Delphi e FPC falham em pontos diferentes.
  Writeln(Checksum(CMS, Length(CMS)));

  // Certo: passe o primeiro elemento, com guarda para o array vazio
  if Length(CMS) > 0 then
    Writeln(Checksum(CMS[0], Length(CMS)));
end;

O lock do cache é um SyncObjs.TCriticalSection em vez da API Windows InitializeCriticalSection, porque a mesma unit também compila sob FPC no Linux, onde essa API não existe

O que permanece devagar de propósito

A derivação de chave R6 em si nunca é cacheada entre loads, e isso é deliberado. Bytes externos e uma senha fornecida explicitamente sempre passam pelo caminho real de autenticação, então todo load com senha de um documento R6 paga ao menos um hash do Algoritmo 2.B, e ambos um hash do lado do usuário e um do lado do dono quando a senha do dono é tentada. O reuso com escopo descrito acima remove a duplicata dentro de um único load e nada além disso

  • O número de 66 ms para 16 ms vale para alvos Delphi com o backend AES nativo linkado. Builds FPC tomam o bloco dirigido por tabelas, e a geração de código FPC no Windows rodou 2 a 4 vezes mais lenta que a do Delphi sob as mesmas assertions
  • O cache de verificação se ata a um intervalo de tempo de um minuto, então checagens repetidas dos mesmos bytes em uma janela curta são baratas, enquanto um veredito em cache nunca sobrevive ao minuto em que foi computado
  • Um cache de autoridade de revisão mesclada foi implementado, depurado e então removido. Ele não fechou o gap do FPC e deixou os callbacks de segurança do Delphi visivelmente mais lentos, o que é um bom lembrete para medir antes de manter um cache
  • A verificação de assinaturas precisa dos bytes originais do arquivo criptografado, então mantenha a origem disponível. Descriptografia mais rápida não faz nada para remover esse requisito

FAQ

Reutilizar a chave pré-escaneada enfraquece a checagem de senha?

Não. O HotPDF reusa a chave de arquivo recuperada apenas dentro da mesma chamada de sobrecarga de senha que validou a senha com as checagens completas do Algoritmo 2.A, e a flag de escopo é limpa em um bloco finally. Uma chamada isolada ao DecryptLoadedDocument sempre executa a derivação completa, então uma chave deixada de uma abertura anterior não pode ser emprestada com uma senha errada

Por não cachear o resultado do hash R6 entre carregamentos?

Cacheando o hash entre loads seria aceitar uma senha sem conferi-la contra o arquivo sendo aberto. O HotPDF trata toda origem externa de bytes e toda senha explícita como entrada não confiável que precisa se autenticar por conta própria, então o ganho mensurável de velocidade veio de tornar o Algoritmo 2.B mais rápido em vez de pulá-lo

Devo passar um TFileStream ao LoadFromStream?

Prefira o LoadFromFile para arquivos no disco, porque ele já bufferiza internamente. Quando precisar da sobrecarga de stream, passe um TMemoryStream ou um TBufferedFileStream, já que o HotPDF lê o stream que você fornecer diretamente durante a primeira passada estrutural e um handle stream cru custa uma chamada de sistema por cada leitura pequena

Carregamento protegido por senha, criptografia AES-256 R6, verificação de assinaturas CMS e o resto da API de documento carregado vêm juntos em uma biblioteca VCL nativa para Delphi e C++Builder. Detalhes de recursos e versões de IDE suportadas estão na página do produto HotPDF Component