Artigo Técnico

Encriptação AES-256 de PDF a Alta Velocidade para Documentos Massivos

Encriptar um PDF de 2 GB parece um problema de streaming: abrir o ficheiro, fazer passar dois gigabytes por AES-256 e escrever o resultado. Esse modelo mental está errado de um modo que determina todo o orçamento de desempenho. A norma ISO 32000-1 §7.6 define a granularidade da encriptação de PDF ao nível do objeto individual — cada stream e cada string são encriptados separadamente, cada um com o seu próprio vetor de inicialização e o seu próprio padding. Um arquivo digitalizado de 2 GB com 500.000 objetos corresponde a 500.000 pequenas operações CBC, não a uma única passagem longa, e a essa escala o custo fixo à volta de cada operação pesa mais do que a aritmética AES em si

Este artigo é sobre esse custo fixo: para onde vai o tempo quando código Delphi aplica AES-256 a documentos muito grandes, e como recuperá-lo. Para o lado da configuração — palavras-passe, flags de permissão, a decisão de compatibilidade entre a revisão 5 e a 6 — consulte o artigo complementar sobre como configurar a encriptação AES-256 no HotPDF; nada disso é repetido aqui

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

O esqueleto do ficheiro permanece em texto simples. Tabelas de referência cruzada, números de objeto, chaves de dicionário, a árvore de páginas: nada disso é encriptado, o que é como um leitor consegue localizar objetos antes de validar uma palavra-passe. O que a norma encripta é o conteúdo — dados de stream como descrições de página, imagens, tipos de letra e anexos, mais strings como valores de metadados e texto de anotações. Sob o crypt filter AES-256, cada um é processado de forma independente: um novo IV aleatório de 16 bytes, CBC sobre os bytes, padding de bloco até um limite de 16 bytes, e o IV escrito em claro antes do texto cifrado

Diagrama PDF da granularidade da encriptação AES-256 de PDFs, em que uma chave de ficheiro de 256 bits partilhada sela meio milhão de streams de conteúdo e cadeias separadamente, enquanto o esqueleto estrutural permanece em texto claro
O esqueleto permanece legível enquanto cada fluxo e cadeia é selado por si — cada item transporta um IV de 16 bytes novo, encadeamento CBC e preenchimento de bloco sob uma única chave de ficheiro partilhada de 256 bits

Daqui resultam duas consequências. Primeiro, o texto cifrado é sempre mais longo do que o texto simples: o IV acrescenta 16 bytes e o padding acrescenta mais 1 a 16, pelo que uma string de 100 bytes ocupa 128 bytes em disco e um stream vazio produz sempre 32. Código que dimensiona o buffer de saída pelo comprimento de entrada, ou que escreve de volta apenas tantos bytes quantos leu, produz ficheiros que falham a desencriptação no último bloco de cada objeto. Segundo, o custo acompanha a contagem de objetos, não apenas a contagem de bytes. Um arquivo digitalizado concentra os seus bytes em algumas grandes streams de imagem, mas carrega centenas de milhares de streams curtas e strings pequenas onde o overhead por operação, e não o AES, é que pesa na fatura

A única concessão no design do AES-256 está na gestão das chaves. Os security handlers até à revisão 4 derivavam uma chave distinta para cada objeto, fazendo hash da chave do ficheiro em conjunto com os números de objeto e de geração, obrigando a um novo key schedule de cada vez. Os esquemas /V 5 abandonaram a derivação por objeto: uma única chave de ficheiro aleatória de 256 bits encripta todos os objetos do documento. Esse facto legitima todas as otimizações que se seguem — o dispendioso estado criptográfico pode ser construído uma vez por ficheiro, não uma vez por objeto

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

Um documento de revisão 6 declara o seu esquema no dicionário /Encrypt do trailer, e as entradas que importam cabem em poucas 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 reforçado da ISO 32000-2. /CF define o crypt filter nomeado — /AESV3 significa AES-256 em modo CBC com o IV antecedido — e /StmF e /StrF atribuem esse filtro a streams e strings, respetivamente. /O, /U, /OE e /UE guardam o material de verificação de palavra-passe e de encapsulamento de chave, e /Perms transporta uma cópia encriptada com AES dos bits de permissão para que um editor hostil não consiga alterar /P silenciosamente

