HotPDF, Delphi PDF компонент, теперь отвергает signature wrapping: с v2.759.0 и VerifyLoadedSignatureEx, и пакетный валидатор требуют, чтобы зазор между двумя сегментами /ByteRange был ровно hex-строкой /Contents, включая разделители, а v2.761.0 добавляет AddLoadedSignedSignatureField, чтобы вторую подпись можно было дописать к уже подписанному PDF чистой инкрементальной ревизией. Две правки — из одной истории, потому что корректная вторая подпись — это ровно та раскладка, которой ждёт более строгий верификатор
Ситуация, вскрывшая проблему, обыкновенная. Договор подписывает поставщик, затем он уходит к согласующему, который обязан контрподписать, не потревожив первую подпись. Вторая ревизия дописывается за первой, её собственный /ByteRange накрывает весь подросший файл, и обе подписи должны верифицироваться. Добраться туда вручную значило написать инкрементальную секцию самому, а тестовая фикстура, делавшая ровно это, оказалась хрестоматийной структурой signature wrapping, которую старый верификатор принимал с улыбкой. Если вы раньше не заглядывали в API верификации, руководство по проверке цифровых подписей PDF с HotPDF покрывает основы, на которых строится эта статья
Что именно лежит в зазоре ByteRange?
Зазор обязан содержать полное значение /Contents и ничего больше: ISO 32000-1 §12.8.3.3 говорит, что hex-строка с её разделителями < и > заполняет ровно пространство между двумя диапазонами байтов, и ISO 32000-2 §12.8.1 несёт то же правило дальше. Table 252 и документы PAdES говорят лишь, что дайджест исключает значение Contents, что легко прочесть как исключение одних hex-цифр. Более ранние релизы HotPDF прочли именно так: PreparePDFForSigning и потоковая подготовка CMS хешировали и угловые скобки, с комментарием в исходнике, настаивавшим, что скобки обязаны быть покрыты. Валидаторы, сверяющие зазор со значением подписи, помечают ту раскладку как неверный диапазон байтов, поэтому v2.759.0 выносит оба разделителя из подписанных диапазонов. Быстрая независимая проверка любого подписанного файла — два байта: байт по смещению ByteRange[1] обязан быть <, а байт по смещению ByteRange[2] - 1 — >
Почему проверка непустого зазора пропускает signature wrapping?
Проверка непустого зазора доказывает лишь, что из дайджеста что-то выкинули, но не что именно, — а это и есть вся поверхность атаки. Заглушка /Contents резервируется тысячами нулевых цифр, тогда как настоящий CMS-контейнер редко её заполняет. Атакующий может закрыть hex-строку раньше времени внутри того нулевого заполнения знаком >, вписать новые объекты или подделанную ревизию в остаток зарезервированного места и не тронуть диапазоны байтов. CMS-подпись всё ещё верифицируется, потому что каждый подписанный байт не изменился, диапазоны по-прежнему стартуют с 0 и кончаются размером файла, а старый верификатор HotPDF отчитывал svValid с CoversWholeDocument в True. PDF-ридер тем временем парсит всё, что сидит в той неподписанной дыре
HotPDF теперь обращается с зазором как с данными, которые надо валидировать побайтово. Верификатор читает зазор, снимает разделители, принимает только hex-цифры плюс PDF-whitespace (табуляция, line feed, form feed, carriage return, пробел), декодирует цифры и требует точного равенства результата словарю подписи /Contents. Всё прочее понижает результат до svInvalidByteRange. Проверка работает и в одноподписанном пути, и в ValidateLoadedSignatureBatch, который вёл собственную логику покрытия и нуждался в той же починке. Файлы, произведённые HotPDF до v2.759.0, — с зазором из одних цифр и скобками сразу внутри диапазонов — по-прежнему верифицируются, так что архивные документы не покраснеют внезапно
var
Pdf: THotPDF;
Info: THPDFSignatureInfo;
Status: THPDFSignatureVerifyStatus;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.LoadFromFile('SignedTwice.pdf');
for I := 0 to Pdf.GetLoadedSignatureFieldCount - 1 do
begin
Status := Pdf.VerifyLoadedSignatureEx(I, Info);
case Status of
svValid:
if Info.CoversWholeDocument then
Writeln(Info.FieldName, ': valid, covers the whole file')
else
Writeln(Info.FieldName, ': valid, ',
Info.UnsignedTrailingBytes, ' bytes appended later');
svInvalidByteRange:
Writeln(Info.FieldName, ': ByteRange gap rejected (wrapping?)');
else
Writeln(Info.FieldName, ': failed, status ', Ord(Status));
end;
end;
finally
Pdf.Free;
end;
end;
Как добавить вторую подпись к уже подписанному PDF?
Откройте подписанный файл через BeginIncrementalUpdate, позовите AddLoadedSignedSignatureField, сохраните через SaveIncrementalUpdate, затем подпишите подготовленный файл классовой функцией THotPDF.SignPDFWithPFX. До v2.761.0 документированный рецепт позвать THPDFPage.AddSignedSignatureField после BeginIncrementalUpdate работать не мог: CurrentPage в инкрементальном режиме — nil, и ничто не могло прикрепить заглушку /V к полю на загруженном документе. Новый метод создаёт виджет на загруженной странице и подвешивает под /V тот же словарь-заглушку, каким пользуется путь нового документа, так что оба подписных маршрута делят одну сериализацию. Про самую первую подпись — в статье о создании цифровых подписей PAdES в Delphi разобран весь PFX-конвейер
var
Pdf: THotPDF;
FieldIndex: Integer;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.BeginIncrementalUpdate('Signed.pdf');
// Страница 0, прямоугольник виджета в пунктах, 8192 байта зарезервированы под CMS
FieldIndex := Pdf.AddLoadedSignedSignatureField(0, 320, 60, 520, 120,
'ApproverSignature', 8192);
if FieldIndex < 0 then
raise Exception.Create('Page index out of range');
Pdf.SaveIncrementalUpdate('Prepared.pdf');
finally
Pdf.Free;
end;
if not THotPDF.SignPDFWithPFX('Prepared.pdf', 'SignedTwice.pdf',
'approver.pfx', 'pfx-password') then
raise Exception.Create('Second signature failed');
end;
AddLoadedSignedSignatureField намеренно тише своих собратьев. Прочие создатели полей AddLoaded* выставляют на AcroForm /NeedAppearances true, что велит вьюверу перегенерировать appearance полей; на подписанном документе такая регенерация может переписать подписанное содержимое, поэтому новый метод снимает флаг обратно, если источник его не нёс. /SigFlags хранит исходное значение OR 3 (SignaturesExist плюс AppendOnly, ISO 32000-1 Table 219). Звать MarkDirty на странице тоже не нужно: добавление в /Annots и /Fields протаскивает dirty-флаг в владеющий косвенный объект, а явная пометка страницы лишь притащила бы в новую ревизию неизменённый словарь страницы, который анализ ревизий затем отчитал бы как модификацию страницы. Напоследок заглушка пишет /ByteRange до /Contents, потому что патчер сперва находит маяк /ByteRange и ищет вперёд парную hex-строку
Что меняется, когда CMS производит внешний подписант или HSM?
В рабочем процессе ничего, но смещения теперь значат то, что говорит спецификация. PreparePDFForSigning возвращает два 0-базных диапазона, чей зазор — вся строка /Contents, а ContentsHexStart — 1-базный индекс первой hex-цифры в AnsiString. Более короткий CMS добивается цифрами 0 в конец, перед закрывающей >. Поскольку PreparePDFForSigning патчит первый непропатченный маяк, готовьте ровно одну заглушку на ревизию и предпочитайте InsertSignatureHexAt с возвращёнными смещениями поисковому InsertSignatureHex, когда в файле уже есть прежние подписи
var
Bytes, ToSign, CmsHex: AnsiString;
R1Start, R1Len, R2Start, R2Len, HexStart, HexLen: Integer;
begin
Bytes := LoadFileAsAnsiString('Prepared.pdf'); // ваш хелпер
if not THotPDF.PreparePDFForSigning(Bytes, R1Start, R1Len,
R2Start, R2Len, HexStart, HexLen) then
raise Exception.Create('No signature placeholder found');
// Зазор — вся hex-строка: '<' кончает диапазон 1, '>' предшествует диапазону 2
Assert(Bytes[R1Start + R1Len + 1] = '<');
Assert(Bytes[R2Start] = '>');
ToSign := Copy(Bytes, R1Start + 1, R1Len) + Copy(Bytes, R2Start + 1, R2Len);
CmsHex := SignDetachedWithHsm(ToSign); // ваш CMS-подписант, hex DER
if not THotPDF.InsertSignatureHexAt(Bytes, HexStart, HexLen, CmsHex) then
raise Exception.Create('CMS does not fit the reserved space');
SaveAnsiStringToFile(Bytes, 'SignedTwice.pdf'); // ваш хелпер
end;
Где пределы новых проверок?
Проверка зазора закрывает одну конкретную дыру, и продавать её за больше, чем она есть, не стоит. svValid по-прежнему значит целостность байтов плюс ключ, совпадающий со вшитым сертификатом; доверие к тому сертификату — отдельное решение. Зазор валидируется, только когда у верификатора есть исходные байты: VerifyLoadedSignatureEx читает их из загруженного файла, а перегрузки с TStream берут их у вас. Для первой подписи в контрподписанном файле CoversWholeDocument корректно False, а вопрос, добавила ли дописанная ревизия только подпись или тронула и страницы, — это вопрос к анализу DocMDP, FieldMDP и ревизий в HotPDF. Учтите также, что проверка attached PDF MAC сверяет смещения с позициями < и >, поэтому принимает и старую, и новую раскладку; любой ваш инструмент, жёстко вбитый под смещения до v2.759.0, споткнётся первым, встретив свежеподписанный файл
Если ваше приложение на Delphi или C++Builder подписывает, контрподписывает или аудирует PDF, самый безопасный путь — позволить одной библиотеке производить и верифицировать одну и ту же раскладку. HotPDF, нативный Delphi PDF компонент везёт более строгую валидацию зазора, инкрементальные вторые подписи и хуки для внешнего подписанта из примеров выше в одном компоненте