Articol tehnic

SASLprep AES-256 parole PDF în Delphi cu PDFlibPas

Un PDF criptat AES-256 cu o parolă non-ASCII se deschide în programul care l-a scris și nicăieri altundeva. Cauza este aproape întotdeauna un pas de pregătire lipsă: ISO 32000-2 §7.6.4.3.3 cere ca parola să fie procesată cu profilul SASLprep al stringprep înainte de a fi codificată UTF-8 și hashuită. PDFlibPas, biblioteca PDF pentru Delphi și C++Builder, efectuează acea pregătire în interiorul Encrypt, EncryptFile și DecryptFile

Aceasta nu este povestea parolei greșite și nici povestea biților de permisiune. Dacă utilizatorii dumneavoastră tastează o parolă pe care nu ați emis-o niciodată, mecanismul de reîncercare din articolul despre reîncercarea parolelor pentru PDF-uri criptate este ceea ce doriți, iar dacă încercați să aflați ce impune de fapt un fișier existent, auditul criptării și permisiunilor acoperă acel teren. Acesta este mai restrâns și mai ciudat: parola este corectă, utilizatorul a tastat-o corect, iar fișierul tot refuză să se deschidă în altă parte

De ce se deschide o parolă non-ASCII într-un cititor, dar nu în altul?

Pentru că cele două programe hashuiesc secvențe diferite de octeți din aceleași taste apăsate. Derivarea cheii pentru revizia 6 din ISO 32000-2 §7.6.4.3.3 ia parola ca octeți UTF-8, o trunchiază la 127 de octeți, adaugă un salt și rulează hash-ul întărit; rezultatul este verificat față de intrările /U și /O din dicționarul de criptare. Nimic din acel lanț nu este vag. Un singur octet diferit oriunde în intrare produce un digest complet diferit, validarea eșuează, iar cititorul poate spune exact un singur lucru: parolă greșită

Octeții diverg pentru că Unicode oferă mai multe moduri de a tasta ceea ce pare a fi aceeași parolă. O parolă chineză poate ajunge ca și caractere precompuse dintr-o metodă de intrare și ca forme de compatibilitate din alta. O parolă germană sau franceză copiată dintr-un procesor de text poate purta un SPAȚIU FĂRĂ RUPERE (U+00A0) unde utilizatorul crede că există un spațiu obișnuit, sau o CRATIMĂ MOALE (U+00AD) care se afișează ca nimic. SASLprep există pentru a reduce toate acestea la o singură formă canonică înainte ca cineva să hashuiască ceva, astfel încât fiecare implementare conformă să derive aceeași cheie din aceeași intenție

Ce schimbă de fapt SASLprep la o parolă?

RFC 4013 definește SASLprep ca un profil al cadrului stringprep din RFC 3454, și este vorba de patru pași ordonați, nu de o singură transformare. Maparea vine prima: tabelul C.1.2 din RFC 3454 (spații non-ASCII) este mapat la U+0020, iar tabelul B.1 (caractere frecvent mapate la nimic) este șters complet. Urmează normalizarea la Unicode NFKC, care este pasul ce pliază caracterele de compatibilitate și secvențele de combinare. Apoi verificarea de ieșire interzisă respinge orice se află în tabelele C.2.1 până la C.9. În final, regula bidirecțională din secțiunea 6 a RFC 3454 este aplicată șirului normalizat

PDFlibPas implementează întregul profil în unitatea PDFlibSASLprep, care expune un singur punct de intrare. PLSASLprepPassword preia parola brută, scrie forma pregătită într-un parametru var și returnează False când parola trebuie refuzată. Funcția este deliberat totală pe calea fericită: o parolă doar-ASCII revine identică pe biți, deci nimic nu se schimbă pentru implementările existente

uses
  PDFlibSASLprep;

var
  Prepared: WideString;
begin
  // RFC 4013: mapping, then NFKC, then prohibited output, then the bidi rule
  PLSASLprepPassword('I' + WideChar($00AD) + 'X', Prepared);  // -> 'IX'   B.1 deletes SOFT HYPHEN
  PLSASLprepPassword('a' + WideChar($00A0) + 'b', Prepared);  // -> 'a b'  C.1.2 maps NBSP to U+0020
  PLSASLprepPassword(WideString(WideChar($00AA)), Prepared);  // -> 'a'    NFKC folds ORDINAL INDICATOR
  PLSASLprepPassword(WideString(WideChar($2168)), Prepared);  // -> 'IX'   NFKC folds ROMAN NUMERAL NINE
  PLSASLprepPassword('user', Prepared);                       // -> 'user' ASCII is never touched
