Articolo tecnico

Analisi delle modifiche al PDF dopo la firma con PDFium

Per scoprire cosa è cambiato in un PDF dopo la firma, il PDFium Component per Delphi e Lazarus offre TPdf.AnalyzeSignatureRevisions, un analizzatore delle modifiche post-firma che ricostruisce ogni revisione incrementale dai byte originali del file, valuta ogni successiva modifica di oggetto contro le regole DocMDP e FieldMDP di quella firma, e riporta le definizioni di oggetti shadow come un rischio a parte. La situazione che mira è familiare a chiunque maneggi contratti: un modulo certificato parte, torna indietro con altri due salvataggi incrementali, e ogni firma continua a verificare. È quanto atteso, perché una firma copre solo i byte della propria revisione. La vera domanda è se quei salvataggi successivi erano permessi, e una spunta verde sulla firma non risponde a questa domanda

Perché l'API delle firme di PDFium non può mostrare cosa è cambiato dopo la firma?

L'API delle firme di PDFium non può mostrare le modifiche post-firma perché legge solo il dizionario della firma: /Contents, /ByteRange, /SubFilter e il valore di permesso DocMDP. PDFium non ha un grafo di revisioni incrementali, non parsa i parametri di transform FieldMDP, e non offre alcun diff a livello di oggetto tra revisioni, quindi l'analizzatore in FPdfPades.pas lavora direttamente sui byte grezzi. Questo ha una conseguenza pratica da tenere in conto nel progetto. TPdf.AnalyzeSignatureRevisions legge i byte trattenuti al caricamento del documento, mai una copia prodotta da SaveAs, perché un file riscritto ha perso proprio la struttura di revisioni che si sta analizzando. Se il documento arriva da una sorgente progressiva che non ha finito di scaricarsi, il report restituisce SourceStatus = pvssIncomplete e Status = prasIndeterminate invece di analizzare un file troncato

Ricostruire i confini di revisione da startxref, stream xref e /Prev

L'analizzatore ricostruisce i confini di revisione seguendo ogni startxref a ritroso attraverso tabelle xref classiche, stream di riferimenti incrociati, voci /XRefStm ibride e la catena /Prev, come definito per gli aggiornamenti incrementali in ISO 32000-1 §7.5.6 e §7.5.8. La lunghezza coperta da ciascuna firma è la fine del suo secondo span ByteRange, e l'analizzatore mappa quella lunghezza sulla revisione la cui sezione xref la contiene. Quando nessuna revisione combacia, la firma riceve prrCoveredRevisionNotFound e uno stato Indeterminate. Lo stato di ogni oggetto viene poi riprodotto fino alla revisione coperta, e ogni voce xref successiva viene confrontata con quello stato. Conta più di quanto sembri: alcuni writer riscrivono la tabella xref completa a ogni salvataggio incrementale, e una voce che punta ancora allo stesso oggetto immutato viene saltata invece che segnalata come modifica. Senza quel confronto, un riempimento di modulo perfettamente legittimo annegherebbe in centinaia di false modifiche

Come AnalyzeSignatureRevisions ricostruisce le revisioni incrementali dai byte PDF grezzi in Delphi: il ByteRange della firma termina dentro la revisione coperta, la catena Prev dell'xref cammina a ritroso attraverso ogni salvataggio successivo, lo stato degli oggetti viene riprodotto fino alla revisione coperta, e le voci riscritte immutate vengono saltate invece che segnalate come modifiche
Una firma copre solo i byte della propria revisione, così l'analizzatore mappa il secondo span ByteRange su una revisione e valuta ogni voce xref successiva contro lo stato degli oggetti riprodotto

Le definizioni shadow sono il caso che merita più attenzione. Un corpo di oggetto che compare dentro il range di byte di una revisione successiva ma non è referenziato dall'xref di quella revisione è invisibile a un viewer normale, eppure è esattamente il tipo di messa in scena su cui contano gli shadow attack: il contenuto nascosto viene piantato prima o dopo la firma e attivato in seguito ribaltando un riferimento. AnalyzePadesSignatureRevisionsBytes registra un simile oggetto come modifica non autorevole con IsAuthoritative = False, lo valuta prdSuspicious a prescindere dal livello di permesso, e aggiunge prrUnreferencedObjectDefinition all'insieme dei rischi. Due rischi correlati coprono altri trucchi strutturali: prrDuplicateObjectDefinition scatta quando una sezione xref elenca lo stesso oggetto più di una volta, e prrSignatureObjectRedefined scatta quando una revisione successiva ridefinisce un oggetto firma esistente

