Artigo Técnico

Porque alguns PDFs AES-256 R6 recusam a palavra-passe certa

Quando um PDF AES-256 revisão 6 recusa uma palavra-passe que sabe estar certa, o culpado habitual é o contador de rondas no hash endurecido R6 (ISO 32000-2 Algorithm 2.B). O PDFium Component for Delphi tinha exatamente este bug antes da v2.57.2: o TPdf.SaveAsEncrypted contava as rondas a partir da base errada, e cerca de 30% dos ficheiros que produziu 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 todos os testes que encriptavam um ficheiro e depois o verificavam com o mesmo código, e que um único round trip bem-sucedido não provava nada. Este post percorre o que o Algorithm 2.B realmente pede, onde uma furada de uma ronda se esconde, porque é que só falha algumas vezes, e como construir um regression test que apanhe um bug destes antes de um cliente o apanhar

O que diz afinal a regra de terminação do hash R6?

O Algorithm 2.B só para depois de pelo menos 64 rondas, e depois apenas quando o último byte do output AES mais recente E é menor ou igual ao número da ronda menos 32, em que o número da ronda é a contagem de rondas já executadas com base 1. Depois da N-ésima ronda compara-se com N. Não com N − 1, não com o índice da próxima ronda. Essa única definição é toda a superfície do bug

O resto do algoritmo é mecânico. Comece com K como o SHA-256 de palavra-passe, salt e (para a chave do owner) o valor /U de 48 bytes. Cada ronda constrói K1 como password ‖ K ‖ UK repetido 64 vezes, encripta-o com AES-128-CBC sem padding usando os primeiros 16 bytes de K como chave e os 16 seguintes como IV, toma os primeiros 16 bytes de E como um inteiro big-endian de 128 bits mod 3, e escolhe SHA-256, SHA-384 ou SHA-512 em consequência 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 define-o; o comprimento do loop dependente dos dados é deliberado, porque torna o trabalho por tentativa de palavra-passe imprevisível para um cracker de GPU

Aqui está a forma correta do loop, escrita como ilustração e não como implementação para copiar e colar. O BuildK1, o AesCbcNoPad128, o FirstBlockMod3 e os helpers de hash fazem as vezes das primitivas que a sua base de código já tiver

// Ilustração: esqueleto do loop do Algorithm 2.B (ISO 32000-2, revisão 6)
K := Sha256(Password + Salt + UK);
RoundNumber := 0;                 // rondas executadas até agora
repeat
  Inc(RoundNumber);               // esta é a ronda 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 assenta no topo do corpo, por isso quando o teste do until corre, o RoundNumber guarda exatamente o número de rondas completadas, que é o que a norma quer dizer com número da ronda

Onde o off-by-one se esconde numa porta para Delphi

O bug do PDFium Component era um contador que começava em −1 com o incremento no topo do loop, por isso o valor testado na cláusula until era sempre a contagem de rondas executadas menos um. Duas coisas correm mal ao mesmo tempo: o mínimo de 64 rondas torna-se 65, e o limiar do byte fica um notch mais apertado do que a norma diz. Ambos deslocam o ponto de saída exatamente uma ronda nos casos em que importa

A mesma furada tem vários disfarces igualmente plausíveis. Começar em 0 e incrementar no fundo do corpo, depois de o teste já ter sido escrito contra o valor pré-incremento. Usar um índice de loop for de base 0 e reutilizá-lo na comparação. Traduzir uma referência C cuja variável de loop é verificada antes do ++. Cada uma destas lê-se naturalmente numa revisão de código, e cada uma produz um hash que parece perfeitamente aleatório, porque é: é apenas um output SHA diferente, com aspeto igualmente válido, que nenhum outro leitor calcula. O CalculateR6Hash corrigido em FPdfEncrypt.pas agora traz um comentário que fixa a semântica de base 1, precisamente porque a versão errada não parece errada

Porque é que um PDF AES-256 R6 falha só algumas vezes?

A falha é probabilística porque a ronda extra só muda o resultado quando o byte de terminação aterra na janela de uma ronda entre o limiar correto e o deslocado. Em todos os outros casos as duas versões saem na mesma ronda e produzem o mesmo K, por isso o ficheiro abre bem. Na medição do PDFium Component, 18 de 60 encriptações (cerca de 30%) produziram valores /U, /O, /UE e /OE que discordavam de uma implementação padrão; os outros 70% estavam byte a byte corretos

Sales aleatórios frescos são sorteados em cada gravação, por isso o mesmo documento e a mesma palavra-passe têm sucesso numa corrida e falham na seguinte. De um balcão de suporte isso parece armazenamento caprichoso, um upload corrompido, ou um utilizador a enganar-se na palavra-passe. Ninguém pensa culpar um contador de loop quando sete em cada dez ficheiros abrem. Se o seu produto vê relatórios intermitentes de "palavra-passe errada" em PDFs que o seu próprio código encriptou, e só na revisão 6, este é o primeiro sítio a procurar