A estrutura de custo esconde-se em /OE e /UE. Desembrulhar a chave do ficheiro a partir delas executa o Algorithm 2.B, uma função de derivação de chave iterada que encadeia rondas de SHA-256, SHA-384 e SHA-512 — pelo menos 64 delas, com uma regra de paragem dependente dos dados — construída deliberadamente lenta para que adivinhar palavras-passe continue dispendioso. Esse custo é pago uma vez quando o writer produz o ficheiro e uma vez quando um leitor o abre, cada uma na ordem de poucos milissegundos. Num ficheiro com meio milhão de objetos, a KDF é ruído, e se uma gravação é lenta, o Algorithm 2.B não é o suspeito; o ciclo por objeto é que é

Reutilizar o handle da chave, reutilizar o buffer de rascunho

A implementação ingénua é uma função utilitária arrumada: um helper EncryptAes256Cbc que abre o provider CNG do Windows, seleciona CBC, gera o objeto de chave, encripta um buffer e desfaz tudo no final. Correta, testável por unidade e desastrosa dentro de um ciclo de 500.000 iterações. A documentação da Microsoft assinala BCryptOpenAlgorithmProvider como dispendiosa e recomenda colocar o handle em cache, e BCryptGenerateSymmetricKey executa o key schedule completo do AES e aloca estado do provider — puro desperdício quando a chave nunca muda ao longo do documento

A RTL do Delphi não inclui nenhuma unit de importação para bcrypt, pelo que os pontos de entrada são declarados diretamente. A classe abaixo constrói todo o estado criptográfico uma vez e depois encripta qualquer número de objetos sem qualquer alocação em regime permanente:

Comparação PDF entre um auxiliar AES-256 ingénuo que reconstrói o provider CNG do Windows, o modo de encadeamento e o key schedule para cada objeto PDF, e um construtor TPdfObjectEncryptor içado cujo ciclo não aloca nada
Reabrir o fornecedor CNG e reconstruir o key schedule custa cerca de 150 microssegundos por objeto; o construtor içado paga isso uma vez e o ciclo aquecido não aloca nada
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;  // espaço de trabalho do key object CNG, alocado uma vez
    FScratch: TBytes;    // rascunho do texto cifrado, cresce e depois mantém-se
  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);
  // O key schedule do AES é construído aqui uma única vez e reutilizado para cada objeto
  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
  // Novo IV aleatório por objeto; viaja em claro antes dos dados
  CngCheck(BCryptGenRandom(nil, @IV[0], 16, BCRYPT_USE_SYSTEM_PREFERRED_RNG),
    'BCryptGenRandom');
  Src := PByte(Plain);  // nil para uma entrada vazia é válido: bloco apenas de padding

  // Consulta de tamanho: o padding do CBC acrescenta sempre 1 a 16 bytes, pelo que Need > Length(Plain)
  IVWork := IV;  // O BCryptEncrypt avança o buffer do IV enquanto encadeia
  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);  // cresce um punhado de vezes, depois mantém-se

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

  // Layout AESV3: o IV de 16 bytes, seguido do texto cifrado com padding
  Dest.WriteBuffer(IV[0], 16);
  Dest.WriteBuffer(FScratch[0], Written);
end;

Há três detalhes que sustentam tudo isto. A consulta de tamanho — a primeira chamada a BCryptEncrypt, com um buffer de saída nil — devolve o comprimento do texto cifrado com padding, nunca igual ao comprimento de entrada; o padding é determinístico, pelo que é possível calcular ((Len div 16) + 1) * 16 diretamente e reduzir para metade o número de chamadas, mas a consulta é o contrato documentado. Segundo, o BCryptEncrypt avança o buffer do IV no próprio local enquanto encadeia, pelo que uma cópia de trabalho entra em cada chamada e o IV original chega intacto à saída. Terceiro, o FScratch apenas cresce, até ao maior objeto do ficheiro, e a partir daí o ciclo não aloca mais nada

O que vale a reutilização do handle, medido

