PDFium VCL peut désormais compléter une chaîne de signature PDF et vérifier la révocation sur le réseau avec son backend OpenSSL : quand OnlineRetrieval est activé, ConfigureSslCmsVerifier installe un vérificateur qui télécharge les certificats intermédiaires manquants depuis les URL caIssuers AIA et les CRL depuis les points de distribution CRL, dans un budget fixe de temps, de requêtes et d'octets par appel de vérification. Les certificats téléchargés ne sont jamais que du matériau de chaîne. La confiance continue de venir exclusivement du magasin système et des ancres que vous configurez
L'écart que cela comble se montre la première fois que vous validez des PDF du monde réel sur un serveur Linux. Une grande partie des signataires n'embarquent que leur propre certificat feuille dans le CMS, si bien qu'OpenSSL ne peut pas atteindre une racine, TrustStatus revient invalide, et la révocation ne tourne jamais parce que la chaîne n'est jamais devenue digne de confiance. Avant la v3.121.0, le backend OpenSSL décrit dans vérifier des signatures PDF avec OpenSSL dans PDFium VCL était strictement hors ligne et OnlineRetrieval n'avait aucun effet sur lui. Un point mérite d'être posé d'emblée : le moteur PDFium lui-même ne fait aucune vérification CMS, donc chaque règle ci-dessous vit dans la couche PAdES du composant et son binding OpenSSL, où vous pouvez la lire
Dans quel ordre le backend OpenSSL vérifie-t-il, télécharge-t-il et contrôle-t-il ?
D'abord l'intégrité, puis la confiance, puis la révocation, et le réseau n'est touché qu'entre les étapes qui en ont besoin. VerifyCmsWithSsl vérifie la signature CMS et les attributs signés (RFC 5652) avec l'évaluation de chaîne supprimée, et si cela échoue, il renvoie immédiatement, avant même qu'une session de téléchargement n'existe, si bien qu'un document aux octets cassés ne déclenche aucune requête sortante. Ce n'est que si la chaîne échoue ensuite et que OnlineRetrieval est activé qu'il suit les liens AIA et vérifie à nouveau. Les points de distribution CRL ne sont téléchargés qu'une fois la chaîne de confiance, parce qu'une CRL pendue à un chemin non fiable ne prouve rien. Les trois verdicts restent séparés du début à la fin : une signature valide avec une chaîne incomplète est toujours rapportée comme une signature valide
uses
PDFium, FPdfCrypto, FPdfCryptoSsl, FPdfPades;
var
Pdf: TPdf;
Probe: TPdfCmsVerifyOptions;
Diags: TPdfSslVerifyDiagnostics;
Trust: TPadesTrustValidationOptions;
Verdict: TPadesValidationResult;
I: Integer;
begin
ConfigureSslTrustAnchors(LoadCorporateRoots); // DER ; la seule confiance supplémentaire
ConfigureSslCmsVerifier;
Probe := TPdfCmsVerifyOptions.Default;
Probe.OnlineRetrieval := True;
Probe.CheckRevocation := True;
Diags := SslVerifyOptionsDiagnostics(Probe);
if psvdOnlineRetrievalIgnored in Diags then
Log('no HTTP transport or CMS_add1_cert: validation stays offline');
Trust := TPadesTrustValidationOptions.Default; // ptnpOffline par défaut
Trust.NetworkPolicy := ptnpOnline;
Trust.CheckRevocation := True;
Trust.UrlRetrievalTimeoutMs := 10000; // par appel de vérification
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'signed-contract.pdf';
Pdf.Active := True;
Verdict := Pdf.ValidatePadesTrust(Trust);
for I := 0 to High(Verdict.Signatures) do
Log(Format('#%d trust=%d revocation=%d', [I,
Ord(Verdict.Signatures[I].CertificateTrustStatus),
Ord(Verdict.Signatures[I].RevocationStatus)]));
finally
Pdf.Free;
end;
end;
Pourquoi les certificats téléchargés ne sont-ils jamais ajoutés au magasin de confiance ?
Parce que les URL viennent du certificat en cours de validation, et que c'est le signataire qui les a choisies. L'entrée authorityInfoAccess caIssuers (RFC 5280 §4.2.2.1) est un indice sur l'endroit où vit l'émetteur, rien de plus. Si ce qui répond à cette URL entrait dans le magasin d'ancres de confiance, n'importe qui pourrait signer avec une clé faite maison, pointer l'AIA vers son propre serveur, et recevoir un verdict vert. RetrieveIntermediates passe donc chaque certificat analysé à CMS_add1_cert, qui le place dans l'ensemble non fiable de cette structure CMS précise, et OpenSSL doit toujours construire un chemin depuis lui vers une ancre que vous avez configurée ou que le magasin système détient déjà. Il y a une raison plus discrète aussi : l'argument certificat de CMS_verify n'est pas un substitut prêt à l'emploi des certificats embarqués dans le CMS, donc ajouter au CMS lui-même est le chemin fiable
La boucle de récupération est délibérément étroite. RetrieveIntermediates tourne au plus 4 tours, chacun collectant les URL caIssuers de chaque certificat désormais dans le CMS, et s'arrête dès qu'un tour n'ajoute rien ou que le budget de temps est épuisé. Une réponse doit se décoder avec d2i_X509 comme un unique certificat DER qui consomme l'intégralité du corps ; les octets traînants sont rejetés, et un paquet PKCS#7 de certificats seuls servi depuis une URL .p7c est sauté plutôt que déballé. La méthode d'accès OCSP de la même extension AIA est ignorée, puisque ce backend ne parle pas OCSP. Côté révocation, RetrieveCrls ne lit que les URI fullName de chaque DistributionPoint (RFC 5280 §4.2.1.13) depuis les certificats du CMS et les ancres configurées, et les CRL téléchargées vont dans un second X509_STORE indépendant avec contrôle CRL de toute la chaîne, si bien qu'une CRL manquante ou périmée change RevocationStatus sans jamais toucher TrustStatus
// Condensé depuis VerifyCmsWithSsl (FPdfCryptoSsl.pas) ; mise en place BIO omise.
// Chaque appel _CMS_verify reçoit un BIO de contenu frais
if _CMS_verify(Cms, nil, nil, Bio, nil,
CMS_NO_SIGNER_CERT_VERIFY or CMS_BINARY) <> 1 then
Exit; // signature cassée : aucun réseau du tout
if Options.OnlineRetrieval and SslCapabilities.OnlineRetrieval then
FetchSession := TPdfCryptoFetchSession.Create(Options.UrlRetrievalTimeoutMs);
Store := BuildStore(False, nil, RevocationChecked, CrlsMalformed);
if (_CMS_verify(Cms, nil, Store, Bio, nil, CMS_BINARY) <> 1) and
(FetchSession <> nil) then
begin
RetrieveIntermediates(Cms, FetchSession); // CMS_add1_cert, non fiable seulement
// revérifiez la chaîne contre le même magasin d'ancres
end;
if Options.CheckRevocation and (FetchSession <> nil) and
(Result.TrustStatus = pcvsValid) then
RetrievedCrls := RetrieveCrls(Cms, FetchSession);
// second magasin séparé : CRL configurées plus téléchargées
Store := BuildStore(True, RetrievedCrls, RevocationChecked, CrlsMalformed);
Combien coûte au maximum un appel de vérification ?
Un plafond fixe, imposé par un unique TPdfCryptoFetchSession que les étapes AIA et CRL d'un même appel de vérification partagent. Les limites sont des constantes dans FPdfCryptoHttp, pas des suggestions :
- Temps :
UrlRetrievalTimeoutMs, qui vaut 15000 par défaut dansTPdfCmsVerifyOptions.Defaultcomme dansTPadesTrustValidationOptions.Default; une session créée avec 0 retombe sur 30000, et l'horloge démarre une fois la signature passée, couvrant chaque requête ultérieure - Requêtes : au plus 8 par session, comptées avant que le transport ne soit tenté, si bien qu'un hôte mort consomme quand même un créneau
- Octets : 1 Mio par réponse et 4 Mio au total, avec les URL de plus de 2048 caractères refusées avant toute connexion
La comptabilité est plus stricte qu'il n'y paraît. Les octets reçus d'une réponse échouée comptent quand même dans le total, si bien qu'un serveur répondant 404 avec une grosse page ne peut pas drainer le budget gratuitement. La lecture qui franchit la limite par réponse abandonne le téléchargement au lieu de passer un corps tronqué à l'analyseur ASN.1, et un HTTP 200 au corps vide est rejeté net, parce que le chemin AIA indexerait sinon Data[0] d'un tableau vide. Seules les URL http:// et https:// simples passent, sans redirections, cookies, identifiants ni découverte automatique de proxy, tandis que le HTTPS garde ses contrôles normaux de certificat et de nom d'hôte. La déduplication des URL est cantonnée à un appel à dessein : la validation suivante doit pouvoir voir une CRL fraîchement publiée. Le budget est aussi par appel, pas par document, et ValidatePadesTrust vérifie chaque signature et chaque jeton d'horodatage séparément, si bien que le pire cas grandit avec le nombre de signatures
Pourquoi une requête WinHTTP en timeout peut-elle encore écrire dans votre mémoire ?
Parce que renvoyer sur timeout n'annule pas les callbacks déjà en vol. Le transport Windows pilote WinHTTP de façon asynchrone et attend sur un événement avec le temps restant de la session, et quand cette attente abandonne, la requête peut encore terminer une lecture et signaler ensuite. Pointez la lecture asynchrone vers un tampon de pile et cette complétion tardive écrit dans une trame qui appartient alors à quelque fonction sans rapport. La correction, c'est la propriété, pas le minutage : l'événement et le tampon de lecture de 16 Kio vivent dans un enregistrement en tas à deux références, une tenue par l'appelant et une libérée seulement par le callback final HANDLE_CLOSING, si bien que celui des deux côtés qui finit en dernier libère la mémoire
type
PHttpState = ^THttpState;
THttpState = record
References: LongInt; // appelant + callback final HANDLE_CLOSING
Event: THandle;
Status, Count: DWORD;
Buffer: array[0..16383] of Byte; // les lectures asynchrones atterrissent ici, jamais sur une pile
end;
procedure ReleaseState(State: PHttpState);
begin
if InterlockedDecrement(State.References) = 0 then
begin
CloseHandle(State.Event);
Dispose(State);
end;
end;
// Dans le callback de statut : HANDLE_CLOSING est la dernière notification que
// WinHTTP envoie pour la requête, donc il lâche la seconde référence
if Status = HttpHandleClosing then
begin
ReleaseState(State);
Exit;
end;
Ce que libcurl doit fournir sur FPC Unix
Un résolveur asynchrone et un build thread-safe, ou la récupération en ligne reste désactivée. Sur FPC Unix, le transport passe par libcurl, la même dépendance derrière le backend d'horodatage libcurl pour les cibles non Windows, et le binding refuse toute bibliothèque dont le masque de fonctionnalités manque de CURL_VERSION_ASYNCHDNS ou de CURL_VERSION_THREADSAFE. La raison, c'est que CURLOPT_NOSIGNAL, qu'une bibliothèque dans le processus de quelqu'un d'autre doit poser, combiné à un résolveur synchrone, signifie qu'une résolution DNS peut simplement survivre au timeout. Le second piège est l'arrêt : curl_global_cleanup n'attend pas les threads DNS asynchrones, si bien qu'une fois libcurl initialisé, le module reste mappé jusqu'à la sortie du processus au lieu de laisser un thread d'arrière-plan courir dans du code déchargé. Quand une des deux exigences échoue, SslCapabilities.OnlineRetrieval est False et SslVerifyOptionsDiagnostics rapporte psvdOnlineRetrievalIgnored au lieu de prétendre que le réseau a été consulté
Ce que le résultat garantit et ce qu'il ne garantit pas
Un RevocationStatus valide venant de ce backend signifie que des CRL courantes couvrant toute la chaîne ont été trouvées, configurées ou téléchargées, et qu'aucune n'y listait un certificat ; rien de plus. Pas d'OCSP, donc une AC qui ne publie la révocation que par OCSP laisse le résultat non pris en charge, et un échec réseau ressemble exactement à une AC qui ne publie rien. Notez aussi que psvdNoCrlsConfigured ne décrit que les CRL que vous avez configurées, si bien qu'avec la récupération en ligne, c'est un indice, pas une prévision d'échec. Quand une piste d'audit doit être reproductible sans accès réseau, laissez NetworkPolicy à sa valeur par défaut ptnpOffline : aucune session de téléchargement n'est créée et le backend n'ouvre jamais de connexion, ce qui rejoint le contrat hors ligne côté CryptoAPI décrit dans les contrôles de révocation hors ligne des signatures PDF sur Windows
Le code de récupération, les budgets et les bindings de transport sont livrés en source avec le composant PDFium Delphi, si bien que vous pouvez confirmer exactement quelles URL une validation peut contacter et combien elle peut télécharger avant d'activer ptnpOnline sur un serveur qui manipule des documents non fiables