Article technique

Vérifier les signatures PDF avec OpenSSL dans PDFium VCL

PDFium VCL traite la vérification CMS comme un backend remplaçable derrière l'interface IPdfCmsVerifier, si bien que le validateur PAdES peut tourner sur Windows via CryptoAPI, sur macOS via le trousseau, et partout où OpenSSL est présent via ConfigureSslCmsVerifier. L'interface est petite. Trois comportements d'OpenSSL en dessous d'elle produisent des réponses fausses et assurées si vous l'implémentez naïvement

La motivation est assez claire dès qu'une application Delphi quitte Windows. La validation de signatures est l'un des rares domaines où la pile cryptographique de la plateforme n'est pas un détail d'implémentation : elle décide quels certificats sont de confiance, quels algorithmes existent, et ce que signifie la révocation. La coder en dur et le code ne se porte pas. L'abstraire mal et chaque plateforme rapporte une réponse d'une forme différente que l'appelant ne peut pas comparer

Ce que l'abstraction doit réellement porter

Deux formes de vérification et trois verdicts indépendants. Une signature PDF est détachée : le contenu signé est les deux plages d'octets de part et d'autre du trou /Contents, donc VerifyDetached prend deux segments plutôt qu'un tampon. Un jeton d'horodatage est attaché, portant son propre contenu, donc VerifyAttached ne prend que le DER

Le résultat se scinde en trois statuts parce qu'ils répondent à trois questions différentes et peuvent se contredire. SignatureStatus dit si les octets ont été signés par la clé du certificat signataire. TrustStatus dit si ce certificat chaîne vers quelque chose en qui vous avez confiance. RevocationStatus dit si le certificat était encore valide au moment pertinent. Un document avec une signature mathématiquement parfaite d'un certificat dont vous n'avez jamais entendu parler est valide, non fiable et inconnu, et réduire cela à un seul booléen est la façon dont les validateurs finissent par mentir aux utilisateurs

uses
  FPdfCrypto, FPdfCryptoSsl;

var
  Options: TPdfCmsVerifyOptions;
begin
  if not SslAvailable then
    raise Exception.Create('libcrypto not usable: ' + SslMissingSymbols);

  ConfigureSslTrustAnchors(LoadCorporateRoots);   // DER, peut être vide
  ConfigureSslCrls(LoadFreshCrls);                // DER, peut être vide
  ConfigureSslCmsVerifier;                        // installe le backend

  Writeln('backend  : ', PadesCmsVerificationBackendName);
  Writeln('library  : ', SslLibraryPath, ' ', SslLibraryVersion);
  Writeln('ABI      : ', SslAbiLayout);           // ulong=<n> long=<n>

  Options := TPdfCmsVerifyOptions.Default;
  Options.CheckRevocation := True;
  Options.CollectChainCertificates := True;
end;

SslAbiLayout ressemble à une curiosité et n'en est pas une. Chaque code d'erreur OpenSSL et chaque drapeau de magasin traverse la frontière comme un unsigned long C, qui fait quatre octets sur Windows et huit sur Linux et macOS. Déclarez-le comme un type fixe 32 bits et le code marche sur Windows, puis lit silencieusement une moitié de valeur sur LP64. Rapporter les largeurs supposées sous forme d'une chaîne sur laquelle on peut affirmer dans un test transforme toute une classe de dérive d'ABI de plateforme en un contrôle d'une ligne. Quiconque a travaillé le même problème avec CK_ULONG dans une liaison PKCS#11 le reconnaîtra immédiatement ; cette histoire se trouve dans le packing de structures PKCS#11 et la largeur de CK_ULONG

Pourquoi la seconde passe de vérification voit-elle un contenu vide ?

Parce que CMS_verify lit le BIO de contenu détaché jusqu'à la fin de fichier, et un BIO qui a été lu n'est pas remis en place pour vous. Vérifier en deux passes est une conception raisonnable, d'abord la signature cryptographique seule avec l'évaluation de chaîne supprimée, puis l'évaluation complète, et cela échoue d'une façon exceptionnellement trompeuse si les deux passes partagent un même BIO

