Quando um PDF AES-256 revision 6 rejeita uma senha que você sabe que está correta, o culpado de costume é o contador de rodadas no hash reforçado do R6 (Algoritmo 2.B da ISO 32000-2). O PDFium Component para Delphi tinha exatamente esse bug antes da v2.57.2: o TPdf.SaveAsEncrypted contava rodadas a partir da base errada, e cerca de 30% dos arquivos que ele produzia eram recusados pelo FPDF_LoadDocument com FPDF_ERR_PASSWORD
A parte desagradável não é a aritmética. A parte desagradável é que o defeito era invisível a todo teste que criptografava um arquivo e depois o verificava com o mesmo código, e que uma única ida e volta bem-sucedida não provava nada. Este post percorre o que o Algoritmo 2.B realmente pede, onde um deslize de uma rodada se esconde, por que ele falha só às vezes, e como construir um teste de regressão que pega um bug assim antes de um cliente pegar
O que a regra de terminação do hash R6 realmente diz?
O Algoritmo 2.B para apenas depois de pelo menos 64 rodadas, e então apenas quando o último byte da saída AES mais recente E é menor ou igual ao número da rodada menos 32, em que o número da rodada é a contagem 1-based de rodadas já executadas. Depois da N-ésima rodada você compara com N. Não N − 1, não o índice da próxima rodada. Essa única definição é toda a superfície do bug
O resto do algoritmo é mecânico. Comece com K como o SHA-256 da senha, do salt e (para a chave do owner) do valor /U de 48 bytes. Cada rodada constrói K1 como senha ‖ K ‖ UK repetido 64 vezes, o criptografa com AES-128-CBC sem padding usando os primeiros 16 bytes de K como chave e os próximos 16 como IV, toma os primeiros 16 bytes de E como um inteiro de 128 bits big-endian mod 3, e escolhe SHA-256, SHA-384 ou SHA-512 de acordo para produzir o próximo K. O hash final são os primeiros 32 bytes de K. A ISO 32000-2 §7.6.4.3 o define; o comprimento do loop dependente de dados é deliberado, porque torna o trabalho por tentativa de senha imprevisível para um cracker de GPU
Aqui está a forma correta do loop, escrita como ilustração e não como implementação pronta para usar. BuildK1, AesCbcNoPad128, FirstBlockMod3 e os helpers de hash representam quaisquer primitivas que a sua base de código já tenha
// Ilustração: esqueleto do loop do Algoritmo 2.B (ISO 32000-2, revision 6)
K := Sha256(Password + Salt + UK);
RoundNumber := 0; // rodadas executadas até agora
repeat
Inc(RoundNumber); // esta é a rodada 1, 2, 3 ...
K1 := BuildK1(Password, K, UK); // (Password || K || UK) repetido 64 vezes
E := AesCbcNoPad128(Copy(K, 0, 16), Copy(K, 16, 16), K1);
case FirstBlockMod3(E) of // primeiros 16 bytes de E, big-endian, mod 3
0: K := Sha256(E);
1: K := Sha384(E);
2: K := Sha512(E);
end;
until (RoundNumber >= 64) and (E[High(E)] <= RoundNumber - 32);
Result := Copy(K, 0, 32);
Leia a linha de terminação com o contador em mente. O incremento fica no topo do corpo, então quando o teste do until roda, o RoundNumber guarda exatamente o número de rodadas completadas, que é o que a norma quer dizer com número da rodada
Onde o off-by-one se esconde ao portar para Delphi
O bug do PDFium Component era um contador que começava em −1 com o incremento no topo do loop, então o valor testado na cláusula until era sempre a contagem de rodadas executadas menos um. Duas coisas dão errado ao mesmo tempo: o mínimo de 64 rodadas vira 65, e o limiar do byte fica um ponto mais apertado do que a norma diz. Ambos deslocam o ponto de saída em exatamente uma rodada nos casos em que importa
O mesmo deslize tem vários disfarces igualmente plausíveis. Começar em 0 e incrementar no fundo do corpo, depois que o teste já foi escrito contra o valor pré-incremento. Usar um índice de loop for que é 0-based e reutilizá-lo na comparação. Traduzir uma referência C cuja variável de loop é verificada antes do ++. Cada um desses lê naturalmente em uma revisão de código, e cada um produz um hash que parece perfeitamente aleatório, porque é: é só uma saída SHA diferente, igualmente de aparência válida, que nenhum outro leitor calcula. O CalculateR6Hash corrigido no FPdfEncrypt.pas agora carrega um comentário que fixa a semântica 1-based, justamente porque a versão errada não parece errada
Por que um PDF AES-256 R6 falha só de vez em quando?
A falha é probabilística porque a rodada extra só muda o resultado quando o byte de terminação cai na janela de uma rodada entre o limiar correto e o deslocado. Em todos os outros casos as duas versões saem na mesma rodada e produzem o mesmo K, então o arquivo abre bem. Na medição do PDFium Component, 18 de 60 criptografias (cerca de 30%) produziram valores de /U, /O, /UE e /OE que discordavam de uma implementação padrão; os outros 70% estavam byte a byte corretos
Salts aleatórios novos são sorteados a cada salvamento, então o mesmo documento e a mesma senha funcionam em uma execução e falham na seguinte. De um suporte técnico isso parece armazenamento instável, um upload corrompido ou um usuário digitando a senha errado. Ninguém pensa em culpar um contador de loop quando sete em cada dez arquivos abrem. Se o seu produto vê relatórios intermitentes de "senha errada" em PDFs que o seu próprio código criptografou, e só no revision 6, esse é o primeiro lugar para olhar
O revision 5 é imune, já que usa uma única passada de SHA-256 sem iteração. O caminho AESV4 da ISO/TS 32003 não é: o valor erR7 de TPdfEncryptionRevision reutiliza o Algoritmo 2.B para os seus hashes de senha, então a mesma correção cobre os arquivos GCM descritos em criptografia AES-256-GCM e integridade de PDF MAC com ISO/TS 32003
A armadilha da autoconsistência: por que testes de round-trip passam
Uma ida e volta pelo seu próprio encrypt e pelo seu próprio verify sempre funciona, porque os dois lados rodam o mesmo loop errado e concordam com o mesmo hash errado. O defeito só existe em relação à implementação de outra pessoa, então o juiz precisa ser um independente: o motor PDFium no pdfium.dll, o Acrobat, o qpdf, ou qualquer leitor que você não escreveu
Essa é a lição geral desconfortável para quem escreve código de documentos vizinho de criptografia. Testes de unit que calculam um hash e o comparam com a saída da função em teste provam determinismo, não correção. Vetores de known-answer da norma ou de uma ferramenta de referência são melhores, e uma ida e volta entre implementações é o mais próximo de ground truth que você consegue. O PDFium Component está em uma posição sortuda aqui: a criptografia é feita em Pascal pelo EncryptDocumentBytes, enquanto a descriptografia no carregamento passa pelo security handler C++ do próprio PDFium, então reabrir o arquivo salvo com o TPdf é uma verificação genuinamente independente em vez de um espelho
uses
SysUtils, PDFium, FPdfEncrypt;
procedure SaveR6(const Source, Target: string);
var
Pdf: TPdf;
Opts: TPdfEncryptOptions;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := Source;
Pdf.Active := True;
Opts := TPdfEncryptOptions.Default;
Opts.UserPassword := 'userpw';
Opts.OwnerPassword := 'ownerpw';
Opts.Revision := erR6; // AESV3, hash de senha do Algoritmo 2.B
if not Pdf.SaveAsEncrypted(Target, Opts) then
raise Exception.Create('SaveAsEncrypted failed');
finally
Pdf.Free;
end;
end;
O TPdfEncryptOptions.Default começa no revision 5 com todas as permissões permitidas e metadados criptografados, então defina o Revision explicitamente quando quiser R6. O TPdf.SaveAsEncryptedToStream recebe o mesmo record de opções quando a saída vai para um TStream em vez de um arquivo. Note que as senhas são AnsiString no record de opções, o que importa se você estiver passando credenciais não ASCII; normalize e codifique-as antes de chegarem ao record
Como testar por regressão um bug de criptografia probabilístico?
Repita a ida e volta entre implementações vezes o suficiente para que uma taxa de falha de 30% não escorregue. Uma única execução perde o bug do PDFium Component cerca de 70% das vezes. Vinte execuções, cada uma com salts novos, derrubam a taxa de perda para cerca de 0.08% (0.7 elevado à vigésima potência), que é o motivo de o teste de regressão embarcado fazer loop 20 vezes em vez de uma
procedure TEncryptTests.R6_EncryptedDocumentReopensWithUserPassword;
var
Pdf: TPdf;
I: Integer;
Target: string;
begin
Target := TempOutputPath('encrypt_r6_roundtrip.pdf');
for I := 1 to 20 do
begin
SaveR6(SamplePdf, Target); // salts novos em cada salvamento
Pdf := TPdf.Create(nil);
try
Pdf.Password := 'userpw';
Pdf.FileName := Target;
Pdf.Active := True; // o PDFium verifica /U independentemente
AssertTrue('Run ' + IntToStr(I) + ' must reopen', Pdf.Active);
finally
Pdf.Free;
end;
end;
end;
Emparelhe com o caso negativo, uma senha errada que precisa falhar, para que um loader quebrado não faça o teste positivo passar vazivamente. E se você compila com Lazarus, rebuild o pacote da biblioteca antes de rodar o projeto de teste depois de tocar em Lib\*.pas; o build de teste liga as units .ppu pré-compiladas do pacote, e rodar contra código objeto velho te dá um verde tranquilizador em um bug que você na verdade não corrigiu. Seeding é uma armadilha relacionada que vale revisar na mesma passada: FPC Random produzindo chaves de criptografia constantes é outro caso em que a saída parece aleatória e está silenciosamente errada
FAQ
O bug do contador de rodadas R6 afeta arquivos criptografados com revision 5?
Não. O revision 5 faz o hash da senha com uma única passada de SHA-256 e não tem contagem de iteração, então não há contador de rodadas para errar. Só o revision 6 e o revision AESV4 da ISO/TS 32003, que ambos usam o Algoritmo 2.B, são afetados
Um arquivo escrito pela versão com bug ainda pode ser aberto?
Cerca de 70% deles abrem normalmente porque o hash deles por acaso casou. O resto carrega valores de /U e /O que nenhum leitor padrão reproduz, então a verificação de senha falha em todo lugar; a correção prática é re-criptografar a partir da origem em texto plano com v2.57.2 ou posterior
Por que testar contra o PDFium em vez do meu próprio código de descriptografia?
O seu próprio decryptor compartilha a mesma implementação do Algoritmo 2.B que o seu encryptor, então uma contagem de rodadas errada se cancela e toda ida e volta passa. Uma implementação independente como o security handler do PDFium é a única verificação que pode discordar de você
Criptografia, validação de PDF MAC e o resto do pipeline de salvamento vêm no PDFium Component para Delphi e C++Builder, com código-fonte e testes de regressão para Delphi e Lazarus