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

Значение подписи, которое зашифрованный PDF хранит открыто

Зашифрованный AES договор, подписанный в другом месте, загружается со значением подписи, которое декодируется в случайные байты, или — под AES-GCM — со словарём подписи, который вообще не расшифровывается. За пределами trailer /ID и словаря шифрования ISO 32000-2 §7.6.2 оставляет незакрытым только /Contents подписи, и PDFlibPas держит его открытым и на чтении, и на записи, тогда как /Reason и /M остаются зашифрованными

Исключение, которое ISO 32000-2 добавил в правила шифрования

ISO 32000-2 §7.6.2 говорит, что шифрование покрывает каждую строку и каждый поток в файле, с четырьмя исключениями:

  • значения записи /ID в trailer
  • строки внутри словаря /Encrypt
  • строки внутри потоков, которые шифруются как целое
  • любая шестнадцатеричная строка, являющаяся значением /Contents в словаре подписи

Первые три — старые. ISO 32000-1 §7.6.1 перечисляет только их, так что расшифровщик, написанный по тексту 2008 года, не имеет указания пропускать что-либо в словаре подписи. Инструменты подписания всё равно писали значение открыто. Причина механическая: подписант резервирует hex-заглушку, пишет весь файл, хеширует всё, кроме заглушки, собирает CMS и вшивает свой hex в зазор. Дайджест покрывает байты так, как они лежат на диске, шифротекст включительно. Шифрование значения подписи не защитило бы ничего и заставило бы шаг вшивания знать файловый ключ и уместить IV вместе с паддингом в заглушку, размер которой определялся до того, как появился хеш. Остальной словарь следует обычным правилам:

Запись словаря подписиТип PDFХранится зашифрованной?
/Contentsшестнадцатеричная строкаНет, исключение по ISO 32000-2 §7.6.2
/Reason, /Location, /ContactInfo, /Nameтекстовая строкаДа
/Mстрока датыДа
/ByteRangeмассив целыхНет, целые никогда не шифруются
/Type, /Filter, /SubFilterимяНет, имена никогда не шифруются

Reader, пропустивший исключение, прогоняет шифр по чистому DER. Результат зависит от алгоритма. RC4 — потоковый шифр, поэтому выход сохраняет длину и превращается в шум; парсер CMS видит первый байт, который больше не 0x30, и сообщает о malformed подписи. AES-CBC принимает первые 16 байтов за initialization vector, а остальное за шифротекст, что заканчивается ошибкой паддинга или укороченным куском мусора. AES-GCM, определённый для PDF в ISO/TS 32003, — самый жёсткий случай. Каждая зашифрованная строка несёт собственный nonce и authentication tag, и открытое значение тег не проходит. Расшифровщик, считающий один проваленный тег фатальным для всего словаря, теряет вместе со значением подписи ещё и /Reason с /M

Словарь подписи зашифрованного PDF в PDFlibPas на Delphi: шестнадцатеричный /Contents остаётся открытым согласно ISO 32000-2 §7.6.2, пока /Reason и /M зашифрованы, плюс сценарии отказа RC4, AES-CBC и AES-GCM
Подпись /Contents остаётся читаемым DER в зашифрованном PDF, пока /Reason и /M — шифротекст, и попытка расшифровать этот DER по-своему ломается у каждого шифра

Как отличить /Contents подписи от всех прочих /Contents?

Совпадение по имени ключа — очевидная первая попытка, и она проваливается в обе стороны. У словарей аннотаций тоже есть запись /Contents — текстовая строка с комментарием, который набрал рецензент, — и эта строка обязана шифроваться. Пропустите её, и каждая стикер-заметка в конфиденциальном файле читается без пароля. Совпадение по /Type /Sig ломается наоборот: таблица 255 ISO 32000-2 делает /Type в словаре подписи необязательным со значением по умолчанию Sig и требует его только для /DocTimeStamp, так что корректный продюсер может его не писать. Поэтому PDFlibPas считает строку /Contents значением подписи лишь когда тот же словарь несёт массив /ByteRange плюс либо /Type со значением Sig или DocTimeStamp, либо вовсе без /Type, но с именем /Filter. /Filter обязателен в каждом словаре подписи, а /ByteRange — в каждой подписи, принадлежащей полю подписи; у аннотации нет ни того, ни другого. Библиотека прогоняет тот же тест и при шифровании при сохранении, и при расшифровании при загрузке, так что /Contents выходит из загрузки с теми байтами, которые записал продюсер