La seconde passe obtient zéro octet de contenu. En mode détaché, ce n'est pas une erreur, car un tampon de contenu vide est une entrée légale. Le condensé simplement ne correspond pas, et l'échec fait surface comme un échec de construction de chaîne plutôt que comme un échec de contenu, ce qui vous envoie inspecter certificats et magasins de confiance alors que le vrai problème est une position de flux. Reconstruisez le BIO mémoire avec BIO_new_mem_buf à chaque passe. Cela coûte une allocation et supprime entièrement la possibilité

Ce que le drapeau no-verify supprime et ne supprime pas

CMS_NO_SIGNER_CERT_VERIFY supprime l'évaluation de chaîne, pas la recherche du certificat signataire. En interne, OpenSSL résout et attache les certificats signataires avant de consulter le drapeau, si bien qu'après une première passe portant ce drapeau, le signataire est déjà disponible et ses identifiants d'algorithme peuvent être lus immédiatement. Il n'est pas nécessaire de lancer une seconde vérification complète juste pour obtenir le certificat signataire, ce que le nom du drapeau vous pousse à supposer

Une règle de possession va avec cela. La référence du signataire appartient à la structure CMS et ne doit pas être libérée indépendamment. Elle est valide tant que la structure l'est, et la libérer produit une corruption dont le symptôme apparaît ailleurs entièrement, en général pendant le nettoyage d'un objet sans rapport

Pourquoi activer le contrôle CRL rejette-t-il chaque signature ?

Parce qu'OpenSSL ne contrôle les CRL que contre ce que le magasin détient déjà et ne va rien chercher de lui-même. Il ne suit pas les points de distribution de CRL et ne parle pas OCSP. Posez X509_V_FLAG_CRL_CHECK sur un magasin sans aucune CRL et chaque chaîne échoue avec une incapacité à obtenir une CRL de certificat. Le résultat ressemble à un contrôle de révocation qui fonctionne et trouve des problèmes. C'est un contrôle de révocation qui ne tourne jamais du tout

Le backend ne pose donc le drapeau que lorsque ConfigureSslCrls a réellement fourni au moins une CRL. Sans cela, RevocationStatus revient en pcvsUnsupported, ce qui est une déclaration honnête que la question n'a pas reçu de réponse. Pour la même raison, OnlineRetrieval n'a aucun effet sur ce backend et aucun point de contrôle pcvstOnlineRetrieval n'est émis : il n'y a pas de chemin de récupération depuis lequel rapporter une progression

Schéma du vérificateur CMS OpenSSL de PDFium VCL montrant trois pièges : un BIO de contenu partagé lu jusqu'à la fin de fichier laisse la seconde passe de vérification avec zéro octet, CMS_NO_SIGNER_CERT_VERIFY supprime l'évaluation de chaîne mais pas la recherche du signataire, et le contrôle CRL sur un magasin vide rejette chaque chaîne sans qu'aucune révocation ne tourne jamais
Chaque piège produit un verdict faux et assuré : une position de flux prend le masque d'un échec de confiance, le drapeau no-verify supprime moins que son nom le suggère, et une révocation jamais exécutée ressemble à une révocation qui a trouvé des problèmes

C'est une position de conception qui mérite d'être défendue en général. Un validateur qui ne peut pas vérifier la révocation devrait le dire. Rapporter un certificat non contrôlé comme non révoqué est la façon la plus courante dont les outils de validation de signatures induisent leurs utilisateurs en erreur, et c'est exactement la classe de confusion explorée dans pourquoi les validateurs rejettent les signatures PAdES

// Les points de contrôle laissent une interface montrer quelle étape
// tourne, et vous disent quelles étapes un backend exécute réellement
type
  TSignatureProbe = class
    procedure Checkpoint(Stage: TPdfCmsVerifyStage);
  end;

