Articolo tecnico

Il valore della firma che un PDF cifrato lascia in chiaro

Un contratto cifrato AES e firmato altrove si carica con un valore di firma che decodifica in byte casuali, oppure, sotto AES-GCM, con un dizionario di firma che non si decifra. Fuori dal /ID del trailer e dal dizionario di cifratura, ISO 32000-2 §7.6.2 lascia non cifrata solo la /Contents della firma, e PDFlibPas la mantiene in chiaro in lettura e in scrittura mentre /Reason e /M restano cifrati

L'eccezione che ISO 32000-2 ha aggiunto alle regole di cifratura

ISO 32000-2 §7.6.2 dice che la cifratura copre ogni stringa e stream del file, con quattro eccezioni:

  • i valori della voce /ID del trailer
  • le stringhe dentro il dizionario /Encrypt
  • le stringhe dentro gli stream, che vengono cifrati nell'insieme
  • qualsiasi stringa esadecimale che sia il valore di /Contents in un dizionario di firma

Le prime tre sono vecchie. ISO 32000-1 §7.6.1 ne elenca solo quelle, quindi un decryptor scritto sul testo del 2008 non ha alcuna istruzione di saltare qualcosa in un dizionario di firma. Gli strumenti di firma hanno comunque scritto il valore in chiaro, e la ragione è meccanica: il signer riserva un placeholder esadecimale, scrive l'intero file, fa l'hash di tutto tranne il placeholder, costruisce il CMS e incolla il suo esadecimale nel buco. Quell'hash copre i byte così stanno su disco, ciphertext incluso. Cifrare il valore della firma non proteggerebbe nulla, e costringerebbe il passo di patching a conoscere la file key e a far stare un IV più il padding in un placeholder dimensionato prima che l'hash esistesse. Il resto del dizionario segue le regole normali:

Voce del dizionario di firmaTipo PDFConservata cifrata?
/Contentsstringa esadecimaleNo, esente per ISO 32000-2 §7.6.2
/Reason, /Location, /ContactInfo, /Namestringa di testoSì
/Mstringa dataSì
/ByteRangearray di interiNo, gli interi non vengono mai cifrati
/Type, /Filter, /SubFilternameNo, i name non vengono mai cifrati

Un reader che si perde l'eccezione fa girare il cipher su DER in chiaro. Quel che esce dipende dall'algoritmo. RC4 è una stream cipher, quindi l'output conserva la lunghezza e diventa rumore: il parser CMS vede un primo byte che non è più 0x30 e segnala una firma malformata. AES-CBC tratta i primi 16 byte come vettore di inizializzazione e il resto come ciphertext, il che finisce in un errore di padding o in un blob più corto di spazzatura. AES-GCM, definito per il PDF da ISO/TS 32003, è il caso più ostile: ogni stringa cifrata porta con sé il proprio nonce e il proprio tag di autenticazione, e un valore in chiaro non passa la verifica del tag. Un decryptor che considera fatale per l'intero dizionario anche un solo tag fallito perde allora /Reason e /M insieme al valore della firma

Dizionario di firma PDF cifrato in Delphi con PDFlibPas, con la /Contents esadecimale lasciata in chiaro per ISO 32000-2 §7.6.2 mentre /Reason e /M restano cifrati, più le modalità di fallimento di RC4, AES-CBC e AES-GCM
La /Contents della firma resta DER leggibile in un PDF cifrato mentre /Reason e /M sono ciphertext, e decifrando comunque quel DER si fallisce in modo diverso per ogni cipher

Come si distingue la /Contents di una firma da ogni altra /Contents?

Filtrare sul nome della chiave è il primo tentativo ovvio, e fallisce in entrambe le direzioni. I dizionari di annotazione hanno pure loro una voce /Contents — una stringa di testo con il commento scritto da chi ha revisionato — e quella stringa va cifrata: se la saltate, ogni post-it in un file confidenziale è leggibile senza password. Filtrare su /Type /Sig si rompe nell'altra direzione: la Tabella 255 di ISO 32000-2 rende /Type opzionale in un dizionario di firma, con Sig come default, e lo richiede solo per /DocTimeStamp, quindi un producer conforme può ometterlo. PDFlibPas tratta perciò una stringa /Contents come valore di firma solo quando lo stesso dizionario porta un array /ByteRange più un /Type pari a Sig o DocTimeStamp, oppure nessun /Type accanto a un name /Filter. Il /Filter è richiesto in ogni dizionario di firma e il /ByteRange in ogni firma che appartiene a un campo firma; un'annotazione non ha né l'uno né l'altro. La libreria esegue lo stesso test quando cifra al save e quando decifra al load, così la /Contents esce da un load con i byte scritti dal producer

