Articol tehnic

Signature wrapping PDF: goluri ByteRange și a doua semnătură

HotPDF, componenta Delphi PDF, respinge acum signature wrapping: din v2.759.0, atât VerifyLoadedSignatureEx cât și validatorul de lot cer ca golul dintre cele două segmente /ByteRange să fie exact șirul hex /Contents, delimitatori incluși, iar v2.761.0 adaugă AddLoadedSignedSignatureField, ca o a doua semnătură să poată fi adăugată unui PDF deja semnat ca o revizie incrementală curată. Cele două schimbări merg împreună, pentru că o a doua semnătură corectă e exact aranjamentul pe care îl așteaptă verificatorul mai strict

Situația care a scos la iveală problema e obișnuită. Un contract e semnat de furnizor, apoi ajunge la un aprobator care trebuie să contrasemeze fără să tulbure prima semnătură. A doua revizie e adăugată după prima, propriul ei /ByteRange acoperă tot fișierul crescut, și ambele semnături ar trebui să verifice. Să ajungi acolo de mână însemna să scrii singur o secțiune incrementală, iar fixture-ul de test care făcea exact asta s-a dovedit a fi o structură de manual de signature wrapping pe care vechiul verificator o accepta fericit. Dacă nu v-ați uitat până acum la API-ul de verificare, ghidul verificării semnăturilor digitale PDF cu HotPDF acoperă bazele pe care se construiește articolul de față

Ce aparține exact de golul ByteRange?

Golul trebuie să conțină valoarea completă /Contents și nimic altceva: ISO 32000-1 §12.8.3.3 spune că șirul hexazecimal, cu delimitatorii lui < și >, încape exact în spațiul dintre cele două intervale de octeți, iar ISO 32000-2 §12.8.1 duce regula aceea mai departe. Tabelul 252 și documentele PAdES spun doar că digestul exclude valoarea Contents, ceea ce e ușor de citit ca excluzând doar cifrele hex. Versiunile HotPDF anterioare o citeau așa: PreparePDFForSigning și pregătirea CMS în streaming hash-uiau și parantezele unghiulare, cu un comentariu din sursă care insista că parantezele trebuie acoperite. Verificatoarele care compară golul cu valoarea semnăturii marchează acel aranjament ca interval de octeți invalid, deci v2.759.0 scoate ambii delimitatori din intervalele semnate. O verificare independentă rapidă pe orice fișier semnat e să vă uitați la doi octeți: octetul de la offset-ul ByteRange[1] trebuie să fie <, iar octetul de la offset-ul ByteRange[2] - 1 trebuie să fie >

Anatomia unui ByteRange de semnătură PDF corect umplut în HotPDF: primul interval acoperă fișierul de la octetul zero, golul ține șirul hex /Contents complet, cu delimitatorii mai mic și mai mare incluși, al doilea interval acoperă trailerul până la capăt, iar două verificări pe câte un octet la ByteRange[1] și ByteRange[2] - 1 confirmă aranjamentul pe orice fișier semnat
Din v2.759.0, delimitatorii stau în afara intervalelor semnate, deci digestul acoperă doar cifrele, iar golul poate fi validat octet cu octet

De ce o verificare care cere doar gol nevid ratează signature wrapping?

O verificare de gol non-gol dovedește doar că ceva a fost lăsat în afara digestului, nu ce a fost lăsat, iar asta e toată suprafața de atac. Placeholder-ul /Contents e rezervat cu mii de cifre zero, în timp ce un container CMS real îl umple rar. Un atacator poate închide șirul hex devreme în interiorul acelui padding de zerouri cu un >, poate scrie obiecte noi sau o revizie falsificată în restul spațiului rezervat și poate lăsa intervalele de octeți neatinse. Semnătura CMS se verifică în continuare pentru că fiecare octet semnat e neschimbat, intervalele pornesc tot la 0 și se termină la mărimea fișierului, iar vechiul verificator HotPDF raporta svValid cu CoversWholeDocument setat pe True. Un cititor de PDF, între timp, parsează orice stă în gaura aceea nesemnată

