Artigo Técnico

Chaves de encriptação repetidas no FPC: correção PDFiumPas

Antes da versão 3.114.8, o PDFiumPas gerava o material de chaves de encriptação PDF em destinos não Windows com a função Random da runtime library, e como ninguém chamava o Randomize, todos os processos produziam a mesma sequência de bytes. Os builds Free Pascal em Linux e macOS escreviam portanto chaves de encriptação de ficheiro, salts, IVs CBC e prefixos de nonce AES-GCM idênticos, execução após execução. A versão 3.114.8 lê antes o /dev/urandom e dispara uma exceção quando não consegue

O defeito em si é um ciclo de quatro linhas. A lição mais útil é perceber porque é que uma suite de testes que encripta e desencripta centenas de documentos, com AESV3 e AESV4, com e sem PDF MAC, esteve verde o tempo todo. Aleatoriedade constante por processo é invisível para qualquer teste que corra dentro de um só processo, e é exatamente assim que os testes de encriptação costumam estar escritos

Onde precisa o PDFiumPas de bytes aleatórios?

Todos os bytes aleatórios na stack de encriptação do PDFiumPas vêm de um único procedimento, o AesGenerateRandomBytes na unidade FPdfAes, pelo que uma fonte má 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 sítios:

  • A chave de encriptação de ficheiro de 32 bytes, gerada de fresco pelo DeriveEncryptionKeys para cada documento e depois embrulhada em /UE e /OE sob chaves derivadas da password
  • Dois salts de 16 bytes, um guardado nos últimos 16 bytes de /U e outro nos últimos 16 bytes de /O, cada um dividido num salt de validação de 8 bytes e num salt de chave 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 encriptado sob a chave de ficheiro
  • Um IV CBC de 16 bytes acrescentado à frente de cada string e stream encriptados 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 o EnableIntegrityProtection está ativo
Todos os bytes aleatórios na stack de encriptação do PDFiumPas fluem do AesGenerateRandomBytes em FPdfAes para seis consumidores: a chave de encriptação de ficheiro de 32 bytes embrulhada em /UE e /OE, os salts de /U e /O, os bytes de preenchimento de /Perms, o IV CBC do AESV3, o prefixo de nonce GCM do AESV4, e o salt KDF e a chave MAC
Um gerador partilhado significa que uma fonte má contamina o material de chaves todo de uma vez, razão pela qual a correção aterrou num único procedimento em vez de em cada sítio de chamada

Porque é que todos os processos produziam a mesma chave?

O AesGenerateRandomBytes só usava o gerador do sistema operativo em Windows; em todo o resto enchia o buffer a partir do gerador pseudoaleatório da RTL, e esse gerador começa de RandSeed = 0 a menos que o programa chame o Randomize. O comentário acima do ciclo dizia que o gerador era semeado a partir do GetTickCount64. Nenhuma linha de código alguma vez o fez, o que tornava o comentário o único sítio onde a semente existia:

// Ramo não Windows do AesGenerateRandomBytes antes de 3.114.8
// (o comentário acima prometia uma semente GetTickCount64 que nunca foi 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, pelo que o primeiro documento que qualquer processo encripta partilha a sua chave de ficheiro com o primeiro documento de todos os outros processos a correr a mesma build, o segundo com o segundo, e por aí adiante. A chave de ficheiro em R5, R6 e R7 não depende da password de todo, já que a password apenas a embrulha, o que significa que alguém capaz de reproduzir a sequência detém a chave sem saber password nenhuma. O AESV4 acrescenta uma segunda falha: a mesma chave com o mesmo prefixo de 8 bytes e um contador a recomeçar em zero repete nonces GCM, o que a NIST SP 800-38D §8 proíbe frontalmente. Um nonce GCM repetido sob a mesma chave revela o XOR dos dois plaintexts e expõe a subchave de autenticação, pelo que os tags de que a encriptação AESV4-GCM e o token PDF MAC dependem deixam de significar nada. Confidencialidade e integridade vão-se embora ao mesmo tempo

Gerador RTL sem semente no PDFiumPas em builds FPC não Windows: com RandSeed 0 todos os processos emitem a mesma sequência, pelo que o documento um no processo A transporta a mesma chave de ficheiro que o documento um no processo B, e o AESV4 repete nonces GCM porque a mesma chave encontra o mesmo prefixo com o contador a recomeçar em zero
Como a chave de ficheiro nunca depende da password, quem conseguir reproduzir a sequência detém a chave por completo, e nonces GCM repetidos destroem confidencialidade e integridade em conjunto

