O HotPDF carrega PDFs assinados e encriptados com AES-256 no Delphi muito mais depressa do que costumava, porque três custos separados foram removidos do caminho de carregamento: análise de tokens sem buffer do fluxo de origem, uma segunda passagem da verificação de palavra-passe do Algoritmo 2.A da ISO 32000-2 durante a desencriptação, e um loop interno AES-128-CBC lento dentro do hash endurecido R6. Nos alvos Delphi, a correção R6 sozinha levou o carregamento de um documento de cerca de 66 ms para 16 ms
Nenhum destes aparecia nos pequenos ficheiros de teste que a maioria das suites usa. Uma amostra não assinada e não encriptada de um ou dois kilobytes abre em algumas centésimas de segundo independentemente do que o parser faz. O problema começa com os documentos que os clientes realmente enviam: um contrato com uma assinatura CMS incorporada, cerca de 35 KB, protegido com uma palavra-passe de revisão 6. Essa combinação liga todos os caminhos de código caros ao mesmo tempo, e cada um tinha a sua própria razão para ser mais lento do que precisava. Este artigo passa por eles pela ordem em que foram encontrados, juntamente com os erros de cache-key que surgiram quando os resultados de verificação de assinaturas começaram a ser memoizados
Por que é que um PDF assinado de 34 KB leva 170 ms a carregar?
Um PDF assinado de 34 KB levava 170 ms a carregar porque o parser lia um THandleStream nu diretamente, e cada byte de token se transformava na sua própria chamada de sistema ReadFile. Um tokenizer PDF avança um byte de cada vez enquanto procura delimitadores, pelo que um fluxo de handle sem buffer converte uma varredura barata em memória em milhares de transições ao kernel
Os detalhes explicam por que o bug sobreviveu. O THotPDF.LoadFromFile já embrulha a sua fonte de ficheiro num buffer de leitura interno há muito tempo, e a sonda AutoDecrypt já tinha sido corrigida pela mesma razão depois de passar 1,4 s a reconsultar o tamanho de um ficheiro de 56 MB. Documentos assinados e encriptados tomam uma rota diferente, porém. Para garantir que leituras preguiçosas de objetos e a inspeção de assinaturas veem sempre a mesma revisão imutável, mesmo depois de o chamador libertar o seu próprio fluxo, o THotPDF.LoadFromStream tira uma fotografia da origem e analisa-a uma segunda vez. Essa segunda análise, juntamente com o carregamento de reutilização usado para revisões autenticadas, lia o handle da fotografia sem buffer à frente. Ambos passam agora pelo mesmo leitor com buffer interno que o caminho de ficheiro usa
Esse buffer é interno, pelo que nunca o cria você mesmo. O que controla é o fluxo que entrega ao LoadFromStream, e o HotPDF lê esse fluxo tal como está durante a primeira passagem estrutural. Se os dados já estão em memória, passe um TMemoryStream. Se estão em disco, chame o LoadFromFile, ou no Delphi 10.1 Berlin e posteriores dê ao carregador 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;
Libertar o fluxo de memória logo após o carregamento é seguro para entrada assinada e encriptada, porque o componente já guarda a sua própria fotografia dos bytes. Guarde de qualquer forma o ficheiro ou buffer original se planear verificar assinaturas depois, já que a verificação faz hash dos segmentos brutos /ByteRange do ficheiro em vez do modelo de objetos desencriptado
Medir carregamento e verificação como fases separadas
O tempo de carregamento e o tempo de verificação de assinaturas devem medir-se separadamente, porque falham de formas diferentes e corrigem-se em lugares diferentes. A fase de carregamento cobre análise, verificação de palavra-passe e desencriptação. A fase de verificação refaz o hash dos intervalos de bytes assinados e verifica a assinatura CMS. Se os fundir num só número, uma regressão num deles esconde-se no outro
O TStopwatch da RTL de System.Diagnostics chega para isto. O harness abaixo carrega com o overload de palavra-passe do THotPDF.LoadFromFile e depois verifica a primeira assinatura através do overload TStream do VerifyLoadedSignature. O overload de fluxo é a escolha certa para um ficheiro encriptado, porque o caminho de carregamento por palavra-passe não guarda um nome de ficheiro de origem para os overloads sem parâmetros 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;
Corra-o várias vezes e ignore a primeira execução. A primeira chamada paga custos de configuração de uma só vez, incluindo construir tabelas AES e correr o seu autoteste, e o cache de disco também distorce a primeira leitura. O que procura é o número de regime permanente para um ficheiro real de cliente, medido contra uma cópia não assinada e não encriptada do mesmo documento
Por que foi a palavra-passe AES-256 derivada duas vezes?
O HotPDF derivava a chave de ficheiro AES-256 duas vezes porque dois estágios do mesmo carregamento corriam cada um a verificação completa de palavra-passe por sua conta. Antes da análise estrutural, o RecoverReloadKeysFromStreamRanges pré-analisa os bytes brutos para que contentores de fluxos de objetos encriptados possam ser desencriptados enquanto os filtros estão a ser descodificados. Essa pré-análise já executa as verificações do Algoritmo 2.A da ISO 32000-2 §7.6.4.3.3, validando a palavra-passe contra os salts de utilizador e de proprietário e recuperando a chave de encriptação do ficheiro. O DecryptLoadedDocument depois ignorava esse resultado e fazia a derivação inteira outra vez
Para a revisão 4 e anteriores, a duplicata custava pouco mais que um loop MD5. Para a revisão 6, o Algoritmo 2.A chama o hash endurecido do Algoritmo 2.B, pelo que a duplicata duplicava a parte mais cara de abrir o ficheiro. Reutilizar a chave recuperada é simples. Reutilizá-la com segurança é a parte mais difícil, porque uma chave em cache que sobreviva à sua própria verificação de palavra-passe transforma-se num bypass de autenticação
A correção limita a reutilização com uma flag de escopo que só é verdadeira entre a pré-análise e a chamada de desencriptação dentro dos overloads de palavra-passe do LoadFromFile e do LoadFromStream, e é limpa num bloco finally. A flag só é definida quando a pré-análise realmente validou a palavra-passe. Uma chamada direta ao DecryptLoadedDocument depois de um carregamento simples corre sempre a derivação completa, porque essa palavra-passe nunca foi pré-analisada. Sem essa restrição, uma chave de recarga obsoleta de uma abertura bem-sucedida anterior podia desencriptar silenciosamente o documento para um chamador posterior que fornecesse a palavra-passe errada, e isso é um bug muito pior do que um carregamento lento
Onde gasta o tempo o hash endurecido R6?
O hash endurecido R6 gasta quase todo o seu tempo na encriptação AES-128-CBC. O Algoritmo 2.B da ISO 32000-2 §7.6.4.3.4 repete uma ronda pelo menos 64 vezes. Cada ronda constrói um buffer de 64 cópias da palavra-passe, do hash atual e, do lado do proprietário, da string /U, encripta 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 texto cifrado lidos como um número módulo 3. Os hashes são curtos. Os buffers a encriptar têm vários kilobytes por ronda
A implementação desse loop no HotPDF, o PDF20HashDanceR6, costumava encriptar o buffer um bloco de cada vez através de um AES de referência orientado a bytes. Agora tenta primeiro uma rotina CBC nativa sem padding, que nos alvos Delphi chega ao backend AES C ligado. Com essa mudança um carregamento completo de documento caiu de cerca de 66 ms para 16 ms. Se a rotina nativa não estiver disponível ou devolver um comprimento que não coincida, o código regride para o loop de blocos histórico, e esse loop pode por sua vez usar um bloco AES-128 orientado a tabelas cujas T-tables são geradas a partir da S-box existente. A cifra é a mesma em todos os casos, pelo que a chave derivada é byte-idêntica independentemente do caminho que a calculou
Este artigo trata da velocidade. Acertar na regra de terminação do Algoritmo 2.B é um problema de correção separado, e um off-by-one aí rejeita palavras-passe corretas só algumas das vezes. O lado da configuração de R5 versus R6 está coberto no guia de configuração de encriptação PDF AES-256 com o HotPDF
Quatro formas de um bloco AES rápido mentir-lhe
Um bloco AES orientado a tabelas pode produzir texto cifrado errado e ainda assim passar o seu próprio autoteste. Cada uma das quatro armadilhas abaixo surgiu ao construir o caminho rápido, e duas delas passaram o autoteste tal como foi inicialmente desenhado
- Reentrada do autoteste. A rotina de prontidão define a sua flag de inicialização antes de correr o autoteste, pelo que uma chamada reentrante feita durante o teste toma o caminho de referência. A primeira versão do teste comparava portanto a implementação de referência consigo própria. A correção é um bloco rápido bruto sem guarda que o autoteste chama diretamente, para que o caminho de tabelas seja o código que realmente é testado
- Esquemas de tabelas trocados. Trocar a ordem de bytes de duas das quatro tabelas de ronda (Te1 e Te3) produz texto cifrado errado que o autoteste reentrante não tinha como notar. O que o apanhou foi um programa de sonda autónomo que imprimia o texto cifrado
69c4e0d86a7b0430d8cdb78070b4c55ado Apêndice C.1 do FIPS-197 para a chave000102…0fe texto simples00112233…ff. O autoteste agora exige tanto esse vetor como igualdade de bytes com o bloco de referência antes de confiar no caminho rápido - Verificações de intervalo e overflow do FPC. Sob Free Pascal com
-Cre-Co, conversões explícitasByte()ao estilo Delphi e aritmética de deslocamento de largura de byte levantam exceções de intervalo ou overflow. Os índices de tabela e a duplicação GF(2^8) são agora calculados à largura de inteiro nativa e mascarados comand $FF - Micompilação do inliner do FPC. Pequenos auxiliares de carga e armazenamento big-endian que recebem parâmetros
PBytecompilavam para violações de acesso quando marcadosinlinesob FPC. Remover oinlinecorrigiu-os, 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. Encaminhar todos os blocos AES-128 da biblioteca pelas tabelas rápidas causou uma violação de acesso determinística do FPC durante um carregamento de candidato a publicação, pelo que essa mudança foi revertida. O design que viaja é conservador: CBC nativo primeiro, um ramo explícito de tabelas rápidas 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 tem de compilar sob vários compiladores precisa de uma sonda de resposta-conhecida independente corrida em cada compilador alvo. Uma falha de teste ao nível da DLL não lhe diz se 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 guardar em cache a verificação de assinaturas sem guardar a resposta errada?
Uma cache de verificação de assinaturas só é segura se a sua chave cobrir todas as entradas que podem mudar o veredicto, incluindo entradas que vivem fora dos intervalos de bytes assinados. A camada de documentos headless do HotPDF memoiza resultados de verificação CMS fazendo hash dos bytes CMS, dos bytes do dicionário de assinatura no gap /Contents, do conteúdo dos intervalos cobertos, de uma impressão digital de política, dos orçamentos de verificação, do tamanho total da origem e de um balde de tempo de um minuto. Três dessas entradas só foram acrescentadas depois de um bug mostrar que faltavam
Os ficheiros de certificados fidedignos são indexados pelo conteúdo, não pelo caminho. A camada de operações escreve o mesmo material PEM num nome de ficheiro temporário fresco para cada comando, pelo que uma chave baseada no caminho nunca casava e a cache nunca acertava. O tamanho total da origem teve de fazer parte da chave porque o UnsignedTrailingBytes reportado depende dele: uma revisão base e uma atualização incremental posterior podem partilhar exatamente o mesmo conteúdo coberto tendo comprimentos finais diferentes, e uma cache indexada só pelos intervalos entregaria o veredicto da revisão base ao ficheiro atualizado. O contexto do que esses bytes finais significam está no guia do HotPDF para verificar assinaturas digitais PDF no Delphi
O terceiro bug era uma armadilha da linguagem Pascal, não um erro de design. As rotinas de hash recebem um buffer const sem tipo, e passar uma variável de matriz dinâmica a um parâmetro sem tipo passa o endereço da referência da matriz, não os dados da matriz
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 da matriz e o que quer que a siga na pilha.
// Uma leitura de 16 KB daí corre para fora da página da pilha e levanta
// uma violação de acesso, e Delphi e FPC falham em pontos diferentes.
Writeln(Checksum(CMS, Length(CMS)));
// Certo: passe o primeiro elemento, com guarda para a matriz vazia
if Length(CMS) > 0 then
Writeln(Checksum(CMS[0], Length(CMS)));
end;
O bloqueio da cache é SyncObjs.TCriticalSection em vez da API Windows InitializeCriticalSection, porque a mesma unidade também compila sob FPC em Linux, onde essa API não existe
O que continua devagar de propósito
A derivação de chave R6 em si nunca é guardada em cache entre carregamentos, e isso é deliberado. Bytes externos e uma palavra-passe fornecida explicitamente passam sempre pelo caminho real de autenticação, pelo que cada carregamento por palavra-passe de um documento R6 paga pelo menos um hash do Algoritmo 2.B, e um hash do lado do utilizador e um do lado do proprietário quando a palavra-passe do proprietário é tentada. A reutilização delimitada descrita acima remove a duplicata dentro de um único carregamento e nada além disso
- O valor de 66 ms para 16 ms aplica-se a alvos Delphi com o backend AES nativo ligado. Builds FPC tomam o bloco orientado a tabelas, e a geração de código FPC em Windows correu 2 a 4 vezes mais devagar que Delphi sob as mesmas asserções
- A cache de verificação liga-se a um balde de tempo de um minuto, pelo que verificações repetidas dos mesmos bytes numa janela curta são baratas, enquanto um veredicto em cache nunca sobrevive ao minuto em que foi calculado
- Uma cache de autoridade de revisão fundida foi implementada, depurada e depois removida. Não fechou o fosso do FPC e tornou os callbacks de segurança do Delphi mensuravelmente mais lentos, o que é um bom lembrete para medir antes de manter uma cache
- A verificação de assinaturas precisa dos bytes originais do ficheiro encriptado, por isso mantenha a origem disponível. Desencriptação mais rápida não faz nada para remover esse requisito
FAQ
Reutilizar a chave pré-analisada enfraquece a verificação de palavra-passe?
Não. O HotPDF reutiliza a chave de ficheiro recuperada apenas dentro da mesma chamada de overload de palavra-passe que validou a palavra-passe com as verificações completas do Algoritmo 2.A, e a flag de escopo é limpa num bloco finally. Uma chamada autónoma ao DecryptLoadedDocument executa sempre a derivação completa, pelo que uma chave deixada de uma abertura anterior não pode ser emprestada com uma palavra-passe errada
Por que não guardar o resultado do hash R6 em cache entre carregamentos?
Guardar o hash em cache entre carregamentos significaria aceitar uma palavra-passe sem a verificar contra o ficheiro que está a ser aberto. O HotPDF trata toda a fonte de bytes externa e toda a palavra-passe explícita como entrada não fidedigna que tem de se autenticar por si, pelo que o aumento de velocidade mensurável veio de tornar o Algoritmo 2.B mais rápido em vez de o saltar
Devo passar um TFileStream ao LoadFromStream?
Prefira o LoadFromFile para ficheiros em disco, porque já faz buffer internamente. Quando precisar do overload de fluxo, passe um TMemoryStream ou um TBufferedFileStream, já que o HotPDF lê diretamente o fluxo que fornece durante a primeira passagem estrutural e um fluxo de handle cru custa uma chamada de sistema por cada pequena leitura
Carregamento protegido por palavra-passe, encriptação AES-256 R6, verificação de assinaturas CMS e o resto da API de documentos carregados viajam juntos numa única biblioteca VCL nativa para Delphi e C++Builder. Detalhes de funcionalidades e versões de IDE suportadas estão na página do produto HotPDF Component