HotPDF tratează acum golul ca date care trebuie validate octet cu octet. Verificatorul citește golul, taie delimitatorii, acceptă doar cifre hex plus whitespace PDF (tab, line feed, form feed, carriage return, spațiu), decodează cifrele și cere rezultatului să egaleze exact /Contents-ul dicționarului de semnătură. Orice altceva degrandează rezultatul la svInvalidByteRange. Verificarea rulează atât pe calea cu o singură semnătură, cât și în ValidateLoadedSignatureBatch, care își ținea propria logică de acoperire și a avut nevoie de aceeași reparare. Fișierele produse de HotPDF înainte de v2.759.0, al căror gol ținea doar cifre cu parantezele așezate chiar în interiorul intervalelor, se verifică în continuare, deci documentele arhivate nu devin deodată roșii

Cum exploatează signature wrapping un ByteRange PDF verificat cu laxitate în Delphi: atacatorul închide șirul hex devreme în interiorul miilor de cifre zero rezervate, scrie o revizie falsificată în golul nesemnat fără să atingă vreun octet acoperit, iar vechea verificare HotPDF raporta svValid cu CoversWholeDocument true până când v2.759.0 a început să valideze golul octet cu octet
Un gol non-gol dovedește doar că ceva a fost lăsat în afara digestului, nu ce — gaura umplută cu zeruri e toată suprafața de atac
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;

Cum adăugați o a doua semnătură unui PDF deja semnat?

Deschideți fișierul semnat cu BeginIncrementalUpdate, apelați AddLoadedSignedSignatureField, salvați cu SaveIncrementalUpdate, apoi semnați fișierul pregătit cu funcția de clasă THotPDF.SignPDFWithPFX. Înainte de v2.761.0, rețeta documentată de apelare a lui THPDFPage.AddSignedSignatureField după BeginIncrementalUpdate nu putea funcționa, pentru că CurrentPage e nil în modul incremental și nimic nu putea atârna un placeholder /V de un câmp pe un document încărcat. Metoda nouă creează widget-ul pe pagina încărcată și atârnă același dicționar placeholder pe care îl folosește calea document-nou sub /V, deci ambele rute de semnare partajează o singură serializare. Pentru prima semnătură în sine, articolul despre crearea de semnături digitale PAdES în Delphi trece pas cu pas prin pipeline-ul PFX

var
  Pdf: THotPDF;
  FieldIndex: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.BeginIncrementalUpdate('Signed.pdf');
    // Pagina 0, dreptunghi de widget în puncte, 8192 de octeți rezervați pentru 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 e în mod deliberat mai liniștită decât suratele ei. Celelalte creatoare de câmpuri AddLoaded* setează /NeedAppearances true pe AcroForm, ceea ce spune unui viewer să regenereze aparențele câmpurilor; pe un document semnat, regenerarea aceea poate rescrie conținut semnat, deci metoda nouă scoate flag-ul înapoi cu excepția cazului în care sursa îl avea deja. /SigFlags își păstrează valoarea originală OR 3 (SignaturesExist plus AppendOnly, ISO 32000-1 Tabelul 219). Nu trebuie nici să apelați MarkDirty pe pagină: adăugarea la /Annots și /Fields propagă flag-ul de dirty la obiectul indirect care le deține, iar o marcare explicită a paginii ar trage doar un dicționar de pagină neschimbat în revizia nouă, pe care analiza de revizii ar raporta-o apoi ca modificare de pagină. În fine, placeholder-ul scrie /ByteRange înaintea lui /Contents, pentru că patcher-ul localizează întâi sentinela /ByteRange și caută înainte șirul hex pereche