end;

Ambiguitatea U+200B pe care tabelele nu o rezolvă

Un punct de cod ajunge simultan în două tabele RFC 3454, iar cele două tabele nu sunt de acord. SPAȚIUL DE LĂȚIME ZERO (U+200B) cade în intervalul C.1.2 U+2000 până la U+200B, unde regula spune să fie mapat la U+0020, și cade de asemenea în intervalul B.1 U+200B până la U+200D, unde regula spune să fie șters. Citiți pasul de mapare într-o ordine sau alta și obțineți octeți diferiți din aceeași parolă: a+U+200B+b se pregătește ca a b conform C.1.2 și ca ab conform B.1. RFC 4013 numește ambele tabele și nu spune care câștigă, așa că aceasta este o ambiguitate reală în specificație, nu o eroare de citire. PDFlibPas testează întâi apartenența la C.1.2 și, prin urmare, mapează U+200B la un spațiu, comportamentul pe care s-au fixat și alte implementări stringprep larg răspândite; potrivirea cu acestea este singurul lucru care contează aici, deoarece obiectivul este concordanța la nivel de octet cu orice cititor folosește clientul

Citirea fișierelor vechi: pregătită întâi, brută a doua

Soluția își creează propria problemă de compatibilitate. Fiecare fișier AES-256 scris înainte de schimbare a hashuit parola UTF-8 brută, așa că a face cititorul strict conform ar bloca clienții în afara propriilor arhive. PDFlibPas rezolvă asta pe partea de citire încercând doi candidați în ordine. TPDFDocument.SetPassword construiește o listă de candidați care începe cu forma pregătită și revine la forma brută, adăugând intrarea pregătită doar atunci când documentul este într-adevăr AES-256 și cele două forme diferă. Pentru o parolă ASCII, formele sunt identice, lista conține o singură intrare, iar costul întregului mecanism este o singură comparație de șiruri. DecryptFile face același lucru pe calea sa de rescriere directă AES-256, apelând PLDirectDecryptFileAES256 mai întâi cu parola pregătită

var
  Lib: TPDFlib;
  Bytes: AnsiString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);
    Lib.DrawText(100, 100, 'saslprep roundtrip');
    // Strength 3 and 4 are the two AES-256 values; both are prepared before hashing
    Lib.Encrypt('ow' + WideChar($00AD) + 'ner', 'pa' + WideChar($00AD) + 'ss', 4,
      Lib.EncodePermissions(1, 0, 0, 0, 0, 0, 0, 1));
    Bytes := Lib.SaveToString;
  finally
    Lib.Free;
  end;

  Lib := TPDFlib.Create;
  try
    // 'pass' is what SASLprep produced and what any conforming reader computes,
    // so the plain ASCII form opens a file created with the soft-hyphen form
    if Lib.LoadFromString(Bytes, 'pass') = 1 then
      Caption := IntToStr(Lib.PageCount);
  finally
    Lib.Free;
  end;
end;

Mecanismul de rezervă poartă o gardă ce merită copiată. A doua încercare din DecryptFile rulează doar când formele pregătită și brută diferă și prima încercare nu a raportat niciun cod de eroare structurală. Un eșec structural înseamnă că intrarea este deteriorată sau nu este revizia de criptare pe care ați presupus-o, iar reîncercarea unui fișier corupt cu o altă parolă doar consumă o a doua analiză completă pe intrare ostilă; raționamentul din spatele acestui reflex este expus în nota despre analizarea în siguranță a PDF-urilor nesigure. Rețineți de asemenea că nu există rezervă pe partea de scriere, iar acea asimetrie este intenționată. Citirea tolerează istoria, scrierea nu: fiecare fișier AES-256 nou primește octeții conformi

Ce parole sunt respinse complet, și ce este eroarea 604?

