Artigo Técnico

Chaves PDF repetidas no FPC: correção de RNG no PDFiumPas

Antes da versão 3.114.8, o PDFiumPas gerava material de chave de criptografia PDF em alvos não Windows com a função Random da RTL, e como nada chamava Randomize, todo processo produzia a mesma sequência de bytes. Builds de Free Pascal no Linux e no macOS portanto gravavam chaves de criptografia de arquivo idênticas, salts, CBC IVs e prefixos de nonce AES-GCM iguais a cada execução. A versão 3.114.8 lê /dev/urandom em vez disso e dispara uma exceção quando não consegue

O defeito em si é um loop de quatro linhas. A lição mais útil é por que uma suíte de testes que criptografa e descriptografa centenas de documentos, com AESV3 e AESV4, com e sem PDF MAC, ficou verde o tempo todo. Aleatoriedade constante por processo é invisível para todo teste que roda dentro de um processo, e é exatamente assim que testes de criptografia costumam ser escritos

Onde o PDFiumPas precisa de bytes aleatórios?

Todo byte aleatório na pilha de criptografia do PDFiumPas vem de uma única procedure, a AesGenerateRandomBytes na unit FPdfAes, então uma fonte ruim contamina tudo. O security handler padrão na ISO 32000-2 §7.6.4 e a extensão AESV4 na ISO/TS 32003 consomem esses bytes nestes lugares:

  • A chave de criptografia de arquivo de 32 bytes, gerada nova pelo DeriveEncryptionKeys para cada documento e depois embrulhada em /UE e /OE sob chaves derivadas da senha
  • Dois salts de 16 bytes, um guardado nos últimos 16 bytes de /U e um nos últimos 16 bytes de /O, cada um dividido num salt de validação de 8 bytes e num key salt de 8 bytes
  • Os bytes 12 a 15 do plaintext por trás de /Perms, que a ISO 32000-2 preenche com dados aleatórios antes de o bloco ser criptografado sob a chave de arquivo
  • Um CBC IV de 16 bytes prefixado a toda string e stream criptografados num documento AESV3
  • Um prefixo de nonce de 8 bytes para documentos AESV4, seguido de um contador por objeto de 4 bytes que começa em zero
  • O /KDFSalt de 32 bytes e a chave MAC quando EnableIntegrityProtection está setado
Todo byte aleatório na pilha de criptografia do PDFiumPas flui da AesGenerateRandomBytes na FPdfAes para seis consumidores: a chave de criptografia de arquivo de 32 bytes embrulhada em /UE e /OE, os salts de /U e /O, os bytes de preenchimento de /Perms, o CBC IV do AESV3, o prefixo de nonce GCM do AESV4, e o KDF salt e a chave MAC
Um gerador compartilhado significa que uma fonte ruim contamina o material de chave em todos os lugares de uma vez, e é por isso que a correção caiu numa única procedure em vez de em cada ponto de chamada

Por que todo processo produzia a mesma chave?

A AesGenerateRandomBytes usava o gerador do sistema operacional só no Windows; em todo o resto ela preenchia o buffer com o gerador pseudo-aleatório da RTL, e esse gerador parte de RandSeed = 0 a menos que o programa chame Randomize. O comentário acima do loop dizia que o gerador era semeado a partir do GetTickCount64. Nenhuma linha de código jamais fez isso, o que tornava o comentário o único lugar em que a semente existia:

// Ramo não Windows da AesGenerateRandomBytes antes da 3.114.8
// (o comentário acima prometia uma semente GetTickCount64 nunca aplicada)
P := PByte(Buffer);
for I := 0 to Count - 1 do
  P[I] := Byte(Random(256));

A sequência recomeça a cada processo e avança dentro dele, então o primeiro documento que qualquer processo criptografa compartilha a chave de arquivo dele com o primeiro documento de todo outro processo rodando a mesma build, o segundo com o segundo, e assim por diante. A chave de arquivo em R5, R6 e R7 não depende da senha de forma alguma, já que a senha só a embrulha, o que significa que qualquer um capaz de reproduzir a sequência segura a chave sem saber senha nenhuma. O AESV4 adiciona uma segunda falha: a mesma chave com o mesmo prefixo de 8 bytes e um contador recomeçando em zero repete nonces GCM, o que a NIST SP 800-38D §8 proíbe terminantemente. Um nonce GCM repetido sob uma chave revela o XOR dos dois plaintexts e expõe a subchave de autenticação, então as tags de que a criptografia AESV4-GCM e o token PDF MAC dependem param de significar algo. Confidencialidade e integridade vão embora juntas

Gerador da RTL sem semente no PDFiumPas em builds FPC não Windows: com RandSeed 0 todo processo emite a mesma sequência, então o documento um no processo A carrega a mesma chave de arquivo que o documento um no processo B, e o AESV4 repete nonces GCM porque a mesma chave encontra o mesmo prefixo com o contador recomeçando em zero
Como a chave de arquivo nunca depende da senha, qualquer um que reproduza a sequência segura a chave de vez, e nonces GCM repetidos destroem confidencialidade e integridade juntas

O escopo é mais estreito do que aquele parágrafo pode sugerir. Builds Windows nunca foram afetadas, porque o ramo Windows sempre chamou CryptGenRandom pela advapi32 com CRYPT_VERIFYCONTEXT e disparava erro quando isso falhava. O que ficou exposto foi a saída de builds não Windows anteriores à 3.114.8, o que na prática significa aplicações Lazarus e Free Pascal no Linux e no macOS, mais uma entrada para a lista de armadilhas de Delphi versus FPC em builds do PDFium