O ficheiro que motivou este exercício foi um arquivo de empréstimos digitalizado de 1,8 GB: 412.000 objetos encriptados, transportando 1.710 MB de payload depois de subtraída a estrutura em texto simples. Mesma máquina, mesmo ficheiro, armazenamento NVMe, uma única thread:

  • Configuração por chamada (provider aberto e chave gerada dentro do helper): fase de encriptação 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 ao longo de 412.000 chamadas, ou seja, cerca de 150 µs por chamada gastos a abrir um provider, a definir um modo de encadeamento e a reconstruir um key schedule para uma chave que nunca mudou. Nada disso era criptografia. Com AES-NI, a encriptação CBC de buffers grandes corre perto de 1,4 GB/s num único núcleo, pelo que a própria aritmética AES é responsável por cerca de 1,2 s dos 9,6; a maior parte do resto são as duas transições em modo utilizador do BCryptEncrypt por objeto, mais a geração de IV por objeto. Agrupar os IVs em lote — uma única chamada a BCryptGenRandom a preencher 4.096 deles — reduziu a execução para 8,9 s. Para lá disso, está-se no limite mínimo por objeto da API, e a alavanca que resta é o paralelismo: os objetos /V 5 são independentes sob a chave de ficheiro partilhada, pelo que quatro threads de trabalho, cada uma com o seu próprio objeto de chave, levaram a fase a 3,1 s, até o writer de saída se tornar o ponto de serialização

PDF: Gráfico de barras dos tempos de encriptação AES-256 num PDF digitalizado de 1,8 GB, a cair de 71,3 segundos para 9,6 segundos com estado CNG içado, 8,9 segundos com IVs em lote e 3,1 segundos usando quatro threads worker
Medido numa digitalização de 1,8 GB com 412 000 objetos: içar o estado CNG recupera cerca de 61,7 segundos de sobrecarga pura de API, IVs em lote aparam mais, e quatro threads de trabalho alcançam 3,1 s antes de o escritor serializar

Reescrita completa versus gravação incremental

A granularidade também determina o custo de uma gravação. Adicionar encriptação a um documento existente em texto simples reescreve todos os objetos, por definição: cada stream e string muda de conteúdo e de comprimento, cada deslocamento de referência cruzada move-se, e não existe nenhum caminho incremental. Deve orçamentar-se como uma reescrita sequencial completa, e escrever para um ficheiro temporário que depois é renomeado sobre o alvo, porque uma falha a meio da encriptação deixaria, de outra forma, um ficheiro parcialmente cifrado que nenhuma palavra-passe consegue abrir

O sentido inverso é o barato. Uma vez encriptado um ficheiro, uma atualização incremental anexa novos objetos encriptados com a mesma chave de ficheiro e deixa intocado cada byte original. Carimbar uma anotação de aprovação num arquivo encriptado de 2 GB custa alguns kilobytes de saída anexada, não uma reescrita de 2 GB. O corolário para pipelines: encriptar apenas uma vez, como último passo do trabalho, e deixar que os toques seguintes viajem em gravações incrementais. Uma rotação de palavra-passe que também rode a chave do ficheiro volta a ser uma reescrita completa — deve ser planeada como tal

Medir o throughput sem se enganar a si próprio

As afirmações sobre throughput de encriptação tendem a estar erradas no numerador, no denominador, ou em ambos. O numerador deve ser os bytes de payload: a soma dos comprimentos de stream e de string efetivamente passados pelo AES, depois da compressão, que o writer pode ir totalizando à medida que avança. O tamanho do ficheiro exagera esse valor — o arquivo acima tem 1,8 GB em disco, mas apenas 1.710 MB dele chegam a passar pela cifra. O denominador deve ser apenas a fase de encriptação, delimitada com TStopwatch de System.Diagnostics, com a análise, o deflate e a E/S de disco fora dos limites da medição. Incluir esses fatores faz com que o mesmo código de encriptação meça várias vezes mais lento num ficheiro que simplesmente comprime pior. Os valores acima são comparáveis precisamente porque ambos os lados da divisão contêm apenas encriptação

Nada disto precisa de ser código próprio. O HotPDF encapsula a mesma engenharia por trás de propriedades de componente — ActivateProtection, CryptKeyLength, UseAES256R6 — à altitude certa para aplicações VCL interativas, com as armadilhas da ordem de atribuição cobertas em o artigo sobre AES-256 no HotPDF. Para pipelines não assistidos, a PDF Library for Delphi aplica AES-256 revisão 6 a ficheiros existentes numa única chamada a EncryptFile com Strength 4, e verifica depois o que ficou gravado em disco, um fluxo de trabalho percorrido em o artigo de auditoria de encriptação da PDF Library for Delphi

Os caminhos de encriptação aqui descritos estão disponíveis no HotPDF Delphi Component para Delphi e C++Builder e na biblioteca PDF Library for Delphi; ambas as páginas de produto contêm a referência completa de encriptação