Tehnični članak

SASLprep AES-256 PDF gesla v Delphiju s PDFlibPas

PDF, šifriran z AES-256 in geslom, ki ni ASCII, se odpre v programu, ki ga je zapisal, in nikjer drugje. Vzrok je skoraj vedno manjkajoč korak priprave: ISO 32000-2 §7.6.4.3.3 zahteva, da se geslo obdela s profilom SASLprep okvira stringprep, preden se kodira v UTF-8 in zgosti. PDFlibPas, PDF knjižnica za Delphi in C++Builder, to pripravo izvede znotraj Encrypt, EncryptFile in DecryptFile

To ni zgodba o napačnem geslu in ne zgodba o bitih dovoljenj. Če vaši uporabniki vtipkajo geslo, ki ga nikoli niste izdali, je mehanizem ponovnega poskusa v članku o ponovnih poskusih gesel za šifrirane PDF-je tisto, kar potrebujete, če pa ugotavljate, kaj obstoječa datoteka dejansko uveljavlja, to področje pokriva revizija šifriranja in dovoljenj. Ta je ožja in bolj čudna: geslo je pravilno, uporabnik ga je vtipkal pravilno, datoteka pa se še vedno noče odpreti drugje

Zakaj se geslo brez ASCII znakov odpre v enem bralniku, v drugem pa ne?

Ker programa iz istih pritisnjenih tipk zgostita različna zaporedja bajtov. Izpeljava ključa revizije 6 v ISO 32000-2 §7.6.4.3.3 vzame geslo kot bajte UTF-8, ga skrajša na 127 bajtov, doda sol in požene utrjeno zgoščevalno funkcijo; rezultat se preveri proti vnosoma /U in /O v slovarju šifriranja. Nič v tej verigi ni ohlapno. En sam različen bajt kjer koli v vhodu proizvede popolnoma drugačen prstni odtis, preverjanje odpove, bralnik pa lahko reče natanko eno stvar: napačno geslo

Bajti se razidejo, ker Unicode ponuja več načinov, kako natipkati navidezno isto geslo. Kitajsko geslo lahko iz enega vhodnega načina pride kot vnaprej sestavljeni znaki, iz drugega pa kot kompatibilnostne oblike. Nemško ali francosko geslo, prekopirano iz urejevalnika besedil, lahko nosi NETRGAJOČ PRESLEDEK (U+00A0) tam, kjer uporabnik misli, da je navaden presledek, ali MEHKI VEZAJ (U+00AD), ki se izriše kot nič. SASLprep obstaja, da vse to strne v eno kanonično obliko, preden kdor koli kar koli zgosti, tako da vsaka skladna implementacija izpelje enak ključ iz enakega namena

Kaj SASLprep dejansko spremeni pri geslu?

RFC 4013 opredeljuje SASLprep kot profil okvira stringprep iz RFC 3454, in to so štirje urejeni koraki, ne ena sama pretvorba. Najprej pride preslikava: tabela C.1.2 iz RFC 3454 (presledki, ki niso ASCII) se preslika v U+0020, tabela B.1 (znaki, ki se pogosto preslikajo v nič) pa se povsem izbriše. Sledi normalizacija v Unicode NFKC, ki je korak, ki zloži kompatibilnostne znake in kombinacijska zaporedja. Nato preverjanje prepovedanega izhoda zavrne vse v tabelah C.2.1 do C.9. Nazadnje se na normaliziran niz uporabi dvosmerno pravilo iz RFC 3454 razdelka 6

PDFlibPas implementira celoten profil v enoti PDFlibSASLprep, ki izpostavi eno samo vstopno točko. PLSASLprepPassword vzame surovo geslo, pripravljeno obliko zapiše v var parameter in vrne False, kadar je treba geslo zavrniti. Funkcija je namerno popolna na srečni poti: geslo, ki vsebuje le ASCII, se vrne bajtno enako, zato se pri obstoječih namestitvah nič ne spremeni

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;

Nejasnost U+200B, ki je tabele ne rešijo

Ena kodna točka pristane v dveh tabelah RFC 3454 hkrati, ti dve tabeli pa si nasprotujeta. PRESLEDEK NIČELNE ŠIRINE (U+200B) pade znotraj obsega C.1.2 U+2000 do U+200B, kjer pravilo pravi, naj se preslika v U+0020, hkrati pa pade tudi znotraj obsega B.1 U+200B do U+200D, kjer pravilo pravi, naj se izbriše. Preberite korak preslikave v enem ali drugem vrstnem redu in iz istega gesla dobite različne bajte: a+U+200B+b se pripravi v a b po C.1.2 in v ab po B.1. RFC 4013 imenuje obe tabeli in ne pove, katera prevlada, zato gre za resnično nejasnost v specifikaciji, ne za napako pri branju. PDFlibPas najprej preveri članstvo v C.1.2 in zato U+200B preslika v presledek, kar je obnašanje, pri katerem so obstali drugi razširjeno uporabljeni implementaciji stringprep; ujemanje z njimi je edino, kar je tu pomembno, ker je cilj bajtno ujemanje s katerim koli bralnikom, ki ga stranka uporablja

Branje starih datotek: najprej pripravljeno, nato surovo

Popravek ustvari lastno težavo z združljivostjo. Vsaka datoteka AES-256, zapisana pred spremembo, je zgostila surovo geslo UTF-8, zato bi stroga skladnost bralnika stranke zaklenila iz njihovih lastnih arhivov. PDFlibPas to na strani branja reši tako, da po vrsti preizkusi dva kandidata. TPDFDocument.SetPassword zgradi seznam kandidatov, ki se začne s pripravljeno obliko in preklopi na surovo obliko, pripravljeni vnos pa doda le, kadar je dokument dejansko AES-256 in se obliki razlikujeta. Za geslo, ki vsebuje le ASCII, sta obliki enaki, seznam vsebuje en vnos, strošek celotnega mehanizma pa je ena sama primerjava nizov. DecryptFile počne isto vzdolž svoje neposredne poti za prepisovanje AES-256, saj najprej pokliče PLDirectDecryptFileAES256 s pripravljenim geslom

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;

