Articolo tecnico

Firmare PDF con PAdES B-B in Delphi usando PDFium

Il componente PDFium firma un PDF con una firma digitale PAdES B-B tramite il suo metodo SignPades: carica il documento, calcola l'hash dell'intervallo di byte firmato, crea una struttura CAdES CMS e aggiunge la firma come aggiornamento incrementale. Il backend crittografico è disponibile solo su Windows, pertanto proteggi ogni chiamata con PadesCryptoAvailable prima di firmare

La situazione è comune. Un PDF di un contratto arriva sulla tua scrivania, l'ufficio legale vuole che sia firmato digitalmente prima dell'invio e decidi di utilizzare la stessa build di PDFium che usi per renderizzare e ispezionare i documenti, solo per scoprire che PDFium non è affatto in grado di scrivere una firma. Le sue API per le firme sono esclusivamente di sola lettura. Il componente PDFium colma questa lacuna gestendo l'intera pipeline di firma in Pascal, dalla funzione hash fino all'inserimento a livello di byte, e questo articolo esamina tale pipeline dall'inizio alla fine

Perché PDFium non può scrivere una firma digitale?

PDFium espone le firme come oggetti di sola lettura e non offre strumenti per crearne. La famiglia FPDFSignatureObj_* consente di enumerare una firma esistente, leggerne il campo /Contents e ispezionarne il /ByteRange, ma non esiste un corrispettivo che crei un dizionario delle firme, riservi uno spazio in /Contents o scriva un intervallo di byte; il salvataggio incrementale esiste (FPDF_SaveAsCopy con FPDF_INCREMENTAL) ma non contiene hook di firma. Qualsiasi componente che firma un PDF basandosi su PDFium deve quindi generare autonomamente ogni byte della firma, motivo per cui il componente PDFium implementa questo meccanismo a partire da tre unità in puro Pascal. FPC 3.2.2 include md5 e sha1 ma non SHA-2, e le API SHA-256 di System.Hash di Delphi non sono compatibili a livello di codice sorgente con FPC, pertanto FPdfSha256 è un'implementazione autonoma di FIPS 180-4 che mantiene ogni percorso di codice CMS su un unico tipo TSHA256Digest senza ramificazioni del compilatore. FPdfAsn1 fornisce il codificatore e il lettore DER necessari alle strutture CMS, e FPdfCms assembla i dati CAdES SignedData su entrambi

Come si firma digitalmente un PDF in Delphi?

Carica il documento, quindi chiama SignPades con l'impronta digitale (thumbprint) del certificato. Il componente PDFium risolve l'impronta rispetto all'archivio certificati "MY" dell'utente corrente, estrae il certificato corrispondente e la sua chiave privata, e scrive una copia firmata nel percorso specificato

uses
  PDFium, FPdfCrypto;

procedure SignContract(const AThumbprint: string);
var
  Pdf: TPdf;
begin
  if not PadesCryptoAvailable then
    raise Exception.Create('PAdES signing requires the Windows CNG backend');

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract.pdf';   // il documento da firmare
    Pdf.Active := True;
    // Secondo argomento: impronta digitale SHA-1 di un certificato nell'archivio
    // "MY" dell'utente corrente. Primo argomento: destinazione della copia firmata.
    if not Pdf.SignPades('contract-signed.pdf', AThumbprint) then
      raise Exception.Create('Signing failed');
  finally
    Pdf.Free;
  end;
end;

PadesCryptoAvailable è il controllo da eseguire per primo, ogni volta. Su Windows restituisce True e il backend crypt32/ncrypt è attivo; su qualsiasi altra piattaforma restituisce False e una chiamata di firma solleverebbe un'eccedenza EPadesCrypto. Considerare obbligatorio questo controllo impedisce a una build Linux o macOS di fallire a runtime su un percorso non praticabile. L'impronta stessa è l'hash SHA-1 del certificato, lo stesso valore mostrato nella scheda Dettagli del gestore dei certificati di Windows, e identifica un firmatario specifico senza mai inserire materiale delle chiavi nel codice sorgente

Cosa contiene il CMS: attributi firmati e RFC 5652

Una firma di baseline PAdES non è una firma RSA grezza sul file; si tratta di una struttura CAdES CMS SignedData contenente un insieme prescritto di attributi firmati, e FPdfCms.BuildSignedData emette esattamente tale insieme: content-type, message-digest e signing-certificate-v2, l'attributo ESS che lega la firma al certificato del firmatario tramite hash. Un dettaglio specifico mette in difficoltà quasi ogni implementazione CMS creata artigianalmente. La specifica RFC 5652 §5.4 richiede che il digest degli attributi firmati venga calcolato sulla codifica DER SET OF, tag 0x31, mentre gli stessi attributi viaggiano all'interno di SignerInfo sotto il tag IMPLICIT [0], 0xA0. Il componente PDFium codifica l'insieme degli attributi una volta, calcola il digest della forma 0x31, quindi riscrive solo il byte del tag iniziale impostandolo su 0xA0 per l'emissione, in modo che un unico buffer serva a entrambi i ruoli senza un secondo passaggio sull'albero

var
  Pdf: TPdf;
  Opts: TPadesSignOptions;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract.pdf';
    Pdf.Active := True;

    Opts := TPadesSignOptions.Default;
    Opts.CertificateThumbprint := 'a1b2c3d4e5f6...';  // firmatario nell'archivio MY
    Opts.Reason := 'I approve this agreement';
    Opts.Location := 'Berlin, DE';
    Opts.ContentsSize := 16384;                        // larghezza esadecimale di /Contents

    if not Pdf.SignPades('contract-signed.pdf', Opts) then
      raise Exception.Create('Signing failed');
  finally
    Pdf.Free;
  end;
