Odborný článok

SASLprep AES-256 heslá PDF v Delphi s PDFlibPas

PDF zašifrovaný AES-256 s neASCII heslom sa otvorí v programe, ktorý ho zapísal, a nikde inde. Príčinou je takmer vždy chýbajúci prípravný krok: ISO 32000-2 §7.6.4.3.3 vyžaduje, aby bolo heslo spracované profilom SASLprep frameworku stringprep skôr, než sa zakóduje do UTF-8 a hashuje. PDFlibPas, PDF knižnica pre Delphi a C++Builder, vykonáva túto prípravu vnútri Encrypt, EncryptFile a DecryptFile

Toto nie je príbeh o nesprávnom hesle a ani príbeh o bitoch oprávnení. Ak vaši používatelia zadávajú heslo, ktoré ste nikdy nevydali, mechanizmus opakovaných pokusov v článku o opakovaných pokusoch pri šifrovaných PDF heslách je to, čo chcete, a ak sa snažíte zistiť, čo existujúci súbor skutočne vynucuje, audit šifrovania a oprávnení pokrýva túto pôdu. Tento článok je užší a zvláštnejší: heslo je správne, používateľ ho zadal správne, a súbor sa napriek tomu odmieta otvoriť inde

Prečo sa neASCII heslo otvorí v jednej čítačke, ale nie v druhej?

Pretože oba programy hashujú odlišné bajtové sekvencie z tých istých stlačení klávesov. Odvodenie kľúča revízie 6 v ISO 32000-2 §7.6.4.3.3 berie heslo ako UTF-8 bajty, orezáva na 127 bajtov, pripojí soľ a spustí sprísnený hash; výsledok sa porovná s položkami /U a /O v slovníku šifrovania. Nič v tomto reťazci nie je nejasné. Jeden odlišný bajt kdekoľvek na vstupe vyprodukuje úplne odlišný digest, validácia zlyhá, a čítačka má presne jednu vec, ktorú môže povedať: nesprávne heslo

Bajty sa rozchádzajú, pretože Unicode ponúka niekoľko spôsobov, ako napísať niečo, čo vyzerá ako to isté heslo. Čínske heslo môže prísť ako predkomponované znaky z jednej vstupnej metódy a ako kompatibilné formy z inej. Nemecké alebo francúzske heslo skopírované z textového editora môže niesť NO-BREAK SPACE (U+00A0) tam, kde používateľ verí, že je bežná medzera, alebo SOFT HYPHEN (U+00AD), ktorý sa vykreslí ako nič. SASLprep existuje na to, aby to všetko zbalil do jednej kanonickej formy skôr, než ktokoľvek čokoľvek zahashuje, tak aby každá zhodná implementácia odvodila ten istý kľúč z toho istého zámeru

Čo SASLprep na hesle skutočne mení?

RFC 4013 definuje SASLprep ako profil frameworku stringprep z RFC 3454, a je to štyri usporiadané kroky, nie jedna transformácia. Mapovanie prichádza ako prvé: tabuľka C.1.2 z RFC 3454 (neASCII medzery) sa mapuje na U+0020, a tabuľka B.1 (znaky bežne mapované na nič) sa priamo odstráni. Nasleduje normalizácia na Unicode NFKC, čo je krok, ktorý skladá kompatibilné znaky a kombinujúce sekvencie. Potom kontrola zakázaného výstupu odmietne čokoľvek v tabuľkách C.2.1 až C.9. Nakoniec sa na normalizovaný reťazec aplikuje obojsmerné (bidi) pravidlo z RFC 3454 sekcie 6

PDFlibPas implementuje celý profil v jednotke PDFlibSASLprep, ktorá sprístupňuje jediný vstupný bod. PLSASLprepPassword berie surové heslo, zapisuje pripravenú formu do var parametra a vracia False, keď sa heslo musí odmietnuť. Funkcia je na šťastnej ceste zámerne totálna: čisto ASCII heslo sa vráti bajtovo identické, takže sa na existujúcich nasadeniach nič nemení

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čnosť U+200B, ktorú tabuľky nevyriešia

