Tehnički članak

SASLprep AES-256 lozinke za PDF u Delphiju s PDF Library for Delphi

AES-256 PDF šifriran ne-ASCII lozinkom otvara se u programu koji ga je napisao i nigdje drugdje. Uzrok je gotovo uvijek nedostajući korak pripreme: ISO 32000-2 §7.6.4.3.3 zahtijeva da se lozinka obradi profilom SASLprep od stringprep prije UTF-8 kodiranja i hashiranja. PDF Library for Delphi, PDF biblioteka za Delphi i C++Builder, izvodi tu pripremu unutar Encrypt, EncryptFile i DecryptFile

Ovo nije priča o pogrešnoj lozinci niti priča o bitovima dopuštenja. Ako vaši korisnici upisuju lozinku koju nikad niste izdali, mehanika ponovnog pokušaja u članku o ponovnom pokušaju lozinke šifriranog PDF-a ono je što tražite, a ako pokušavate saznati što postojeća datoteka zapravo provodi, revizija šifriranja i dopuštenja pokriva to područje. Ovaj je uži i čudniji: lozinka je ispravna, korisnik ju je ispravno upisao, a datoteka se svejedno odbija otvoriti negdje drugdje

Zašto se ne-ASCII lozinka otvara u jednom čitaču, a ne u drugom?

Zato što dva programa hashiraju različite bajtovne nizove iz istih pritisaka tipki. Izvod ključa revizije 6 u ISO 32000-2 §7.6.4.3.3 uzima lozinku kao UTF-8 bajtove, skraćuje na 127 bajtova, dodaje sol i pokreće ojačani hash; rezultat se provjerava protiv unosa /U i /O u rječniku šifriranja. Ništa u tom lancu nije nejasno. Jedan bajt koji se razlikuje bilo gdje u ulazu proizvodi potpuno drugačiji sažetak, provjera propada, a čitač može reći točno jednu stvar: pogrešna lozinka

Bajtovi se razilaze jer Unicode nudi nekoliko načina da upišete ono što izgleda kao ista lozinka. Kineska lozinka može stići kao unaprijed sastavljeni znakovi iz jedne metode unosa, a kao kompatibilni oblici iz druge. Njemačka ili francuska lozinka kopirana iz obrade teksta može nositi NO-BREAK SPACE (U+00A0) tamo gdje korisnik vjeruje da postoji obični razmak, ili SOFT HYPHEN (U+00AD) koji se ne prikazuje uopće. SASLprep postoji upravo da sve to sažme u jedan kanonski oblik prije nego itko išta hashira, tako da svaka usklađena implementacija izvodi isti ključ iz iste namjere

PDF Library for Delphi dijagram iste ne-ASCII PDF lozinke heširane u dva različita UTF-8 bajtovna niza, gdje čitač koji preskače pripremu pada na /U provjeri, a SASLprep-om pripremljeni čitač izvodi radni ključ
Čitač koji preskoči pripremu hashira drugačije bajtove, pa se AES-256 validacija urušava u lažnu prijavu krive lozinke

Što SASLprep zapravo mijenja u lozinci?

RFC 4013 definira SASLprep kao profil okvira stringprep iz RFC 3454, i to su četiri poredana koraka, a ne jedna transformacija. Prvo dolazi mapiranje: tablica C.1.2 iz RFC 3454 (ne-ASCII razmaci) mapira se na U+0020, a tablica B.1 (znakovi uobičajeno mapirani u ništa) briše se sasvim. Slijedi normalizacija u Unicode NFKC, korak koji preklapa kompatibilne znakove i kombinirajuće sljedove. Zatim provjera zabranjenog izlaza odbija sve u tablicama C.2.1 do C.9. Naposljetku, na normalizirani niz primjenjuje se dvosmjerno (bidi) pravilo iz odjeljka 6 RFC 3454

PDF Library for Delphi implementira cijeli profil u jedinici PDFlibSASLprep, koja izlaže jednu ulaznu točku. PLSASLprepPassword uzima sirovu lozinku, upisuje pripremljeni oblik u var parametar i vraća False kad lozinku treba odbiti. Funkcija je namjerno potpuna na sretnom putu: čisto ASCII lozinka vraća se bajt-identična, tako da se ništa u postojećim postavljanjima ne mijenja

uses
  PDFlibSASLprep;

var
  Prepared: WideString;
begin
  // RFC 4013: mapiranje, zatim NFKC, zatim zabranjeni izlaz, zatim bidi pravilo
  PLSASLprepPassword('I' + WideChar($00AD) + 'X', Prepared);  // -> 'IX'   B.1 briše MEKI CRTICU (SOFT HYPHEN)
  PLSASLprepPassword('a' + WideChar($00A0) + 'b', Prepared);  // -> 'a b'  C.1.2 mapira NBSP na U+0020
  PLSASLprepPassword(WideString(WideChar($00AA)), Prepared);  // -> 'a'    NFKC sažima ORDINAL INDICATOR
  PLSASLprepPassword(WideString(WideChar($2168)), Prepared);  // -> 'IX'   NFKC sažima RIMSKI BROJ DEVET
  PLSASLprepPassword('user', Prepared);                       // -> 'user' ASCII se nikad ne dira
