Műszaki cikk

SASLprep AES-256 PDF jelszavak Delphiben PDFlibPas-szal

Egy nem ASCII jelszóval titkosított AES-256 PDF abban a programban nyílik meg, amely írta, és sehol máshol. Az ok szinte mindig egy hiányzó előkészítési lépés: az ISO 32000-2 §7.6.4.3.3 megköveteli, hogy a jelszót a stringprep SASLprep profiljával dolgozzuk fel, mielőtt UTF-8 kódolásra kerülne és hasheljük. A PDFlibPas, a Delphi és C++Builder PDF-könyvtára, ezt az előkészítést az Encrypt, EncryptFile és DecryptFile belsejében végzi el

Ez nem a hibás jelszó története, és nem is a jogosultsági bitek története. Ha a felhasználók olyan jelszót gépelnek be, amelyet mi soha nem adtunk ki, az titkosított PDF-jelszavak újrapróbálásáról szóló cikk újrapróbálási mechanizmusa az, amire szükségünk van, ha pedig azt próbáljuk kideríteni, mit érvényesít valójában egy meglévő fájl, arra a titkosítási és jogosultsági audit ad választ. Ez a cikk ennél szűkebb és furcsább: a jelszó helyes, a felhasználó helyesen gépelte be, és a fájl mégis megtagadja a megnyitást máshol

Miért nyílik meg egy nem ASCII jelszó az egyik olvasóban, de a másikban nem?

Mert a két program ugyanazokból a billentyűleütésekből különböző bájtsorozatokat hashel. Az ISO 32000-2 §7.6.4.3.3 6-os revíziójú kulcslevezetése a jelszót UTF-8 bájtokként veszi, 127 bájtra csonkolja, hozzáfűz egy sót, és lefuttatja a megerősített hash-t; az eredmény a titkosítási szótár /U és /O bejegyzéseivel kerül összevetésre. Ebben a láncban semmi nem elmosódott. Egyetlen eltérő bájt bárhol a bemenetben teljesen más digestet eredményez, az ellenőrzés meghiúsul, és az olvasó pontosan egy dolgot mondhat: hibás jelszó

A bájtok azért térnek el egymástól, mert a Unicode több módot is kínál ugyanannak látszó jelszó begépelésére. Egy kínai jelszó érkezhet előre komponált karakterekként az egyik beviteli módból, és kompatibilitási formákként egy másikból. Egy szövegszerkesztőből kimásolt német vagy francia jelszó hordozhat egy TÖRÉS NÉLKÜLI SZÓKÖZT (U+00A0) ott, ahol a felhasználó közönséges szóközt hisz látni, vagy egy LÁGY KÖTŐJELET (U+00AD), amely egyáltalán nem jelenik meg semmiként. A SASLprep azért létezik, hogy mindezt egyetlen kanonikus formává vonja össze, mielőtt bárki bármit hashelne, így minden megfelelő implementáció ugyanazt a kulcsot vezeti le ugyanabból a szándékból

Mit változtat valójában a SASLprep egy jelszón?

Az RFC 4013 a SASLprep-et az RFC 3454-ben leírt stringprep keretrendszer profiljaként definiálja, és négy sorrendben végrehajtott lépésből áll, nem egyetlen átalakításból. Először a leképezés jön: az RFC 3454 C.1.2 táblázata (nem ASCII szóközök) U+0020-ra kerül leképezésre, a B.1 táblázat (általában semmire leképezett karakterek) pedig teljesen törlésre kerül. Ezt követi a Unicode NFKC-re való normalizálás, ez az a lépés, amely összevonja a kompatibilitási karaktereket és a kombináló szekvenciákat. Ezután a tiltott kimenet ellenőrzés elutasít mindent a C.2.1-től C.9-ig terjedő táblázatokban. Végül az RFC 3454 6. szakaszának kétirányúsági szabálya kerül alkalmazásra a normalizált sztringre

A PDFlibPas a teljes profilt a PDFlibSASLprep egységben valósítja meg, amely egyetlen belépési pontot tesz közzé. A PLSASLprepPassword átveszi a nyers jelszót, az előkészített formát egy var paraméterbe írja, és False értékkel tér vissza, amikor a jelszót el kell utasítani. A függvény szándékosan teljesen konzisztens a szerencsés úton: egy csak ASCII karaktereket tartalmazó jelszó bájtra azonosan tér vissza, így a meglévő telepítéseken semmi nem változik

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;

