Технічна стаття

SASLprep для паролів AES-256 PDF у Delphi з PDFlibPas

PDF, зашифрований AES-256 з паролем поза ASCII, відкривається в програмі, яка його записала, і ніде більше. Причина майже завжди — відсутній крок підготовки: ISO 32000-2 §7.6.4.3.3 вимагає, щоб пароль оброблявся профілем SASLprep стрінгпрепу перед тим, як його закодують у UTF-8 і хешують. PDFlibPas, бібліотека PDF для Delphi та C++Builder, виконує цю підготовку всередині Encrypt, EncryptFile та DecryptFile

Це не історія про неправильний пароль і не про біти дозволів. Якщо ваші користувачі вводять пароль, який ви ніколи не видавали, вам потрібна механіка повторних спроб зі статті про повторні спроби пароля зашифрованого PDF, а якщо ви намагаєтеся з'ясувати, що насправді забезпечує наявний файл, це охоплює аудит шифрування та дозволів. Ця стаття вужча й дивніша: пароль правильний, користувач ввів його правильно, а файл все одно відмовляється відкриватись деінде

Чому пароль поза ASCII відкривається в одному читачі, а в іншому — ні?

Тому що дві програми хешують різні послідовності байтів з тих самих натискань клавіш. Виведення ключа ревізії 6 в ISO 32000-2 §7.6.4.3.3 бере пароль як байти UTF-8, обрізає до 127 байтів, додає сіль і запускає загартований хеш; результат звіряється зі записами /U та /O у словнику шифрування. Ніщо в цьому ланцюжку не є розмитим. Один відмінний байт будь-де у вхідних даних видає повністю інший дайджест, перевірка зазнає невдачі, і читач може сказати рівно одне: неправильний пароль

Байти розходяться, бо Unicode пропонує кілька способів набрати те, що виглядає як той самий пароль. Китайський пароль може прийти як складені попередньо символи з одного методу вводу і як сумісні форми з іншого. Німецький чи французький пароль, скопійований з текстового процесора, може нести НЕРОЗРИВНИЙ ПРОБІЛ (U+00A0) там, де користувач вважає, що є звичайний пробіл, або М'ЯКИЙ ДЕФІС (U+00AD), що рендериться як нічого. SASLprep існує для того, щоб згорнути все це в одну канонічну форму до того, як хтось щось хешує, тож кожна сумісна реалізація виводить той самий ключ з того самого наміру

Що саме SASLprep змінює в паролі?

RFC 4013 визначає SASLprep як профіль фреймворку stringprep з RFC 3454, і це чотири впорядковані кроки, а не одне перетворення. Спочатку йде відображення: таблиця C.1.2 RFC 3454 (пробіли поза ASCII) відображається на U+0020, а таблиця B.1 (символи, що зазвичай відображаються в нічого) видаляється повністю. Далі слідує нормалізація до Unicode NFKC, крок, що згортає сумісні символи та комбіновані послідовності. Потім перевірка забороненого виводу відхиляє все з таблиць C.2.1 по C.9. Нарешті двонапрямне правило з RFC 3454 розділ 6 застосовується до нормалізованого рядка

PDFlibPas реалізує весь профіль у модулі PDFlibSASLprep, який надає одну точку входу. PLSASLprepPassword бере сирий пароль, записує підготовлену форму в параметр var і повертає False, коли пароль слід відхилити. Функція навмисно тотальна на щасливому шляху: пароль лише з ASCII повертається побайтово ідентичним, тож у наявних розгортаннях нічого не змінюється

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;

Неоднозначність U+200B, яку таблиці не вирішують

Одна кодова точка потрапляє одразу у дві таблиці RFC 3454, і ці дві таблиці розходяться. ZERO WIDTH SPACE (U+200B) потрапляє в діапазон C.1.2 U+2000–U+200B, де правило каже відобразити його на U+0020, і водночас у діапазон B.1 U+200B–U+200D, де правило каже видалити його. Прочитайте крок відображення в будь-якому порядку — і отримаєте різні байти з того самого пароля: a+U+200B+b готується до a b за C.1.2 і до ab за B.1. RFC 4013 називає обидві таблиці й не каже, яка перемагає, тож це справжня неоднозначність у специфікації, а не помилка читання. PDFlibPas перевіряє належність до C.1.2 першою і тому відображає U+200B на пробіл — це поведінка, на якій зупинилися інші широко розгорнуті реалізації stringprep; узгодження з ними — єдине, що тут має значення, бо мета — байтова згода з будь-яким читачем, яким клієнт випадково користується

Читання старих файлів: спочатку підготовлений, потім сирий

Виправлення створює власну проблему сумісності. Кожен файл AES-256, записаний до зміни, хешував сирий пароль UTF-8, тож зробити читач строго конформним заблокувало б клієнтів від їхніх власних архівів. PDFlibPas вирішує це на боці читання, пробуючи два кандидати по черзі. TPDFDocument.SetPassword будує список кандидатів, що починається з підготовленої форми і повертається до сирої, і додає підготовлений запис лише тоді, коли документ дійсно AES-256, а дві форми різняться. Для пароля з ASCII форми ідентичні, список містить один запис, а вартість усього механізму — одне порівняння рядків. DecryptFile робить те саме на своєму прямому шляху переписування AES-256, викликаючи PLDirectDecryptFileAES256 спочатку з підготовленим паролем

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;

