Technický článek

SASLprep pro hesla AES-256 PDF v Delphi s PDFlibPas

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. PDFlibPas, 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

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

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

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 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á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

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é

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;

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ů