Technický článek

SASLprep pro hesla AES-256 PDF v Delphi s PDF Library for Delphi

PDF šifrovaný AES-256 s heslem obsahujícím ne-ASCII znaky se otevře v programu, který jej vytvořil, a nikde jinde. Příčinou je téměř vždy chybějící krok přípravy: ISO 32000-2 §7.6.4.3.3 vyžaduje, aby heslo bylo zpracováno profilem SASLprep ze stringprep ještě předtím, než se zakóduje do UTF-8 a hashuje. PDF Library for Delphi, PDF knihovna pro Delphi a C++Builder, provádí tuto přípravu uvnitř Encrypt, EncryptFile a DecryptFile

Toto není příběh o špatném heslu ani o bitech oprávnění. Pokud vaši uživatelé zadávají heslo, které jste nikdy nevydali, mechanismus opakovaných pokusů v článku o opakovaných pokusech u hesel šifrovaných PDF je to, co chcete, a pokud se snažíte zjistit, co existující soubor skutečně vynucuje, audit šifrování a oprávnění pokrývá tuto oblast. Tento je užší a podivnější: heslo je správné, uživatel jej zadal správně, a soubor se přesto odmítá otevřít jinde

Proč se heslo s ne-ASCII znaky otevře v jedné čtečce a v jiné ne?

Protože oba programy hashují různé sekvence bajtů ze stejných stisků kláves. Odvození klíče pro revizi 6 v ISO 32000-2 §7.6.4.3.3 bere heslo jako bajty UTF-8, oříznuté na 127 bajtů, připojí salt a spustí zesílený hash; výsledek se porovnává proti položkám /U a /O ve slovníku šifrování. Nic v tomto řetězci není mlhavé. Jeden odlišný bajt kdekoli ve vstupu vyprodukuje zcela odlišný digest, validace selže a čtečka má přesně jednu věc, kterou může říct: špatné heslo

Bajty se rozcházejí, protože Unicode nabízí několik způsobů, jak napsat, co vypadá jako stejné heslo. Čínské heslo může přijít jako předsložené znaky z jedné vstupní metody a jako kompatibilní tvary z jiné. Německé nebo francouzské heslo zkopírované z textového editoru může nést NO-BREAK SPACE (U+00A0) tam, kde uživatel věří, že je obyčejná mezera, nebo SOFT HYPHEN (U+00AD), který se vykreslí jako nic vůbec. SASLprep existuje proto, aby toto vše zhroutil do jedné kanonické formy ještě předtím, než někdo cokoli hashuje, takže každá konformní implementace odvodí stejný klíč ze stejného záměru

Diagram PDF Library for Delphi: stejné heslo PDF mimo ASCII se hašuje na dvě různé bajtové sekvence UTF-8, čtečka přeskočivší přípravu propadne u kontroly /U a čtečka připravená SASLprep odvodí funkční klíč
Čtečka, která přípravu vynechá, hashuje jiné bajty, takže validace AES-256 se zřítí do chybného hlášení o špatném hesle

Co SASLprep skutečně mění na heslu?

RFC 4013 definuje SASLprep jako profil frameworku stringprep z RFC 3454, a jde o čtyři seřazené kroky místo jedné transformace. Nejprve přijde mapování: tabulka C.1.2 z RFC 3454 (ne-ASCII mezery) se mapuje na U+0020, a tabulka B.1 (znaky běžně mapované na nic) se rovnou odstraní. Následuje normalizace na Unicode NFKC, což je krok, který skládá kompatibilní znaky a kombinující sekvence. Poté kontrola zakázaného výstupu odmítá cokoli v tabulkách C.2.1 až C.9. Nakonec se na normalizovaný řetězec aplikuje obousměrné pravidlo z RFC 3454 oddílu 6

PDF Library for Delphi implementuje celý profil v jednotce PDFlibSASLprep, která vystavuje jediný vstupní bod. PLSASLprepPassword bere syrové heslo, zapisuje připravenou formu do var parametru a vrací False, musí-li být heslo odmítnuto. Funkce je záměrně bezeztrátová na šťastné cestě: heslo obsahující jen ASCII se vrací bajtově identické, takže se na existujících nasazeních nic nemění

uses
  PDFlibSASLprep;

var
  Prepared: WideString;
begin
  // RFC 4013: mapování, poté NFKC, poté zakázaný výstup, poté obousměrné pravidlo
  PLSASLprepPassword('I' + WideChar($00AD) + 'X', Prepared);  // -> 'IX'   B.1 smaže SOFT HYPHEN
  PLSASLprepPassword('a' + WideChar($00A0) + 'b', Prepared);  // -> 'a b'  C.1.2 mapuje NBSP na U+0020
  PLSASLprepPassword(WideString(WideChar($00AA)), Prepared);  // -> 'a'    NFKC složí ORDINAL INDICATOR
  PLSASLprepPassword(WideString(WideChar($2168)), Prepared);  // -> 'IX'   NFKC složí ROMAN NUMERAL NINE
  PLSASLprepPassword('user', Prepared);                       // -> 'user' ASCII se nikdy nemění