procedure TSignatureProbe.Checkpoint(Stage: TPdfCmsVerifyStage);
begin
  case Stage of
    pcvstCryptographicSignature: Status('checking the signature');
    pcvstChainBuild:             Status('building the certificate chain');
    pcvstOnlineRetrieval:        Status('fetching validation data');
    pcvstRevocationCheck:        Status('checking revocation');
  end;
end;

// Lisez les trois verdicts séparément ; ils ont le droit de se contredire
if Result.SignatureStatus = pcvsValid then
  case Result.TrustStatus of
    pcvsValid:         Report('signed and trusted');
    pcvsInvalid:       Report('signed, chain rejected');
    pcvsUnsupported,
    pcvsIndeterminate: Report('signed, trust not established');
  end;
if Result.RevocationStatus = pcvsUnsupported then
  Report('revocation was not checked on this backend');

Se lier à une bibliothèque qu'on ne peut pas épingler

OpenSSL a renommé ses accesseurs de pile entre 1.0 et 1.1, si bien que la même fonction logique a deux noms d'export possibles selon la construction que l'hôte possède. La liaison résout d'abord le nom plus récent et retombe sur l'ancien, et n'enregistre un symbole manquant que si aucun ne se résout. C'est la bonne forme pour toute liaison dynamique vers une bibliothèque que vous ne livrez pas : préférer les noms courants, tolérer les noms historiques, et ne rapporter que l'absence réelle

SslMissingSymbols est ce qui transforme un chargement échoué en événement diagnostiquable. Un résultat non vide sur un hôte qui possède clairement libcrypto signifie que la version installée est plus ancienne que l'API que cette construction cible, ce qui est une conversation de support complètement différente d'une bibliothèque absente. ConfigureSslLibraryPath couvre l'autre cas courant, un hôte avec plusieurs constructions OpenSSL où celle du chemin de recherche par défaut n'est pas celle que vous voulez

Choisir un backend par plateforme

L'arrangement pratique est de sélectionner au démarrage et de consigner lequel a répondu. Sur Windows, le backend plateforme s'intègre aux magasins de certificats qu'une entreprise gère déjà, ce qui est normalement ce que vous voulez. Sur macOS, le backend trousseau suit le même raisonnement et est décrit dans la vérification des signatures avec SecTrust sur macOS. OpenSSL est l'option portable, et c'est aussi le bon choix quand vous avez besoin d'une politique de validation identique entre plateformes plutôt qu'une qui suit le magasin de confiance de chaque plateforme

Schéma PDFium VCL de l'abstraction IPdfCmsVerifier portant VerifyDetached sur les deux plages d'octets autour du trou Contents et VerifyAttached pour les jetons d'horodatage, les trois verdicts indépendants SignatureStatus, TrustStatus et RevocationStatus, et des backends par plateforme sélectionnés au démarrage via CryptoAPI, SecTrust ou ConfigureSslCmsVerifier
L'interface porte deux formes de vérification et trois verdicts parce qu'ils répondent à des questions différentes et peuvent se contredire, et le backend installé est consigné à côté de chaque verdict pour que les résultats stockés puissent être reproduits

Lequel que vous installiez, journalisez PadesCmsVerificationBackendName à côté de chaque verdict que vous consignez. Un résultat de validation stocké sans le backend qui l'a produit ne peut pas être reproduit plus tard, parce que les trois valeurs de statut signifient des choses subtilement différentes selon la pile qui a répondu. La couche d'inspection de signatures au-dessus de tout cela, y compris la façon dont les niveaux PAdES sont rapportés, est couverte dans l'inspection des signatures numériques PDF et des niveaux PAdES

Tout cela est livré en source avec le composant Delphi PDFium, ce qui compte ici plus qu'à l'accoutumée : pour un validateur de signatures, pouvoir lire exactement quels drapeaux un backend pose et quels contrôles il saute n'est pas un confort, c'est la seule façon de savoir ce qu'affirme réellement une coche verte dans votre application