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