Una definizione di oggetto shadow dentro il range di byte di una revisione PDF successiva: il corpo dell'oggetto esiste ma nessuna voce xref lo referenzia, quindi i viewer non lo mostrano mai, e AnalyzeSignatureRevisions in PDFium Component lo registra come non autorevole, lo valuta prdSuspicious e solleva prrUnreferencedObjectDefinition accanto ai rischi di duplicazione e di firma ridefinita
Il contenuto nascosto viene piantato prima o dopo la firma e attivato in seguito ribaltando un riferimento, ed è per questo che un corpo non referenziato viene valutato suspicious a prescindere dal livello di permesso DocMDP
uses
  SysUtils, TypInfo, PDFium, FPdfPades;

const
  ShadowTag: array[Boolean] of string = ('', ' (shadow)');
var
  Pdf: TPdf;
  Report: TPadesRevisionAnalysisReport;
  i, j: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract-returned.pdf';
    Pdf.Active := True;
    Report := Pdf.AnalyzeSignatureRevisions;
    Writeln('Revisions: ', Report.RevisionCount,
      '  Signatures: ', Report.SignatureCount,
      '  Overall: ', GetEnumName(TypeInfo(TPadesRevisionAnalysisStatus),
        Ord(Report.Status)));
    for i := 0 to High(Report.Signatures) do
      with Report.Signatures[i] do
      begin
        Writeln(Format('Signature %d covers revision %d, %d later, P=%d, FieldMDP=%s',
          [SignatureIndex, CoveredRevisionIndex, LaterRevisionCount,
           DocMdpPermission,
           GetEnumName(TypeInfo(TPadesFieldMdpAction), Ord(FieldMdpAction))]));
        for j := 0 to High(Changes) do
          Writeln(Format('  rev %d  obj %d  %s -> %s%s',
            [Changes[j].RevisionIndex, Changes[j].ObjectNumber,
             GetEnumName(TypeInfo(TPadesRevisionChangeKind), Ord(Changes[j].Kind)),
             GetEnumName(TypeInfo(TPadesRevisionDecision), Ord(Changes[j].Decision)),
             ShadowTag[not Changes[j].IsAuthoritative]]));
      end;
  finally
    Pdf.Free;
  end;
end;

Come vengono fatti rispettare DocMDP e FieldMDP per ogni firma?

DocMDP e FieldMDP vengono fatti rispettare separatamente per ogni firma, alla revisione coperta di quella stessa firma, così una firma di certificazione e una successiva firma di approvazione nello stesso file possono arrivare a verdetti diversi sulla stessa modifica. Ogni oggetto successivo viene prima classificato in un TPadesRevisionChangeKind dalle sue voci /Type, /Subtype e /FT e dal ruolo che gioca nei grafi di pagina, modulo, annotazione e DSS. Qualsiasi cosa porti /JavaScript, /JS, /Launch, /OpenAction, /AA, /RichMedia o /EmbeddedFile diventa prckActiveContent. La decisione poi segue ISO 32000-1 §12.8.2.2: con P=1 è vietato tutto tranne i dati di riferimenti incrociati e il materiale di validazione; P=2 consente il riempimento di moduli e ulteriori firme ma respinge le modifiche alle annotazioni; P=3 consente anche le annotazioni. Contenuto di pagina, struttura del documento, metadati, contenuto attivo e oggetti cancellati sono vietati a qualsiasi livello DocMDP, e valutati prdSuspicious quando la firma non porta alcun DocMDP, visto che una firma di approvazione non vieta formalmente nulla ma il lettore non vede più ciò che era stato firmato