Jeden bod kódu pristane naraz v dvoch tabuľkách RFC 3454, a tie dve tabuľky si protirečia. ZERO WIDTH SPACE (U+200B) padá do rozsahu C.1.2 U+2000 až U+200B, kde pravidlo hovorí namapovať ho na U+0020, a padá aj do rozsahu B.1 U+200B až U+200D, kde pravidlo hovorí odstrániť ho. Prečítajte si krok mapovania v ktoromkoľvek poradí a z toho istého hesla dostanete odlišné bajty: a+U+200B+b sa pripraví na a b podľa C.1.2 a na ab podľa B.1. RFC 4013 pomenúva obe tabuľky a nehovorí, ktorá vyhráva, takže ide o skutočnú nejednoznačnosť v špecifikácii, nie o chybu čítania. PDFlibPas testuje najprv členstvo v C.1.2, a preto mapuje U+200B na medzeru, čo je správanie, na ktorom sa ustálili aj iné široko nasadené implementácie stringprep; zhoda s nimi je jediné, na čom tu záleží, pretože cieľom je bajtová zhoda s akoukoľvek čítačkou, ktorú zákazník práve používa

Čítanie starých súborov: najprv pripravené, potom surové

Oprava vytvára svoj vlastný problém s kompatibilitou. Každý AES-256 súbor zapísaný pred zmenou hashoval surové UTF-8 heslo, takže striktné zosúladenie čítačky so špecifikáciou by zamklo zákazníkov mimo ich vlastných archívov. PDFlibPas to na strane čítania rieši skúšaním dvoch kandidátov v poradí. TPDFDocument.SetPassword zostaví zoznam kandidátov, ktorý začína pripravenou formou a padá späť na surovú formu, a pripravenú položku pridá iba vtedy, keď je dokument skutočne AES-256 a obe formy sa líšia. Pri ASCII hesle sú formy identické, zoznam drží jednu položku, a cena celého mechanizmu je jedno porovnanie reťazcov. DecryptFile robí to isté na svojej priamej AES-256 prepisovacej ceste, volajúc PLDirectDecryptFileAES256 najprv s pripraveným heslom

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 nesie jednu poistku, ktorú stojí za to prevziať. Druhý pokus v DecryptFile beží iba vtedy, keď sa pripravená a surová forma líšia a zároveň prvý pokus nehlásil žiadny tvrdý chybový kód. Štrukturálne zlyhanie znamená, že vstup je poškodený alebo nejde o revíziu šifrovania, ktorú ste predpokladali, a opakovaný pokus na poškodenom súbore s ďalším heslom iba spáli druhý plný parse nad nepriateľským vstupom; zdôvodnenie tohto reflexu je rozobraté v poznámke o bezpečnom parsovaní nedôveryhodných PDF. Všimnite si tiež, že na strane zápisu neexistuje žiadny fallback, a táto asymetria je zámerná. Čítanie toleruje históriu, zápis nie: každý nový AES-256 súbor dostane konformné bajty

Ktoré heslá sa rovno odmietnu, a čo je chyba 604?

SASLprep môže heslo úplne odmietnuť, a keď to urobí, šifrovanie musí zlyhať nahlas, nie ticho nahradiť niečím iným. Encrypt a EncryptFile pripravia heslo vlastníka aj používateľa vždy, keď je Strength 3 alebo 4, pri odmietnutí vrátia 0 a nastavia LastErrorCode na PDFLIB_ERROR_PASSWORD_SASLPREP, čo je 604. Spúšťajú ho dve rodiny vstupov. Tabuľky zakázaného výstupu odmietajú riadiace znaky (C.2.1 a C.2.2), znaky súkromného použitia (C.3), neznaky (C.4), osamotené náhradné znaky (C.5), U+FFFD (C.6), ideografické popisné znaky (C.7) a rozsahy zobrazovacej kontroly a tagovania (C.8 a C.9). Samostatne, bidi pravidlo z RFC 3454 sekcie 6 odmieta akýkoľvek reťazec, ktorý obsahuje znak RandALCat z tabuľky D.1, pokiaľ reťazec zároveň nezačína aj nekončí takýmto znakom a neobsahuje vôbec žiadne písmená zľava doprava

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;

