Artigo Técnico

Criptografia de PDF AES-256 de Alta Velocidade para Documentos Massivos

Criptografar um PDF de 2 GB parece um problema de streaming: abrir o arquivo, passar dois gigabytes pelo AES-256, escrever o resultado. Esse modelo mental está errado de uma forma que decide todo o orçamento de desempenho. A ISO 32000-1 §7.6 define a granularidade da criptografia de PDF no objeto individual — cada fluxo e cada string são criptografados separadamente, cada um com seu próprio vetor de inicialização e seu próprio preenchimento. Um arquivo compactado de 2 GB com 500.000 objetos são 500.000 pequenas operações CBC, não uma longa passagem, e nessa escala o custo fixo em torno de cada operação importa mais do que a aritmética AES dentro dela

Este artigo é sobre esse custo fixo: para onde vai o tempo quando o código Delphi aplica o AES-256 em documentos muito grandes e como recuperá-lo. Para o lado da configuração — senhas, sinalizadores de permissão, a chamada de compatibilidade da revisão 5 versus 6 — consulte o artigo complementar sobre configurar criptografia AES-256 no HotPDF; nada disso se repete aqui

Meio milhão de operações CBC, não uma passagem

O esqueleto do arquivo permanece em texto simples. Tabelas de referência cruzada, números de objetos, chaves de dicionário, a árvore de páginas: nada disso é criptografado, e é assim que um leitor pode localizar objetos antes de validar uma senha. O que o padrão criptografa é o conteúdo — dados de fluxo, como descrições de página, imagens, fontes e anexos, além de strings como valores de metadados e texto de anotação. Sob o filtro de criptografia AES-256, cada um é processado por conta própria: um novo IV aleatório de 16 bytes, CBC sobre os bytes, preenchimento de bloco para um limite de 16 bytes, e o IV gravado de forma clara à frente do texto cifrado

Duas consequências se seguem. Primeiro, o texto cifrado é sempre mais longo que o texto simples: o IV adiciona 16 bytes e o preenchimento adiciona mais de 1 a 16, portanto, uma string de 100 bytes ocupa 128 bytes no disco e um fluxo vazio ainda produz 32. O código que dimensiona o buffer de saída para o comprimento da entrada ou reescreve apenas tantos bytes quanto leu produz arquivos que falham na descriptografia no último bloco de cada objeto. Segundo, o custo acompanha a contagem de objetos, não apenas a contagem de bytes. Um arquivo compactado concentra seus bytes em alguns grandes fluxos de imagem, mas carrega centenas de milhares de fluxos curtos e strings pequenas onde a sobrecarga por operação, não o AES, é a conta principal

A única misericórdia no design do AES-256 é o manuseio de chaves. Manipuladores de segurança até a revisão 4 derivavam uma chave distinta para cada objeto combinando a chave do arquivo com os números do objeto e da geração, forçando um novo cronograma de chave a cada vez. Os esquemas /V 5 abandonaram a derivação por objeto: uma chave de arquivo aleatória de 256 bits criptografa cada objeto no documento. Esse fato autoriza todas as otimizações abaixo — o caro estado criptográfico pode ser construído uma vez por arquivo, não uma vez por objeto

O dicionário R6 /Encrypt: uma abertura lenta, objetos baratos

Um documento da revisão 6 declara seu esquema no dicionário /Encrypt do trailer, e as entradas que importam cabem em algumas linhas:

/Filter /Standard
/V 5  /R 6  /Length 256
/CF << /StdCF << /CFM /AESV3  /Length 32  /AuthEvent /DocOpen >> >>
/StmF /StdCF    /StrF /StdCF
/O ...48 bytes...   /U ...48 bytes...
/OE ...32 bytes...  /UE ...32 bytes...
/Perms ...16 bytes...  /P -3904  /EncryptMetadata true

/V 5 seleciona a arquitetura de chave de 256 bits e /R 6 o handshake fortalecido da ISO 32000-2. /CF define o filtro de criptografia nomeado — /AESV3 significa AES-256 no modo CBC com o IV prefixado — e /StmF e /StrF atribuem esse filtro a fluxos e strings, respectivamente. /O, /U, /OE e /UE contêm o material de verificação de senha e empacotamento de chave, e /Perms carrega uma cópia criptografada por AES dos bits de permissão para que um editor hostil não possa inverter /P silenciosamente

