Articolo tecnico

Dove finisce una revisione PDF? Update falsi dopo la firma

Un contratto viene firmato, poi guadagna un altro update, e uno script di audit fonde quell'update nella revisione firmata oppure fa passare la pagina riscritta come materiale di validazione. Nessuna delle due cose succede quando una revisione finisce dove startxref nomina una sezione raggiunta dalla catena /Prev e un oggetto riscritto conserva la sua classe precedente — le due regole alla base della revision map di PDFlibPas

Perché %%EOF è il posto sbagliato dove tagliare un file

Ogni incremental update è terminato dal suo marcatore end-of-file (ISO 32000-1 §7.5.6), quindi spezzare il file a ogni %%EOF sembra il modo ovvio di trovare le revisioni. Il marcatore è però solo cinque byte, e nulla impedisce che quei byte compaiano dentro un content stream, un file incorporato o un font program. I file linearizzati aggiungono un secondo problema: l'Allegato F (§F.3.4) lascia che il trailer della prima pagina finisca col proprio startxref e %%EOF vicino all'inizio del file, molto prima della tabella di cross-reference principale. Spezzare a ogni marcatore inventa revisioni che nessuna sezione di cross-reference sostiene. Indovinare quali marcatori sono finti è peggio: quell'ipotesi è ciò che un falsario può modellare. La revision map di PDFlibPas lavora al contrario: segue la catena /Prev, registra l'offset di byte di ogni sezione di cross-reference che la catena ha davvero letto e solo allora cerca le sequenze startxref N … %%EOF, conservando un confine solo quando N è uno di quegli offset registrati. Un marcatore vagante dentro uno stream dovrebbe avere davanti uno startxref che nomina una sezione reale prima di contare qualcosa

Revision map in Delphi con PDFlibPas che segue la catena /Prev e confronta gli offset startxref per trovare i confini di revisione degli incremental update, ignorando i marcatori %%EOF vaganti dentro gli stream e i trailer di prima pagina linearizzati
Solo gli offset startxref che la catena /Prev ha davvero letto segnano la fine di una revisione, e i byte %%EOF vaganti dentro gli stream non hanno voce su dove PDFlibPas taglia il file

I byte fra quei tre token sono meno ordinati di quanto §7.5.5 lasci pensare. Lo standard disegna startxref, l'offset e il marcatore su righe consecutive, ma i marcatori end-of-line e gli altri caratteri white-space di §7.2.2 (Tabella 1) possono ripetersi, e nessuna regola limita quanto. Perdente un marcatore con padding e due revisioni si fondono: quella firmata ora sembra finire dopo byte che nessuno ha firmato. La revision map di PDFlibPas segue la sequenza da keyword a operando a marcatore per quanto white-space li separi e ovunque cada quel padding nel file, e conta ogni keyword una volta sola. La keyword trailer ha un giunto lasco simile: << è un delimitatore (§7.2.2, Tabella 2) e §7.5.5 non esige mai un'interruzione di riga dopo trailer, quindi trailer<< su una riga è sintassi conforme. Letta riga per riga, quella riga somiglia a un'intestazione di sottosezione di cross-reference — e la catena si ferma a metà strada

Percorrere la revision map con PDFlibPas

Gli accessor di revisione sono dichiarati su TPDFlib e implementati nell'edizione Small Business: create un TPDFlibSmallBusiness dall'unità PDFlibSmallBusiness (Enterprise discende da essa e porta le stesse chiamate). Su un TPDFlib semplice restituiscono il loro valore di fallimento (0, 7 o -1) e impostano LastErrorCode a PDFLIB_ERROR_SMALLBUSINESS_REQUIRED. Le revisioni sono numerate da 0, il documento originale. Ogni chiamata prende un nome di file e una password e costruisce la map dai byte su disco, quindi le risposte descrivono il file così come è conservato, mai un documento che avete per caso caricato e modificato. GetDocumentRevisionKind riferisce la modifica più profonda presente in una revisione: un update che aggiunge una risposta OCSP e riscrive anche una pagina viene riportato come riscrittura di pagina:

TipoSignificatoSorgente tipica
0Solo housekeeping: oggetti che non cambiano nulla di ciò che un reader vedeCross-reference stream, un nuovo contenitore di object stream
1Materiale di validazioneDocument security store, VRI, certificati, CRL, risposte OCSP
2Document time stampUna firma /DocTimeStamp e il campo che la porta
3FirmaUn campo firma esistente che riceve il suo valore
4Campo moduloUn valore di campo o la sua appearance
5AnnotazioneQualsiasi annotazione diversa da un widget
6Contenuto del documentoTutto ciò che può cambiare ciò che il documento mostra
7Non determinabileQualcosa nella revisione non si è potuto leggere; trattatela come non sicura
uses
  System.SysUtils, PDFlibSmallBusiness;

procedure ListRevisions(const FileName, Password: WideString);
const
  KindNames: array[0..7] of string = ('housekeeping', 'validation material',
    'document time stamp', 'signature', 'form field', 'annotation',
    'content', 'undetermined');
var
  Pdf: TPDFlibSmallBusiness;
  Count, I, J, Kind, Changes: Integer;
begin
  Pdf := TPDFlibSmallBusiness.Create;
  try
    Count := Pdf.GetDocumentRevisionCount(FileName, Password);
    if Count = 0 then
      raise Exception.Create('the cross-reference chain could not be followed');
    if Pdf.GetDocumentRevisionsComplete(FileName, Password) <> 1 then
      Writeln('WARNING: history incomplete, every figure below is a lower bound');
    for I := 0 to Count - 1 do
    begin
      Kind := Pdf.GetDocumentRevisionKind(FileName, Password, I);
      if (Kind < 0) or (Kind > 7) then
        Kind := 7;
      Writeln(Format('revision %d: bytes %d..%d, %s', [I,
        Pdf.GetDocumentRevisionStart(FileName, Password, I),
        Pdf.GetDocumentRevisionEnd(FileName, Password, I),
        KindNames[Kind]]));
      // una riga 'objnum,change,class,detail' per oggetto
      Changes := Pdf.GetDocumentRevisionChanges(FileName, Password, I);
      if Changes <> 0 then
      try
        for J := 1 to Pdf.GetStringListCount(Changes) do  // gli item contano da 1
          Writeln('    ' + Pdf.GetStringListItem(Changes, J));
      finally
        Pdf.ReleaseStringList(Changes);
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

GetDocumentRevisionsComplete è la chiamata da leggere prima di fidarsi di tutte le altre. Restituisce 1 solo quando la catena è stata percorsa fino alla sezione originale; una /Prev rotta o ciclica, o una catena più profonda di 256 anelli, lascia gli update più vecchi invisibili, e ogni conteggio e indice diventa allora un limite inferiore. Una profondità di 256 lascia spazio alla storia di save-e-firma dei file reali, e una catena che punta a se stessa finisce comunque quasi subito. Fate attenzione all'indicizzazione nel ciclo: GetStringListItem conta da 1 mentre le revisioni contano da 0, e far partire il ciclo interno da 0 fa cadere il primo oggetto modificato di ogni revisione senza alcun errore

Come un update falso si spaccia per materiale di validazione

La validazione a lungo termine PAdES accoda legittimamente update dopo l'ultima firma: un document security store (/Type /DSS), dizionari /VRI per firma, stream di certificati e revoche, document time stamp. I livelli di certificazione li lasciano passare, il che rende il materiale di validazione l'etichetta più preziosa che un update possa indossare. È anche la più economica da falsificare. Il /Type è ciò che l'update ha scelto di scrivere. Un viewer che disegna una pagina non guarda mai il /Type di un content stream, di un resource dictionary o di un font, quindi un update può riscrivere il content stream della pagina 3, aggiungere /Type /DSS al suo dizionario, e ogni renderer dipingerà comunque il nuovo testo mentre un classificatore che crede al /Type vede housekeeping. /VRI, /Sig e /DocTimeStamp funzionano allo stesso modo

