HotPDF, Delphi PDF компонентата, вече отхвърля signature wrapping: от v2.759.0 насам и VerifyLoadedSignatureEx, и batch валидаторът изискват празнината между двата сегмента на /ByteRange да бъде точно hex низът /Contents, заедно с разделителите, а v2.761.0 добавя AddLoadedSignedSignatureField, така че втори подпис да може да бъде долепен към вече подписан PDF като чиста инкрементална ревизия. Двете промени вървят заедно, защото коректен втори подпис е точно оформлението, което по-строгият верификатор очаква
Ситуацията, разкрила проблема, е обикновена. Договор е подписан от доставчика, след това е изпратен на одобряващ, който трябва да сложи контраподпис, без да обезпокои първия подпис. Втората ревизия се долепя след първата, нейният собствен /ByteRange обхваща целия пораснал файл и двата подписа трябва да минат валидация. Да стигнеш до там на ръка значеше да напишеш сам инкрементална секция, а тестовият fixture, правещ точно това, се оказа учебникова signature-wrapping структура, която старият верификатор щастливо приемаше. Ако не сте гледали verification 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 и streaming CMS подготовката хешираха и ъгловите скоби, с коментар в кода, настояващ скобите задължително да са покрити. Валидатори, сравняващи празнината със стойността на подписа, флагват това оформление като невалиден byte range, затова v2.759.0 маха и двата разделителя извън подписаните диапазони. Бърза независима проверка върху всеки подписан файл е да погледнете два байта: байтът на отместване ByteRange[1] трябва да е <, а байтът на отместване ByteRange[2] - 1 трябва да е >
Защо проверка за непразна празнина пропуска signature wrapping?
Проверка за непразна празнина доказва само, че нещо е оставено извън дайджеста, не какво е оставено, а точно това е цялата атакуваща повърхност. Placeholder-ът /Contents се резервира с хиляди нули, докато истински CMS контейнер рядко го запълва. Нападател може да затвори hex низа рано вътре в нулевия padding с >, да запише нови обекти или фалшива ревизия в остатъка от резервираното място и да остави байтовите диапазони недокоснати. CMS подписът пак минава валидация, защото всеки подписан байт е непроменен, диапазоните пак започват от 0 и свършват на размера на файла, а старият HotPDF верификатор докладваше svValid с CoversWholeDocument зададен на True. PDF четец междувременно парсва каквото и да седи в тази неподписана дупка
HotPDF вече третира празнината като дана, която се валидира байт по байт. Верификаторът чете празнината, отрязва разделителите, приема само hex цифри плюс PDF whitespace (табулация, line feed, form feed, carriage return, интервал), декодира цифрите и изисква резултатът да е равен точно на /Contents в signature речника. Всичко друго понижава резултата до 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, после подпишете подготвения файл с class функцията THotPDF.SignPDFWithPFX. Преди v2.761.0 документираният рецепт от викане на THPDFPage.AddSignedSignatureField след BeginIncrementalUpdate не можеше да работи, защото CurrentPage е nil в инкрементален режим и нищо не можеше да закачи placeholder /V към поле на зареден документ. Новият метод създава widget-а на заредената страница и закача същия placeholder речник, който пътят за нов документ ползва, под /V, така че двата пътя за подписване споделят една сериализация. За самия първи подпис статията за създаване на PAdES цифрови подписи в Delphi минава през PFX pipeline-а
var
Pdf: THotPDF;
FieldIndex: Integer;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.BeginIncrementalUpdate('Signed.pdf');
// Страница 0, правоъгълник на widget в пунктове, 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, което казва на viewer да преизгради appearance-ите на полетата; върху подписан документ това преизграждане може да пренапише подписано съдържание, затова новият метод маха флага отново, освен ако източникът вече не го е носил. /SigFlags пази оригиналната си стойност OR 3 (SignaturesExist плюс AppendOnly, ISO 32000-1 Table 219). Не е нужно също да викате MarkDirty върху страницата: добавянето към /Annots и /Fields разпространява dirty флага към притежаващия indirect обект, а изрична маркировка на страница би само завлякла непроменен page речник в новата ревизия, която анализ на ревизии после докладва като модификация на страница. Най-после placeholder-ът записва /ByteRange преди /Contents, защото patcher-ът намира първо sentinel-а /ByteRange и търси напред съответстващия hex низ
Какво се променя, когато външен подписващ или HSM произвежда CMS-а?
В работния поток нищо не се променя, но отместванията вече значат това, което спецификацията казва. PreparePDFForSigning връща два диапазона от нула, чиято празнина е целият низ /Contents, а ContentsHexStart е едно-базираният индекс на първата hex цифра в AnsiString. По-кратък CMS се подплатя с 0 на края, преди затварящата >. Понеже PreparePDFForSigning кърпи първия некърпен sentinel, който намери, подгответе точно по един placeholder на ревизия и предпочитайте InsertSignatureHexAt с върнатите отмествания пред търсещия InsertSignatureHex, когато в файла вече има по-ранни подписи
var
Bytes, ToSign, CmsHex: AnsiString;
R1Start, R1Len, R2Start, R2Len, HexStart, HexLen: Integer;
begin
Bytes := LoadFileAsAnsiString('Prepared.pdf'); // вашият helper
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'); // вашият helper
end;
Къде са границите на новите проверки?
Проверката на празнината затваря една конкретна дупка и не бива да се преувеличава. svValid и досега значи цялостност на байтовете плюс ключ, съвпадащ с вградения сертификат; доверието в този сертификат е отделно решение. Празнината се валидира само когато верификаторът има изходни байтове, които VerifyLoadedSignatureEx чете от заредения файл, а TStream overload-ите взимат от вас. За първия подпис в контраподписан файл CoversWholeDocument правилно е False, а дали долепената ревизия е само добавила подпис или е променила и страници е въпрос за DocMDP, FieldMDP и анализа на ревизии в HotPDF. Обърнете внимание също, че закачената проверка на PDF MAC сравнява отместванията с позициите на < и >, така че приема и старото, и новото оформление; всеки ваш собствен инструмент, hard-коднал отместванията от преди v2.759.0, ще се провали първи, щом срещне свежо подписан файл
Ако вашето Delphi или C++Builder приложение подписва, слага контраподписи или одитира PDF-и, най-безопасният път е една библиотека да произвежда и верифицира същото оформление. HotPDF, native Delphi PDF компонентата доставя по-строгата валидация на празнината, инкрементални втори подписи и hook-овете за външен подписващ, показани по-горе, в една компонента