Fluxul HotPDF Delphi pentru contrasemnarea unui PDF deja semnat: BeginIncrementalUpdate deschide fișierul, AddLoadedSignedSignatureField creează widget-ul și rezervă placeholder-ul /Contents, SaveIncrementalUpdate adaugă o a doua revizie, iar SignPDFWithPFX o umple, lăsând prima semnătură validă cu UnsignedTrailingBytes în timp ce noul ByteRange acoperă tot fișierul crescut
Un placeholder per revizie, pregătit și patch-uit de aceeași serializare pe ambele rute de semnare — aranjamentul curat pe care îl așteaptă verificatorul mai strict

Ce se schimbă când un semnatar extern sau un HSM produce CMS-ul?

Nimic nu se schimbă în flux, dar offset-urile înseamnă acum ce spune specificația. PreparePDFForSigning întoarce două intervale pe bază de zero al căror gol e tot șirul /Contents, iar ContentsHexStart e indexul pe bază de unu al primei cifre hex din AnsiString. Un CMS mai scurt e umplut cu 0 la capăt, înaintea lui > de închidere. Fiindcă PreparePDFForSigning patch-uiește prima sentinelă nepatch-uită pe care o găsește, pregătiți exact un placeholder per revizie și preferați InsertSignatureHexAt cu offset-urile întoarse în locul lui InsertSignatureHex bazat pe căutare când în fișier există deja semnături anterioare

var
  Bytes, ToSign, CmsHex: AnsiString;
  R1Start, R1Len, R2Start, R2Len, HexStart, HexLen: Integer;
begin
  Bytes := LoadFileAsAnsiString('Prepared.pdf');   // helper-ul vostru
  if not THotPDF.PreparePDFForSigning(Bytes, R1Start, R1Len,
    R2Start, R2Len, HexStart, HexLen) then
    raise Exception.Create('No signature placeholder found');

  // Golul e tot șirul hex: '<' închide intervalul 1, '>' precede intervalul 2
  Assert(Bytes[R1Start + R1Len + 1] = '<');
  Assert(Bytes[R2Start] = '>');

  ToSign := Copy(Bytes, R1Start + 1, R1Len) + Copy(Bytes, R2Start + 1, R2Len);
  CmsHex := SignDetachedWithHsm(ToSign);           // semnatarul vostru CMS, DER hex
  if not THotPDF.InsertSignatureHexAt(Bytes, HexStart, HexLen, CmsHex) then
    raise Exception.Create('CMS does not fit the reserved space');
  SaveAnsiStringToFile(Bytes, 'SignedTwice.pdf');  // helper-ul vostru
end;

Unde sunt limitele noilor verificări?

Verificarea golului închide o anumită gaură și nu trebuie vândută cu prea mult. svValid înseamnă în continuare integritate de octeți plus o cheie care se potrivește cu certificatul încorporat; încrederea în certificatul acela e o decizie separată. Golul e validat doar când verificatorul are octeții sursă, pe care VerifyLoadedSignatureEx îi citește din fișierul încărcat, iar overload-urile TStream le iau de la voi. Pentru prima semnătură dintr-un fișier contrasemnat, CoversWholeDocument e corect False, iar dacă revizia adăugată a doar semnat sau a și schimbat pagini e o întrebare pentru DocMDP, FieldMDP și analiza de revizii în HotPDF. Rețineți de asemenea că verificarea PDF MAC atașată compară offset-urile cu pozițiile lui < și >, deci acceptă ambele aranjamente, vechi și nou; orice unealtă proprie care hard-codează offset-urile de dinainte de v2.759.0 va eșua prima când întâlnește un fișier proaspăt semnat

Dacă aplicația voastră Delphi sau C++Builder semnează, contrasemnează sau auditează PDF-uri, calea cea mai sigură e să lasați o singură bibliotecă să producă și să verifice același aranjament. HotPDF, componenta Delphi PDF nativă livrează validarea mai strictă a golului, a doua semnătură incrementală și hook-urile de semnatar extern arătate mai sus într-o singură componentă