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

PDF signature wrapping: празнини в ByteRange и втори подписи

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 трябва да е >

Анатомия на правилно запълнен PDF signature ByteRange в HotPDF: първият диапазон покрива файла от байт нула, празнината държи пълния hex низ /Contents, включително разделителите по-малко от и по-голямо от, вторият диапазон покрива trailer-а до края, а две проверки по един байт на ByteRange[1] и ByteRange[2] - 1 потвърждават оформлението върху всеки подписан файл
От v2.759.0 насам разделителите седят извън подписаните диапазони, така че дайджестът покрива само цифрите, а празнината може да се валидира байт по байт

Защо проверка за непразна празнина пропуска 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, чиита празнина е държала само цифри със скобите седящи точно вътре в диапазоните, пак минават валидация, така че архивирани документи не изведнъж почервеняват

Как signature wrapping експлоатира хлабаво проверяван PDF ByteRange в Delphi: нападателят затваря hex низа рано вътре в хилядите резервирани нули, записва фалшива ревизия в неподписаната празнина, без да пипне нито един покрит байт, а старата HotPDF проверка докладваше svValid с CoversWholeDocument true, докато 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 низ

Работният поток на HotPDF Delphi за контраподписване на вече подписан PDF: BeginIncrementalUpdate отваря файла, AddLoadedSignedSignatureField създава widget-а и резервира placeholder-а /Contents, SaveIncrementalUpdate долепя втора ревизия, а SignPDFWithPFX я запълва, оставяйки първия подпис валиден с UnsignedTrailingBytes, докато новият ByteRange обхваща целия пораснал файл
Един placeholder на ревизия, подготвян и кърпен от същата сериализация и на двата пътя за подписване — чистото оформление, което по-строгият верификатор очаква

Какво се променя, когато външен подписващ или 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-овете за външен подписващ, показани по-горе, в една компонента