O âmbito é mais estreito do que aquele parágrafo pode sugerir. Os builds Windows nunca foram afetados, porque o ramo Windows sempre chamou o CryptGenRandom através da advapi32 com CRYPT_VERIFYCONTEXT e disparava 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 em Linux e macOS, mais uma entrada para a lista de armadilhas Delphi contra FPC em builds PDFium

Porque é que o Randomize nunca foi a correção certa?

Chamar o Randomize teria escondido o sintoma sem corrigir a fonte, porque o RandSeed é um valor de 32 bits e o Randomize deriva-o do relógio. Isso limita o número de sequências de chaves possíveis a 2^32, e saber aproximadamente quando um ficheiro foi escrito corta a pesquisa para muito abaixo disso, o que não é nada ao lado de uma chave AES de 256 bits. O material de chaves tem de vir da pool de entropia do kernel, pelo que o AesGenerateRandomBytes em 3.114.8 lê o /dev/urandom, cicla sobre leituras curtas, e dispara se a pool não conseguir entregar cada 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 coincide com o que o ramo Windows sempre fez quando o CryptGenRandom está indisponível. Uma gravação encriptada falhada é um incidente de que se apercebe no próprio dia; uma gravação bem-sucedida com chaves previsíveis é daquelas de que fica a saber por outra pessoa. Duas consequências práticas se seguem. Um contentor mínimo ou chroot sem um /dev populado passa a falhar a encriptação em vez de degradar em silêncio, por isso monte-o. E como a exceção se propaga para fora do TPdf.SaveAsEncrypted depois de o ficheiro de destino ter sido aberto com fmCreate, fica para trás um ficheiro de saída vazio para o seu error handler apagar

Porque é que os testes round-trip nunca o apanharam?

Um teste round-trip não consegue ver aleatoriedade constante, porque a desencriptação recupera a chave de ficheiro que a encriptação tiver escolhido. O teste encripta um documento, volta a abri-lo com a password, desembrulha a chave de /UE, e desencripta todos os objetos; uma chave previsível desembrulha e desencripta exatamente tão bem como uma aleatória, e os tags GCM verificam porque foram calculados com essa mesma chave. Até um teste que encripta duas vezes e afirma que as duas saídas diferem passa, já que a segunda chamada no mesmo processo saca os bytes seguintes da sequência. A propriedade que importa, uma chave diferente em cada processo, só é observável comparando saídas entre processos. Sempre que o mesmo código produz e consome um valor, os testes ficam cegos a classes inteiras de defeito, e a aleatoriedade é o exemplo mais puro

Como pode testar a aleatoriedade de chaves entre processos?

Corra uma pequena sonda duas vezes como processos separados na plataforma de destino 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 de todos em cada ficheiro encriptado, por isso imprimi-lo em logs de CI não divulga nada, mas vem do mesmo gerador que a chave de ficheiro:

Teste de aleatoriedade entre processos para o PDFiumPas: o programa SaltProbe chama o DeriveEncryptionKeys e imprime em hex os bytes 32 a 47 de /U, o job corre-o duas vezes como processos separados e falha quando as linhas coincidem, e os PDFs expedidos são comparados pelos últimos 16 bytes das suas strings /U
A aleatoriedade constante é invisível dentro de um processo porque a desencriptação recupera a chave que a encriptação tiver escolhido, pelo que a propriedade que importa só é observável comparando saídas 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);                 // tem de diferir em cada execução
end.

Ligue a sonda à build de todos os destinos não Windows: corra-a duas vezes, faça o job falhar se as duas linhas coincidirem. A mesma comparação funciona em ficheiros já em circulação. Pegue em dois PDFs encriptados escritos por execuções diferentes da mesma aplicação, leia as strings /U dos seus dicionários Encrypt, e compare os últimos 16 bytes; salts idênticos identificam uma build afetada, e os documentos devem ser encriptados de novo a partir do seu plaintext com a 3.114.8 ou posterior, para que cada um receba uma chave de ficheiro fresca. O hábito geral é exercitar os 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 timestamps 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 chaves retirado do gerador do sistema operativo em todas as plataformas. Detalhes e downloads estão na página do componente PDFium para Delphi