Тест шифрования в PDFlibPas на Delphi отличает /Contents подписи от /Contents аннотации по /ByteRange, /Type Sig или DocTimeStamp и /Filter перед шифрованием или расшифровкой строки
Комментарии аннотаций остаются зашифрованными, потому что PDFlibPas считает /Contents значением подписи только рядом с /ByteRange и подходящим /Type либо с именем /Filter, когда /Type отсутствует

Сначала шифруем, потом подписываем с PDFlibPas

Подписывайте последним. Дайджест подписи покрывает сохранённые байты, поэтому любая операция, переписывающая файл после подписания, ломает каждую подпись в нём. Применение шифрования к подписанному документу перешифровывает каждую строку и каждый поток; снятие шифрования переписывает их ровно так же основательно. Рабочая последовательность: зашифровать, сохранить, затем подписать зашифрованный файл. Процесс подписания добавляет инкрементальное обновление, чьи новые строки шифруются существующим файловым ключом, а заглушка /Contents остаётся открытой. Открывайте файл в процессе подписания с owner-паролем; user-пароля достаточно, только когда разрешения включают бит 9 таблицы 22 ISO 32000-2, где поля подписи названы явно. Encrypt и процесс подписания доступны в каждой редакции PDFlibPas

uses
  SysUtils, PDFlibrary;

const
  OwnerPwd = 'owner-secret';
  UserPwd  = 'reader-secret';

procedure EncryptCopy(const Source, Target: string);
var
  Lib: TPDFlib;
  Perms: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile(Source, '') <> 1 then
      raise Exception.CreateFmt('Load failed (%d)', [Lib.LastErrorCode]);
    // печать, заполнение полей (бит 9 покрывает поля подписи), копия для доступности, печать в высоком разрешении
    Perms := Lib.EncodePermissions(1, 0, 0, 0, 1, 1, 0, 1);
    // Strength 4 = AES-256 revision 6: файловый ключ используется напрямую, /ID в нём не участвует
    if Lib.Encrypt(OwnerPwd, UserPwd, 4, Perms) <> 1 then
      raise Exception.CreateFmt('Encrypt failed (%d)', [Lib.LastErrorCode]);
    if Lib.SaveToFile(Target) <> 1 then
      raise Exception.CreateFmt('Save failed (%d)', [Lib.LastErrorCode]);
  finally
    Lib.Free;
  end;
end;

procedure SignField(Lib: TPDFlib; const Source, Target, FieldName,
  PFXFile, PFXPassword: string);
var
  SP, R: Integer;
begin
  SP := Lib.NewSignProcessFromFile(Source, OwnerPwd);
  if SP = 0 then
    raise Exception.CreateFmt('Input not found: %s', [Source]);
  try
    Lib.SetSignProcessField(SP, FieldName);   // создаётся невидимо, если отсутствует
    Lib.SetSignProcessPFXFromFile(SP, PFXFile, PFXPassword);
    Lib.SetSignProcessInfo(SP, 'Approved for release', 'Head office', '');
    // запас в заглушке под штамп времени подписи, который добавят позже
    Lib.SetSignProcessReserveContentsBytes(SP, 16384);
    Lib.EndSignProcessToFile(SP, Target);     // всегда возвращает 0
    R := Lib.GetSignProcessResult(SP);
    if R <> 1 then
      raise Exception.CreateFmt('Signing %s failed (result %d)', [FieldName, R]);
  finally
    Lib.ReleaseSignProcess(SP);
  end;
end;

Strength 4 выбирает AES-256 revision 6, и этот выбор убирает два сценария отказа. Под RC4 и AES-128 Algorithm 1 (ISO 32000-1 §7.6.2) выводит отдельный ключ для каждого объекта из его номера объекта и номера поколения. Algorithm 2 (ISO 32000-1 §7.6.3.3, шаг e) подмешивает в файловый ключ ещё и первый элемент /ID. Обновление подписанием переписывает словарь AcroForm и массив /Annots страницы, так что каждый переписанный объект обязан сохранить исходный номер поколения, а trailer обновления — нести тот же первый элемент /ID. Ошибётесь с поколением — объект перестаёт расшифровываться. Ошибётесь с /ID — перестаёт всё. Revision 6 использует файловый ключ напрямую (ISO 32000-2 §7.6.3.3, Algorithm 1.A) и извлекает его без обращения к /ID (§7.6.4.3.3, Algorithm 2.A). PDFlibPas сохраняет номера поколений у переписанных объектов на любой strength и выводит ключ из /ID так, как он записан, — даже когда malformed файл несёт /ID 7 0 R вместо прямого массива, которого требует таблица 15 ISO 32000-2; пустой /ID [] проваливает проверку пароля. Путь от пароля к ключу разобран глубже рядом со статьёй об открытии зашифрованных PDF с сырым файловым ключом