Por que Randomize nunca foi a correção certa?

Chamar Randomize teria escondido o sintoma sem corrigir a fonte, porque RandSeed é um valor de 32 bits e o Randomize o deriva do relógio. Isso limita o número de streams de chave possíveis a 2^32, e saber mais ou menos quando um arquivo foi escrito corta a busca bem abaixo disso, o que não é nada perto de uma chave AES de 256 bits. Material de chave precisa vir do pool de entropia do kernel, então a AesGenerateRandomBytes na 3.114.8 lê /dev/urandom, itera sobre leituras curtas, e dispara erro se o pool não entregar todo byte pedido:

Handle := FileOpen('/dev/urandom', fmOpenRead or fmShareDenyNone);
if Handle <> THandle(-1) then
try
  Remaining := Count;
  while Remaining > 0 do
  begin
    Got := FileRead(Handle, P^, Remaining);
    if Got <= 0 then
      Break;               // falha ou fim inesperado do stream
    Inc(P, Got);
    Dec(Remaining, Got);
  end;
  if Remaining = 0 then
    Exit;
finally
  FileClose(Handle);
end;
raise Exception.Create('FPdfAes: /dev/urandom unavailable; refusing to emit predictable key/IV');

Recusar é deliberado, e bate com o que o ramo Windows sempre fez quando o CryptGenRandom não está disponível. Um save criptografado que falha é um incidente que você nota no mesmo dia; um save bem-sucedido com chaves previsíveis é um que você fica sabendo pelos outros. Duas consequências práticas decorrem. Um container mínimo ou chroot sem um /dev populado agora falha na criptografia em vez de degradar em silêncio, então monte-o. E como a exceção se propaga para fora do TPdf.SaveAsEncrypted depois de o arquivo alvo ter sido aberto com fmCreate, um arquivo de saída vazio fica para trás para o seu error handler apagar

Por que os testes de round-trip nunca pegaram isso?

Um teste de round-trip não consegue ver aleatoriedade constante, porque a descriptografia recupera qualquer chave de arquivo que a criptografia tenha escolhido. O teste criptografa um documento, abre de novo com a senha, desembrulha a chave de /UE, e descriptografa todo objeto; uma chave previsível desembrulha e descriptografa exatamente tão bem quanto uma aleatória, e as tags GCM verificam porque foram computadas com essa mesma chave. Até um teste que criptografa duas vezes e assevera que as duas saídas diferem passa, já que a segunda chamada no mesmo processo puxa os próximos bytes da sequência. A propriedade que importa, uma chave diferente em todo processo, só é observável comparando a saída entre processos. Sempre que o mesmo código produz e consome um valor, os testes ficam cegos para classes inteiras de defeito, e aleatoriedade é o exemplo mais puro

Como testar a aleatoriedade de chaves entre processos?

Rode uma pequena sonda duas vezes como processos separados na plataforma alvo e compare a saída. A sonda abaixo chama o DeriveEncryptionKeys e imprime o salt guardado nos bytes 32 a 47 da entrada /U. Esse valor é escrito à vista em todo arquivo criptografado, então imprimi-lo em logs de CI não divulga nada, mas ele vem do mesmo gerador que a chave de arquivo:

Teste de aleatoriedade entre processos para o PDFiumPas: o programa SaltProbe chama DeriveEncryptionKeys e imprime o hex dos bytes 32 a 47 de /U, o job o roda duas vezes como processos separados e falha quando as linhas batem, e PDFs entregues são comparados pelos últimos 16 bytes de suas strings /U
Aleatoriedade constante é invisível dentro de um processo porque a descriptografia recupera qualquer chave que a criptografia tenha escolhido, então a propriedade que importa só é observável comparando a saída entre processos
program SaltProbe;
{$mode delphi}
uses
  SysUtils, FPdfEncrypt;
var
  Opts: TPdfEncryptOptions;
  Keys: TPdfEncryptionKeys;
  I: Integer;
  Hex: string;
begin
  Opts := TPdfEncryptOptions.Default;
  Opts.UserPassword := 'probe';
  Opts.Revision := erR6;
  DeriveEncryptionKeys(Opts, Keys);
  Hex := '';
  for I := 32 to 47 do          // /U = hash de 32 bytes + salt de 16 bytes
    Hex := Hex + IntToHex(Keys.UEntry[I], 2);
  WriteLn(Hex);                 // precisa diferir a cada execução
end.

Ligue a sonda ao build para todo alvo não Windows: rode duas vezes, e faça o job falhar se as duas linhas baterem. A mesma comparação funciona em arquivos já em circulação. Pegue dois PDFs criptografados escritos por execuções diferentes da mesma aplicação, leia as strings /U dos dicionários Encrypt deles, e compare os últimos 16 bytes; salts idênticos identificam uma build afetada, e os documentos devem ser criptografados de novo a partir do plaintext deles com a 3.114.8 ou posterior para que cada um receba uma chave de arquivo nova. O hábito geral é exercitar caminhos de código não Windows na própria plataforma em vez de confiar na execução Windows, o mesmo raciocínio por trás do backend de timestamp libcurl para builds não Windows

O PDFiumPas é um componente PDF para Delphi e Lazarus construído sobre o motor PDFium, com AES-256, AES-GCM e o token PDF MAC implementados nativamente em Pascal e material de chave vindo do gerador do sistema operacional em toda plataforma. Detalhes e downloads estão na página do componente PDFium para Delphi