Práve toto bidi pravidlo prekvapí váš podporný tím. Arabské alebo hebrejské heslo končiace západnou číslicou, alebo také, ktoré má v strede zablúdené latinské písmeno, špecifikácia odmietne, aj keď v poli na zadanie vyzerá úplne rozumne. Zobrazte 604 ako správu o znakoch v hesle, nie ako všeobecné zlyhanie šifrovania, inak niekto strávi popoludnie hľadaním chyby vo vašom odvodzovaní kľúča

Úprimné limity: NFKC, približná LCat a jedna pasca Delphi

Dve časti implementácie sú aproximácie, a obe si zaslúžia byť vyslovené jasne namiesto toho, aby boli pochované. Normalizáciu NFKC vykonáva Windows API NormalizeString, dynamicky načítané z Normaliz.dll. Keď táto knižnica nie je dostupná, použije sa namapovaný reťazec nenormalizovaný, čo znamená, že kroky mapovania a zákazu stále bežia, ale kompatibilné skladanie nie. V praxi sa táto DLL dodáva s každým vydaním Windows od Visty, takže degradovaná cesta je záležitosťou pred-Vista a ne-Windows systémov, nie živou hrozbou, ale heslo, ktoré sa spolieha na skladanie NFKC, by tam vyprodukovalo odlišné bajty, a to je skutočná, hoci vzdialená, odchýlka. Bidi kontrola je druhá aproximácia: detekcia znakov LCat používa bežné rozsahy písmen namiesto úplnej tabuľky D.2 z RFC 3454, a smer tejto chyby je to, čo ju robí prijateľnou. Vynechaný znak LCat môže spôsobiť iba to, že bidi pravidlo prejde tam, kde by ho špecifikácia odmietla, nikdy naopak, a nikdy sa nedotýka krokov mapovania alebo normalizácie, takže pripravená bajtová sekvencia prijatého hesla zostáva nezmenená. Zostatkové riziko je preto skôr odchýlka v politike než odchýlka v bajtoch: exotické heslo v neobvyklom písme, ktoré by prísnejšia implementácia úplne odmietla prijať. Každé heslo, ktoré prijmú obe strany, sa hashuje identicky, čo je vlastnosť, na ktorej interoperabilita v skutočnosti závisí

Nakoniec ešte jedna syntaktická pasca Delphi, ktorá stojí hodinu, ak ste na ňu ešte nenarazili. Keď funkcia vracia procedurálny typ, jej priradenie bez zátvoriek ju nevolá. Kompilátor prečíta Proc := GetNormalizeProc; ako vzatie adresy samotného GetNormalizeProc, a potom nahlási E2009 s nepomocnou sťažnosťou, že konvencie volania sa líšia, pretože accessor používa predvolenú konvenciu, zatiaľ čo importovaný typ API je stdcall. Prázdne zátvorky sú povinné

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;

Príprava hesla je jeden z tých detailov, ktorý sa nikdy neobjaví v zozname funkcií, a pritom rozhoduje o tom, či šifrovaný dokument prežije kontakt so zákazníkom v inom locale. Vstupné body Encrypt, EncryptFile, DecryptFile a SetPassword opísané tu sú súčasťou losLab PDF Developer Library Pascal Edition pre Delphi a C++Builder, ktorej stránka produktu nesie úplnú referenciu šifrovania a kompletnú tabuľku chybových kódov