La revision map di PDFlibPas ignora l'etichetta che un update scrive su un oggetto che riscrive. Un oggetto riscritto conta come materiale di validazione, time stamp o firma solo quando la versione precedente di quello stesso oggetto lo era già. Gli stream di certificati, CRL e risposte OCSP non portano un tipo proprio, quindi vengono riconosciuti dal document security store che punta a loro — e conta solo uno store scritto nella revisione che si sta giudicando o in una più vecchia. Uno store aggiunto dopo non può tendere la mano indietro e garantire un oggetto scritto dalla revisione firmata. Le modifiche alla struttura del documento contano come contenuto ogni volta che cambiano ciò che un reader risolverebbe:

GetDocumentRevisionKind di PDFlibPas che riporta come contenuto del documento un update falso /Type /DSS che riscrive il content stream di una pagina, perché la revision map classifica un oggetto riscritto dalla sua versione precedente
Etichettare /Type /DSS un content stream di pagina riscritto non cambia nulla per il renderer, e PDFlibPas riporta comunque l'update come contenuto del documento perché quell'oggetto era contenuto di pagina prima
  • sostituire un object stream esistente, perché ogni oggetto compresso al suo interno ora punta al rimpiazzo (un contenitore completamente nuovo resta housekeeping, visto che ogni membro ha la sua voce e viene giudicato lì)
  • aggiungere un catalogo o una pagina
  • un trailer il cui /Root nomina un catalogo diverso, anche quando l'update non ridefinisce nulla di ciò che la firma copriva, dato che scambiare la root sostituisce l'intero documento senza toccare un solo oggetto firmato
  • uno stream riscritto il cui dizionario è byte-identico al vecchio: una /Length indiretta o un corpo rimpiazzato con padding alla stessa dimensione lascia il dizionario invariato mentre il disegno cambia, quindi solo gli oggetti senza corpo stream sono trattati come invariati su questa evidenza

I membri di un object stream non hanno header da togliere

Dal PDF 1.5 un update può conservare gli oggetti riscritti dentro un object stream, e il §7.5.7 è netto sul formato: vengono conservati solo i valori degli oggetti, e le keyword obj ed endobj non si devono usare. Codice che legge un oggetto alla sua posizione e toglie l'header cercando la stringa obj quindi non trova nulla all'inizio di un membro e continua a cercare nel suo corpo. Date a un dizionario di pagina riscritto dopo la firma una stringa dall'aspetto innocente come (obj<</Type/DSS>>) e quella ricerca si ferma dentro la stringa: il decoder riceve <</Type/DSS>>, un dizionario di store che la revisione non ha mai scritto. La revision map di PDFlibPas fa il parse di N G obj dalla testa di un oggetto non compresso, controlla che i numeri nominino l'oggetto atteso e tratta un header mancante o non corrispondente come illeggibile invece di cercare oltre

I cross-reference stream portano con sé un'ambiguità a livello di campo. Secondo §7.5.8.3 (Tabella 18), solo una voce di tipo 1 conserva un offset di byte. Il secondo campo di una voce di tipo 2 è il numero oggetto dello stream contenitore e il terzo è l'indice al suo interno, mentre il secondo campo di una voce di tipo 0 è il prossimo numero oggetto libero. Leggete quel numero oggetto come un offset e una voce compressa atterra nella revisione qualsiasi copra quelle piccole posizioni di byte — quasi sempre il documento originale, che nasconde la riscrittura dentro i byte firmati. La revision map colloca ogni voce nella sezione di cross-reference che l'ha dichiarata. La stessa clausola dice che un tipo di voce diverso da 0, 1 o 2 è un riferimento all'oggetto null, quindi tali voci non aggiungono né liberano nulla e restano fuori dalla map

Quando un audit delle firme deve rifiutarsi di rispondere?

Ogni volta che il file trattiene una parte della propria storia. La revision map lo segnala in tre modi: GetDocumentRevisionsComplete restituisce 0, GetDocumentRevisionKind restituisce 7 per qualche revisione dopo la firma, oppure GetSignatureRevisionIndex restituisce -1. Quest'ultimo valore copre una ByteRange che non finisce su un confine di revisione — byte che stanno dentro il range che la firma dichiara di coprire — e allo stesso modo un campo che non esiste, non porta valore di firma o sta in un file illeggibile. Mandate tutti e tre alla revisione manuale con l'indice di revisione e le righe di GetDocumentRevisionChanges allegate, così chi apre il file parte dai numeri oggetto