A estrutura de custo se esconde em /OE e /UE. Desempacotar a chave do arquivo deles executa o Algoritmo 2.B, uma função de derivação de chave iterada encadeando rodadas SHA-256, SHA-384 e SHA-512 — pelo menos 64 delas, com uma regra de parada dependente de dados — construída deliberadamente lenta para que a adivinhação de senhas continue cara. Esse preço é pago uma vez quando o gravador produz o arquivo e uma vez quando um leitor o abre, milissegundos de um dígito cada. Em um arquivo de meio milhão de objetos, o KDF é ruído, e se um salvamento for lento, o Algoritmo 2.B não é o suspeito; o loop por objeto é

Reutilize o handle da chave, reutilize o buffer de rascunho

A implementação ingênua é uma função utilitária organizada: um ajudante EncryptAes256Cbc que abre o provedor CNG do Windows, seleciona CBC, gera o objeto de chave, criptografa um buffer e destrói tudo. Correto, testável por unidade e desastroso dentro de um loop de 500.000 iterações. A documentação da Microsoft sinaliza BCryptOpenAlgorithmProvider como caro e recomenda fazer o cache do handle, e BCryptGenerateSymmetricKey executa o cronograma de chave AES completo e aloca o estado do provedor — puro desperdício quando a chave nunca muda em todo o documento

A RTL do Delphi não vem com nenhuma unidade de importação bcrypt, portanto, declare os pontos de entrada diretamente. A classe abaixo constrói todo o estado criptográfico uma vez e depois criptografa qualquer número de objetos sem alocação em estado estacionário:

uses
  Winapi.Windows, System.SysUtils, System.Classes;

const
  BCRYPT_AES_ALGORITHM  = 'AES';
  BCRYPT_CHAINING_MODE  = 'ChainingMode';
  BCRYPT_CHAIN_MODE_CBC = 'ChainingModeCBC';
  BCRYPT_OBJECT_LENGTH  = 'ObjectLength';
  BCRYPT_BLOCK_PADDING            = $00000001;
  BCRYPT_USE_SYSTEM_PREFERRED_RNG = $00000002;

type
  NTSTATUS = Integer;
  BCRYPT_HANDLE = Pointer;