SASLprep poate refuza complet o parolă, iar când o face, criptarea trebuie să eșueze zgomotos, nu să înlocuiască tacit ceva. Encrypt și EncryptFile pregătesc atât parola de proprietar, cât și pe cea de utilizator ori de câte ori Strength este 3 sau 4, returnează 0 la refuz și setează LastErrorCode la PDFLIB_ERROR_PASSWORD_SASLPREP, care este 604. Două familii de intrare o declanșează. Tabelele de ieșire interzisă resping caracterele de control (C.2.1 și C.2.2), punctele de cod de uz privat (C.3), non-caracterele (C.4), surogații izolați (C.5), U+FFFD (C.6), caracterele de descriere ideografică (C.7) și intervalele de control de afișare și etichetare (C.8 și C.9). Separat, regula bidi din secțiunea 6 a RFC 3454 respinge orice șir care conține un caracter RandALCat din tabelul D.1, cu excepția cazului în care șirul începe și se termină cu unul și nu conține deloc litere de la stânga la dreapta

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    // U+0007 is a C.2.1 control character, so preparation refuses the password
    if Lib.Encrypt('owner', 'bad' + WideChar($0007), 4,
         Lib.EncodePermissions(1, 0, 0, 0, 0, 0, 0, 1)) = 0 then
    begin
      if Lib.LastErrorCode = PDFLIB_ERROR_PASSWORD_SASLPREP then  // 604
        ShowMessage('The password contains characters that PDF encryption does not permit.');
    end;
  finally
    Lib.Free;
  end;
end;

Acea regulă bidi este cea care vă va surprinde departamentul de suport. O parolă arabă sau ebraică terminată cu o cifră occidentală, sau una cu o literă latină rătăcită în mijloc, este respinsă de specificație chiar dacă pare perfect rezonabilă în câmpul de introducere. Afișați 604 ca un mesaj despre caracterele parolei, nu ca un eșec generic de criptare, altfel cineva va petrece o după-amiază căutând un bug în derivarea cheii dumneavoastră

Limite sincere: NFKC, un LCat aproximativ și o capcană Delphi

Două părți ale implementării sunt aproximări, și ambele merită enunțate clar, nu ascunse. Normalizarea NFKC este efectuată de API-ul Windows NormalizeString, încărcat dinamic din Normaliz.dll. Când acea bibliotecă nu este disponibilă, șirul mapat este folosit nenormalizat, ceea ce înseamnă că pașii de mapare și interdicție tot rulează, dar plierea de compatibilitate nu. În practică, DLL-ul a fost livrat cu fiecare versiune Windows începând cu Vista, așa că traseul degradat este o preocupare pentru pre-Vista și non-Windows, nu una curentă, dar o parolă care se bazează pe plierea NFKC ar produce acolo octeți diferiți, iar aceasta este o divergență reală, chiar dacă îndepărtată. Verificarea bidi este a doua aproximare: detectarea caracterelor LCat folosește intervalele de litere comune în loc de tabelul complet D.2 din RFC 3454, iar direcția acelei erori este ce o face acceptabilă. Un caracter LCat omis poate face doar ca regula bidi să treacă acolo unde specificația ar fi respins, niciodată invers, și nu atinge niciodată pașii de mapare sau normalizare, astfel încât secvența de octeți pregătită a unei parole acceptate rămâne neschimbată. Riscul rezidual este deci o divergență de politică, nu o divergență de octeți: o parolă cu scriere exotică pe care o implementare mai strictă ar refuza să o accepte deloc. Orice parolă acceptată de ambele părți hashuiește identic, ceea ce este proprietatea de care depinde de fapt interoperabilitatea

În final, o capcană de sintaxă Delphi care costă o oră dacă nu ați dat peste ea înainte. Când o funcție returnează un tip procedural, atribuirea ei fără paranteze nu o apelează. Compilatorul citește Proc := GetNormalizeProc; ca luarea adresei lui GetNormalizeProc însuși, apoi raportează E2009 cu plângerea neajutorată că convențiile de apel diferă, deoarece accesorul folosește convenția implicită, iar tipul API importat este stdcall. Parantezele goale sunt obligatorii

type
  TNormalizeString = function(NormForm: Integer; SrcString: PWideChar; SrcLength: Integer;
    DstString: PWideChar; DstLength: Integer): Integer; stdcall;

function GetNormalizeProc: TNormalizeString;   // loads Normaliz.dll on first use
...
var
  Proc: TNormalizeString;
begin
  // Proc := GetNormalizeProc;   // E2009: reads as @GetNormalizeProc, conventions differ
  Proc := GetNormalizeProc();    // correct: calls the accessor and assigns its result
  if not Assigned(Proc) then
    Exit;                        // no NFKC available, mapped string is used as-is
end;

Pregătirea parolei este unul din acele detalii care nu apar niciodată într-o listă de funcționalități și decide dacă un document criptat supraviețuiește contactului cu un client din altă zonă lingvistică. Punctele de intrare Encrypt, EncryptFile, DecryptFile și SetPassword descrise aici fac parte din losLab PDF Developer Library Pascal Edition pentru Delphi și C++Builder, a cărei pagină de produs conține referința completă de criptare și tabelul complet de coduri de eroare