Articolo tecnico

Perché alcuni PDF AES-256 R6 rifiutano la password giusta

Quando un PDF AES-256 revision 6 rifiuta una password che sapete essere corretta, il solito colpevole è il contatore di round nell'hash rafforzato R6 (Algoritmo 2.B di ISO 32000-2). Il PDFium Component for Delphi aveva esattamente questo bug prima della v2.57.2: TPdf.SaveAsEncrypted contava i round dalla base sbagliata, e circa il 30% dei file che produceva veniva rifiutato da FPDF_LoadDocument con FPDF_ERR_PASSWORD

La parte cattiva non è l'aritmetica. La parte cattiva è che il difetto era invisibile a ogni test che cifrava un file e poi lo verificava con lo stesso codice, e che un singolo round trip riuscito non provava niente. Questo post percorre ciò che l'Algoritmo 2.B chiede davvero, dove si nasconde uno scivolamento di un round, perché fallisce solo ogni tanto, e come costruire un test di regressione che becca un bug del genere prima di un cliente

Cosa dice davvero la regola di terminazione dell'hash R6?

L'Algoritmo 2.B si ferma solo dopo almeno 64 round, e poi solo quando l'ultimo byte dell'output AES più recente E è minore o uguale al numero di round meno 32, dove il numero di round è il conteggio a base 1 dei round già eseguiti. Dopo l'N-esimo round confrontate con N. Non con N − 1, non con l'indice del round successivo. Quella definizione da sola è l'intera superficie del bug

Il resto dell'algoritmo è meccanico. Partite con K come SHA-256 di password, salt e (per la chiave owner) il valore /U di 48 byte. Ogni round costruisce K1 come password ‖ K ‖ UK ripetuto 64 volte, lo cifra con AES-128-CBC senza padding usando i primi 16 byte di K come chiave e i successivi 16 come IV, prende i primi 16 byte di E come un intero big-endian a 128 bit mod 3, e sceglie SHA-256, SHA-384 o SHA-512 di conseguenza per produrre il prossimo K. L'hash finale sono i primi 32 byte di K. ISO 32000-2 §7.6.4.3 lo definisce; la lunghezza del loop dipendente dai dati è voluta, perché rende imprevedibile per un cracker GPU il lavoro per ogni tentativo di password

Ecco la forma corretta del loop, scritta come illustrazione piuttosto che come implementazione pronta all'uso. BuildK1, AesCbcNoPad128, FirstBlockMod3 e gli helper di hash stanno per qualunque primitiva il vostro codebase abbia già

// Illustrazione: scheletro del loop dell'Algoritmo 2.B (ISO 32000-2, revisione 6)
K := Sha256(Password + Salt + UK);
RoundNumber := 0;                 // round eseguiti finora
repeat
  Inc(RoundNumber);               // questo è il round 1, 2, 3 ...
  K1 := BuildK1(Password, K, UK); // (Password || K || UK) ripetuto 64 volte
  E  := AesCbcNoPad128(Copy(K, 0, 16), Copy(K, 16, 16), K1);
  case FirstBlockMod3(E) of       // primi 16 byte di 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);

Leggete la riga di terminazione col contatore in mente. L'incremento sta in cima al corpo, così quando il test until gira, RoundNumber contiene esattamente il numero di round completati, che è ciò che lo standard intende per numero di round

Dove si nasconde l'off-by-one in un port Delphi

Il bug del PDFium Component era un contatore che partiva da −1 con l'incremento in cima al loop, così il valore testato nella clausola until era sempre il conteggio di round eseguiti meno uno. Due cose vanno storte insieme: il minimo di 64 round diventa 65, e la soglia del byte è uno scatto più stretta di quanto lo standard dica. Entrambe spostano il punto di uscita di esattamente un round nei casi in cui conta

Lo stesso scivolamento ha diversi travestimenti ugualmente plausibili. Partire da 0 e incrementare in fondo al corpo, dopo che il test è già stato scritto contro il valore pre-incremento. Usare un indice di loop for a base 0 e riutilizzarlo nel confronto. Tradurre un riferimento C la cui variabile di loop viene controllata prima di ++. Ognuna di queste si legge naturalmente in una code review, e ognuna produce un hash che sembra perfettamente casuale, perché lo è: è solo un output SHA diverso, dall'aspetto ugualmente valido, che nessun altro lettore calcola. Il CalculateR6Hash corretto in FPdfEncrypt.pas ora porta un commento che piantona la semantica a base 1, proprio perché la versione sbagliata non sembra sbagliata

Perché un PDF AES-256 R6 fallisce solo ogni tanto?

Il fallimento è probabilistico perché il round extra cambia il risultato solo quando il byte di terminazione atterra nella finestra di un round fra la soglia corretta e quella spostata. In tutti gli altri casi entrambe le versioni escono allo stesso round e producono lo stesso K, così il file si apre bene. Nella misurazione del PDFium Component, 18 cifrature su 60 (circa il 30%) hanno prodotto valori /U, /O, /UE e /OE in disaccordo con un'implementazione standard; il restante 70% era corretto byte per byte

