Техническая статья

Crypt filter в PDF в Delphi: политики StmF, StrF, EFF

PDF-компонент HotPDF для Delphi реализует модель crypt filter ISO 32000-1 §7.6.5 как три независимые policy, а не один переключатель: ConfigureCryptFilterDefaults отдельно назначает string filter /StrF, stream filter /StmF и embedded-file filter /EFF, SetStreamCryptFilter переопределяет один поток, а GetLoadedCryptFilterInfo сообщает, что объявляет входящий файл. Большинство проблем interop зашифрованных PDF живёт именно в зазорах между этими тремя уровнями

Вот ошибка, которая приводит людей в этот слой. Команда выпускает документ, где содержимое страниц должно оставаться читаемым для downstream-инструмента, а прикреплённая нагрузка — нет, поэтому задаёт /EFF /StdCF и оставляет /StmF /Identity. Acrobat открывает документ нормально. Соответствующий стандарту сторонний reader возвращает вложение как мусор из ciphertext, потому что /EFF — producer-side policy о фильтре для embedded files, а обычный reader всё ещё разрешает поток без явной отметки через /StmF. Исправление — не другое значение /EFF. Исправление — явный фильтр /Crypt на самом embedded-file stream

Что на самом деле контролирует слой crypt filter

Crypt filters находятся между алгоритмом шифрования и object graph и решают, какие объекты затрагивает алгоритм, а не как он работает. Словарь /CF внутри encryption dictionary сопоставляет имена с определениями фильтров, каждое из которых содержит метод /CFM, необязательный /Length и /AuthEvent. Три верхнеуровневые записи /StrF, /StmF и /EFF затем выбирают, какой из именованных фильтров применяется к строкам, к потокам без явного фильтра и к embedded files. HotPDF намеренно ограничивает то, что его встроенные handlers будут записывать. ConfigureCryptFilterDefaults принимает только зарезервированные имена активного handler: Standard security handler выдаёт /StdCF или /Identity, public-key handler — /DefaultCryptFilter или /Identity, а любое другое имя вызывает EArgumentException прямо в месте вызова. Фильтры, записанные внешними producers под другими именами, всё равно сохраняются на путях загрузки, инспекции и compatibility rewrite, поэтому HotPDF консервативен как writer и permissive как reader. Действуют ещё два предохранителя: вызов вызывает EInvalidOpException, если сериализация документа уже началась, и снова вызывает её при incremental update, потому что encryption policy нельзя менять между revision одного файла

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'wrapper.pdf';
    Pdf.OwnerPassword := 'owner-secret';
    Pdf.UserPassword := 'open-secret';
    Pdf.CryptKeyLength := aes128;
    // строки шифруются, page streams открыты, attachments шифруются
    Pdf.ConfigureCryptFilterDefaults('StdCF', 'Identity', 'StdCF');
    Pdf.ActivateProtection := True;
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(50, 50, 0, 'Visible stream operators');
    Pdf.AddDocumentAttachment('payload.bin', 'Encrypted payload');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Одно ограничение стоит обозначить заранее, потому что оно проверяется поздно и удивляет пользователей. Именованные crypt filters в HotPDF требуют шифрования документа aes128, aes256 или aesgcm. Настройте policy фильтра поверх RC4 k40 или k128 — validation pass, запускаемый при включении шифрования, вызовет ошибку, а не станет молча повышать тип ключа. Это та же позиция, что и у остального пути шифрования PDF AES-256 в Delphi: неоднозначную конфигурацию нужно отклонять, а не угадывать намерение вызывающего кода

Почему запись /Length означает две разные вещи?

Потому что спецификация задаёт её в разных единицах в зависимости от security handler, и HotPDF обязан соблюдать оба варианта. В словаре crypt filter с /CFM равным /V2 запись /Length выражается в байтах для Standard security handler и в битах для public-key handler. /Length в encryption dictionary, стоящий рядом с /V (ISO 32000-1 §7.6.2), всегда выражен в битах. Прочитайте словарь фильтра с /Length 16 — и получите 128-битный ключ в файле Standard handler либо отклонённый файл в public-key варианте. HotPDF нормализует это при захвате загруженной конфигурации. Он умножает /Length фильтра /V2 на восемь только для файла, не зашифрованного public-key способом, использует /Length уровня документа, если фильтр не указал собственный, и сохраняет результат в THPDFCryptFilterInfo.KeyLengthBits. AESV2 фиксируется на 128 битах, а AESV3 и AESV4 — на 256, поскольку эти методы не имеют согласуемого размера ключа. Дальше вступает строгость: принимаются только 40-битный и 128-битный /V2. Фильтр, который разрешается в любую другую длину, помечается как недоступный, и операция завершается ошибкой, вместо округления до 128 по предположению, что большинство producers имели в виду именно 128. Тихая нормализация длины ключа — прямой путь к файлу, который расшифровывается на вашей машине и больше нигде

