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. PDFlibPas, 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
Š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
PDFlibPas 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: 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;
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. PDFlibPas 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. PDFlibPas 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 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;
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 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 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
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
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 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