Technisch artikel

PDF-handtekeningen verifiëren met OpenSSL in PDFium VCL

PDFium VCL behandelt CMS-verificatie als een vervangbare backend achter de interface IPdfCmsVerifier, zodat de PAdES-validator op Windows via CryptoAPI kan draaien, op macOS via de Keychain, en overal waar OpenSSL aanwezig is via ConfigureSslCmsVerifier. De interface is klein. Drie OpenSSL-gedragingen eronder produceren zelfverzekerde, verkeerde antwoorden als u hem naïef implementeert

De motivatie is helder zodra een Delphi-applicatie Windows verlaat. Handtekeningvalidatie is een van de weinige gebieden waar de platform-crypto-stack geen implementatiedetail is: hij beslist welke certificaten vertrouwd worden, welke algoritmes bestaan en wat revocatie betekent. Eén hardcoden en de code porteert niet. Hem slecht abstraheren en elk platform rapporteert een anders gevormd antwoord dat de aanroeper niet kan vergelijken

Wat de abstractie werkelijk moet dragen

Twee verificatievormen en drie onafhankelijke oordelen. Een PDF-handtekening is detached: de ondertekende inhoud zijn de twee bytebereiken aan weerszijden van het /Contents-gat, dus VerifyDetached neemt twee segmenten in plaats van één buffer. Een timestamp-token is attached en draagt zijn eigen inhoud, dus VerifyAttached neemt alleen de DER

Het resultaat splitst in drie statussen omdat ze drie verschillende vragen beantwoorden en kunnen verschillen. SignatureStatus zegt of de bytes zijn ondertekend door de key in het signer-certificaat. TrustStatus zegt of dat certificaat ketent naar iets dat u vertrouwt. RevocationStatus zegt of het certificaat op het relevante moment nog geldig was. Een document met een wiskundig perfecte handtekening van een certificaat waar u nooit van gehoord hebt, is valid, untrusted en unknown, en dat samenpersen tot één boolean is precies hoe validators uiteindelijk gebruikers voorliegen

uses
  FPdfCrypto, FPdfCryptoSsl;

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

  ConfigureSslTrustAnchors(LoadCorporateRoots);   // DER, mag leeg zijn
  ConfigureSslCrls(LoadFreshCrls);                // DER, mag leeg zijn
  ConfigureSslCmsVerifier;                        // installeert de 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 oogt als een curiositeit en is het niet. Elke OpenSSL-foutcode en elke store-flag gaat de grens over als een C unsigned long, die op Windows vier bytes is en op Linux en macOS acht. Declareer hem als een vast 32-bit type en de code werkt op Windows, om daarna stilletjes de helft van een waarde te lezen op LP64. De aangenomen breedtes als string rapporteren waarop u in een test kunt asserten, maakt van een hele klasse platform-ABI-drift een check van één regel. Wie hetzelfde probleem met CK_ULONG in een PKCS#11-binding heeft doorgewerkt, herkent het meteen; dat verhaal staat in PKCS#11 struct packing en CK_ULONG breedte

Waarom ziet de tweede verificatiepas lege inhoud?

Omdat CMS_verify de detached content BIO tot end of file leest, en een gelezen BIO wordt niet voor u teruggespoeld. Verifiëren in twee passen is een redelijk ontwerp: eerst alleen de cryptografische handtekening met ketenevaluatie onderdrukt, dan de volledige evaluatie, en het faalt op een ongebruikelijk bedrieglijke manier als beide passen één BIO delen

De tweede pas krijgt nul bytes inhoud. In detached mode is dat geen fout, want een lege contentbuffer is legale invoer. De digest matcht simpelweg niet, en de failure toont zich als een chain-building-failure in plaats van een contentfailure, wat u op pad stuurt naar certificaten en trust stores terwijl het werkelijke probleem een streampostitie is. Bouw de memory BIO met BIO_new_mem_buf opnieuw op voor elke pas. Het kost één allocatie en neemt de mogelijkheid volledig weg

Wat de no-verify-flag onderdrukt en wat niet

CMS_NO_SIGNER_CERT_VERIFY onderdrukt de ketenevaluatie, niet het opzoeken van het signer-certificaat. Intern resolveert OpenSSL de signer-certificaten en hangt ze vast voordat hij de flag raadpleegt, dus na een eerste pas met die flag is de signer al beschikbaar en kunnen zijn algorithm identifiers meteen worden gelezen. Er is geen tweede volledige verificatie nodig alleen om het signer-certificaat te verkrijgen, en dat is precies wat de flagnaam u doet vermoeden

Daar hoort één eigendomsregel bij. De signer-referentie behoort aan de CMS-structuur en mag niet onafhankelijk worden vrijgegeven. Hij is geldig zolang de structuur het is, en hem vrijgeven produceert een corruptie waarvan het symptoom ergens compleet anders opduikt, meestal tijdens de opruiming van een ongerelateerd object

Waarom weigert het aanzetten van CRL-checking elke handtekening?