Test di cifratura in Delphi con PDFlibPas che distingue una /Contents di firma da una /Contents di annotazione tramite /ByteRange, /Type Sig o DocTimeStamp e /Filter prima di cifrare o decifrare la stringa
I commenti delle annotazioni restano cifrati perché PDFlibPas tratta la /Contents come valore di firma solo accanto a un /ByteRange e un /Type corrispondente, o a un name /Filter quando /Type manca

Prima cifrate, poi firmate con PDFlibPas

Firmate per ultimi. Il digest della firma copre i byte come sono conservati, quindi qualunque operazione che riscriva il file dopo la firma rompe ogni firma al suo interno. Applicare la cifratura a un documento firmato ri-cifra ogni stringa e stream; toglierla li riscrive con pari profondità. La sequenza che funziona è: cifrare, salvare, poi firmare il file cifrato. Il sign process accoda un incremental update le cui nuove stringhe vengono cifrate con la file key esistente e il cui placeholder /Contents resta in chiaro. Aprite il file nel sign process con la owner password; la user password basta solo quando i permessi includono il bit 9 della Tabella 22 di ISO 32000-2, che nomina esplicitamente i campi firma. Encrypt e il sign process sono disponibili in ogni edizione di PDFlibPas

uses
  SysUtils, PDFlibrary;

const
  OwnerPwd = 'owner-secret';
  UserPwd  = 'reader-secret';

procedure EncryptCopy(const Source, Target: string);
var
  Lib: TPDFlib;
  Perms: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile(Source, '') <> 1 then
      raise Exception.CreateFmt('Load failed (%d)', [Lib.LastErrorCode]);
    // stampa, riempimento campi (il bit 9 copre i campi firma), copia accessibile, stampa high-res
    Perms := Lib.EncodePermissions(1, 0, 0, 0, 1, 1, 0, 1);
    // Strength 4 = AES-256 revision 6: la file key si usa direttamente, senza /ID
    if Lib.Encrypt(OwnerPwd, UserPwd, 4, Perms) <> 1 then
      raise Exception.CreateFmt('Encrypt failed (%d)', [Lib.LastErrorCode]);
    if Lib.SaveToFile(Target) <> 1 then
      raise Exception.CreateFmt('Save failed (%d)', [Lib.LastErrorCode]);
  finally
    Lib.Free;
  end;
end;

procedure SignField(Lib: TPDFlib; const Source, Target, FieldName,
  PFXFile, PFXPassword: string);
var
  SP, R: Integer;
begin
  SP := Lib.NewSignProcessFromFile(Source, OwnerPwd);
  if SP = 0 then
    raise Exception.CreateFmt('Input not found: %s', [Source]);
  try
    Lib.SetSignProcessField(SP, FieldName);   // creato invisibile se manca
    Lib.SetSignProcessPFXFromFile(SP, PFXFile, PFXPassword);
    Lib.SetSignProcessInfo(SP, 'Approved for release', 'Head office', '');
    // margine nel placeholder per una firma con time stamp aggiunta dopo
    Lib.SetSignProcessReserveContentsBytes(SP, 16384);
    Lib.EndSignProcessToFile(SP, Target);     // restituisce sempre 0
    R := Lib.GetSignProcessResult(SP);
    if R <> 1 then
      raise Exception.CreateFmt('Signing %s failed (result %d)', [FieldName, R]);
  finally
    Lib.ReleaseSignProcess(SP);
  end;
end;

Strength 4 seleziona AES-256 revision 6, e quella scelta elimina due modalità di fallimento. Con RC4 e AES-128, l'Algoritmo 1 (ISO 32000-1 §7.6.2) deriva una chiave separata per ogni oggetto dal suo numero oggetto e numero generazione. L'Algoritmo 2 (ISO 32000-1 §7.6.3.3, passo e) immette inoltre il primo elemento /ID nella file key. Un update di firma riscrive il dizionario AcroForm e l'array /Annots della pagina, quindi ogni oggetto riscritto deve conservare il suo numero generazione originale, e il trailer dell'update deve portare lo stesso primo elemento /ID. Sbagliate la generazione e quell'oggetto non si decifra più; sbagliate l'/ID e non si decifra più niente. La revision 6 usa la file key direttamente (ISO 32000-2 §7.6.3.3, Algoritmo 1.A) e la recupera senza guardare l'/ID (§7.6.4.3.3, Algoritmo 2.A). PDFlibPas conserva i numeri generazione sugli oggetti riscritti a ogni strength e deriva la chiave dall'/ID così com'è conservato, anche quando un file malformato porta /ID 7 0 R invece dell'array diretto che la Tabella 15 di ISO 32000-2 richiede; un /ID [] vuoto fallisce la verifica della password. Il percorso password-chiave è ricostruito più a fondo accanto a come aprire PDF cifrati con una file key grezza

