Article technique

Révocation hors ligne des signatures PDF sous Windows

PDFium VCL vérifie la révocation des signatures PDF hors ligne sous Windows en ajoutant CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY à la passe de révocation de CertGetCertificateChain, parce que le drapeau cache-only utilisé pour la construction de chaîne ne couvre pas du tout la récupération CRL ou OCSP. Depuis la v3.119.1, un appel ValidatePadesTrust hors ligne reste hors du réseau, et un résultat propre exige une vraie preuve de révocation par certificat. Le reste de ce billet porte sur les raisons pour lesquelles les deux moitiés de cette phrase avaient besoin d'un correctif

La configuration qui expose le problème est banale. Un service de validation tourne sur un hôte Windows verrouillé, TPadesTrustValidationOptions.NetworkPolicy vaut ptnpOffline (ce qui est aussi le défaut), et l'opérateur attend que chaque réponse vienne du cache de certificats local. Puis quelqu'un remarque des requêtes sortantes vers un point de distribution d'AC dans le journal du pare-feu, ou un traitement par lots qui cale pendant les UrlRetrievalTimeoutMs complets de 15000 ms à chaque signature. Rien dans le code ne demandait le réseau. Windows y est allé quand même

Pourquoi une construction de chaîne hors ligne va-t-elle quand même chercher des CRL sous Windows ?

Parce que CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL ne restreint que la récupération d'URL que fait la construction de chaîne : récupérations d'émetteurs AIA, mises à jour de racines et de CTL. La documentation Microsoft de CertGetCertificateChain dit explicitement que le drapeau ne s'applique pas à la vérification de révocation. La révocation a son propre commutateur, CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY ($80000000), et sans lui les fournisseurs de révocation sont libres de télécharger une CRL ou d'envoyer une requête OCSP même si l'appel environnant a l'air hors ligne. PDFium VCL ajoute désormais ce drapeau en OU dans la passe de révocation chaque fois que OnlineRetrieval est False, en plus des drapeaux de chaîne, CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT et CERT_CHAIN_REVOCATION_ACCUMULATIVE_TIMEOUT. Cela compte pour plus que la latence : une requête OCSP dit au répondeur quel certificat vous examinez, exactement ce qu'un validateur isolé du réseau est censé éviter

Deux commutateurs indépendants gardent la validation de signatures PDF hors ligne sous Windows : CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL restreint seulement la construction de chaîne comme les récupérations d'émetteurs AIA et de racines, tandis que la révocation exige CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY, parce que sans lui les fournisseurs téléchargent toujours des CRL et envoient des requêtes OCSP qui révèlent quel certificat est validé
PDFium VCL ajoute en OU le drapeau cache-only de révocation dans la passe de révocation chaque fois que OnlineRetrieval est False, si bien qu'une validation de confiance ptnpOffline reste hors du réseau pour les deux passes
uses
  PDFium, FPdfCrypto, FPdfPades;

var
  Options: TPadesTrustValidationOptions;
  Report: TPadesValidationResult;
begin
  Options := TPadesTrustValidationOptions.Default; // ptnpOffline, 15000 ms
  Options.CheckRevocation := True;                 // False par défaut
  Options.CheckTimeStamps := True;
  // Hors ligne signifie désormais hors ligne pour la révocation aussi : réponses CRL et OCSP
  // en cache seulement, aucun checkpoint pcvstOnlineRetrieval n'est levé
  Report := Pdf.ValidatePadesTrust(Options);
end;

Deux constructions de chaîne, deux champs d'erreur

Le backend Windows construit la chaîne deux fois, et chaque construction possède désormais son propre créneau d'erreur. Le premier appel CertGetCertificateChain tourne sans drapeaux de révocation et alimente CertVerifyCertificateChainPolicy avec la politique de base, ce qui produit TrustStatus et TrustError. Le deuxième appel ajoute les drapeaux de révocation. Avant la v3.119.1, un échec de ce deuxième appel écrivait GetLastError dans TrustError, si bien qu'une chaîne venant d'être vérifiée comme de confiance pouvait revenir avec l'air infidèle parce qu'un fournisseur de révocation avait toussé. Le correctif lit GetLastError immédiatement et le range dans TPdfCmsVerifyResult.RevocationError, en laissant tranquille le verdict de la première passe. Et un retour True du deuxième appel n'est pas traité comme un succès non plus ; il signifie seulement que Windows a rendu un contexte de chaîne qui vaut la peine d'être inspecté