Salt casuali freschi vengono estratti a ogni salvataggio, così lo stesso documento e la stessa password riescono in un'esecuzione e falliscono nella successiva. Da un banco di assistenza sembra storage instabile, un upload corrotto o un utente che sbaglia a battere la password. Nessuno pensa di dare la colpa a un contatore di loop quando sette file su dieci si aprono. Se il vostro prodotto vede segnalazioni intermittenti di «password errata» su PDF che il vostro stesso codice ha cifrato, e solo sulla revisione 6, questo è il primo posto dove guardare

La revisione 5 è immune, dato che usa un solo passaggio SHA-256 senza iterazione. Il percorso AESV4 di ISO/TS 32003 no: il valore erR7 di TPdfEncryptionRevision riusa l'Algoritmo 2.B per i propri hash delle password, così la stessa correzione copre i file GCM descritti in cifratura AES-256-GCM e integrità PDF MAC con ISO/TS 32003

La trappola dell'auto-coerenza: perché i test round trip passano

Un round trip attraverso la vostra cifratura e la vostra verifica riesce sempre, perché entrambi i lati girano lo stesso loop sbagliato e concordano sullo stesso hash sbagliato. Il difetto esiste solo in relazione all'implementazione di qualcun altro, quindi il giudice dev'essere indipendente: il motore PDFium in pdfium.dll, Acrobat, qpdf, o qualunque lettore che non avete scritto voi

Questa è la lezione generale scomoda per chiunque scriva codice documentale adiacente alla crittografia. I test unitari che calcolano un hash e lo confrontano con l'output della funzione sotto test provano determinismo, non correttezza. I known-answer vector dallo standard o da uno strumento di riferimento sono meglio, e un round trip fra implementazioni è la cosa più vicina alla verità sul terreno che vi capitate. Il PDFium Component sta qui in una posizione fortunata: la cifratura è fatta in Pascal da EncryptDocumentBytes, mentre la decifratura al caricamento passa per il security handler C++ di PDFium stesso, così riaprire il file salvato con TPdf è un controllo genuinamente indipendente e non uno specchio

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 della password dell'Algoritmo 2.B
    if not Pdf.SaveAsEncrypted(Target, Opts) then
      raise Exception.Create('SaveAsEncrypted failed');
  finally
    Pdf.Free;
  end;
end;

TPdfEncryptOptions.Default parte dalla revisione 5 con tutti i permessi consentiti e i metadati cifrati, quindi impostate Revision esplicitamente quando volete R6. TPdf.SaveAsEncryptedToStream prende lo stesso record di opzioni quando l'output va su un TStream invece che su un file. Notate che le password sono AnsiString nel record di opzioni, il che conta se passate credenziali non ASCII; normalizzatele e codificatele prima che raggiungano il record

Come si fa il test di regressione di un bug di cifratura probabilistico?

Ripetete il round trip fra implementazioni abbastanza volte che un tasso di fallimento del 30% non possa sgusciare attraverso. Una singola esecuzione perde il bug del PDFium Component circa il 70% delle volte. Venti esecuzioni, ciascuna con salt freschi, portano il tasso di perdita a circa lo 0,08% (0,7 elevato alla ventesima), che è perché il test di regressione spedito itera 20 volte invece di una

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);       // salt freschi a ogni salvataggio
    Pdf := TPdf.Create(nil);
    try
      Pdf.Password := 'userpw';
      Pdf.FileName := Target;
      Pdf.Active := True;            // PDFium verifica /U indipendentemente
      AssertTrue('Run ' + IntToStr(I) + ' must reopen', Pdf.Active);
    finally
      Pdf.Free;
    end;
  end;
end;

Abbinateci il caso negativo, una password sbagliata che deve fallire, così che un loader rotto non possa far passare il test positivo in modo vacuo. E se compilate con Lazarus, ricompilate il package della libreria prima di eseguire il progetto di test dopo aver toccato Lib\*.pas; la build di test collega le unit .ppu precompilate del package, e girare contro codice oggetto stantio vi dà un verde rassicurante su un bug che non avete in realtà corretto. Il seeding è una trappola correlata che merita una revisione nello stesso passaggio: FPC Random che produce chiavi di cifratura costanti è un altro caso in cui l'output sembra casuale ed è silenziosamente sbagliato

FAQ

Il bug del contatore di round R6 riguarda i file cifrati con la revisione 5?

No. La revisione 5 fa l'hash della password con un solo passaggio SHA-256 e non ha conteggio di iterazioni, quindi non c'è un contatore di round da sbagliare. Sono interessate solo la revisione 6 e la revisione AESV4 di ISO/TS 32003, che usano entrambe l'Algoritmo 2.B

Un file scritto dalla versione bacata si può ancora aprire?

Circa il 70% si apre normalmente perché il suo hash per caso combaciava. Il resto porta valori /U e /O che nessun lettore standard riproduce, quindi il controllo della password fallisce ovunque; la correzione pratica è ricifrare dalla sorgente in chiaro con la v2.57.2 o successiva

Perché testare contro PDFium invece che contro il mio codice di decifratura?

Il vostro decryptor condivide la stessa implementazione dell'Algoritmo 2.B del vostro encryptor, così un conteggio di round sbagliato si cancella e ogni round trip passa. Un'implementazione indipendente come il security handler di PDFium è l'unico controllo che può essere in disaccordo con voi

Cifratura, validazione PDF MAC e il resto della pipeline di salvataggio vengono spediti nel PDFium Component for Delphi and C++Builder, con sorgente e test di regressione sia per Delphi sia per Lazarus