Un editor di testo basta per vedere il risultato. Un dizionario di firma sta non compresso nell'update che l'ha aggiunto, perché il signer patcha /ByteRange e /Contents per offset di file: cercate /ByteRange e ci atterrati sopra. L'esadecimale della /Contents si apre tipicamente con 3082, l'header DER della SEQUENCE CMS SignedData, e finisce in una lunga sequenza di zeri dove il placeholder era più grande della firma. La /Reason è ciphertext che cambia da una sessione di firma all'altra anche quando il testo non cambia, perché AES tira un IV fresco per ogni stringa. Atteso con un qpdf recente: qpdf --password=owner-secret --show-object=14 signed.pdf stampa la /Reason decifrata accanto a una /Contents invariata. Usate per quel controllo un file strength 4. Forma attesa nel file grezzo, con offset e byte variabili:

14 0 obj
<</Type /Sig /Filter /Adobe.PPKLite
  /ByteRange [0 48211 81093 2304]
  /Contents <308240A206092A864886F70D010702A0...000000000000>
  /Reason <7F21C9E04B...>
  /M <D35A0F98...>
  ...>>
endobj

Quali firme si rompono quando si incolla un time stamp?

Ogni firma aggiunta dopo quella timbrata. Una signature time stamp PAdES è un token RFC 3161 conservato come attributo unsigned del firmatario (RFC 5652 §5.3, unsignedAttrs), quindi vive dentro il CMS nella /Contents. Aggiungerne una significa riscrivere quell'esadecimale sul posto, e in un file cifrato la riscrittura funziona solo perché la /Contents è in chiaro: niente ri-cifratura, niente nuovo oggetto — al token basta stare nello spazio riservato al momento della firma. La firma proprietaria sopravvive perché il suo /ByteRange salta quei byte. Le firme successive non godono di questa esenzione: ISO 32000-2 §12.8.1 fa partire il byte range di ogni firma dall'header %PDF- fino alla fine dell'incremental update che l'ha aggiunta, quindi una seconda firma, o un document time stamp, fa l'hash anche della /Contents della prima insieme a tutto il resto. Il buco appartiene a una firma sola: patchatelo dopo che un secondo firmatario ha firmato, e il digest della seconda firma non torna più. La richiesta del token fa parte di firma e validazione PAdES da Delphi; questa sezione riguarda solo quali byte si spostano

PDFlibPas controlla la sovrapposizione prima di fare l'hash di qualunque cosa. Il processo di signature time stamp legge la /ByteRange di ogni altra firma nel documento e confronta ogni coppia di span con il buco /Contents del target. Qualunque sovrapposizione ferma la preparazione con process result 17, e nessun file di output viene scritto. Il processo appartiene all'edizione Enterprise, che istanziate come TPDFlibEnterprise dall'unità PDFlibEnterprise; le altre edizioni conservano i metodi, che restituiscono 0 e impostano LastErrorCode a 900. Tenete d'occhio il numero 17 nei vostri log: da GetPAdESSignatureTimeStampProcessResult significa che una firma successiva copre questa; da GetSignProcessResult significa che un livello di certificazione o un field lock vieta la firma. Mappate i due separatamente. L'helper qui sotto usa SignField e OwnerPwd dal listato precedente

L'ordine delle signature time stamp PAdES in Delphi con PDFlibPas: la /ByteRange di una firma successiva che copre un buco /Contents precedente fa restituire 17 a GetPAdESSignatureTimeStampProcessResult senza scrivere alcun file
Una volta che Approver ha firmato, il suo byte range copre il buco /Contents di Author, e PDFlibPas rifiuta il time stamp tardivo di Author con process result 17 invece di rompere la firma successiva
uses
  SysUtils, PDFlibrary, PDFlibEnterprise;

const
  STS_COVERED_BY_LATER_SIGNATURE = 17;

function StampSignature(Lib: TPDFlibEnterprise; const Source, Target,
  FieldName: string): Integer;
var
  STS: Integer;