A U+200B kétértelműség, amelyet a táblázatok nem oldanak fel

Egy kódpont egyszerre két RFC 3454 táblázatba is beleesik, és a két táblázat nem ért egyet. A NULLA SZÉLESSÉGŰ SZÓKÖZ (U+200B) beleesik a C.1.2 U+2000-től U+200B-ig terjedő tartományba, ahol a szabály szerint U+0020-ra kell leképezni, és beleesik a B.1 U+200B-től U+200D-ig terjedő tartományába is, ahol a szabály szerint törölni kell. Ha a leképezési lépést bármelyik sorrendben olvassuk, ugyanabból a jelszóból más bájtokat kapunk: az a+U+200B+b a C.1.2 szerint a b-vé, a B.1 szerint ab-vé készül elő. Az RFC 4013 mindkét táblázatot megnevezi, és nem mondja meg, melyik győz, így ez a specifikációban valódi kétértelműség, nem olvasási hiba. A PDFlibPas először a C.1.2 tagságot vizsgálja, ezért az U+200B-t szóközre képezi le, ez az a viselkedés, amelyben más széles körben elterjedt stringprep implementációk is megállapodtak; a velük való egyezés az egyetlen, ami itt számít, mert a cél a bájt-egyezés bármelyik olvasóval, amelyet az ügyfél éppen használ

Régi fájlok olvasása: előbb az előkészített, azután a nyers

A javítás saját kompatibilitási problémát teremt. Minden, a változás előtt írt AES-256 fájl a nyers UTF-8 jelszót hashelte, így ha az olvasót szigorúan szabványkövetővé tennénk, az kizárná az ügyfeleket a saját archívumaikból. A PDFlibPas ezt az olvasási oldalon úgy oldja fel, hogy sorrendben két jelöltet próbál ki. A TPDFDocument.SetPassword egy jelöltlistát épít fel, amely az előkészített formával kezdődik, és a nyers formára esik vissza, és csak akkor adja hozzá az előkészített bejegyzést, ha a dokumentum ténylegesen AES-256, és a két forma eltér egymástól. Egy ASCII jelszó esetén a két forma azonos, a lista egyetlen bejegyzést tartalmaz, és a teljes mechanizmus költsége egyetlen sztringösszehasonlítás. A DecryptFile ugyanezt teszi a közvetlen AES-256 átíró útvonalán, először az előkészített jelszóval hívva meg a PLDirectDecryptFileAES256-t

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;

A visszaesési útvonal egyetlen olyan védelmet hordoz, amelyet érdemes átvenni. A DecryptFile-ban a második próbálkozás csak akkor fut le, ha az előkészített és a nyers forma eltér és az első próbálkozás nem jelentett kemény hibakódot. Egy strukturális hiba azt jelenti, hogy a bemenet sérült, vagy nem az a titkosítási revízió, amit feltételeztünk, és egy sérült fájl másik jelszóval való újrapróbálása egyszerűen elégeti egy második teljes elemzés idejét egy ellenséges bemeneten; az e mögötti reflex indoklása a nem megbízható PDF-ek biztonságos elemzéséről szóló jegyzetben olvasható. Fontos megjegyezni azt is, hogy az írási oldalon nincs visszaesési mechanizmus, és ez az aszimmetria szándékos. Az olvasás tolerálja a múltat, az írás nem: minden új AES-256 fájl a szabványkövető bájtokat kapja

Mely jelszavak kerülnek teljesen elutasításra, és mi a 604-es hiba?