end;

L'overload delle opzioni aggiunge i metadati del dizionario delle firme definiti in ISO 32000-1 §12.8.1: Reason, Location, ContactInfo e Name, tutti opzionali e tutti scritti nel dizionario del valore della firma. Un vincolo specifico è facile da trascurare. Se imposti CommitmentTypeOid per aggiungere un attributo firmato CAdES di indicazione del tipo di impegno (commitment-type-indication), non impostare contemporaneamente Reason; la specifica ETSI EN 319 142-1 §6.3 vieta la presenza di entrambi, poiché esprimono lo stesso intento con mezzi diversi

In che modo si collegano il ByteRange e lo slot /Contents?

Una firma deve coprire l'intero file ad eccezione dei byte che contengono la firma stessa, e PAdES risolve questa circolarità con un segnaposto a larghezza fissa gestito con precisione da SignPadesBytes. Riserva una stringa esadecimale /Contents di ContentsSize byte (16384 per impostazione predefinita, ampiamente sufficiente per un tipico CMS SignedData), serializza l'aggiornamento incrementale per individuare l'offset esatto dello slot, quindi calcola il /ByteRange como due intervalli che racchiudono lo slot, ovvero tutto ciò che precede il delimitatore di apertura della stringa esadecimale e tutto ciò che segue il suo delimitatore di chiusura. L'hash SHA-256 viene eseguito solo su questi due intervalli. Il CMS completato viene codificato in formato esadecimale nello slot riservato, riempito di zeri fino alla larghezza fissa, e viene aggiunto l'aggiornamento del riferimento incrociato. Poiché la larghezza è stabilita in anticipo, il riempimento dello slot non sposta alcun byte successivo, motivo per cui l'intervallo di byte rimane valido; i byte del documento originale vengono conservati alla lettera, cosicché una firma precedente sullo stesso file sopravvive intatta esattamente come richiesto dalla firma incrementale ISO 32000-1 §12.8.1

Il backend CNG di Windows e i suoi limiti

Il componente PDFium firma unicamente su Windows, e tale confine è intenzionale. FPdfCryptoWin associa crypt32.dll e ncrypt.dll dinamicamente, senza introdurre dipendenze da DLL in fase di compilazione, e la catena di firma segue lo standard CNG: apre l'archivio MY, trova il certificato tramite hash, acquisisce l'handle della sua chiave privata tramite CryptAcquireCertificatePrivateKey e chiama NCryptSignHash. Sono supportati RSA con PKCS#1 v1.5, RSA-PSS ed ECDSA. ECDSA richiede un adattamento non necessario per gli altri, poiché NCryptSignHash restituisce la coppia grezza r-and-s IEEE P1363 mentre CMS si aspetta una SEQUENCE DER ECDSA-Sig-Value, pertanto il backend la ricodifica secondo RFC 5480

var
  Pdf: TPdf;
  Opts: TPadesSignOptions;
  Output: TFileStream;
begin
  if not PadesCryptoAvailable then
    Exit;   // nessun backend di firma su questa piattaforma

  Opts := TPadesSignOptions.Default;
  Opts.CertificateThumbprint := ReadThumbprintFromConfig;

  Pdf := TPdf.Create(nil);
  Output := TFileStream.Create('contract-signed.pdf', fmCreate);
  try
    Pdf.FileName := 'contract.pdf';
    Pdf.Active := True;
    Pdf.SignPadesToStream(Output, Opts);
  finally
    Output.Free;
    Pdf.Free;
  end;
end;

La conseguenza pratica è che la chiave privata deve risiedere nell'archivio certificati di Windows. Un certificato contenuto in un file PFX funziona solo dopo essere stato importato nell'archivio dell'utente corrente, momento in cui la sua impronta digitale è il valore da passare a SignPades. Questa versione non dispone di percorsi PKCS#11 o HSM né di backend per file di chiavi software, pertanto quando PadesCryptoAvailable restituisce False non è possibile eseguire alcuna firma su quella macchina

Dove si ferma il livello PAdES B-B

PAdES B-B rappresenta la baseline, la base dei quattro livelli PAdES: prova chi ha firmato e che i byte non hanno subito modifiche da allora, e nulla più. Una firma B-B non contiene una marca temporale attendibile, pertanto non può provare quando sia avvenuta la firma, e non incorpora dati di revoca, costringendo un convalidatore a distanza di anni a recuperare autonomamente la catena di certificati e il relativo stato. Queste lacune vengono colmate dai livelli superiori. Quando hai bisogno di un'ora di firma accettata da un revisore, l'aggiunta di una marca temporale RFC 3161 e del DSS per la convalida a lungo termine sposta la firma al livello B-T e superiori; quando vuoi rileggere una firma completata e confermare il livello raggiunto, lo strumento ideale è l'ispezione di una firma PDF e del suo livello PAdES; e prima di firmare qualsiasi cosa, l'auditing di un PDF per rischi di sicurezza ti indica cosa stai per sottoscrivere

I metodi SignPades mostrati qui sono forniti con PDFium Component per Delphi e C++Builder, insieme all'ispezione delle firme di sola lettura fornita nativamente da PDFium