begin
  Result := 0;
  STS := Lib.NewPAdESSignatureTimeStampProcessFromFile(Source, OwnerPwd);
  if STS = 0 then
    Exit;
  try
    Lib.SetPAdESSignatureTimeStampField(STS, FieldName);
    if Lib.ApplyPAdESSignatureTimeStamp(STS, 0) <> 1 then
    begin
      Result := Lib.GetPAdESSignatureTimeStampProcessResult(STS);
      if Result = 1 then
        Result := -1;  // valore con hash fatto, scambio TSA fallito: vedere GetTimeStampError
      Exit;
    end;
    Result := Lib.EndPAdESSignatureTimeStampProcessToFile(STS, Target);
  finally
    Lib.ReleasePAdESSignatureTimeStampProcess(STS);
  end;
end;

procedure SignAndStampInOrder(const EncryptedFile: string);
var
  Lib: TPDFlibEnterprise;
  R: Integer;
begin
  Lib := TPDFlibEnterprise.Create;
  try
    Lib.SetTimeStampServer('https://tsa.example.com/tsa', '', '');

    SignField(Lib, EncryptedFile, 'step1.pdf', 'Author', 'author.pfx', 'pfx-1');
    R := StampSignature(Lib, 'step1.pdf', 'step1-ts.pdf', 'Author');
    if R <> 1 then
      raise Exception.CreateFmt('Author time stamp failed (%d)', [R]);

    SignField(Lib, 'step1-ts.pdf', 'step2.pdf', 'Approver', 'approver.pfx', 'pfx-2');
    R := StampSignature(Lib, 'step2.pdf', 'final.pdf', 'Approver');
    if R <> 1 then
      raise Exception.CreateFmt('Approver time stamp failed (%d)', [R]);

    // Atteso: 17. Il byte range di Approver copre la /Contents di Author in
    // step2.pdf, quindi il processo rifiuta e restamped.pdf non viene mai scritto
    R := StampSignature(Lib, 'step2.pdf', 'restamped.pdf', 'Author');
    if R <> STS_COVERED_BY_LATER_SIGNATURE then
      raise Exception.CreateFmt('Unexpected result %d', [R]);
  finally
    Lib.Free;
  end;
end;

Timbrate ogni firma prima che il firmatario successivo tocchi il file. Costa un round trip TSA per firmatario e dà a ogni firma il suo tempo fidato. Un file che arriva con più firme già in posto è un caso a parte: quelle precedenti non possono più prendere una signature time stamp. Aggiungete invece un document time stamp — una firma /Type /DocTimeStamp con /SubFilter /ETSI.RFC3161 in una nuova revisione, il cui byte range copre tutte le firme precedenti e prova che esistevano in quel momento. ApplyPAdESLongTermArchiveValidation ne aggiunge uno dopo aver allegato il materiale di validazione. Riservate spazio per una signature time stamp mentre firmate, tramite SetSignProcessReserveContentsBytes come nel primo listato. Un token che incapsula la catena di certificati TSA arriva facilmente a parecchi kilobyte, e un placeholder senza quel margine fa fallire l'augmentation successiva con result 13

Decifrare un file firmato e cosa hasha un verifier

I range firmati di un file cifrato sono ciphertext. Un verifier confronta un digest del ciphertext conservato con il digest dentro il CMS: la /ByteRange è fatta di interi, la /Contents è in chiaro, e il controllo di integrità non richiede alcuna chiave — lo richiedono solo il nome del campo e la /Reason. Lo stesso fatto spiega perché decifrare un file firmato e salvarlo in chiaro invalida ogni firma al suo interno anche se non è cambiato un pixel visibile. Conservate l'originale cifrato e firmato come copia di riferimento. Nemmeno i cross-reference stream vengono cifrati (ISO 32000-2 §7.5.8.2), quindi un update scritto con uno xref stream mantiene i propri offset leggibili prima che una chiave qualsiasi venga derivata. Il layout è descritto in object stream e cross-reference stream in Delphi. Quando un range firmato è troppo grande per essere letto in memoria per il confronto, PDFlibPas riporta il controllo di integrità come unknown anziché invalid, perché byte mai confrontati non hanno dimostrato di differire

Il valore di firma di un file cifrato resta scrivibile dopo la firma finché un'altra firma o un document time stamp non lo copre. Mettete il passo del time stamp dentro quella finestra, e la sequenza cifra-poi-firma del primo listato si applica senza cambiamenti ai file multi-firmatario in PDF Library per Delphi