Que prouve réellement un masque d'erreur de confiance à zéro ?

À lui seul, rien. Un TrustStatus.dwErrorStatus agrégé de zéro après la passe de révocation dit qu'aucun bit d'erreur n'a été levé, et une chaîne où aucun élément ne portait d'information de révocation du tout peut produire exactement cela. Le code d'avant mappait l'absence de bit révoqué, de bit inconnu et de bit hors ligne directement vers valide, ce qui est la manière classique pour un validateur de rapporter un certificat non contrôlé comme propre. La nouvelle routine ReadWinRevocationEvidence parcourt chaque chaîne simple et chaque élément, rejette les structures dont cbSize est trop petit pour être lu en sécurité, et ne rapporte un succès que lorsqu'au moins un élément non racine existe et que chaque tel élément porte une CERT_REVOCATION_INFO dont dwRevocationResult vaut zéro

Le parcours de preuves que ReadWinRevocationEvidence effectue sur chaque élément de chaîne Windows en Delphi : pRevocationInfo doit être présent, cbSize doit être assez grand pour être lu, dwRevocationResult doit valoir zéro, et l'élément final n'est exclu que lorsqu'il est marqué auto-signé ou de confiance AC, si bien qu'un masque d'erreur de confiance à zéro ne peut plus cacher un certificat non contrôlé
Un verdict propre exige au moins un élément non racine et une réponse de chaque élément requis, avec RevocationError qui garde le DWORD brut du fournisseur comme CRYPT_E_REVOKED
// Condensé depuis le parcours de preuves : un élément ne compte que lorsqu'un
// fournisseur de révocation a réellement répondu pour lui
for J := 0 to ElementCount - 1 do
begin
  Element := Elements[J];
  ExcludedRoot := (J = ElementCount - 1) and
    ((Element^.TrustStatus.dwInfoStatus and
      (CERT_TRUST_IS_SELF_SIGNED or CERT_TRUST_IS_CA_TRUSTED)) <> 0);
  InfoPresent := (Element^.pRevocationInfo <> nil) and
    (Element^.pRevocationInfo^.cbSize >= SizeOf(TCERT_REVOCATION_INFO));
  if not ExcludedRoot then
  begin
    Inc(RequiredCount);
    if not InfoPresent or
      (Element^.pRevocationInfo^.dwRevocationResult <> 0) then
      Complete := False;
  end;
end;
Complete := Complete and (RequiredCount > 0); // une chaîne racine seule ne prouve rien

Le résultat du fournisseur est gardé brut. RevocationError porte le DWORD dwRevocationResult exactement comme le fournisseur l'a renvoyé, en préférant l'erreur de l'élément révoqué quand il y en a un (CRYPT_E_REVOKED est $80092010), et le masque de bits de confiance n'est jamais maquillé en code d'erreur natif. Le mappage vers TPdfCmsRevocationReason est délibérément grossier : pcrrCertificateRevoked avec pcvsInvalid pour une révocation explicite, pcrrChainUntrusted quand la chaîne a échoué pour des raisons sans rapport avec la révocation, et pcrrUnknown pour tout le reste. Windows a peut-être tenté OCSP plutôt qu'une CRL, donc un résultat hors ligne ou inconnu n'est pas traduit en pcrrCrlExpired. Le backend de vérification CMS OpenSSL peut faire ces distinctions propres aux CRL parce qu'il n'évalue jamais que des CRL que vous lui tendez, tandis que le backend macOS SecTrust laisse les champs à pcrrNone et zéro, ce qui signifie pas de diagnostic détaillé, pas réussi

Où s'arrête l'exclusion de la racine

CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT saute légitimement l'ancre, puisque personne ne publie une CRL qui révoque une racine contre elle-même. Le piège est de décider quel élément est la racine. PDFium VCL exclut le dernier élément d'une chaîne simple seulement quand son dwInfoStatus le marque auto-signé ($00000008) ou explicitement de confiance AC ($00004000). Un hôte hors ligne ne peut souvent pas aller chercher un émetteur manquant, si bien que la chaîne se termine à un intermédiaire ; traiter l'élément final de cette chaîne partielle comme une racine abandonnerait silencieusement le seul certificat dont le statut de révocation a le plus de chances d'être absent du cache. Cet élément reste dans l'ensemble requis, n'a pas de réponse de fournisseur, et le résultat reste pcvsIndeterminate