end;

Nejednoznačnost U+200B koju tablice ne razrješavaju

Jedna kodna točka istovremeno pada u dvije tablice RFC 3454, i te se dvije tablice ne slažu. ZERO WIDTH SPACE (U+200B) pada unutar raspona C.1.2 od U+2000 do U+200B, gdje pravilo kaže mapirati je na U+0020, a također pada unutar raspona B.1 od U+200B do U+200D, gdje pravilo kaže obrisati je. Pročitate li korak mapiranja u bilo kojem redoslijedu, dobijete različite bajtove iz iste lozinke: a+U+200B+b priprema se u a b pod C.1.2, a u ab pod B.1. RFC 4013 imenuje obje tablice i ne kaže koja pobjeđuje, pa je ovo prava nejednoznačnost u specifikaciji, a ne pogreška u čitanju. PDF Library for Delphi prvo testira pripadnost tablici C.1.2 i stoga mapira U+200B u razmak, što je ponašanje na kojem su se ustalile druge široko raspoređene implementacije stringprep-a; usklađivanje s njima ovdje je jedino bitno, jer je cilj bajtovna suglasnost s bilo kojim čitačem koji korisnik koristi

Čitanje starih datoteka: prvo pripremljeno, zatim sirovo

Popravak stvara vlastiti problem kompatibilnosti. Svaka AES-256 datoteka napisana prije promjene hashirala je sirovu UTF-8 lozinku, pa bi strogo usklađen čitač zaključao korisnike izvan njihovih vlastitih arhiva. PDF Library for Delphi ovo rješava na strani čitanja isprobavanjem dva kandidata redom. TPDFDocument.SetPassword gradi popis kandidata koji počinje pripremljenim oblikom i pada natrag na sirov oblik, a pripremljeni unos dodaje samo kad je dokument doista AES-256 i kad se ta dva oblika razlikuju. Za ASCII lozinku oblici su identični, popis drži jedan unos, a cijeli mehanizam košta jednu usporedbu nizova. DecryptFile radi istu stvar duž svog izravnog puta prepisivanja AES-256, pozivajući PLDirectDecryptFileAES256 prvo s pripremljenom lozinkom

var
  Lib: TPDFlib;
  Bytes: AnsiString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);
    Lib.DrawText(100, 100, 'saslprep roundtrip');
    // Strength 3 i 4 su dvije AES-256 vrijednosti; obje se pripremaju prije hashiranja
    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' je ono što je SASLprep proizveo i što svaki usklađeni čitač izračuna,
    // pa obični ASCII oblik otvara datoteku stvorenu s oblikom s mekom crticom
    if Lib.LoadFromString(Bytes, 'pass') = 1 then
      Caption := IntToStr(Lib.PageCount);
  finally
    Lib.Free;
  end;
end;

Rezervni put nosi jednu zaštitu vrijednu kopiranja. Drugi pokušaj u DecryptFile pokreće se samo kad se pripremljeni i sirovi oblici razlikuju i kad je prvi pokušaj prijavio da nema tvrde greške. Strukturni neuspjeh znači da je ulaz oštećen ili nije revizija šifriranja koju ste pretpostavili, a ponovni pokušaj oštećene datoteke s drugom lozinkom samo troši drugu potpunu raščlambu neprijateljskog ulaza; obrazloženje iza tog refleksa izloženo je u bilješci o sigurnoj raščlambi nepouzdanih PDF-ova. Primijetite i da na strani pisanja nema rezervnog puta, i ta je asimetrija namjerna. Čitanje toleriše povijest, pisanje ne: svaka nova AES-256 datoteka dobiva usklađene bajtove

Koje se lozinke posve odbijaju, i što je pogreška 604?

SASLprep može posve odbiti lozinku, a kad to učini, šifriranje mora glasno zakazati, a ne tiho zamijeniti nešto drugo. Encrypt i EncryptFile pripremaju i lozinku vlasnika i korisničku lozinku kad god je Strength 3 ili 4, vraćaju 0 pri odbijanju i postavljaju LastErrorCode na PDFLIB_ERROR_PASSWORD_SASLPREP, što je 604. Dvije obitelji ulaza to pokreću. Tablice zabranjenog izlaza odbijaju kontrolne znakove (C.2.1 i C.2.2), kodne točke privatne uporabe (C.3), ne-znakove (C.4), usamljene surogate (C.5), U+FFFD (C.6), znakove ideografskog opisa (C.7) te raspone kontrole prikaza i označavanja (C.8 i C.9). Odvojeno, bidi pravilo iz odjeljka 6 RFC 3454 odbija svaki niz koji sadrži RandALCat znak iz tablice D.1, osim ako niz i počinje i završava takvim znakom i uopće ne sadrži slova s lijeva na desno

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    // U+0007 je C.2.1 kontrolni znak, pa priprema odbija lozinku
    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;