A revisão 5 é imune, já que usa uma única passagem SHA-256 sem iteração. O caminho AESV4 da ISO/TS 32003 não é: o valor erR7 de TPdfEncryptionRevision reutiliza o Algorithm 2.B para os seus hashes de palavra-passe, por isso a mesma correção cobre os ficheiros GCM descritos em encriptação AES-256-GCM e integridade PDF MAC com ISO/TS 32003

A armadilha da autoconsistência: porque passam os testes de round trip

Um round trip pelo seu próprio encrypt e pelo seu próprio verify tem sempre sucesso, porque os dois lados correm o mesmo loop errado e concordam no mesmo hash errado. O defeito só existe em relação à implementação de outra pessoa, por isso o juiz tem de ser um independente: o motor PDFium em pdfium.dll, o Acrobat, o qpdf, ou qualquer leitor que não escreveu

Esta é a lição geral desconfortável para quem escreve código de documentos próximo de criptografia. Testes unitários que calculam um hash e o comparam com o output da função em teste provam determinismo, não correção. Vetores de resposta conhecida da norma ou de uma ferramenta de referência são melhores, e um round trip entre implementações é o mais próximo de verdade absoluta que se consegue. O PDFium Component está numa posição sortuda aqui: a encriptação é feita em Pascal pelo EncryptDocumentBytes, enquanto a desencriptação na abertura passa pelo security handler C++ do próprio PDFium, por isso reabrir o ficheiro gravado com um TPdf é uma verificação genuinamente independente e não 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 palavra-passe do Algorithm 2.B
    if not Pdf.SaveAsEncrypted(Target, Opts) then
      raise Exception.Create('SaveAsEncrypted failed');
  finally
    Pdf.Free;
  end;
end;

O TPdfEncryptOptions.Default começa na revisão 5 com todas as permissões permitidas e metadados encriptados, por isso defina o Revision explicitamente quando quiser R6. O TPdf.SaveAsEncryptedToStream leva o mesmo record de opções quando o output vai para um TStream em vez de um ficheiro. Note que as palavras-passe são AnsiString no record de opções, o que importa se estiver a passar credenciais não ASCII; normalize-as e codifique-as antes de chegarem ao record

Como fazer regression test a um bug de encriptação probabilístico?

Repita o round trip entre implementações vezes suficientes para que uma taxa de falha de 30% não se escabicha. Uma única corrida perde o bug do PDFium Component cerca de 70% das vezes. Vinte corridas, cada uma com sales frescos, baixam a taxa de falha do teste para cerca de 0.08% (0.7 elevado a 20), que é a razão pela qual o regression test enviado faz o 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);       // sales frescos em cada gravação
    Pdf := TPdf.Create(nil);
    try
      Pdf.Password := 'userpw';
      Pdf.FileName := Target;
      Pdf.Active := True;            // o PDFium verifica /U de forma independente
      AssertTrue('Run ' + IntToStr(I) + ' must reopen', Pdf.Active);
    finally
      Pdf.Free;
    end;
  end;
end;

Emparelhe com o caso negativo, uma palavra-passe errada que tem de falhar, para que um carregador partido não possa fazer o teste positivo passar ao vácuo. E se compila com Lazarus, reconstrua o pacote da biblioteca antes de correr o projeto de testes depois de mexer em Lib\*.pas; o build de testes liga as unidades .ppu pré-compiladas do pacote, e correr contra código objeto caducado dá-lhe um verde tranquilizador num bug que ainda não corrigiu de facto. A sementeira é uma armadilha relacionada que vale a pena rever na mesma passagem: o FPC Random a produzir chaves de encriptação constantes é outro caso em que o output parece aleatório e está errado em silêncio

FAQ

O bug do contador de rondas R6 afeta ficheiros encriptados com revisão 5?

Não. A revisão 5 faz o hash da palavra-passe com uma única passagem SHA-256 e não tem contagem de iterações, por isso não há contador de rondas para errar. Só a revisão 6 e a revisão AESV4 da ISO/TS 32003, que ambas usam o Algorithm 2.B, são afetadas

Um ficheiro escrito pela versão com bug ainda pode ser aberto?

Cerca de 70% deles abrem normalmente porque o hash deles calhou coincidir. Os restantes trazem valores /U e /O que nenhum leitor padrão reproduz, por isso a verificação da palavra-passe falha em todo o lado; a correção prática é reencriptar a partir da origem em texto claro com a v2.57.2 ou posterior

Porque testar contra o PDFium em vez do meu próprio código de desencriptação?

O seu próprio descodificador partilha a mesma implementação do Algorithm 2.B que o seu encriptador, por isso uma contagem de rondas errada cancela-se e todos os round trips passam. Uma implementação independente como o security handler do PDFium é a única verificação que pode discordar de si

Encriptação, validação PDF MAC e o resto do pipeline de gravação vêm no PDFium Component for Delphi and C++Builder, com código-fonte e testes de regressão para Delphi e Lazarus