Omdat OpenSSL CRL's alleen checkt tegen wat de store al bevat en zelf niets fetcht. Hij volgt geen CRL distribution points en spreekt geen OCSP. Zet X509_V_FLAG_CRL_CHECK aan op een store zonder CRL's en elke chain faalt met een onvermogen om een certificate CRL te verkrijgen. Het resultaat oogt als revocatiechecking die werkt en problemen vindt. Het is revocatiechecking die nooit draait

De backend zet de flag daarom alleen als ConfigureSslCrls werkelijk ten minste één CRL heeft geleverd. Zonder die komt RevocationStatus terug als pcvsUnsupported, wat een eerlijke verklaring is dat de vraag niet is beantwoord. Om dezelfde reden heeft OnlineRetrieval geen effect op deze backend en wordt er geen pcvstOnlineRetrieval-checkpoint geëmitteerd: er is geen fetchroute om voortgang over te melden

PDFium VCL OpenSSL CMS-verificatordiagram met drie vallen: een gedeelde content BIO die tot end of file gelezen wordt laat de tweede verificatiepas met nul bytes achter, CMS_NO_SIGNER_CERT_VERIFY onderdrukt ketenevaluatie maar niet signer-opzoeking, en CRL-checking op een lege store weigert elke chain zonder dat revocatie ooit draait
Elke val levert een zelfverzekerd verkeerd oordeel op: een streampostitie doet zich voor als trust-failure, de no-verify-flag onderdrukt minder dan zijn naam suggereert, en nooit-gedraaide revocatie oogt als revocatie die problemen vond

Dit is een ontwerphouding die het verdedigen in het algemeen waard is. Een validator die revocatie niet kan checken, moet dat zeggen. Een ongecontroleerd certificaat als niet ingetrokken rapporteren is dé meest voorkomende manier waarop handtekeningvalidatietools hun gebruikers misleiden, en het is precies de verwarringsklasse die wordt uitgeplozen in waarom validators PAdES-handtekeningen afwijzen

// Checkpoints laten een UI zien welke fase draait, en vertellen u welke
// fasen een backend werkelijk uitvoert
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;

// Lees de drie oordelen apart; ze mogen van elkaar verschillen
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');

Binden aan een library die u niet kunt pinnen

OpenSSL hernoemde zijn stack-accessors tussen 1.0 en 1.1, dus dezelfde logische functie heeft twee mogelijke exportnamen, afhankelijk van de build die de host toevallig heeft. De binding resolved eerst de nieuwere naam en valt terug op de oudere, en registreert pas een ontbrekend symbool als geen van beide resolved. Dat is de juiste vorm voor elke dynamische binding tegen een library die u niet meelevert: geef de voorkeur aan huidige namen, tolereer historische en meld alleen echte afwezigheid

SslMissingSymbols is wat van een mislukte load een diagnoseerbaar moment maakt. Een niet-lege resultaat op een host die duidelijk libcrypto geïnstalleerd heeft, betekent dat de geïnstalleerde versie ouder is dan de API waar deze build op mikt, en dat is een compleet andere supportconversatie dan een library die ontbreekt. ConfigureSslLibraryPath dekt het andere gangbare geval, een host met meerdere OpenSSL-builds waar die op het standaard zoekpad niet degene is die u wilt

Een backend kiezen per platform

De praktische opzet is kiezen bij de opstart en vastleggen welke geantwoord heeft. Op Windows integreert de platformbackend met de certificaatstores die een onderneming al beheert, en dat is normaal wat u wilt. Op macOS past de Keychain-backend in dezelfde redenering en staat beschreven in handtekeningen verifiëren met SecTrust op macOS. OpenSSL is de draagbare optie, en het is ook de juiste keuze als u een validatiebeleid nodig heeft dat over platforms identiek is in plaats van één dat elk platform trust store volgt

PDFium VCL-diagram van de IPdfCmsVerifier-abstractie die VerifyDetached over de twee bytebereiken rond het Contents-gat draagt en VerifyAttached voor timestamp-tokens, de drie onafhankelijke oordelen SignatureStatus, TrustStatus en RevocationStatus, en per platform gekozen backends die bij de opstart via CryptoAPI, SecTrust of ConfigureSslCmsVerifier worden geselecteerd
De interface draagt twee verificatievormen en drie oordelen omdat ze verschillende vragen beantwoorden en mogen verschillen, en de geïnstalleerde backend wordt bij elk oordeel vastgelegd zodat opgeslagen resultaten reproduceerbaar zijn

Welke u ook installeert, log PadesCmsVerificationBackendName naast elk oordeel dat u vastlegt. Een opgeslagen validatieresultaat zonder de backend die het produceerde, is later niet reproduceerbaar, want de drie statuswaarden betekenen subtiel verschillende dingen naargelang welke stack antwoordde. De signatuurinspectielaag bovenop dit alles, inclusief hoe PAdES-niveaus worden gerapporteerd, staat in PDF digitale handtekeningen en PAdES-niveaus inspecteren

Alles wordt als source geleverd met de PDFium Delphi component, en dat telt hier meer dan gewoonlijk: voor een handtekeningvalidator is precies kunnen lezen welke flags een backend zet en welke checks hij overslaat geen nice-to-have, het is de enige manier om te weten wat een groen vinkje in uw applicatie werkelijk claimt