Текстового редактора достаточно, чтобы увидеть результат. Словарь подписи лежит без сжатия в добавившем его обновлении, потому что подписант вшивает /ByteRange и /Contents по файловым смещениям. Ищете /ByteRange — и вы на месте. Hex /Contents обычно открывается на 3082 — DER-заголовок CMS SignedData SEQUENCE — и заканчивается длинной серией нулей там, где заглушка была больше подписи. /Reason — шифротекст, меняющийся от одного прогона подписания к другому, даже когда текст не меняется: AES берёт свежий IV на каждую строку. Ожидаемо для свежего qpdf: qpdf --password=owner-secret --show-object=14 signed.pdf печатает расшифрованный /Reason рядом с неизменным /Contents. Для такой проверки берите файл strength 4. Ожидаемый вид в сыром файле, со смещениями и байтами, которые варьируются:

14 0 obj
<</Type /Sig /Filter /Adobe.PPKLite
  /ByteRange [0 48211 81093 2304]
  /Contents <308240A206092A864886F70D010702A0...000000000000>
  /Reason <7F21C9E04B...>
  /M <D35A0F98...>
  ...>>
endobj

Какие подписи ломаются, когда вшивается штамп времени?

Каждая подпись, добавленная после проштампованной. Штамп времени подписи PAdES — это токен RFC 3161, хранимый как unsigned-атрибут подписанта (RFC 5652 §5.3, unsignedAttrs), то есть он живёт внутри CMS в /Contents. Добавить его — значит переписать тот hex на месте. В зашифрованном файле это переписывание работает только потому, что /Contents открыт: ни перешифровки, ни нового объекта — токену нужно лишь уместиться в резерв, отведённый при подписании. Владеющая подпись выживает, потому что её /ByteRange перепрыгивает эти байты. Более поздние подписи такого исключения не получают. ISO 32000-2 §12.8.1 ведёт byte range каждой подписи от заголовка %PDF- до конца инкрементального обновления, которое её добавило. Вторая подпись или штамп времени документа, стало быть, хеширует /Contents первой подписи вместе со всем остальным. Зазор принадлежит одной-единственной подписи. Вшейте что-то в него после того, как подписал второй подписант, — и дайджест второй подписи больше не сойдётся. Сам запрос токена — часть статьи о подписании и валидации PAdES из Delphi; этот раздел только о том, какие байты двигаются

PDFlibPas проверяет перекрытие до того, как что-то хешировать. Процесс штампа времени подписи читает /ByteRange каждой другой подписи в документе и сравнивает каждую пару интервалов с зазором /Contents целевой. Любое перекрытие останавливает подготовку с результатом процесса 17, и выходной файл не пишется. Процесс принадлежит редакции Enterprise, которую вы инстанцируете как TPDFlibEnterprise из модуля PDFlibEnterprise; другие редакции сохраняют методы, возвращающие 0 и ставящие LastErrorCode в 900. Следите за числом 17 в своих логах: от GetPAdESSignatureTimeStampProcessResult оно значит, что более поздняя подпись накрывает эту; от GetSignProcessResult — что уровень сертификации или блокировка поля запрещает подписание. Разводите эти два значения по отдельности. Вспомогательный код ниже использует SignField и OwnerPwd из предыдущего листинга