FieldMDP, da ISO 32000-1 §12.8.2.4, stringe ulteriormente la decisione sui campi del modulo. pfmaAll blocca ogni campo, pfmaInclude blocca solo i campi elencati, e pfmaExclude blocca tutto tranne i campi elencati. Per applicare Include o Exclude, l'analizzatore risolve ogni campo modificato al suo nome pienamente qualificato attraverso la catena /Parent e lo confronta con la lista di blocco per match esatto, quindi elencate i nomi dei campi terminali invece di aspettarvi che un nome padre copra i figli. Quando un nome non si può risolvere o la transform usa un'azione che il parser non riconosce, la modifica diventa prdIndeterminate e viene sollevato prrFieldMdpUnresolved. Le decisioni per modifica poi si aggregano col peggiore al primo posto, con Suspicious sopra Disallowed, Disallowed sopra Indeterminate, e Indeterminate sopra Allowed, così un solo oggetto shadow pesa più di un numero qualsiasi di legittimi aggiornamenti di campi

La pipeline di valutazione che AnalyzeSignatureRevisions applica a ogni modifica post-firma in Delphi: un TPadesRevisionChangeKind dalle voci Type e Subtype, una decisione DocMDP alla revisione coperta da P=1 a P=3, un controllo di blocco FieldMDP sui nomi di campo pienamente qualificati, e un roll-up col peggiore al primo posto da prdSuspicious giù a prdAllowed
Un solo oggetto shadow pesa più di un numero qualsiasi di legittimi aggiornamenti di campi perché Suspicious sta sopra Disallowed, Indeterminate e Allowed, mentre alcuni rischi vengono registrati accanto allo stato senza declassarlo

Perché alcune modifiche tornano Indeterminate invece che sicure?

Le modifiche tornano Indeterminate ogni volta che l'analizzatore non riesce a dimostrare che una modifica è permessa, perché in un controllo di firma un'incognita non deve mai essere riportata come consentita. Un caso comune invece viene gestito con precisione: la validazione a lungo termine aggiunge un /DSS e riscrive il catalogo, cosa che altrimenti conterebbe come modifica strutturale sotto P=1. L'analizzatore toglie /DSS e /Extensions dai dizionari di catalogo vecchio e nuovo e confronta il resto; quando nulla altro differisce, la riscrittura è trattata come aggiornamento di materiale di validazione e permessa, così l'augmentation B-LT e B-LTA non rompe una firma di certificazione. Altri varchi restano aperti di proposito. Le voci di tipo 2 in uno stream di riferimenti incrociati puntano dentro object stream compressi, e l'analizzatore non espande gli object stream dentro questo confine di sicurezza, quindi quelle modifiche emergono come prckCompressedObject con prrCompressedObjectUnresolved, vietate sotto P=1 e Indeterminate altrimenti. Budget rigidi di 1024 revisioni, 1.000.000 di numeri di oggetto e 2.000.000 di modifiche riportate producono prrResourceLimitExceeded, e una catena xref rotta produce prrMalformedRevisionChain; entrambi finiscono come Indeterminate, mai come un passaggio

const
  StructuralRisks: TPadesRevisionRisks = [prrMalformedRevisionChain,
    prrDuplicateObjectDefinition, prrUnreferencedObjectDefinition,
    prrSignatureObjectRedefined, prrResourceLimitExceeded];

function RevisionVerdict(const R: TPadesRevisionAnalysisReport): string;
begin
  // Alcuni rischi vengono registrati senza cambiare Status, quindi testateli prima
  if R.Risks * StructuralRisks <> [] then
    Exit('review: structural risk in the revision chain');
  case R.Status of
    prasNoLaterChanges: Result := 'accept: nothing was added after signing';
    prasAllowed:        Result := 'accept: every later change is permitted';
    prasDisallowed:     Result := 'reject: a change violates DocMDP or FieldMDP';
    prasSuspicious:     Result := 'reject: shadow or unconstrained content change';
    prasIndeterminate:  Result := 'review: the analyzer could not decide';
  else
    Result := 'not checked: no signatures or no original bytes';
  end;
end;