var
  Reader: THotPDF;
  Info: THPDFCryptFilterInfo;
  I: Integer;
begin
  Reader := THotPDF.Create(nil);
  try
    Reader.AutoLaunch := False;
    if Reader.LoadFromFile('incoming.pdf', 'open-secret') <> 1 then
      Exit;
    // /StrF и /StmF по умолчанию Identity; /EFF по умолчанию /StmF
    WriteLn(Reader.LoadedStringCryptFilterName);        // StdCF
    WriteLn(Reader.LoadedStreamCryptFilterName);        // Identity
    WriteLn(Reader.LoadedEmbeddedFileCryptFilterName);  // StdCF
    for I := 0 to Reader.GetLoadedCryptFilterCount - 1 do
      if Reader.GetLoadedCryptFilterInfo(I, Info) then
        if (Info.Method = hcfmV2) and
           not (Info.KeyLengthBits in [40, 128]) then
          raise Exception.CreateFmt(
            'crypt filter /%s: unsupported V2 key length %d',
            [String(Info.Name), Info.KeyLengthBits]);
  finally
    Reader.Free;
  end;
end;

Что гарантирует /CFM /None и чем отличается /Identity?

Они приводят к одному результату разными путями, и смешение этих понятий ломает lookups. Именованный фильтр с /CFM, равным /None, и именованный фильтр, полностью опустивший /CFM, означают, что этот фильтр не выполняет шифрование или расшифровку — HotPDF перед разрешением сопоставляет отсутствующую запись с None, поэтому оба варианта приходят к hcfmNone с записанной длиной ключа 0. /Identity отличается по сути: это зарезервированное имя, полностью обходящее поиск в /CF, поэтому документ может ссылаться на /Identity, нигде не определяя его в /CF. Имена PDF чувствительны к регистру, поэтому ещё одна деталь реализации не подлежит компромиссу: поиск crypt filter не может быть регистронезависимым. HotPDF выполняет регистрозависимый поиск имён подсловаря /CF, записи фильтра /Length и проверки /Type потока. Файл, определяющий /stdcf, когда /StmF указывает на /StdCF, повреждён, и если считать эти два ключа одинаковыми, обнаружимая ошибка автора превратится в тихо применённый неверный ключ для каждого потока документа

Как заставить /EFF работать для embedded-file streams

Когда /EFF отличается от /StmF, embedded-file stream требует явной ведущей записи /Crypt в своём /Filter и соответствующего словаря /DecodeParms с /Name в той же позиции массива. HotPDF вычисляет это для каждого потока во время сохранения: обнаруживает /Type /EmbeddedFile, наследует настроенный embedded-file filter и выводит явный marker /Crypt только тогда, когда унаследованное имя отличается от эффективного default потока. Если /EFF и /StmF совпадают, marker не записывается, потому что reader всё равно разрешил бы тот же фильтр. Поэтому позиция в массиве так же важна, как имя. При обратном чтении потока HotPDF ищет запись /Crypt в /Filter, запоминает её индекс и затем смотрит в тот же индекс массива /DecodeParms, чтобы найти /Name. /Crypt в индексе 0 вместе с параметрами в индексе 1 разрешается в /Identity, а не в ваш фильтр. По этой же причине writer дополняет массив параметров null-элементом, если раньше у потока был /Filter, но не было /DecodeParms: позиции должны оставаться выровненными

Ниже скрыта ещё более острая ловушка. Если существующий /Filter или /DecodeParms является indirect object — обычный случай для файлов генераторов, разделяющих один массив фильтров между множеством потоков, — вставка /Crypt на месте изменила бы общий filter graph и испортила каждый другой поток, который на него указывает. HotPDF разрешает indirect object и сначала клонирует его в direct object, приватный для потока, очищая номера object и generation, поэтому исходный indirect root никогда не оказывается внутри нового массива. Для потока, который уже использовал ASCIIHexDecode, сериализованный результат выглядит как /Filter [ /Crypt /ASCIIHexDecode ] с /DecodeParms [ << /Type /CryptFilterDecodeParms /Name /StdCF >> ... ]. Та же дисциплина позиций управляет каждой другой цепочкой фильтров, включая цепочки, которые вы проходите при извлечении изображений из загруженного PDF через их decode filters