Порядок штампов времени подписи PAdES в PDFlibPas на Delphi: /ByteRange более поздней подписи (later signature's), накрывший более ранний зазор /Contents, заставляет GetPAdESSignatureTimeStampProcessResult вернуть 17 без записи файла
После того как подписал Approver, его byte range накрывает зазор /Contents автора, и PDFlibPas отказывает запоздалому штампу автора с результатом процесса 17 вместо того, чтобы ломать более позднюю подпись
uses
  SysUtils, PDFlibrary, PDFlibEnterprise;

const
  STS_COVERED_BY_LATER_SIGNATURE = 17;

function StampSignature(Lib: TPDFlibEnterprise; const Source, Target,
  FieldName: string): Integer;
var
  STS: Integer;
begin
  Result := 0;
  STS := Lib.NewPAdESSignatureTimeStampProcessFromFile(Source, OwnerPwd);
  if STS = 0 then
    Exit;
  try
    Lib.SetPAdESSignatureTimeStampField(STS, FieldName);
    if Lib.ApplyPAdESSignatureTimeStamp(STS, 0) <> 1 then
    begin
      Result := Lib.GetPAdESSignatureTimeStampProcessResult(STS);
      if Result = 1 then
        Result := -1;  // значение уже захешировано, обмен с TSA провалился: см. GetTimeStampError
      Exit;
    end;
    Result := Lib.EndPAdESSignatureTimeStampProcessToFile(STS, Target);
  finally
    Lib.ReleasePAdESSignatureTimeStampProcess(STS);
  end;
end;

procedure SignAndStampInOrder(const EncryptedFile: string);
var
  Lib: TPDFlibEnterprise;
  R: Integer;
begin
  Lib := TPDFlibEnterprise.Create;
  try
    Lib.SetTimeStampServer('https://tsa.example.com/tsa', '', '');

    SignField(Lib, EncryptedFile, 'step1.pdf', 'Author', 'author.pfx', 'pfx-1');
    R := StampSignature(Lib, 'step1.pdf', 'step1-ts.pdf', 'Author');
    if R <> 1 then
      raise Exception.CreateFmt('Author time stamp failed (%d)', [R]);

    SignField(Lib, 'step1-ts.pdf', 'step2.pdf', 'Approver', 'approver.pfx', 'pfx-2');
    R := StampSignature(Lib, 'step2.pdf', 'final.pdf', 'Approver');
    if R <> 1 then
      raise Exception.CreateFmt('Approver time stamp failed (%d)', [R]);

    // Ожидаем: 17. Byte range Approver накрывает /Contents автора в
    // step2.pdf, поэтому процесс отказывает и restamped.pdf не пишется
    R := StampSignature(Lib, 'step2.pdf', 'restamped.pdf', 'Author');
    if R <> STS_COVERED_BY_LATER_SIGNATURE then
      raise Exception.CreateFmt('Unexpected result %d', [R]);
  finally
    Lib.Free;
  end;
end;

Штампуйте каждую подпись до того, как файл потрогает следующий подписант. Это один round trip к TSA на подписанта, и каждая подпись получает собственное доверенное время. Файл, пришедший уже с несколькими подписями, — другой случай: более ранние подписи штамп времени подписи больше взять не могут. Вместо них добавляйте штамп времени документа — подпись /Type /DocTimeStamp с /SubFilter /ETSI.RFC3161 в новой ревизии, чей byte range накрывает все прежние подписи и доказывает, что в этот момент они существовали. ApplyPAdESLongTermArchiveValidation добавляет такой после крепления валидационного материала. Отводите место под штамп времени подписи при подписании — через SetSignProcessReserveContentsBytes, как в первом листинге. Токен, встраивающий цепочку сертификатов TSA, легко разрастается до нескольких килобайтов, и заглушка без такого запаса заставит последующее наращивание провалиться с результатом 13

Расшифровка подписанного файла и то, что хеширует верификатор

Подписанные интервалы зашифрованного файла — это шифротекст. Верификатор сравнивает дайджест сохранённого шифротекста с дайджестом внутри CMS: /ByteRange — целые, /Contents — открытый текст, и проверке целостности ключ не нужен — он нужен только имени поля и /Reason. Тот же факт объясняет, почему расшифровка подписанного файла с сохранением его в открытом виде инвалидировала каждую подпись в нём, хотя ни один видимый пиксель не изменился. Храните как эталонную копию зашифрованный, подписанный оригинал. Cross-reference потоки тоже никогда не шифруются (ISO 32000-2 §7.5.8.2), так что обновление, записанное xref-потоком, сохраняет читаемые смещения до вывода какого-либо ключа. Раскладка описана в статье об object streams и cross-reference потоках в Delphi. Когда подписанный интервал слишком велик, чтобы прочесть его в память для сравнения, PDFlibPas сообщает о проверке целостности как о неизвестном исходе, а не как о провале: байты, которые никогда не сравнивались, не показали различий

Значение подписи зашифрованного файла остаётся перезаписываемым после подписания, пока его не накроет другая подпись или штамп времени документа. Укладывайте шаг штампа в это окно, и последовательность «сначала шифрование, потом подписание» из первого листинга переносится на файлы с несколькими подписантами в PDF Library for Delphi без изменений