Articolo tecnico

Cosa chiede RFC 5280 a chi valida il percorso di una firma

Una fattura firmata si verifica byte per byte, il suo firmatario è stato revocato il mese scorso, e la CRL che avete scaricato non elenca mai quel numero di serie: parla per un'altra partizione. La validazione del percorso in HotPDF è progettata intorno a casi così: regole RFC 5280 su scope e anchor, un orologio di valutazione UTC, e timestamp RFC 3161 il cui tempo conta solo quando la catena TSA è fidata

L'orologio di valutazione gira su UTC

Ogni confronto che fa un validatore di percorso è conservato in UTC. RFC 5280 §4.1.2.5 richiede che le date di validità dei certificati siano codificate come UTCTime o GeneralizedTime in Greenwich Mean Time, la thisUpdate e la nextUpdate delle CRL usano le stesse codifiche, e le risposte OCSP di RFC 6960 portano producedAt, thisUpdate e nextUpdate come GeneralizedTime in UTC. Il TDateTime di Delphi non ha fuso orario e Now restituisce l'ora locale di muro, quindi un controllo scritto come if Now > NotAfter mescola due orologi senza alcun avviso del compilatore. L'errore si divide per geografia. A ovest di Greenwich corre in anticipo. Su un server di New York alle 09:00 locali, quando l'orologio mondiale segna 14:00Z, una risposta OCSP prodotta alle 13:59Z sta cinque ore nel futuro dell'orologio ingenuo, ben fuori da una tolleranza di skew di cinque minuti come il default OCSPAllowedClockSkewMinutes di HotPDF, e viene rifiutata come non ancora valida. A Shanghai il segno si ribalta: alle 22:00 locali, che sono le 14:00Z, un certificato di firma con notAfter alle 20:00Z appare già scaduto, con otto ore di anticipo. Nessuno dei due guasti compare su una build agent impostata su UTC — il default sulla maggior parte dei runner CI ospitati

VerifyLoadedSignatureWithTrust di HotPDF in Delphi valuta THPDFCertificateValidationOptions.ValidationTime in UTC, evitando gli errori d'orologio di New York e Shanghai che un Now locale provoca contro i producedAt OCSP e i notAfter dei certificati
Confrontare un Now locale di Delphi con tempi di validità UTC rifiuta risposte OCSP fresche a ovest di Greenwich e fa scadere in anticipo i certificati a est, due guasti che una build agent UTC non mostra mai

HotPDF tratta THPDFCertificateValidationOptions.ValidationTime come UTC. Lasciato al suo default di zero, il tempo di valutazione diventa l'ora UTC corrente, a meno che un timestamp fidato fornisca l'ora della firma (ne parliamo più avanti), e l'istante davvero usato torna in Info.CertificateValidation.EvaluationTime. VerifyLoadedSignatureWithTrust esegue la verifica del byte-range dietro svValid prima di tutto questo, poi costruisce il percorso. Quando bloccate voi un momento storico, diciamo per un auditor che chiede se la firma di una fattura fosse buona alla chiusura del trimestre, la conversione è compito vostro: se ne occupa TTimeZone.Local.ToUniversalTime da System.DateUtils. Applicatela dove il timestamp entra nel vostro sistema piuttosto che alla chiamata di validazione: nelle zone con ora legale un'ora locale si ripete ogni autunno e un'altra non esiste mai in primavera, quindi una colonna di database di timestamp locali non può fare round-trip di ogni istante

uses
  System.SysUtils, System.DateUtils, System.IOUtils,
  HPDFDoc, HPDFSignVerify;

var
  Pdf: THotPDF;
  Options: THPDFCertificateValidationOptions;
  Info: THPDFSignatureInfo;
  Status: THPDFCertificateValidationStatus;
  ClosingLocal: TDateTime;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('invoices-2026-q1.pdf');
    Options := THPDFCertificateValidationOptions.Default;
    SetLength(Options.TrustAnchors, 1);
    Options.TrustAnchors[0] := TFile.ReadAllBytes('issuing-root.cer');
    Options.RevocationMode := rvmRequire;
    // Chiusura di trimestre come l'ERP l'ha registrata, nel fuso locale del server
    ClosingLocal := EncodeDateTime(2026, 3, 31, 23, 59, 59, 0);
    // Certificati, CRL e risposte OCSP portano tutti tempi UTC
    Options.ValidationTime := TTimeZone.Local.ToUniversalTime(ClosingLocal);
    Status := Pdf.VerifyLoadedSignatureWithTrust(0, Options, Info);
    Writeln('Evaluated at (UTC): ',
      DateTimeToStr(Info.CertificateValidation.EvaluationTime));
    case Status of
      cvsTrusted:
        Writeln('Trusted via ',
          string(Info.CertificateValidation.TrustAnchorSubjectDN));
      cvsRevoked:
        Writeln('Revoked at (UTC): ',
          DateTimeToStr(Info.CertificateValidation.RevokedAt));
    else
      Writeln('Not trusted: ', string(Info.CertificateValidation.Issue));
    end;
  finally
    Pdf.Free;
  end;
