PDF, зашифрованный AES-256 с не-ASCII паролем, открывается в программе, которая его создала, и больше нигде. Причина почти всегда — пропущенный шаг подготовки: ISO 32000-2 §7.6.4.3.3 требует, чтобы пароль обрабатывался профилем SASLprep фреймворка stringprep, прежде чем он будет закодирован в 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). Отдельно двунаправленное правило раздела 6 RFC 3454 отклоняет любую строку, содержащую символ 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;
Именно это двунаправленное правило удивит вашу службу поддержки. Арабский или еврейский пароль, оканчивающийся западной цифрой, или пароль с случайной латинской буквой посередине, отклоняется спецификацией, даже если он выглядит вполне разумно в поле ввода. Показывайте 604 как сообщение о символах пароля, а не как общий сбой шифрования, иначе кто-то потратит вечер на поиск ошибки в вашем выводе ключа
Честные границы: NFKC, приближённый LCat и одна ловушка Delphi
Две части реализации — приближения, и обе заслуживают того, чтобы быть названными прямо, а не спрятанными. Нормализация NFKC выполняется API Windows NormalizeString, загружаемым динамически из Normaliz.dll. Когда эта библиотека недоступна, используется отображённая, но ненормализованная строка, то есть шаги отображения и запрета всё равно выполняются, а сворачивание совместимости — нет. На практике эта DLL поставляется с каждым выпуском Windows начиная с Vista, поэтому деградированный путь — забота эпохи до Vista и не-Windows систем, а не живая проблема, но пароль, полагающийся на сворачивание NFKC, дал бы там другие байты, и это реальное, пусть и отдалённое, расхождение. Проверка двунаправленности — второе приближение: обнаружение символов LCat использует общие диапазоны букв вместо полной таблицы D.2 из RFC 3454, и направление этой ошибки как раз и делает её приемлемой. Пропущенный символ LCat может лишь заставить двунаправленное правило пройти там, где спецификация отклонила бы, но никогда наоборот, и это никогда не затрагивает шаги отображения или нормализации, поэтому подготовленная последовательность байт принятого пароля не меняется. Остаточный риск, таким образом, — это расхождение в политике, а не расхождение в байтах: экзотический по письменности пароль, который более строгая реализация вовсе отказалась бы принять. Каждый пароль, принятый обеими сторонами, хешируется одинаково, а это и есть свойство, от которого действительно зависит совместимость
Наконец, синтаксическая ловушка 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, чья страница продукта содержит полный справочник по шифрованию и полную таблицу кодов ошибок