function BCryptOpenAlgorithmProvider(out hAlg: BCRYPT_HANDLE; AlgId,
  Impl: PWideChar; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptCloseAlgorithmProvider(hAlg: BCRYPT_HANDLE;
  Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptSetProperty(hObj: BCRYPT_HANDLE; Prop: PWideChar; Input: PByte;
  cbInput, Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptGetProperty(hObj: BCRYPT_HANDLE; Prop: PWideChar; Output: PByte;
  cbOutput: ULONG; out cbResult: ULONG; Flags: ULONG): NTSTATUS; stdcall;
  external 'bcrypt.dll';
function BCryptGenerateSymmetricKey(hAlg: BCRYPT_HANDLE;
  out hKey: BCRYPT_HANDLE; KeyObj: PByte; cbKeyObj: ULONG; Secret: PByte;
  cbSecret: ULONG; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptDestroyKey(hKey: BCRYPT_HANDLE): NTSTATUS; stdcall;
  external 'bcrypt.dll';
function BCryptEncrypt(hKey: BCRYPT_HANDLE; Input: PByte; cbInput: ULONG;
  Padding: Pointer; IV: PByte; cbIV: ULONG; Output: PByte; cbOutput: ULONG;
  out cbResult: ULONG; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptGenRandom(hAlg: BCRYPT_HANDLE; Buffer: PByte;
  cbBuffer, Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';

procedure CngCheck(Status: NTSTATUS; const Api: string);
begin
  if Status <> 0 then
    raise Exception.CreateFmt('%s failed, NTSTATUS 0x%.8x',
      [Api, Cardinal(Status)]);
end;

type
  TPdfObjectEncryptor = class
  private
    FAlg: BCRYPT_HANDLE;
    FKey: BCRYPT_HANDLE;
    FKeyObject: TBytes;  // CNG key-object workspace, allocated once
    FScratch: TBytes;    // ciphertext scratch, grows and then stays
  public
    constructor Create(const FileKey: TBytes);
    destructor Destroy; override;
    procedure EncryptObject(const Plain: TBytes; Dest: TStream);
  end;

constructor TPdfObjectEncryptor.Create(const FileKey: TBytes);
var
  Mode: string;
  ObjLen, Got: ULONG;
begin
  inherited Create;
  if Length(FileKey) <> 32 then
    raise Exception.Create('AES-256 file key must be 32 bytes');
  CngCheck(BCryptOpenAlgorithmProvider(FAlg, BCRYPT_AES_ALGORITHM, nil, 0),
    'BCryptOpenAlgorithmProvider');
  Mode := BCRYPT_CHAIN_MODE_CBC;
  CngCheck(BCryptSetProperty(FAlg, BCRYPT_CHAINING_MODE,
    PByte(PWideChar(Mode)), (Length(Mode) + 1) * SizeOf(WideChar), 0),
    'BCryptSetProperty');
  CngCheck(BCryptGetProperty(FAlg, BCRYPT_OBJECT_LENGTH, PByte(@ObjLen),
    SizeOf(ObjLen), Got, 0), 'BCryptGetProperty');
  SetLength(FKeyObject, ObjLen);
  // The AES key schedule is built once here and reused for every object
  CngCheck(BCryptGenerateSymmetricKey(FAlg, FKey, PByte(FKeyObject), ObjLen,
    PByte(FileKey), 32, 0), 'BCryptGenerateSymmetricKey');
end;

destructor TPdfObjectEncryptor.Destroy;
begin
  if FKey <> nil then
    BCryptDestroyKey(FKey);
  if FAlg <> nil then
    BCryptCloseAlgorithmProvider(FAlg, 0);
  inherited;
end;

procedure TPdfObjectEncryptor.EncryptObject(const Plain: TBytes; Dest: TStream);
var
  IV, IVWork: array[0..15] of Byte;
  Need, Written: ULONG;
  Src: PByte;
begin
  // Fresh random IV per object; it travels in the clear ahead of the data
  CngCheck(BCryptGenRandom(nil, @IV[0], 16, BCRYPT_USE_SYSTEM_PREFERRED_RNG),
    'BCryptGenRandom');
  Src := PByte(Plain);  // nil for an empty input is valid: padding-only block

  // Size query: CBC padding always adds 1..16 bytes, so Need > Length(Plain)
  IVWork := IV;  // BCryptEncrypt advances the IV buffer while it chains
  CngCheck(BCryptEncrypt(FKey, Src, Length(Plain), nil, @IVWork[0], 16,
    nil, 0, Need, BCRYPT_BLOCK_PADDING), 'BCryptEncrypt(size)');

  if ULONG(Length(FScratch)) < Need then
    SetLength(FScratch, Need);  // grows a handful of times, then stays put

  IVWork := IV;
  CngCheck(BCryptEncrypt(FKey, Src, Length(Plain), nil, @IVWork[0], 16,
    PByte(FScratch), Need, Written, BCRYPT_BLOCK_PADDING), 'BCryptEncrypt');

  // AESV3 layout: the 16-byte IV, then the padded ciphertext
  Dest.WriteBuffer(IV[0], 16);
  Dest.WriteBuffer(FScratch[0], Written);
end;

Três detalhes são fundamentais. A consulta de tamanho — a primeira chamada de BCryptEncrypt, com um buffer de saída nulo — retorna o comprimento do texto cifrado preenchido, nunca igual ao comprimento de entrada; o preenchimento é determinístico, então você pode computar ((Len div 16) + 1) * 16 sozinho e reduzir pela metade a contagem de chamadas, mas a consulta é o contrato documentado. Segundo, BCryptEncrypt avança o buffer IV no local enquanto é encadeado, de forma que uma cópia de trabalho vai para cada chamada e o IV original vai para a saída. Terceiro, FScratch apenas cresce, até o maior objeto do arquivo, após o qual o loop não aloca nada

O que vale a pena reutilizar um handle, medido

O arquivo que forçou este exercício foi um arquivo de empréstimo digitalizado de 1,8 GB: 412.000 objetos criptografados carregando 1.710 MB de carga útil depois que a estrutura de texto simples é subtraída. Mesma máquina, mesmo arquivo, armazenamento NVMe, uma thread:

  • Configuração por chamada (provedor aberto e chave gerada dentro do auxiliar): fase de criptografia 71,3 s — 1.710 MB ÷ 71,3 s ≈ 24 MB/s
  • Estado elevado (a classe acima): 9,6 s — 1.710 MB ÷ 9,6 s ≈ 178 MB/s

A diferença é de 61,7 s em 412.000 chamadas, ou cerca de 150 µs por chamada gasto abrindo um provedor, definindo um modo de encadeamento e reconstruindo um cronograma de chave para uma chave que nunca mudou. Nada disso era criptografia. Com o AES-NI, a criptografia CBC de grandes buffers roda perto de 1,4 GB/s em um núcleo, então a aritmética AES em si é responsável por cerca de 1,2 s dos 9,6; a maior parte do restante são as duas transições de modo de usuário BCryptEncrypt por objeto mais a geração de IV por objeto. O processamento em lote dos IVs — uma chamada de BCryptGenRandom preenchendo 4.096 deles — reduziu a execução para 8,9 s. Passado isso, você está no limite por objeto da API, e a alavanca restante é o paralelismo: os objetos /V 5 são independentes sob a chave de arquivo compartilhada, portanto, quatro threads de trabalho com um objeto de chave cada levaram a fase para 3,1 s antes que o gravador de saída se tornasse o ponto de serialização

Reescrita completa versus salvamento incremental

A granularidade também decide quanto custa um salvamento. Adicionar criptografia a um documento de texto simples existente reescreve cada objeto por definição: cada fluxo e string altera o conteúdo e o comprimento, cada deslocamento de referência cruzada se move e não existe um caminho incremental. Planeje isso como uma reescrita sequencial completa e grave em um arquivo temporário que seja renomeado sobre o alvo, porque, de outra forma, uma falha na metade da criptografia deixa um arquivo semi-cifrado que nenhuma senha abrirá

A direção oposta é a barata. Depois que um arquivo é criptografado, uma atualização incremental anexa novos objetos criptografados com a mesma chave de arquivo e deixa cada byte original intocado. Carimbar uma anotação de aprovação em um arquivo criptografado de 2 GB custa kilobytes de saída anexada, não uma reescrita de 2 GB. O corolário do pipeline: criptografe uma vez, como o último passo do trabalho, e deixe que os toques subsequentes aproveitem salvamentos incrementais. Uma rotação de senha que também rotaciona a chave do arquivo é uma reescrita completa novamente — programe-a como tal

Medindo o rendimento sem se enganar

As alegações de rendimento de criptografia tendem a estar erradas no numerador, no denominador ou em ambos. O numerador deve ser os bytes de carga útil: a soma dos comprimentos de fluxo e string efetivamente passados pelo AES, após a compactação, que o gravador pode totalizar à medida que avança. O tamanho do arquivo superestima — o arquivo compactado acima tem 1,8 GB em disco, mas apenas 1.710 MB dele tocam a cifra. O denominador deve ser apenas a fase de criptografia, delimitada com TStopwatch de System.Diagnostics, com a análise sintática, deflate e I/O de disco fora dos delimitadores. Se você incluir isso, o mesmo código de criptografia será medido várias vezes mais lento em um arquivo que simplesmente compacta de maneira pior. Os números acima são comparáveis ​​precisamente porque ambos os lados da divisão são apenas criptografia

Nada disso precisa ser código de sua propriedade. O HotPDF envolve a mesma engenharia em propriedades do componente — ActivateProtection, CryptKeyLength, UseAES256R6 — na altitude certa para aplicações VCL interativas, com as armadilhas da ordem de atribuição abordadas no artigo AES-256 do HotPDF. Para pipelines não supervisionados, o PDFlibPas aplica a revisão 6 do AES-256 a arquivos existentes em uma única chamada EncryptFile na Força 4 e verifica depois o que parou no disco, um fluxo de trabalho detalhado no artigo de auditoria de criptografia PDFlibPas

Os caminhos de criptografia descritos aqui acompanham o HotPDF Component para Delphi e C++Builder e na biblioteca PDFlibPas; ambas as páginas de produtos trazem a referência completa de criptografia