end;

Quali certificati copre davvero una CRL?

Una CRL parla solo dei certificati dentro il proprio scope, e lo scope è dichiarato nell'estensione Issuing Distribution Point di RFC 5280 §5.2.5. Una CA che partiziona i dati di revoca pubblica più CRL, tutte firmate con la stessa chiave e tutte fresche, e l'assenza di un certificato da una di esse non significa nulla se non è quella CRL a dichiarare di coprirlo. I campi che restringono lo scope sono questi:

  • distributionPoint: la CRL copre solo i certificati la cui estensione CRL Distribution Points nomina questo punto
  • onlyContainsUserCerts e onlyContainsCACerts: solo certificati end-entity, o solo certificati CA
  • onlySomeReasons: revoche per un sottoinsieme di reason code, come keyCompromise
  • onlyContainsAttributeCerts: la CRL riguarda i certificati attributo e non dice nulla sui certificati a chiave pubblica
  • indirectCRL: le voci possono appartenere a issuer diversi dal firmatario della CRL

La ricerca ingenua verifica la firma della CRL, controlla thisUpdate e nextUpdate, cerca il numero di serie e dichiara il certificato buono quando il numero non c'è. Quell'ultimo passo regge solo dopo il test di scope di RFC 5280 §6.3.3 (b)(2). La CRL della partizione 7 non elenca mai un certificato della partizione 3. Una CRL limitata ai certificati CA non elenca mai un firmatario end-entity. Entrambe rispondono "non trovato" per un certificato revocato. HotPDF consulta una CRL base per un certificato solo quando la CRL non è limitata né ai certificati attributo né ad alcuni reason, la sua restrizione user o CA corrisponde al certificato, e ogni distribution point che nomina corrisponde a uno degli URI nell'estensione CRL Distribution Points del certificato stesso, confrontati senza distinzione maiuscole/minuscole — un punto dato come nome relativo all'issuer della CRL non corrisponde a nulla. Una CRL limitata per reason non viene affatto consultata, quindi nemmeno le revoche che elenca contano. Il §6.3.3 permette di assemblare una copertura completa dalle partizioni per reason, tracciando una maschera di reason finché ogni reason è coperto, ma per le firme documentali una risposta OCSP o una CRL a copertura piena è prova più semplice: raccoglietene una di quelle. Senza più prove in scope, lo stato di revoca è unknown, e con rvmRequire la chiamata restituisce cvsRevocationUnknown

Test di scope CRL di HotPDF per il controllo di revoca delle firme PDF in Delphi: i campi Issuing Distribution Point decidono se una CRL base viene consultata, e senza prove in scope rvmRequire restituisce cvsRevocationUnknown secondo RFC 5280
Prima di cercare in una CRL il numero di serie, il suo Issuing Distribution Point deve coprire il certificato; altrimenti una voce assente non prova nulla sulla revoca

I trust anchor che chiudono un percorso devono essere CA

RFC 5280 §6.1.1 consegna all'algoritmo di validazione del percorso il proprio trust anchor come semplice informazione, un nome issuer e una chiave pubblica, e l'algoritmo non chiede mai se il certificato dell'anchor può emettere qualcosa; il test basicConstraints del §6.1.4 gira sui certificati intermedi del percorso. Va bene finché gli anchor sono root CA di uno store curato. I flussi documentali rompono l'assunzione, perché la gente blocca certificati end-entity: un sistema di approvvigionamento che riceve contratti da tre fornitori noti spesso si fida direttamente del certificato di firma di ciascun fornitore. Il pinning funziona per un percorso di un solo certificato, dove firmatario e anchor sono lo stesso certificato. Il pericolo è un percorso a due certificati. La chiave privata di un fornitore può firmare un certificato fresco con qualsiasi subject, il nome del vostro CFO per esempio, e un validatore che tratta ogni chiave anchor come chiave di emissione costruisce un percorso pulito da quel falso fino alla foglia bloccata

HotPDF applica i requisiti di issuer di RFC 5280 all'anchor ogni volta che il percorso è più lungo di un certificato. L'anchor allora ha bisogno di basicConstraints con cA=TRUE (§4.2.1.9), e se porta un'estensione keyUsage il bit keyCertSign deve essere impostato (§4.2.1.3). Una root senza estensione basicConstraints conta come issuer solo quando è self-issued, con subject e issuer uguali, il che tiene in vita le vecchie root versione 1 precedenti alle estensioni. Il pinning continua a funzionare. Un certificato end-entity bloccato continua a validare le proprie firme, mentre un certificato firmato da lui torna cvsPathValidationFailed, con una stringa Issue che dichiara che l'anchor che ha emesso il percorso non è una certificate authority