L'ordinamento di quel cancello è deliberato. prrDuplicateObjectDefinition viene aggiunto all'insieme dei rischi senza declassare da solo Status, e una transform FieldMDP non parsabile tocca lo stato solo quando un campo del modulo cambia davvero, quindi un cancello che guarda solo Status può perdere prove che il report contiene già. Tenete presente anche ciò che il report non rivendica. TPadesRevisionAnalysisReport non dice nulla sulla validità crittografica della firma CMS o sul fatto che il certificato del firmatario abbia una catena fino a una root di cui vi fidate. L'analisi delle revisioni risponde alla domanda su cosa è successo dopo la firma, e sta accanto alla validazione strutturale e di fiducia, non al loro posto

Scrivere seed value e lock MDP al momento della firma

Le stesse regole si possono scrivere al momento della firma tramite TPadesSignatureFieldOptions, che è il membro FieldOptions sia di TPadesSignOptions sia di TPadesRemoteSignOptions. PDFium sa creare un widget ma non sa scrivere /SV, /Lock, una transform FieldMDP o DocMDP, o il dizionario /Perms del catalogo, quindi il writer PAdES incrementale dello stesso componente produce questi oggetti dentro lo stesso aggiornamento xref della firma. FieldName imposta il nome del campo radice, RequiredSeedValues diventa i bit /Ff del dizionario di seed value descritto in ISO 32000-1 §12.7.4.5, Reasons, LegalAttestations e AcceptableCertificates vincolano ciò che un firmatario successivo può scegliere, LockAction con LockFields scrive un /SigFieldLock indiretto, e CertificationPermission da 1 a 3 trasforma la firma in una firma di certificazione. Le transform DocMDP e FieldMDP vanno entrambe in un unico array /Reference sul valore della firma, ciascuna con /Data che punta al catalogo

var
  Options: TPadesSignOptions;
begin
  Options := TPadesSignOptions.Default;
  Options.CertificateThumbprint := 'A1B2C3D4E5F60718293A4B5C6D7E8F9012345678';
  Options.Reason := 'Approved for release';
  Options.FieldOptions.FieldName := 'Certification';
  Options.FieldOptions.CertificationPermission := 2;   // solo riempimento modulo e firma
  Options.FieldOptions.RequiredSeedValues := [psvcSubFilter, psvcDigestMethod];
  Options.FieldOptions.LockAction := pfmaInclude;      // blocca solo questi campi
  SetLength(Options.FieldOptions.LockFields, 2);
  Options.FieldOptions.LockFields[0] := 'Total';
  Options.FieldOptions.LockFields[1] := 'IBAN';
  if not Pdf.SignPades('contract-certified.pdf', Options) then
    Writeln('Signing failed');
end;

Qualche dettaglio è facile sbagliarlo se ve lo scrivete a mano. Il /Perms /DocMDP del catalogo deve referenziare il dizionario del valore della firma, non l'annotazione widget, e il writer tiene il valore della firma come oggetto indiretto a sé per quella ragione. Un dizionario /Perms esistente può già contenere diritti d'uso /UR3, quindi il writer lo copia e vi inserisce /DocMDP invece di sostituirlo, seguendo il dizionario dei permessi in ISO 32000-1 §12.8.4. Un documento che porta già /DocMDP rifiuta una seconda firma di certificazione con EPadesCrypto, e altrettanto fanno le opzioni incoerenti: un lock Include o Exclude senza nomi di campo, un lock All con una lista di campi, un'attestazione legale su una firma non di certificazione, o un punto nel nome del campo radice. La firma remota aggiunge un'ulteriore regola, perché il certificato firmatario è sconosciuto quando PreparePadesRemoteSignature gira: impostare lì CertificateRequired esige una lista esplicita AcceptableCertificates, mentre la firma locale può ripiegare sul certificato firmatario risolto

L'analisi delle revisioni completa la cassetta degli attrezzi delle firme invece di sostituirne una parte. Partite da ispezionare le firme PDF e i livelli PAdES con il PDFium Component per leggere il dizionario e il livello di base, guardate perché i validatori respingono le firme PAdES per i fallimenti strutturali che vengono prima di qualsiasi domanda sulle revisioni, e integrate il verdetto in un più ampio audit dei rischi di sicurezza PDF accanto ai controlli su JavaScript e file incorporati. TPdf.AnalyzeSignatureRevisions, TPadesSignatureFieldOptions e il writer PAdES incrementale mostrato qui viaggiano con il PDFium Component per Delphi, C++Builder e Lazarus