A SASLprep teljesen elutasíthat egy jelszót, és amikor ezt teszi, a titkosításnak hangosan kell meghiúsulnia, nem pedig csendben helyettesítenie valamivel. Az Encrypt és az EncryptFile mind a tulajdonosi, mind a felhasználói jelszót előkészíti, amikor a Strength 3 vagy 4, elutasítás esetén 0-t ad vissza, és a LastErrorCode-ot PDFLIB_ERROR_PASSWORD_SASLPREP-re állítja, ami 604. Két bemenetcsalád váltja ezt ki. A tiltott kimenet táblázatok elutasítják a vezérlőkaraktereket (C.2.1 és C.2.2), a magáncélú kódpontokat (C.3), a nem-karaktereket (C.4), a magányos surrogate-eket (C.5), az U+FFFD-t (C.6), az ideografikus leírókaraktereket (C.7), valamint a megjelenítés-vezérlő és jelölő tartományokat (C.8 és C.9). Külön az RFC 3454 6. szakaszának kétirányúsági szabálya elutasít minden olyan sztringet, amely a D.1 táblázatból egy RandALCat karaktert tartalmaz, hacsak a sztring nem kezdődik és végződik is ilyennel, és nem tartalmaz egyáltalán balról jobbra írt betűt

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;

Az a kétirányúsági szabály fogja meglepni az ügyfélszolgálatot. Egy nyugati számjegyre végződő arab vagy héber jelszót, vagy egy középen elszórt latin betűt tartalmazót a specifikáció elutasítja, még akkor is, ha a beviteli mezőben tökéletesen ésszerűnek tűnik. A 604-et a jelszó karaktereiről szóló üzenetként jelenítsük meg, ne pedig általános titkosítási hibaként, különben valaki egy délutánt tölt majd azzal, hogy hibát keressen a kulcslevezetésünkben

Őszinte korlátok: NFKC, egy közelítő LCat, és egy Delphi-csapda

A megvalósítás két része közelítés, és mindkettőt érdemes egyértelműen kimondani ahelyett, hogy eltemetnénk. Az NFKC normalizálást a Windows NormalizeString API-ja végzi, amelyet dinamikusan töltünk be a Normaliz.dll-ből. Amikor ez a könyvtár nem érhető el, a leképezett sztring normalizálás nélkül kerül felhasználásra, ami azt jelenti, hogy a leképezési és tiltási lépések továbbra is lefutnak, de a kompatibilitási összevonás nem. A gyakorlatban a DLL minden Windows-kiadással együtt szállított a Vista óta, így a leromlott útvonal inkább Vista előtti és nem Windows aggály, mint élő probléma, de egy olyan jelszó, amely az NFKC összevonásra támaszkodik, ott más bájtokat eredményezne, és ez valódi, bár távoli, eltérés. A kétirányúsági ellenőrzés a második közelítés: az LCat karakterek felismerése a gyakori betűtartományokat használja a teljes RFC 3454 D.2 táblázat helyett, és ennek a hibának az iránya az, ami elfogadhatóvá teszi. Egy elmulasztott LCat karakter csak azt okozhatja, hogy a kétirányúsági szabály átmegy ott, ahol a specifikáció elutasította volna, sosem fordítva, és soha nem érinti a leképezési vagy normalizálási lépéseket, így egy elfogadott jelszó előkészített bájtsorozata változatlan marad. A fennmaradó kockázat ezért inkább szabály-eltérés, mint bájt-eltérés: egy egzotikus írásrendszerű jelszó, amelyet egy szigorúbb implementáció egyáltalán nem fogadna el. Minden jelszó, amelyet mindkét oldal elfogad, azonosan hashel, ez az a tulajdonság, amelytől az interoperabilitás ténylegesen függ

Végül egy Delphi szintaktikai csapda, amely egy órát vesz el, ha még nem futottunk bele. Amikor egy függvény eljárási típussal tér vissza, annak zárójelek nélküli hozzárendelése nem hívja meg azt. A fordító a Proc := GetNormalizeProc;-t úgy olvassa, hogy magának a GetNormalizeProc-nak a címét veszi, majd E2009 hibát jelent azzal a nem túl segítőkész panasszal, hogy a hívási konvenciók eltérnek, mert az accessor az alapértelmezett konvenciót használja, míg az importált API-típus stdcall. Az üres zárójelek kötelezők

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;

A jelszó-előkészítés az egyike azoknak a részleteknek, amelyek soha nem jelennek meg egy funkciólistában, mégis eldöntik, hogy egy titkosított dokumentum túléli-e a találkozást egy ügyféllel egy másik locale-ban. Az itt bemutatott Encrypt, EncryptFile, DecryptFile és SetPassword belépési pontok a losLab PDF Developer Library Pascal Edition részei Delphihez és C++Builderhez, amelynek termékoldala hordozza a teljes titkosítási referenciát és a teljes hibakód-táblázatot