Validazione del percorso dei certificati RFC 5280 con HotPDF in Delphi: un certificato end-entity bloccato in TrustAnchors valida le proprie firme, ma un percorso da lui emesso fallisce con cvsPathValidationFailed senza basicConstraints cA=TRUE e keyCertSign
Bloccare il certificato di firma di un fornitore continua a funzionare per le firme di quel fornitore, mentre ogni certificato che la sua chiave firma viene respinto perché l'anchor non è una certificate authority

Quali timestamp possono fissare il tempo di valutazione?

Solo quelli fidati. Un token RFC 3161 incorporato nel CMS afferma che il valore della firma esisteva al genTime, e i validatori giudicano il certificato del firmatario in quell'istante — è così che una firma sopravvive al proprio certificato. Il token è però anche lui una firma CMS come le altre. Verificarlo contro il certificato che porta dimostra coerenza interna e niente su chi l'abbia prodotto: chiunque tenga una chiave di firma scaduta o revocata può gestire il proprio servizio di time-stamping, timbrare una firma nuova con il genTime dell'anno scorso e farsela rientrare nel periodo di validità del certificato. RFC 3161 §2.3 richiede che il certificato TSA porti l'extended key usage id-kp-timeStamping come unico scopo, marcato critical. HotPDF adotta Info.Timestamp.GenerationTime come tempo di valutazione solo dopo che il certificato del token si è validato contro i vostri anchor, all'ora UTC corrente, per lo scopo time-stamping. Un document timestamp, /SubFilter ETSI.RFC3161, è un caso a parte: lì il token è la firma sotto validazione, e il suo genTime vale per il proprio percorso

Siccome il token viene controllato con le vostre opzioni, la root TSA deve stare nella stessa lista TrustAnchors della root del firmatario, e con rvmRequire la catena TSA ha bisogno di prove di revoca proprie. Senza una delle due cose la valutazione ricade sull'ora corrente. La durata del certificato TSA è l'altro limite: il token viene giudicato all'ora corrente, quindi una volta scaduto quel certificato va via anche la valutazione all'ora di firma, e gli archivi aggiungono un document timestamp prima di quel punto, il passo coperto in prove LTV PAdES e rinnovo dei timestamp B-LTA. Codice che tiene i byte CMS grezzi può fare la stessa domanda tramite HPDFCMSTimestampTimeIsTrusted(CMSDER, Options), e ValidateLoadedSignatureBatch la applica a ogni firma di un batch. La funzione qui sotto riferisce su quale orologio è stato preso un verdetto

// Atteso: con il solo SignerRoot, una firma il cui certificato è scaduto
// nel 2025 viene valutata all'ora UTC corrente e non torna cvsTrusted,
// anche se il suo token di timestamp si verifica; con TsaRoot aggiunto,
// la stessa chiamata viene valutata al genTime
function DescribeVerdictClock(Pdf: THotPDF; SigIndex: Integer;
  const SignerRoot, TsaRoot: TBytes): string;
var
  Options: THPDFCertificateValidationOptions;
  Info: THPDFSignatureInfo;
  Status: THPDFCertificateValidationStatus;
  Clock: string;
begin
  Options := THPDFCertificateValidationOptions.Default;
  // La catena TSA viene giudicata contro la stessa lista di anchor del firmatario
  SetLength(Options.TrustAnchors, 2);
  Options.TrustAnchors[0] := SignerRoot;
  Options.TrustAnchors[1] := TsaRoot;
  Options.RevocationMode := rvmBestEffort;
  Options.ValidationTime := 0; // un timestamp fidato può fornire l'ora
  Status := Pdf.VerifyLoadedSignatureWithTrust(SigIndex, Options, Info);
  if Info.Timestamp.Kind <> tskSignature then
    Clock := 'no signature timestamp, current UTC time'
  else if (Info.Timestamp.Status = svValid) and
    (Info.CertificateValidation.EvaluationTime =
     Info.Timestamp.GenerationTime) then
    Clock := 'trusted timestamp genTime'
  else
    Clock := 'timestamp not trusted, current UTC time';
  Result := Format('%s at %s (UTC), status %d', [Clock,
    DateTimeToStr(Info.CertificateValidation.EvaluationTime), Ord(Status)]);
end;

Input ostile prima della prima decisione di fiducia