To bidi pravilo je ono koje će iznenaditi vašu korisničku podršku. Arapska ili hebrejska lozinka koja završava zapadnom znamenkom, ili ona sa zalutalim latiničnim slovom u sredini, specifikacija odbija čak i kad izgleda posve razumno u polju za unos. Prikažite 604 kao poruku o znakovima lozinke, a ne kao generičan neuspjeh šifriranja, ili će netko provesti poslijepodne tražeći grešku u vašem izvodu ključa

PDF Library for Delphi dijagram četiri poredana RFC 4013 SASLprep koraka koji preslikavaju ne-ASCII razmake, brišu znakove preslikane u ništa, preklapaju oblike kompatibilnosti s NFKC i provode provjere zabranjenog izlaza i dvosmjernosti kako bi proizveli jednu stabilnu pripremljenu lozinku
Mapiranje, NFKC normalizacija, sken zabranjenog izlaza i bidi pravilo rastu redom i ostavljaju isključivo ASCII lozinke netaknutima

Iskrena ograničenja: NFKC, približan LCat i jedna Delphi zamka

Dva dijela implementacije su aproksimacije, i oba zaslužuju biti jasno navedena, a ne skrivena. NFKC normalizaciju izvodi Windows API NormalizeString, dinamički učitan iz Normaliz.dll. Kad ta knjižnica nije dostupna, mapirani niz se koristi nenormaliziran, što znači da koraci mapiranja i zabrane i dalje rade, ali kompatibilno preklapanje ne. U praksi je taj DLL isporučivan sa svakim izdanjem Windowsa od Viste naovamo, pa je degradirani put briga pred-Vista i ne-Windows sustava, a ne aktualna, ali lozinka koja se oslanja na NFKC preklapanje ondje bi proizvela drugačije bajtove, i to je stvarna, iako udaljena, razlika. Provjera bidi je druga aproksimacija: otkrivanje LCat znakova koristi uobičajene raspone slova umjesto pune tablice D.2 iz RFC 3454, a smjer te pogreške čini je prihvatljivom. Propušteni LCat znak može samo uzrokovati da bidi pravilo prođe tamo gdje bi stroža implementacija odbila, nikad obrnuto, i nikad ne dira korake mapiranja ili normalizacije, tako da pripremljeni bajtovni niz prihvaćene lozinke ostaje nepromijenjen. Preostali rizik je stoga razlika u politici, a ne razlika u bajtovima: egzotična lozinka pisma koju bi stroža implementacija posve odbila prihvatiti. Svaka lozinka koju obje strane prihvate hashira se identično, a to je svojstvo o kojem interoperabilnost zapravo ovisi

Naposljetku, Delphi sintaktička zamka koja košta sat vremena ako je niste prije susreli. Kad funkcija vraća proceduralni tip, dodjela bez zagrada je ne poziva. Prevoditelj čita Proc := GetNormalizeProc; kao uzimanje adrese samog GetNormalizeProc, zatim prijavljuje E2009 s beskorisnom pritužbom da se konvencije poziva razlikuju, jer pristupnik koristi zadanu konvenciju dok je uvezeni API tip stdcall. Prazne zagrade su obvezne

PDF Library for Delphi dijagram AES-256 šifriranja koje odbija lozinke čiji znakovi pogađaju tablice zabranjenog izlaza C.2.1 do C.9 ili krše dvosmjerno pravilo RFC 3454, vraća nulu s LastErrorCode 604
Dvije porodice ulaza okidaju put odbijanja: zabranjene tablice kodnih točaka i RFC 3454 dvosmjerno pravilo
type
  TNormalizeString = function(NormForm: Integer; SrcString: PWideChar; SrcLength: Integer;
    DstString: PWideChar; DstLength: Integer): Integer; stdcall;

function GetNormalizeProc: TNormalizeString;   // učitava Normaliz.dll pri prvoj upotrebi
...
var
  Proc: TNormalizeString;
begin
  // Proc := GetNormalizeProc;   // E2009: čita se kao @GetNormalizeProc, konvencije se razlikuju
  Proc := GetNormalizeProc();    // ispravno: poziva accessor i dodjeljuje njegov rezultat
  if not Assigned(Proc) then
    Exit;                        // NFKC nije dostupan, mapirani niz se koristi kakav jest
end;

Priprema lozinke jedan je od onih detalja koji se nikad ne pojavljuju na popisu značajki, a odlučuje hoće li šifrirani dokument preživjeti dodir s korisnikom u drugoj lokalizaciji. Ulazne točke Encrypt, EncryptFile, DecryptFile i SetPassword opisane ovdje dio su losLab PDF Developer Library Pascal Edition za Delphi i C++Builder, čija stranica proizvoda nosi potpunu referencu šifriranja i cjelovitu tablicu kodova pogrešaka