Articolo tecnico

Signature wrapping PDF: gap del ByteRange e seconde firme

HotPDF, il componente PDF per Delphi, ora respinge il signature wrapping: dalla v2.759.0 sia VerifyLoadedSignatureEx sia il validatore in batch esigono che il gap tra i due segmenti /ByteRange sia esattamente la stringa esadecimale /Contents, delimitatori inclusi, e la v2.761.0 aggiunge AddLoadedSignedSignatureField così una seconda firma può essere accodata a un PDF già firmato come una pulita revisione incrementale. Le due modifiche stanno insieme, perché una seconda firma corretta è precisamente il layout che il verificatore più severo si aspetta

La situazione che ha esposto il problema è ordinaria. Un contratto viene firmato dal fornitore, poi passa a un approvatore che deve controfirmare senza disturbare la prima firma. La seconda revisione viene accodata dopo la prima, il suo /ByteRange copre l'intero file cresciuto, e entrambe le firme dovrebbero verificare. Arrivarci a mano significava scrivere da sé una sezione incrementale, e la fixture di test che faceva esattamente questo si è rivelata una struttura da manuale di signature wrapping che il vecchio verificatore accettava felicemente. Se non hai mai guardato l'API di verifica prima, la guida alla verifica delle firme digitali PDF con HotPDF copre le basi su cui questo articolo costruisce

Che cosa appartiene esattamente al gap del ByteRange?

Il gap deve contenere il valore /Contents completo e nient'altro: ISO 32000-1 §12.8.3.3 dice che la stringa esadecimale, con i suoi delimitatori < e >, sta precisamente nello spazio tra i due intervalli di byte, e ISO 32000-2 §12.8.1 porta avanti la stessa regola. La Tabella 252 e i documenti PAdES dicono solo che il digest esclude il valore Contents, che è facile leggere come esclusione dei soli cifri esadecimali. Le release HotPDF precedenti lo leggevano così: PreparePDFForSigning e la preparazione CMS in streaming facevano l'hash anche delle parentesi angolari, con un commento nel sorgente che insisteva che le parentesi andavano coperte. I validatori che confrontano il gap con il valore della firma segnalano quel layout come byte range non valido, quindi la v2.759.0 sposta entrambi i delimitatori fuori dagli intervalli firmati. Un rapido controllo indipendente su qualsiasi file firmato è guardare due byte: il byte all'offset ByteRange[1] deve essere < e il byte all'offset ByteRange[2] - 1 deve essere >

Anatomia di un ByteRange di firma PDF riempito correttamente in HotPDF: il primo intervallo copre il file dal byte zero, il gap contiene la stringa esadecimale /Contents completa inclusi i delimitatori minore-di e maggiore-di, il secondo intervallo copre il trailer fino alla fine, e due controlli a un byte a ByteRange[1] e ByteRange[2] - 1 confermano il layout su qualsiasi file firmato
Dalla v2.759.0 i delimitatori siedono fuori dagli intervalli firmati, così il digest copre solo le cifre e il gap può essere validato byte per byte

Perché un controllo sul gap non vuoto si lascia sfuggire il signature wrapping?

Un controllo sul gap non vuoto dimostra solo che qualcosa è stato lasciato fuori dal digest, non cosa, e quella è tutta la superficie d'attacco. Il placeholder /Contents viene riservato con migliaia di cifri zero, mentre un contenitore CMS reale raramente lo riempie. Un attaccante può chiudere in anticipo la stringa esadecimale dentro quel padding di zeri con un >, scrivere nuovi oggetti o una revisione contraffatta nel resto dello spazio riservato, e lasciare gli intervalli di byte intatti. La firma CMS verifica ancora perché ogni byte firmato è invariato, gli intervalli partono ancora da 0 e finiscono alla dimensione del file, e il vecchio verificatore HotPDF riportava svValid con CoversWholeDocument impostato a True. Un lettore PDF, nel frattempo, analizza qualsiasi cosa sieda in quel buco non firmato

HotPDF ora tratta il gap come dati da validare byte per byte. Il verificatore legge il gap, toglie i delimitatori, accetta solo cifri esadecimali più spazi bianchi PDF (tab, line feed, form feed, carriage return, spazio), decodifica le cifre ed esige che il risultato eguagli esattamente il /Contents del dizionario di firma. Qualsiasi altra cosa degrada il risultato a svInvalidByteRange. Il controllo gira sia nel percorso a firma singola sia in ValidateLoadedSignatureBatch, che conservava la propria logica di copertura e aveva bisogno della stessa correzione. I file prodotti da HotPDF prima della v2.759.0, il cui gap conteneva solo cifre con le parentesi proprio dentro gli intervalli, verificano ancora, quindi i documenti in archivio non diventano improvvisamente rossi

Come il signature wrapping sfrutta un ByteRange PDF vagamente controllato in Delphi: l'attaccante chiude in anticipo la stringa esadecimale dentro migliaia di cifri zero riservati, scrive una revisione contraffatta nel gap non firmato senza toccare alcun byte coperto, e il vecchio controllo HotPDF riportava svValid con CoversWholeDocument true finché la v2.759.0 non ha iniziato a validare il gap byte per byte
Un gap non vuoto dimostra solo che qualcosa è stato lasciato fuori dal digest, non cosa — il buco riempito di padding è tutta la superficie d'attacco
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;