Nadomestna pot nosi eno varovalko, vredno prevzema. Drugi poskus v DecryptFile teče le, kadar se pripravljena in surova oblika razlikujeta in je prvi poskus poročal brez trde napake. Strukturna napaka pomeni, da je vhod poškodovan ali ni tiste revizije šifriranja, ki ste jo predpostavili, ponovni poskus s poškodovano datoteko in drugim geslom pa preprosto zažge še en polni prehod razčlenjevanja nad sovražnim vhodom; razlog za ta refleks je razložen v opombi o varnem razčlenjevanju nezaupanja vrednih PDF-jev. Upoštevajte tudi, da na strani pisanja ni nadomestne poti, in ta asimetrija je namerna. Branje prenaša zgodovino, pisanje ne: vsaka nova datoteka AES-256 dobi skladne bajte

Katera gesla so povsem zavrnjena, in kaj je napaka 604?

SASLprep lahko geslo popolnoma zavrne, in kadar to stori, mora šifriranje glasno odpovedati, ne tiho zamenjati nečesa drugega. Encrypt in EncryptFile pripravita tako lastniško kot uporabniško geslo, kadar koli je Strength 3 ali 4, ob zavrnitvi vrneta 0 in nastavita LastErrorCode na PDFLIB_ERROR_PASSWORD_SASLPREP, kar je 604. Sprožita ga dve družini vhoda. Tabele prepovedanega izhoda zavrnejo kontrolne znake (C.2.1 in C.2.2), zasebno rabljene kodne točke (C.3), ne-znake (C.4), osamljene nadomestne znake (C.5), U+FFFD (C.6), ideografske opisne znake (C.7) ter obsega za nadzor prikaza in označevanje (C.8 in C.9). Ločeno pa dvosmerno pravilo iz RFC 3454 razdelka 6 zavrne vsak niz, ki vsebuje znak RandALCat iz tabele D.1, razen če se niz začne in konča z njim in sploh ne vsebuje črk od leve proti desni

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;

Prav to dvosmerno pravilo bo presenetilo vašo podporno službo. Arabsko ali hebrejsko geslo, ki se konča z zahodno številko, ali tako, ki ima sredi sebe zabloden latinski znak, specifikacija zavrne, čeprav v vnosnem polju izgleda povsem razumno. Napako 604 pokažite kot sporočilo o znakih v geslu, ne kot generično napako šifriranja, sicer bo nekdo popoldne iskal hrošča v vaši izpeljavi ključa

Iskrene meje: NFKC, približen LCat in ena Delphi past

Dva dela implementacije sta približka, oba pa si zaslužita jasno navedbo, ne skrivanje. Normalizacija NFKC se izvaja z API-jem Windows NormalizeString, naloženim dinamično iz Normaliz.dll. Kadar ta knjižnica ni na voljo, se uporabi preslikan niz brez normalizacije, kar pomeni, da koraka preslikave in prepovedi še vedno tečeta, kompatibilnostno zlaganje pa ne. V praksi se je DLL dostavljala z vsako izdajo Windows od Viste naprej, zato je degradirana pot skrb za obdobje pred Visto in ne-Windows sisteme, ne pa nekaj živega, gesla, ki bi bilo odvisno od zlaganja NFKC, pa bi tam proizvedlo drugačne bajte, kar je resnično, čeprav oddaljeno razhajanje. Preverjanje dvosmernosti je druga aproksimacija: zaznavanje znakov LCat uporablja pogoste obsege črk namesto polne tabele D.2 iz RFC 3454, smer te napake pa je tisto, kar jo naredi sprejemljivo. Zgrešen znak LCat lahko povzroči le, da dvosmerno pravilo mine tam, kjer bi ga specifikacija zavrnila, nikoli obratno, in se nikoli ne dotakne korakov preslikave ali normalizacije, zato je pripravljeno zaporedje bajtov sprejetega gesla nespremenjeno. Preostalo tveganje je torej razhajanje politike, ne razhajanje bajtov: eksotično geslo z znaki pisave, ki bi ga strožja implementacija povsem zavrnila. Vsako geslo, ki ga sprejmeta obe strani, se zgosti enako, kar je lastnost, od katere medobratovalnost dejansko odvisna

Nazadnje, past sintakse Delphi, ki vas stane uro, če je še niste doživeli. Kadar funkcija vrne proceduralni tip, dodelitev brez oklepajev je ne pokliče. Prevajalnik Proc := GetNormalizeProc; prebere kot vzeti naslov same GetNormalizeProc, nato pa poroča E2009 z nekoristno pritožbo, da se klicne konvencije razlikujejo, ker dostopnik uporablja privzeto konvencijo, uvoženi tip API pa je stdcall. Prazna oklepaja sta obvezna

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;

Priprava gesla je ena tistih podrobnosti, ki se nikoli ne pojavi na seznamu funkcij, odloči pa, ali šifriran dokument preživi stik s stranko v drugem jezikovnem okolju. Vstopne točke Encrypt, EncryptFile, DecryptFile in SetPassword, opisane tukaj, so del losLab PDF Developer Library Pascal Edition za Delphi in C++Builder, katere stran izdelka nosi celotno referenco šifriranja in celotno tabelo kod napak