Parte del lavoro di validazione gira su dati che un attaccante controlla completamente, prima che qualcosa sia stato fidato. Gli intermedi mancanti vengono scaricati tramite gli URL caIssuers dell'Authority Information Access di RFC 5280 §4.2.2.1, e ogni intermedi scaricato può a sua volta pubblicizzare l'URL del proprio issuer, quindi la worklist cresce mentre la percorrete. In Delphi questo esclude un ciclo for: il valore finale di uno statement for viene valutato una volta sola, prima che il ciclo parta — gli URL aggiunti durante la camminata non vengono mai visitati, e una catena a cui mancano due livelli resta incompleta. Rileggete Count a ogni passaggio con un ciclo while. In HotPDF, MaxFetch su HPDFFetchAIAIntermediates pone un tetto a quanti certificati una camminata scarica, e MaxFetchedIntermediates fissa lo stesso tetto quando FetchMissingIntermediates lascia che la validazione del percorso scarichi da sé

Il suggerimento Retry-After è un altro valore che sceglie uno sconosciuto. Il suo server è stato nominato da un certificato non verificato, e può chiedere una pausa di un'ora su ogni richiesta. Limitatelo. THPDFSignatureEvidenceRetryPolicy.MaxRetryAfterMS fissa quel tetto per le richieste di prove e AIA di HotPDF, 30.000 ms per default. La routine qui sotto applica entrambe le regole per i team che perlano l'AIA da soli, diciamo per pre-riscaldare la cache degli intermedi di un livello di firma offline

uses
  System.SysUtils, System.Generics.Collections;

type
  // Il vostro trasporto: scarica un target caIssuers e restituisce gli
  // URL caIssuers pubblicizzati dal certificato che ha ricevuto
  TFetchIssuer = reference to function(const URL: string;
    out NextURLs: TArray<string>; out RetryAfterMS: Cardinal): Boolean;

function WalkCAIssuers(const SeedURLs: TArray<string>;
  const Fetch: TFetchIssuer; MaxFetch: Integer;
  MaxRetryAfterMS: Cardinal): Integer;
var
  Queue: TList<string>;
  Seen: TDictionary<string, Boolean>;
  NextURLs: TArray<string>;
  URL: string;
  I, Attempt: Integer;
  RetryAfter: Cardinal;
  Ok: Boolean;

  procedure Enqueue(const AURL: string);
  begin
    if not Seen.ContainsKey(LowerCase(AURL)) then
    begin
      Seen.Add(LowerCase(AURL), True);
      Queue.Add(AURL);
    end;
  end;

begin
  Result := 0;
  Queue := TList<string>.Create;
  Seen := TDictionary<string, Boolean>.Create;
  try
    for URL in SeedURLs do
      Enqueue(URL);
    I := 0;
    // Un ciclo for fisserebbe Queue.Count all'ingresso e non raggiungerebbe mai
    // gli URL che i certificati scaricati accodano qui sotto
    while (I < Queue.Count) and (Result < MaxFetch) do
    begin
      Ok := False;
      for Attempt := 1 to 3 do
      begin
        NextURLs := nil;
        RetryAfter := 0;
        Ok := Fetch(Queue[I], NextURLs, RetryAfter);
        if Ok or (Attempt = 3) then
          Break;
        // La pausa è un desiderio del server, e un certificato non verificato
        // ha scelto il server
        if RetryAfter > MaxRetryAfterMS then
          RetryAfter := MaxRetryAfterMS;
        Sleep(RetryAfter);
      end;
      Inc(I);
      Inc(Result);
      if Ok then
        for URL in NextURLs do
          Enqueue(URL);
    end;
  finally
    Seen.Free;
    Queue.Free;
  end;
end;

Il controllo della firma CMS gira prima della validazione del percorso, perché costruire un percorso per una firma che non si verifica è lavoro buttato, e usa la chiave pubblica del certificato incorporato nella firma. Quell'ordine mette una dimensione di chiave scelta dall'attaccante a comandare nell'istante in cui gira l'aritmetica più costosa. Non è ancora fidato nulla. Il costo della verifica RSA cresce sia con la lunghezza del modulo che con quella dell'esponente, quindi un certificato con un modulo enorme e un esponente altrettanto lungo può tenere un thread occupato in esponenziazione modulare su un server che verifica file caricati. HotPDF rifiuta un modulo RSA oltre i 16.384 bit, rifiuta un esponente più lungo del modulo, e oltre i 3.072 bit accetta solo esponenti di al più 64 bit. Le chiavi di firma vere stanno fra 2.048 e 4.096 bit con e = 65537, un esponente a 17 bit, quindi il tetto non le tocca mai; il pavimento è una policy separata e configurabile, AlgorithmPolicy.MinimumRSAKeyBits, che vale 2.048 per default

La stessa firma può essere cvsTrusted al genTime del suo timestamp e fallire alla data di oggi. Registrate Info.CertificateValidation.EvaluationTime e TrustAnchorSubjectDN accanto a ogni verdetto che il componente PDF HotPDF per Delphi restituisce, e un auditor che rilegge il registro più tardi vede su quale orologio e su quale anchor poggia la risposta