Резервний варіант несе одну гвардію, вартою копіювання. Друга спроба в DecryptFile виконується лише тоді, коли підготовлена й сира форми різняться і перша спроба не повідомила про жорсткий код помилки. Структурна відмова означає, що вхідні дані пошкоджені або це не та ревізія шифрування, яку ви припускали, і повторна спроба з пошкодженим файлом з іншим паролем просто спалює другий повний розбір над ворожими вхідними даними; логіка за цим рефлексом викладена в нотатці про безпечний розбір недовірених PDF. Зауважте також, що резервного варіанту на боці запису немає, і ця асиметрія навмисна. Читання толерує історію, запис — ні: кожен новий файл AES-256 отримує конформні байти

Які паролі відхиляються повністю, і що таке помилка 604?

SASLprep може повністю відмовити паролю, і коли це стається, шифрування має голосно провалитися, а не тихо підставити щось інше. Encrypt та EncryptFile готують і власника, і пароль користувача щоразу, коли Strength дорівнює 3 чи 4, повертають 0 при відмові й встановлюють LastErrorCode у PDFLIB_ERROR_PASSWORD_SASLPREP, що дорівнює 604. Дві родини вхідних даних це запускають. Таблиці забороненого виводу відхиляють керуючі символи (C.2.1 та C.2.2), кодові точки приватного використання (C.3), не-символи (C.4), самотні сурогати (C.5), U+FFFD (C.6), символи ідеографічного опису (C.7) та діапазони контролю відображення й тегування (C.8 та C.9). Окремо, правило bidi з RFC 3454 розділу 6 відхиляє будь-який рядок, що містить символ RandALCat з таблиці D.1, якщо тільки рядок не починається й не закінчується таким символом і не містить жодних лівосторонніх літер взагалі

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;

Це правило bidi — те, що здивує вашу службу підтримки. Арабський чи гебрайський пароль, що закінчується західною цифрою, або пароль зі сторонньою латинською літерою посередині, відхиляється специфікацією, навіть якщо він виглядає цілком розумно в полі вводу. Показуйте 604 як повідомлення про символи пароля, а не як загальний збій шифрування, інакше хтось витратить пообіддя, шукаючи помилку в вашому виведенні ключа

Чесні межі: NFKC, приблизний LCat і одна пастка Delphi

Дві частини реалізації є наближеннями, і обидві заслуговують на пряме формулювання, а не приховування. Нормалізація NFKC виконується Windows API NormalizeString, що завантажується динамічно з Normaliz.dll. Коли ця бібліотека недоступна, відображений рядок використовується без нормалізації, тобто кроки відображення й заборони все ще виконуються, але сумісне згортання — ні. На практиці ця DLL постачається з кожним випуском Windows, починаючи з Vista, тож деградований шлях — це турбота для до-Vista та не-Windows систем, а не жива проблема, але пароль, що покладається на згортання NFKC, там дав би інші байти, і це реальна, хоч і віддалена, розбіжність. Перевірка bidi — друге наближення: виявлення символів LCat використовує звичайні діапазони літер замість повної таблиці D.2 RFC 3454, і напрямок цієї помилки — саме те, що робить її прийнятною. Пропущений символ LCat може лише спричинити те, що правило bidi пройде там, де специфікація б відхилила, ніколи навпаки, і це ніколи не зачіпає кроки відображення чи нормалізації, тож підготовлена послідовність байтів прийнятого пароля не змінюється. Тому залишковий ризик — це розбіжність політики, а не розбіжність байтів: екзотичний пароль з нестандартним письмом, який строгіша реалізація взагалі відмовилась би прийняти. Кожен пароль, прийнятий обома сторонами, хешується ідентично, і саме ця властивість насправді потрібна для взаємодії

Нарешті, синтаксична пастка Delphi, що коштує годину, якщо ви з нею не стикались раніше. Коли функція повертає процедурний тип, присвоєння без дужок не викликає її. Компілятор читає Proc := GetNormalizeProc; як взяття адреси самого GetNormalizeProc, після чого повідомляє E2009 з малокорисною скаргою, що угоди виклику відрізняються, бо аксесор використовує угоду за замовчуванням, тоді як імпортований тип API — stdcall. Порожні дужки обов'язкові

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;

Підготовка пароля — одна з тих деталей, що ніколи не з'являється у списку функцій і вирішує, чи переживе зашифрований документ контакт з клієнтом в іншій локалі. Точки входу Encrypt, EncryptFile, DecryptFile та SetPassword, описані тут, — частина losLab PDF Developer Library Pascal Edition для Delphi та C++Builder, чия сторінка продукту несе повну довідку про шифрування та повну таблицю кодів помилок