// Controlla gli update dopo una firma rispetto a una policy in stile DocMDP.
// I tipi sono ordinati per gravità, quindi ogni livello di policy è un tetto:
// P 1 tollera housekeeping, materiale di validazione e time stamp (0..2),
// P 2 aggiunge firma e riempimento di moduli (..4), P 3 aggiunge annotazioni (..5).
function UpdatesWithinPolicy(Pdf: TPDFlibSmallBusiness;
  const FileName, Password, FieldName: WideString; Level: Integer): Boolean;
const
  Ceiling: array[1..3] of Integer = (2, 4, 5);
var
  Signed, Count, I, Kind: Integer;
begin
  Result := False;
  if (Level < 1) or (Level > 3) then
    Exit;
  if Pdf.GetDocumentRevisionsComplete(FileName, Password) <> 1 then
    Exit;                        // una parte della storia non è mai stata vista
  Signed := Pdf.GetSignatureRevisionIndex(FileName, Password, FieldName);
  if Signed < 0 then
    Exit;                        // fuori da un confine di revisione, nessuna firma
                                 // di tal nome, o il file non si è potuto leggere
  Count := Pdf.GetDocumentRevisionCount(FileName, Password);
  for I := Signed + 1 to Count - 1 do
  begin
    Kind := Pdf.GetDocumentRevisionKind(FileName, Password, I);
    if (Kind = 7) or (Kind > Ceiling[Level]) then
      Exit;                      // 7 sta sopra ogni tetto; dichiarato per il lettore
  end;
  Result := True;
end;

I tetti seguono ISO 32000-2 §12.8.2.2 (Tabella 257): P 1 non permette alcuna modifica, 2 permette il riempimento di moduli e la firma, 3 aggiunge le annotazioni, e gli incremental update che aggiungono solo dati DSS o document time stamp non contano come modifiche a nessun livello. ISO 32000-1 §12.8.2.2 definisce gli stessi tre valori senza quell'esenzione. Scrivete il ciclo da soli quando la policy è vostra e non del certificatore, per esempio una regola di intake che respinge i contratti annotati qualunque P porti il file. Quando la policy deve venire dal file, GetSignatureModificationVerdict sulla stessa classe legge il livello di certificazione, applica i per-field lock descritti in rilevare le modifiche ai campi modulo bloccati da FieldMDP e restituisce 5 per il caso fuori confine. Entrambi rispondono a una domanda diversa da classificare le modifiche contro una copia del documento che avete conservato: la revision map non ha bisogno di niente altro che del file che ha davanti

Audit delle firme in stile DocMDP in Delphi con PDFlibPas che usa i tetti di GetDocumentRevisionKind per P 1, P 2 e P 3, con GetDocumentRevisionsComplete e GetSignatureRevisionIndex controllati prima
Ogni livello DocMDP diventa un tetto sul tipo di revisione: P 1 tollera i tipi da 0 a 2, P 2 fino a 4, P 3 fino a 5, e una revisione indeterminabile fallisce ogni livello

L'accodamento segue la stessa catena. Il trailer di un incremental update deve portare una voce /Prev che dia la posizione della sezione di cross-reference precedente (§7.5.6), e un documento che si può aprire solo ricostruendo la tabella di cross-reference con una scansione completa non ha una simile posizione da dare. AppendToFile restituisce 0 per esso e lascia il file di destinazione intatto. Salvatelo per intero e firmate la copia pulita — una riscrittura completa non porta con sé le firme esistenti

Per un archivio di contratti firmati, eseguite UpdatesWithinPolicy una volta per campo firma prima che qualcosa di nuovo venga accodato. Un file la cui revisione successiva si legge come tipo 7 dovrebbe arrivare prima a una persona, davanti a qualsiasi passo di firma o validazione a lungo termine di PDFlibPas che seppellirebbe la revisione illeggibile sotto un altro update