AES-256 PDF šifrovan sa ne-ASCII lozinkom otvara se u programu koji ga je napisao i nigde drugde. Uzrok je gotovo uvek nedostajući korak pripreme: ISO 32000-2 §7.6.4.3.3 zahteva da se lozinka obradi profilom SASLprep od stringprep pre nego što se UTF-8 kodira i heš-uje. PDFlibPas, PDF biblioteka za Delphi i C++Builder, izvodi tu pripremu unutar Encrypt, EncryptFile i DecryptFile
Ovo nije priča o pogrešnoj lozinci i nije priča o bitovima dozvola. Ako vaši korisnici kucaju lozinku koju nikad niste izdali, mašinerija ponovnog pokušaja u članku o ponovnom pokušaju šifrovanih PDF lozinki je ono što vam treba, a ako pokušavate da shvatite šta postojeći fajl zapravo primenjuje, revizija šifrovanja i dozvola pokriva to tlo. Ovo je uže i čudnije: lozinka je tačna, korisnik ju je tačno ukucao, a fajl se i dalje odbija da se otvori negde drugde
Zašto se ne-ASCII lozinka otvara u jednom čitaču, a ne u drugom?
Zato što dva programa heš-uju različite sekvence bajtova iz istih pritisaka tastera. Izvođenje 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 so, i pokreće ojačani heš; rezultat se proverava naspram unosa /U i /O u rečniku šifrovanja. Ništa u tom lancu nije nejasno. Jedan bajt koji se razlikuje bilo gde u ulazu proizvodi potpuno drugačiji digest, validacija propada, i čitač ima tačno jednu stvar koju može reći: pogrešna lozinka
Bajtovi se razilaze zato što Unicode nudi nekoliko načina da se ukuca ono što izgleda kao ista lozinka. Kineska lozinka može stići kao unapred sastavljeni znakovi iz jedne metode unosa, a kao kompatibilni oblici iz druge. Nemačka ili francuska lozinka kopirana iz procesora teksta može nositi NO-BREAK SPACE (U+00A0) tamo gde korisnik veruje da je obični razmak, ili SOFT HYPHEN (U+00AD) koji se renderuje kao ništa. SASLprep postoji da sve to spoji u jedan kanonski oblik pre nego što bilo ko išta heš-uje, tako da svaka usklađena implementacija izvede isti ključ iz iste namere
Šta SASLprep zapravo menja na lozinci?
RFC 4013 definiše SASLprep kao profil okvira stringprep iz RFC 3454, i to je četiri uređena koraka, a ne jedna transformacija. Mapiranje dolazi prvo: RFC 3454 tabela C.1.2 (ne-ASCII razmaci) se mapira na U+0020, a tabela B.1 (znakovi koji se uobičajeno mapiraju u ništa) se u potpunosti briše. Sledi normalizacija u Unicode NFKC, korak koji sklapa kompatibilne znakove i kombinujuće sekvence. Zatim provera zabranjenog izlaza odbacuje sve u tabelama C.2.1 do C.9. Na kraju se pravilo dvosmernosti iz RFC 3454 odeljka 6 primenjuje na normalizovan string
PDFlibPas implementira ceo profil u jedinici PDFlibSASLprep, koja izlaže jednu ulaznu tačku. PLSASLprepPassword uzima sirovu lozinku, upisuje pripremljeni oblik u var parametar, i vraća False kad lozinka mora biti odbijena. Funkcija je namerno totalna na srećnoj putanji: lozinka samo sa ASCII se vraća bajt-identična, tako da se ništa u vezi sa postojećim implementacijama ne menja
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;
Nejasnoća U+200B koju tabele ne razrešavaju
Jedna kodna tačka sleti u dve RFC 3454 tabele istovremeno, i te dve tabele se ne slažu. ZERO WIDTH SPACE (U+200B) upada u opseg C.1.2 od U+2000 do U+200B, gde pravilo kaže da se mapira na U+0020, a upada i u opseg B.1 od U+200B do U+200D, gde pravilo kaže da se briše. Pročitajte korak mapiranja u bilo kom redosledu i dobićete različite bajtove iz iste lozinke: a+U+200B+b se priprema u a b pod C.1.2, a u ab pod B.1. RFC 4013 imenuje obe tabele i ne kaže koja pobeđuje, tako da je ovo prava nejasnoća u specifikaciji, a ne greška u čitanju. PDFlibPas prvo testira članstvo u C.1.2 i zato mapira U+200B na razmak, što je ponašanje na kome su se ustalile druge široko rasprostranjene implementacije stringprep-a; poklapanje sa njima je jedino što je ovde bitno, jer je cilj bajt saglasnost sa bilo kojim čitačem koji klijent koristi
Čitanje starih fajlova: pripremljena prvo, sirova drugo
Ispravka pravi sopstveni problem kompatibilnosti. Svaki AES-256 fajl napisan pre promene heš-ovao je sirovu UTF-8 lozinku, tako da bi činjenje čitača strogo usaglašenim zaključalo klijente van njihovih sopstvenih arhiva. PDFlibPas ovo rešava na strani čitanja isprobavanjem dva kandidata redom. TPDFDocument.SetPassword gradi listu kandidata koja počinje pripremljenim oblikom i pada nazad na sirovi oblik, i dodaje pripremljeni unos samo kad je dokument zaista AES-256 i kad se dva oblika razlikuju. Za ASCII lozinku oblici su identični, lista drži jedan unos, a cena celog mehanizma je jedno poređenje stringova. DecryptFile radi istu stvar duž svoje direktne putanje prepisivanja AES-256, pozivajući PLDirectDecryptFileAES256 prvo sa pripremljenom lozinkom
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;
Fallback nosi jednu stražu vrednu kopiranja. Drugi pokušaj u DecryptFile se izvodi samo kad se pripremljeni i sirovi oblici razlikuju i kad je prvi pokušaj prijavio bez tvrdog koda greške. Strukturalni neuspeh znači da je ulaz oštećen ili nije revizija šifrovanja koju ste pretpostavili, a ponovni pokušaj na pokvarenom fajlu sa drugom lozinkom samo troši drugi pun parse preko neprijateljskog ulaza; razlog iza tog refleksa je izložen u belešci o bezbednom parsiranju nepouzdanih PDF-ova. Primetite i da nema fallback-a na strani pisanja, i ta asimetrija je namerna. Čitanje toleriše istoriju, pisanje ne: svaki novi AES-256 fajl dobija usaglašene bajtove
Koje lozinke se u potpunosti odbijaju, i šta je greška 604?
SASLprep može u potpunosti odbiti lozinku, i kad to učini, šifrovanje mora glasno otkazati, a ne tiho zameniti 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. Dve porodice ulaza to okidaju. Tabele zabranjenog izlaza odbacuju kontrolne znakove (C.2.1 i C.2.2), kodne tačke privatne upotrebe (C.3), ne-znakove (C.4), usamljene surogate (C.5), U+FFFD (C.6), ideografske opisne znakove (C.7), i opsege kontrole prikaza i tagovanja (C.8 i C.9). Odvojeno, pravilo dvosmernosti iz RFC 3454 odeljka 6 odbacuje svaki string koji sadrži RandALCat znak iz tabele D.1 osim ako string i počinje i završava se njime i ne sadrži nijedno slovo sleva-nadesno
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;
To pravilo dvosmernosti je ono koje će iznenaditi vaš tim za podršku. Arapska ili hebrejska lozinka koja se završava zapadnom cifrom, ili ona sa zalutalim latiničnim slovom u sredini, biva odbijena po specifikaciji čak i kada izgleda savršeno razumno u polju za unos. Prikažite 604 kao poruku o znakovima lozinke, ne kao generičku grešku šifrovanja, ili će neko provesti popodne tražeći bag u izvođenju vašeg ključa
Iskrena ograničenja: NFKC, približan LCat, i jedna Delphi zamka
Dva dela implementacije su aproksimacije, i oba zaslužuju da budu jasno navedena, a ne zakopana. Normalizacija NFKC se izvodi Windows API-jem NormalizeString, dinamički učitanim iz Normaliz.dll. Kad ta biblioteka nije dostupna, mapirani string se koristi nenormalizovan, što znači da se koraci mapiranja i zabrane i dalje izvode, ali kompatibilno sklapanje ne. U praksi je DLL isporučivan sa svakim izdanjem Windows-a od Vista-e, tako da je degradirana putanja briga za pre-Vista i ne-Windows sisteme, a ne živa briga, ali lozinka koja se oslanja na sklapanje NFKC bi tamo proizvela drugačije bajtove, i to je stvarna, iako udaljena, divergencija. Provera dvosmernosti je druga aproksimacija: detekcija LCat znakova koristi uobičajene opsege slova umesto pune RFC 3454 tabele D.2, a smer te greške je ono što je čini prihvatljivom. Propušten LCat znak može samo prouzrokovati da pravilo dvosmernosti prođe tamo gde bi ga strožija implementacija odbila, nikad obrnuto, i nikad ne dira korake mapiranja ili normalizacije, tako da pripremljena bajtovna sekvenca prihvaćene lozinke ostaje nepromenjena. Preostali rizik je zato divergencija politike, a ne bajtovna divergencija: lozinka egzotičnog pisma koju bi strožija implementacija u potpunosti odbila da prihvati. Svaka lozinka koju obe strane prihvataju heš-uje se identično, što je svojstvo od kog interoperabilnost zapravo zavisi
Na kraju, Delphi sintaksna zamka koja košta sat vremena ako je niste sreli ranije. Kad funkcija vraća proceduralni tip, dodela bez zagrada ga ne poziva. Kompajler čita Proc := GetNormalizeProc; kao uzimanje adrese samog GetNormalizeProc, a zatim prijavljuje E2009 sa nekorisnom pritužbom da se konvencije poziva razlikuju, jer accessor koristi podrazumevanu konvenciju, dok je uvezeni API tip stdcall. Prazne zagrade su obavezne
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;
Priprema lozinke je jedan od onih detalja koji se nikad ne pojavljuje na listi funkcija, a odlučuje da li šifrovan dokument preživi kontakt sa klijentom u drugom lokalu. Ulazne tačke Encrypt, EncryptFile, DecryptFile i SetPassword opisane ovde deo su losLab PDF Developer Library Pascal Edition za Delphi i C++Builder, čija stranica proizvoda nosi kompletnu referencu šifrovanja i potpunu tabelu kodova grešaka