HotPDF, Delphi PDF-компонент, тепер відкидає signature wrapping: з v2.759.0 і VerifyLoadedSignatureEx, і батчевий валідатор вимагають, щоб проміжок між двома сегментами /ByteRange був точно /Contents-hex-рядком, розділювачі включно, а 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-пробіли (табуляція, переведення рядка, form feed, повернення каретки, пробіл), декодує цифри і вимагає, щоб результат точно дорівнював /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* ставлять /NeedAppearances true на AcroForm, що велить переглядачу регенерувати appearances полів; на підписаному документі та регенерація може переписати підписаний вміст, тож новий метод знімає прапорець назад, якщо джерело його вже не несло. /SigFlags тримає своє початкове значення OR 3 (SignaturesExist плюс AppendOnly, ISO 32000-1 Table 219). Вам також не треба викликати MarkDirty на сторінці: додавання в /Annots і /Fields поширює брудний прапорець на володіючий непрямий об'єкт, а явна позначка сторінки лише втягнула б незмінений словник сторінки в нову ревізію, яку аналіз ревізій потім звітував би як модифікацію сторінки. Нарешті, заповнювач пише /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. Зауважте ще, що перевірка приєднаного PDF MAC звіряє зміщення з позиціями < і >, тож приймає і старий, і новий макет; будь-який ваш власний інструмент, що захардкодив зміщення до v2.759.0, впаде першим, зустрівши свіжо підписаний файл
Якщо ваша програма на Delphi чи C++Builder підписує, ставить другі підписи чи аудитує PDF, найбезпечніший шлях — дозволити одній бібліотеці породжувати і верифікувати той самий макет. HotPDF, нативний Delphi PDF-компонент постачає строгішу валідацію проміжку, інкрементальні другі підписи і гачки зовнішнього підписувача, показані вище, в одному компоненті