Comment les résultats de révocation de signature et d'horodatage sont-ils tenus séparés ?

Comme des champs séparés qui ne s'écrasent jamais l'un l'autre. Le validateur PAdES vérifie le CMS détaché de la signature du document et le CMS attaché du jeton d'horodatage RFC 3161 dans deux appels indépendants, et la v3.119.0 a donné à chacun ses propres diagnostics sur TPadesSignatureValidation : RevocationReason et NativeRevocationError pour le signant, TimeStampRevocationReason et NativeTimeStampRevocationError pour la TSA. Un certificat TSA révoqué ne peut donc pas se faire passer pour un signant révoqué, et un échec d'horodatage n'efface pas un résultat d'intégrité déjà établi. Quand CheckRevocation est False, ou que la validation n'a jamais atteint cette étape, les champs restent pcrrNone et 0, donc lisez-les toujours à côté de RevocationStatus et TimeStampRevocationStatus

La révocation de signature et d'horodatage reste séparée dans PDFium VCL : le CMS détaché de la signature du document remplit RevocationReason et NativeRevocationError, le CMS attaché du jeton RFC 3161 remplit TimeStampRevocationReason et NativeTimeStampRevocationError, et les étapes non contrôlées laissent pcrrNone et zéro à côté de leurs champs de statut
Un certificat TSA révoqué ne peut donc pas se faire passer pour un signant révoqué, et un échec d'horodatage n'efface jamais un résultat d'intégrité que la vérification de signature a déjà établi
for I := 0 to High(Report.Signatures) do
begin
  S := Report.Signatures[I];
  case S.RevocationStatus of
    pcsInvalid:
      Log(Format('sig %d: signer revoked, provider 0x%.8x',
        [I, S.NativeRevocationError]));
    pcsIndeterminate:
      Log(Format('sig %d: revocation unknown, reason %d, provider 0x%.8x',
        [I, Ord(S.RevocationReason), S.NativeRevocationError]));
    pcsNotChecked:
      Log(Format('sig %d: revocation not checked', [I]));
  end;
  if S.TimeStampRevocationStatus = pcsIndeterminate then
    Log(Format('sig %d: TSA revocation unknown, provider 0x%.8x',
      [I, S.NativeTimeStampRevocationError]));
end;

Le rapport de preuves suit la même règle. L'export CSV ajoute revocationReason, nativeRevocationError et les colonnes d'horodatage jusqu'à nativeTimeStampRevocationError à la fin de l'ordre de colonnes existant, si bien que les analyseurs plus anciens continuent de fonctionner, et l'export JSON ajoute des champs correspondants sans changer la signification des anciens. Si la validation hors ligne revient indéterminée en boucle, le correctif durable est en amont : rassemblez le matériel de validation au moment de signer, comme décrit dans les signatures PDF de longue durée avec horodatages RFC 3161 et DSS, au lieu d'espérer que la machine qui vérifie ait un cache chaud

Ce que la matrice de tests prouve et ne prouve pas

La matrice de vérification Windows a passé 30 scénarios contrôlés d'API de chaîne et une fumée CMS hors ligne réelle sur chaque cible Delphi et FPC Win32 et Win64. La fumée réelle vérifie une signature valide sous une AC privée non fiable, tandis que les issues propres et explicitement révoquées viennent de réponses CertGetCertificateChain simulées plutôt que d'ancres de confiance installées ou de récupérations réelles. C'est une frontière honnête qui mérite d'être énoncée : la gestion des drapeaux, l'isolation des erreurs et le parcours de preuves sont verrouillés, mais ce que contient le cache de révocation d'une machine donnée un jour donné reste l'affaire de Windows, et un cache vide produit désormais correctement inconnu au lieu d'une requête réseau ou d'un faux valide

La gestion de la révocation hors ligne, les diagnostics par champ et les exports de preuves font partie de l'API de validation de signatures PDF dans PDFium VCL pour Delphi et C++Builder, à côté des backends OpenSSL et macOS pour les déploiements multiplateformes