PDF zaszyfrowany AES-256 hasłem spoza ASCII otwiera się w programie, który go zapisał, i nigdzie indziej. Przyczyną jest niemal zawsze brakujący krok przygotowania: ISO 32000-2 §7.6.4.3.3 wymaga, by hasło zostało przetworzone profilem SASLprep stringprep, zanim zostanie zakodowane w UTF-8 i zahaszowane. PDFlibPas, biblioteka PDF dla Delphi i C++Builder, wykonuje to przygotowanie wewnątrz Encrypt, EncryptFile i DecryptFile
To nie jest historia o złym haśle ani o bitach uprawnień. Jeśli Twoi użytkownicy wpisują hasło, którego nigdy nie wydałeś, mechanizm ponawiania z artykułu o ponawianiu haseł zaszyfrowanych PDF-ów jest tym, czego potrzebujesz, a jeśli chcesz ustalić, co faktycznie wymusza istniejący plik, audyt szyfrowania i uprawnień pokrywa ten obszar. Ten artykuł jest węższy i dziwniejszy: hasło jest poprawne, użytkownik wpisał je poprawnie, a plik i tak odmawia otwarcia się gdzie indziej
Dlaczego hasło spoza ASCII otwiera się w jednym czytniku, a w innym nie?
Ponieważ oba programy haszują różne sekwencje bajtów z tych samych naciśnięć klawiszy. Wyprowadzanie klucza w rewizji 6 z ISO 32000-2 §7.6.4.3.3 bierze hasło jako bajty UTF-8, obcina do 127 bajtów, dołącza sól i uruchamia utwardzony hash; wynik jest sprawdzany względem wpisów /U i /O w słowniku szyfrowania. Nic w tym łańcuchu nie jest rozmyte. Jeden różniący się bajt gdziekolwiek w danych wejściowych daje zupełnie inny skrót, walidacja zawodzi, a czytnik ma do powiedzenia dokładnie jedną rzecz: złe hasło
Bajty się rozjeżdżają, ponieważ Unicode oferuje kilka sposobów wpisania czegoś, co wygląda jak to samo hasło. Chińskie hasło może przyjść jako znaki prekomponowane z jednej metody wprowadzania i jako formy kompatybilności z innej. Hasło niemieckie czy francuskie skopiowane z edytora tekstu może nieść NO-BREAK SPACE (U+00A0) tam, gdzie użytkownik jest przekonany, że jest zwykła spacja, albo SOFT HYPHEN (U+00AD), który renderuje się jako nic. SASLprep istnieje, by zwinąć to wszystko do jednej kanonicznej formy, zanim ktokolwiek cokolwiek zahaszuje, tak by każda zgodna implementacja wyprowadzała ten sam klucz z tej samej intencji
Co SASLprep faktycznie zmienia w haśle?
RFC 4013 definiuje SASLprep jako profil frameworku stringprep z RFC 3454, i to cztery uporządkowane kroki, a nie jedna transformacja. Najpierw jest mapowanie: tabela C.1.2 z RFC 3454 (spacje spoza ASCII) jest mapowana na U+0020, a tabela B.1 (znaki powszechnie mapowane na nic) jest po prostu usuwana. Następuje normalizacja do Unicode NFKC, czyli krok, który zwija znaki kompatybilności i sekwencje łączące. Potem sprawdzenie zakazanego wyjścia odrzuca cokolwiek z tabel C.2.1 do C.9. Na koniec do znormalizowanego ciągu stosowana jest reguła dwukierunkowości z sekcji 6 RFC 3454
PDFlibPas implementuje cały profil w module PDFlibSASLprep, który udostępnia jeden punkt wejścia. PLSASLprepPassword bierze surowe hasło, zapisuje przygotowaną formę do parametru var i zwraca False, gdy hasło musi zostać odrzucone. Funkcja jest celowo totalna na ścieżce szczęśliwej: hasło czysto ASCII wraca identyczne bajtowo, więc nic w istniejących wdrożeniach się nie zmienia
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;
Niejednoznaczność U+200B, której tabele nie rozstrzygają
Jeden punkt kodowy trafia jednocześnie do dwóch tabel RFC 3454, a te dwie tabele są ze sobą sprzeczne. ZERO WIDTH SPACE (U+200B) mieści się w zakresie C.1.2 od U+2000 do U+200B, gdzie reguła mówi, by zmapować go na U+0020, a mieści się też w zakresie B.1 od U+200B do U+200D, gdzie reguła mówi, by go usunąć. Odczytaj krok mapowania w dowolnej kolejności, a z tego samego hasła dostaniesz różne bajty: a+U+200B+b przygotowuje się do a b według C.1.2 i do ab według B.1. RFC 4013 wymienia obie tabele i nie mówi, która wygrywa, więc to prawdziwa niejednoznaczność w specyfikacji, a nie błąd odczytu. PDFlibPas testuje najpierw przynależność do C.1.2 i dlatego mapuje U+200B na spację, co jest zachowaniem, na którym osiadły inne szeroko wdrożone implementacje stringprep; dopasowanie do nich jest tu jedyną rzeczą, która się liczy, ponieważ celem jest zgodność bajtowa z tym, jakiego czytnika akurat użyje klient
Odczyt starych plików: najpierw przygotowane, potem surowe
Naprawa stwarza własny problem zgodności. Każdy plik AES-256 zapisany przed zmianą haszował surowe hasło UTF-8, więc uczynienie czytnika ściśle zgodnym zablokowałoby klientom dostęp do ich własnych archiwów. PDFlibPas rozwiązuje to po stronie odczytu, próbując po kolei dwóch kandydatów. TPDFDocument.SetPassword buduje listę kandydatów zaczynającą się od formy przygotowanej i wracającą do formy surowej, i dodaje wpis przygotowany tylko wtedy, gdy dokument faktycznie jest AES-256, a obie formy się różnią. Dla hasła ASCII obie formy są identyczne, lista zawiera jeden wpis, a koszt całego mechanizmu to pojedyncze porównanie ciągów. DecryptFile robi to samo na swojej bezpośredniej ścieżce przepisywania AES-256, wywołując PLDirectDecryptFileAES256 najpierw z przygotowanym hasłem
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;
Zabezpieczenie ma jedną osłonę wartą skopiowania. Druga próba w DecryptFile uruchamia się tylko wtedy, gdy formy przygotowana i surowa się różnią oraz pierwsza próba nie zgłosiła twardego kodu błędu. Awaria strukturalna oznacza, że dane wejściowe są uszkodzone albo nie są tą rewizją szyfrowania, którą zakładałeś, a ponawianie zepsutego pliku z innym hasłem po prostu spala drugi pełny parsing wrogich danych wejściowych; rozumowanie stojące za tym odruchem opisuje notatka o bezpiecznym parsowaniu niezaufanych plików PDF. Zauważ też, że po stronie zapisu nie ma żadnego mechanizmu awaryjnego, i ta asymetria jest zamierzona. Odczyt toleruje historię, zapis nie: każdy nowy plik AES-256 dostaje zgodne bajty
Które hasła zostają odrzucone od razu i czym jest błąd 604?
SASLprep może odrzucić hasło całkowicie, a gdy to robi, szyfrowanie musi zawieść głośno, a nie po cichu podstawić coś innego. Encrypt i EncryptFile przygotowują zarówno hasło właściciela, jak i użytkownika, ilekroć Strength wynosi 3 lub 4, zwracają 0 przy odrzuceniu i ustawiają LastErrorCode na PDFLIB_ERROR_PASSWORD_SASLPREP, czyli 604. Wyzwalają go dwie rodziny danych wejściowych. Tabele zakazanego wyjścia odrzucają znaki kontrolne (C.2.1 i C.2.2), punkty kodowe prywatnego użytku (C.3), non-characters (C.4), samotne surogaty (C.5), U+FFFD (C.6), znaki opisu ideograficznego (C.7) oraz zakresy sterowania wyświetlaniem i tagowania (C.8 i C.9). Osobno, reguła bidi z sekcji 6 RFC 3454 odrzuca każdy ciąg zawierający znak RandALCat z tabeli D.1, chyba że ciąg zarówno zaczyna się, jak i kończy takim znakiem i nie zawiera żadnych liter pisanych od lewej do prawej
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;
To właśnie reguła bidi zaskoczy Twój dział wsparcia. Hasło arabskie czy hebrajskie kończące się zachodnią cyfrą, albo takie z zabłąkaną literą łacińską pośrodku, jest odrzucane przez specyfikację, mimo że w polu wprowadzania wygląda całkiem rozsądnie. Wyeksponuj błąd 604 jako komunikat o znakach hasła, nie jako ogólną awarię szyfrowania, albo ktoś spędzi popołudnie na szukaniu błędu w Twoim wyprowadzaniu klucza
Uczciwe granice: NFKC, przybliżony LCat i jedna pułapka Delphi
Dwie części implementacji są przybliżeniami i obie zasługują na to, by je jasno wskazać, a nie ukrywać. Normalizacja NFKC jest wykonywana przez Windows API NormalizeString, ładowane dynamicznie z Normaliz.dll. Gdy ta biblioteka jest niedostępna, użyty jest zmapowany ciąg bez normalizacji, co oznacza, że kroki mapowania i zakazu wciąż działają, ale zwijanie kompatybilności nie. W praktyce ta DLL jest dostarczana z każdym wydaniem Windows od czasów Visty, więc ścieżka zdegradowana to kwestia sprzed Visty i systemów spoza Windows, a nie coś realnego dziś, ale hasło polegające na zwijaniu NFKC dałoby tam inne bajty i to prawdziwa, choć odległa, rozbieżność. Sprawdzenie bidi to drugie przybliżenie: wykrywanie znaków LCat używa popularnych zakresów liter zamiast pełnej tabeli D.2 z RFC 3454, a kierunek tego błędu jest tym, co czyni go akceptowalnym. Pominięty znak LCat może tylko spowodować, że reguła bidi przejdzie tam, gdzie specyfikacja by odrzuciła, nigdy odwrotnie, i nigdy nie dotyka kroków mapowania ani normalizacji, więc przygotowana sekwencja bajtów zaakceptowanego hasła pozostaje niezmieniona. Ryzyko szczątkowe to więc rozbieżność w polityce, nie rozbieżność bajtowa: egzotyczne hasło w danym skrypcie, którego bardziej rygorystyczna implementacja w ogóle by nie zaakceptowała. Każde hasło zaakceptowane przez obie strony haszuje się identycznie, a to jest właściwość, od której faktycznie zależy interoperacyjność
Na koniec pułapka składniowa Delphi, która kosztuje godzinę, jeśli nigdy wcześniej się na nią nie trafiło. Gdy funkcja zwraca typ proceduralny, przypisanie go bez nawiasów nie wywołuje jej. Kompilator odczytuje Proc := GetNormalizeProc; jako wzięcie adresu samego GetNormalizeProc, po czym zgłasza E2009 z niezbyt pomocną skargą, że konwencje wywołania się różnią, ponieważ akcesor używa konwencji domyślnej, podczas gdy importowany typ API to stdcall. Puste nawiasy są obowiązkowe
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;
Przygotowanie hasła to jeden z tych szczegółów, który nigdy nie pojawia się na liście funkcji, a decyduje o tym, czy zaszyfrowany dokument przetrwa kontakt z klientem w innym locale. Opisane tu punkty wejścia Encrypt, EncryptFile, DecryptFile i SetPassword są częścią losLab PDF Developer Library Pascal Edition dla Delphi i C++Builder, której strona produktu niesie pełną dokumentację szyfrowania i kompletną tabelę kodów błędów