// Editor уже содержит загруженный документ, а ContentStream — это
// THPDFStreamObject, чей /Filter является indirect-именем /ASCIIHexDecode
Editor.OwnerPassword := 'owner-secret';
Editor.UserPassword := 'open-secret';
Editor.CryptKeyLength := aes128;
Editor.ConfigureCryptFilterDefaults('StdCF', 'Identity');
Editor.SetStreamCryptFilter(ContentStream, 'StdCF');
Editor.ActivateProtection := True;
Editor.SaveLoadedDocument('out.pdf');

// Пустое имя очищает override и при следующем сохранении убирает устаревший /Crypt
// вместе с его decode-параметрами
Editor.SetStreamCryptFilter(ContentStream, '');
Editor.SaveLoadedDocument('cleared.pdf');

Наследуют ли object streams policy документа /Encrypt?

Нет, и предположение об обратном — надёжный способ получить мусор. Object stream должен следовать фактической policy /StmF или собственному явному marker /Crypt: само наличие словаря /Encrypt не превращает каждый контейнер /ObjStm в ciphertext. В документе с /StmF /Identity object streams остаются plaintext, даже если его строки полностью зашифрованы, и decoder, который всё равно расшифровывает их, передаёт стадии inflate вход, который никогда не был результатом deflate

Последствие для объектов-членов стоит прочитать дважды. Согласно ISO 32000-1 §7.5.7, строки внутри зашифрованного object stream уже являются plaintext после расшифровки самого контейнера, поэтому повторная расшифровка была бы double-decrypt. HotPDF защищается, выясняя, был ли контейнер каждого объекта type-2 зашифрован, и пропуская объект, если это так; пропуски подсчитываются в XRefProbeDecryptObjStmSkips как прямое свидетельство срабатывания защиты. Когда контейнер был plaintext, строки членов ничем не были покрыты, поэтому HotPDF материализует эти члены и применяет /StrF к каждому отдельно — с ключом, как реально сделано в реализации, по номеру object и generation члена, а не по номеру содержащего объекта /ObjStm. Инвертируйте это правило в файле со смешанной policy — и каждая строка в каждом сжатом объекте декодируется в шум. Контейнерные правила этого механизма подробнее разобраны в заметках о PDF object streams и incremental updates

Где HotPDF отказывается угадывать

Семантика crypt filter не существует ниже /V 4, поэтому HotPDF отклоняет любое per-stream override в таком файле явной ошибкой, а не записывает marker /Crypt, который ни один соответствующий стандарту reader не станет учитывать. То же относится к чтению: encryption dictionary с /V ниже 4 очищает все три загруженных имени фильтров, потому что сообщать там нечего. Ещё три границы намеренно enforced:

  • Per-stream filter, отличный от Identity, в public-key encrypted document отклоняется, поскольку stream-specific policy у public-key handler требует stream-specific recipient envelope, который HotPDF пока не выдаёт
  • Public-key encrypted embedded files, у которых /EFF отличается от эффективного /StmF, отклоняются по той же причине, а не записываются в форме, расшифровываемой никем
  • Быстрый direct-file path AES-256 применяется только когда строки, потоки и embedded files разрешаются в один и тот же метод crypt filter, а ни один объект файла не содержит явный /Crypt; смешанная policy или plaintext metadata заставляет перейти на полный object-graph path

Ни одно из этих решений не связано с производительностью. Они отмечают места, где неверная догадка даёт PDF, который открывается в одном viewer, ломается в другом и не сообщает разработчику ничего, пока на это не пожалуется заказчик. Отказ в ConfigureCryptFilterDefaults или во время сохранения стоит одного exception; тихо применённый неправильный key к embedded file стоит цикла поддержки. Если вы создаёте ПО на Delphi или C++Builder, которое производит или потребляет зашифрованный PDF — с выборочно открытым содержимым страниц и зашифрованными вложениями, encrypted payload wrappers PDF 2.0 или interop с файлами, чьи crypt filter policy выбирали не вы, — описанный здесь crypt filter API входит в текущий PDF-компонент HotPDF для Delphi вместе с путями шифрования, object streams и incremental updates, на которых он построен