end;

Nejednoznačnost U+200B, kterou tabulky neřeší

Jeden kódový bod spadá do dvou tabulek RFC 3454 zároveň, a obě tabulky si odporují. ZERO WIDTH SPACE (U+200B) spadá do rozsahu C.1.2 U+2000 až U+200B, kde pravidlo říká mapovat jej na U+0020, a zároveň spadá do rozsahu B.1 U+200B až U+200D, kde pravidlo říká jej odstranit. Přečtěte si krok mapování v kterémkoli pořadí a dostanete ze stejného hesla jiné bajty: a+U+200B+b se připraví na a b podle C.1.2 a na ab podle B.1. RFC 4013 jmenuje obě tabulky a neříká, která vyhrává, takže jde o skutečnou nejednoznačnost ve specifikaci, ne o chybu čtení. PDF Library for Delphi testuje členství v C.1.2 nejprve a tedy mapuje U+200B na mezeru, což je chování, na kterém se usadily jiné široce nasazené implementace stringprep; shoda s nimi je tady jediná věc, na které záleží, protože cílem je bajtová shoda s tím, jakou čtečku zákazník náhodou používá

Čtení starých souborů: nejprve připravené, pak syrové

Oprava vytváří vlastní problém kompatibility. Každý soubor AES-256 zapsaný před touto změnou hashoval syrové heslo UTF-8, takže učinit čtečku striktně konformní by uzamklo zákazníky mimo jejich vlastní archivy. PDF Library for Delphi to na straně čtení řeší zkoušením dvou kandidátů v pořadí. TPDFDocument.SetPassword staví seznam kandidátů, který začíná připravenou formou a padá zpět na syrovou formu, a přidává připravenou položku pouze tehdy, je-li dokument skutečně AES-256 a obě formy se liší. U ASCII hesla jsou formy identické, seznam nese jednu položku, a cenou celého mechanismu je jediné porovnání řetězců. DecryptFile dělá totéž na své přímé přepisovací cestě AES-256, volaje PLDirectDecryptFileAES256 nejprve s připraveným heslem

var
  Lib: TPDFlib;
  Bytes: AnsiString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);
    Lib.DrawText(100, 100, 'saslprep roundtrip');
    // Strength 3 a 4 jsou dvě hodnoty AES-256; obě se připraví před hashováním
    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 to, co SASLprep vyprodukoval a co spočítá každá konformní čtečka,
    // takže čistě ASCII forma otevře soubor vytvořený s formou obsahující soft-hyphen
    if Lib.LoadFromString(Bytes, 'pass') = 1 then
      Caption := IntToStr(Lib.PageCount);
  finally
    Lib.Free;
  end;
end;

Záložní mechanismus nese jednu hlídku, kterou stojí za to okopírovat. Druhý pokus v DecryptFile proběhne pouze tehdy, když se připravená a syrová forma liší a první pokus nehlásil žádný tvrdý chybový kód. Strukturální selhání znamená, že vstup je poškozený, nebo nejde o revizi šifrování, kterou jste předpokládali, a opakovat pokus na rozbitém souboru s jiným heslem jen spálí druhé plné parsování nad nepřátelským vstupem; úvaha za tímto reflexem je rozepsána v poznámce o bezpečném parsování nedůvěryhodných PDF. Všimněte si také, že na straně zápisu žádný záložní mechanismus není, a tato asymetrie je záměrná. Čtení toleruje historii, zápis ne: každý nový soubor AES-256 dostane konformní bajty

Která hesla se rovnou odmítnou, a co je chyba 604?

SASLprep může heslo zcela odmítnout, a když to udělá, šifrování musí selhat hlasitě místo tichého nahrazení něčím jiným. Encrypt a EncryptFile připravují heslo vlastníka i uživatele vždy, když je Strength 3 nebo 4, vrací 0 při odmítnutí a nastavují LastErrorCode na PDFLIB_ERROR_PASSWORD_SASLPREP, což je 604. Spouštějí to dvě rodiny vstupu. Tabulky zakázaného výstupu odmítají řídicí znaky (C.2.1 a C.2.2), kódové body soukromého užití (C.3), ne-znaky (C.4), osamocené náhradní znaky (C.5), U+FFFD (C.6), znaky ideografického popisu (C.7) a rozsahy zobrazovacích řídicích a značkovacích znaků (C.8 a C.9). Samostatně obousměrné pravidlo z RFC 3454 oddílu 6 odmítá jakýkoli řetězec, který obsahuje znak RandALCat z tabulky D.1, pokud řetězec zároveň nezačíná a nekončí takovým znakem a neobsahuje žádné písmeno zleva doprava

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    // U+0007 je řídicí znak C.2.1, takže příprava heslo odmítne
    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ávě toto obousměrné pravidlo překvapí váš support desk. Arabské nebo hebrejské heslo končící západní číslicí, nebo takové s bloudícím latinským písmenem uprostřed, specifikace odmítne, i když v poli pro zadání vypadá naprosto rozumně. Vystavte 604 jako zprávu o znacích v hesle, ne jako obecné selhání šifrování, jinak někdo stráví odpoledne hledáním chyby ve vašem odvození klíče