Come aggiungi una seconda firma a un PDF già firmato?

Apri il file firmato con BeginIncrementalUpdate, chiama AddLoadedSignedSignatureField, salva con SaveIncrementalUpdate, poi firma il file preparato con la class function THotPDF.SignPDFWithPFX. Prima della v2.761.0 la ricetta documentata di chiamare THPDFPage.AddSignedSignatureField dopo BeginIncrementalUpdate non poteva funzionare, perché CurrentPage è nil in modalità incrementale e niente poteva attaccare un placeholder /V a un campo su un documento caricato. Il nuovo metodo crea il widget sulla pagina caricata e appende sotto /V lo stesso dizionario placeholder che usa il percorso del nuovo documento, così entrambe le rotte di firma condividono una sola serializzazione. Per la prima firma in sé, l'articolo sulla creazione di firme digitali PAdES in Delphi attraversa la pipeline PFX

var
  Pdf: THotPDF;
  FieldIndex: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.BeginIncrementalUpdate('Signed.pdf');
    // Pagina 0, rettangolo del widget in punti, 8192 byte riservati al 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 è deliberatamente più silenziosa delle sue sorelle. Gli altri creatori di campi AddLoaded* impostano /NeedAppearances true sull'AcroForm, il che dice a un viewer di rigenerare le appearance dei campi; su un documento firmato quella rigenerazione può riscrivere contenuto firmato, quindi il nuovo metodo toglie di nuovo il flag a meno che la sorgente lo portasse già. /SigFlags conserva il proprio valore originale OR 3 (SignaturesExist più AppendOnly, ISO 32000-1 Tabella 219). Non devi nemmeno chiamare MarkDirty sulla pagina: aggiungere a /Annots e /Fields propaga il flag dirty all'oggetto indiretto proprietario, e un marcamento esplicito della pagina trascinerebbe solo un dizionario di pagina invariato nella nuova revisione, che l'analisi delle revisioni riporterebbe poi come modifica di pagina. Infine, il placeholder scrive /ByteRange prima di /Contents, perché il patcher localizza prima la sentinella /ByteRange e cerca in avanti la stringa esadecimale corrispondente

Il flusso di lavoro HotPDF Delphi per la controfirma di un PDF già firmato: BeginIncrementalUpdate apre il file, AddLoadedSignedSignatureField crea il widget e riserva il placeholder /Contents, SaveIncrementalUpdate accoda una seconda revisione, e SignPDFWithPFX la riempie, lasciando la prima firma valida con UnsignedTrailingBytes mentre il nuovo ByteRange copre l'intero file cresciuto
Un placeholder per revisione, preparato e patchato dalla stessa serializzazione su entrambe le rotte di firma — il layout pulito che il verificatore più severo si aspetta

Che cosa cambia quando un firmatario esterno o un HSM produce il CMS?

Nel flusso di lavoro non cambia nulla, ma gli offset ora significano ciò che la specifica dice. PreparePDFForSigning restituisce due intervalli a base zero il cui gap è l'intera stringa /Contents, e ContentsHexStart è l'indice a base uno del primo cifro esadecimale nella AnsiString. Un CMS più corto viene riempito con 0 alla fine, prima della chiusura >. Dato che PreparePDFForSigning patcha la prima sentinella non ancora patchata che trova, prepara esattamente un placeholder per revisione, e preferisci InsertSignatureHexAt con gli offset restituiti alla InsertSignatureHex basata sulla ricerca quando nel file esistono già firme precedenti

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

  // Il gap è l'intera stringa esadecimale: '<' chiude l'intervallo 1, '>' precede l'intervallo 2
  Assert(Bytes[R1Start + R1Len + 1] = '<');
  Assert(Bytes[R2Start] = '>');

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

Dove stanno i limiti dei nuovi controlli?

Il controllo sul gap chiude un buco specifico e non va venduto oltre misura. svValid significa ancora integrità dei byte più una chiave che corrisponde al certificato incorporato; la fiducia in quel certificato è una decisione separata. Il gap viene validato solo quando il verificatore ha i byte sorgente, che VerifyLoadedSignatureEx legge dal file caricato e gli overload su TStream prendono da te. Per la prima firma in un file controfirmato, CoversWholeDocument è correttamente False, e se la revisione accodata ha solo aggiunto una firma o ha anche modificato pagine è una domanda per DocMDP, FieldMDP e analisi delle revisioni in HotPDF. Nota anche che il controllo PDF MAC allegato confronta gli offset con le posizioni di < e >, quindi accetta sia il vecchio sia il nuovo layout; qualunque strumento tuo che hard-coda gli offset pre-v2.759.0 fallirà per primo quando incontra un file appena firmato

Se la tua applicazione Delphi o C++Builder firma, controfirma o audita PDF, il percorso più sicuro è lasciare che una sola libreria produca e verifichi lo stesso layout. HotPDF, il componente PDF nativo per Delphi spedisce la validazione del gap più severa, le seconde firme incrementali e gli hook per firmatari esterni mostrati qui sopra in un unico componente