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
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
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:
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