Diagram PDF Library for Delphi čtyř uspořádaných kroků SASLprep podle RFC 4013: mapování mezer mimo ASCII, mazání znaků mapovaných na nic, skládání kompatibilních forem s NFKC a provedení kontrol zakázaného výstupu a obousměrnosti dávající jedno stabilní připravené heslo
Mapování, normalizace NFKC, scan zakázaného výstupu a pravidlo bidi běží v pořadí a hesla jen z ASCII nechávají nedotčená

Poctivé limity: NFKC, přibližný LCat a jedna past Delphi

Dvě části implementace jsou aproximace, a obě si zaslouží otevřené vyjádření místo zahrabání. Normalizace NFKC se provádí přes API Windows NormalizeString, dynamicky načtené z Normaliz.dll. Není-li tato knihovna dostupná, použije se mapovaný řetězec bez normalizace, což znamená, že kroky mapování a zákazu stále proběhnou, ale kompatibilní skládání ne. V praxi se tato DLL dodává s každým vydáním Windows od Visty, takže degradovaná cesta je záležitostí pre-Vista a ne-Windows systémů spíš než živou hrozbou, ale heslo spoléhající na skládání NFKC by tam vyprodukovalo jiné bajty, a to je skutečná, byť vzdálená, odchylka. Kontrola bidi je druhou aproximací: detekce znaků LCat používá běžné rozsahy písmen místo plné tabulky D.2 z RFC 3454, a směr této chyby je to, co ji dělá přijatelnou. Zmeškaný znak LCat může způsobit pouze to, že bidi pravidlo projde tam, kde by je specifikace odmítla, nikdy naopak, a nikdy se nedotkne kroků mapování nebo normalizace, takže připravená bajtová sekvence přijatého hesla zůstává nezměněná. Zbytkové riziko je tedy odchylkou zásady, ne odchylkou bajtů: heslo v exotickém písmu, které by přísnější implementace odmítla přijmout vůbec. Každé heslo, které obě strany přijmou, se hashuje identicky, což je vlastnost, na které interoperabilita skutečně závisí

Nakonec syntaktická past Delphi, která vás stojí hodinu, pokud jste na ni ještě nenarazili. Když funkce vrací procedurální typ, přiřazení bez závorek ji nezavolá. Kompilátor přečte Proc := GetNormalizeProc; jako vzetí adresy samotné GetNormalizeProc, a poté nahlásí E2009 s nepomocnou stížností, že se volací konvence liší, protože přístupová funkce používá výchozí konvenci, zatímco importovaný typ API je stdcall. Prázdné závorky jsou povinné

Diagram PDF Library for Delphi šifrování AES-256 odmítajícího hesla, jejichž znaky zasáhnou tabulky zakázaného výstupu C.2.1 až C.9 nebo poruší obousměrné pravidlo RFC 3454, s návratem nuly a LastErrorCode 604
Dvě rodiny vstupů spustí cestu odmítnutí: zakázané tabulky kódových bodů a bidirekční pravidlo RFC 3454
type
  TNormalizeString = function(NormForm: Integer; SrcString: PWideChar; SrcLength: Integer;
    DstString: PWideChar; DstLength: Integer): Integer; stdcall;

function GetNormalizeProc: TNormalizeString;   // načte Normaliz.dll při prvním použití
...
var
  Proc: TNormalizeString;
begin
  // Proc := GetNormalizeProc;   // E2009: čte se jako @GetNormalizeProc, konvence se liší
  Proc := GetNormalizeProc();    // správně: zavolá accessor a přiřadí jeho výsledek
  if not Assigned(Proc) then
    Exit;                        // NFKC není k dispozici, mapovaný řetězec se použije beze změny
end;

Příprava hesla je jeden z těch detailů, který se nikdy neobjeví v seznamu funkcí a přitom rozhoduje o tom, zda šifrovaný dokument přežije kontakt se zákazníkem v jiném locale. Vstupní body Encrypt, EncryptFile, DecryptFile a SetPassword popsané zde jsou součástí losLab PDF Developer Library Pascal Edition pro Delphi a C++Builder, jejíž stránka produktu nese kompletní referenci šifrování a úplnou tabulku chybových kódů