Artigo Técnico

Por que alguns PDFs AES-256 R6 rejeitam a senha correta

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