PDFium VCL treats CMS verification as a replaceable backend behind the IPdfCmsVerifier interface, so the PAdES validator can run on Windows through CryptoAPI, on macOS through the Keychain, and anywhere OpenSSL is present through ConfigureSslCmsVerifier. The interface is small. Three OpenSSL behaviours underneath it produce confident, wrong answers if you implement it naively
The motivation is plain enough once a Delphi application leaves Windows. Signature validation is one of the few areas where the platform crypto stack is not an implementation detail: it decides which certificates are trusted, which algorithms exist, and what revocation means. Hard-code one and the code does not port. Abstract it badly and every platform reports a differently shaped answer that the caller cannot compare
What the abstraction actually has to carry
Two verification shapes and three independent verdicts. A PDF signature is detached: the signed content is the two byte ranges on either side of the /Contents hole, so VerifyDetached takes two segments rather than one buffer. A timestamp token is attached, carrying its own content, so VerifyAttached takes only the DER
The result splits into three statuses because they answer three different questions and can disagree. SignatureStatus says whether the bytes were signed by the key in the signer certificate. TrustStatus says whether that certificate chains to something you trust. RevocationStatus says whether the certificate was still valid at the relevant time. A document with a mathematically perfect signature from a certificate you have never heard of is valid, untrusted and unknown, and collapsing that into a single boolean is how validators end up lying to users
uses
FPdfCrypto, FPdfCryptoSsl;
var
Options: TPdfCmsVerifyOptions;
begin
if not SslAvailable then
raise Exception.Create('libcrypto not usable: ' + SslMissingSymbols);
ConfigureSslTrustAnchors(LoadCorporateRoots); // DER, may be empty
ConfigureSslCrls(LoadFreshCrls); // DER, may be empty
ConfigureSslCmsVerifier; // installs the 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 looks like a curiosity and is not. Every OpenSSL error code and every store flag crosses the boundary as a C unsigned long, which is four bytes on Windows and eight on Linux and macOS. Declare it as a fixed 32-bit type and the code works on Windows, then silently reads half a value on LP64. Reporting the assumed widths as a string you can assert on in a test turns a whole class of platform ABI drift into a one-line check. Anyone who has worked through the same problem with CK_ULONG in a PKCS#11 binding will recognise it immediately; that story is in PKCS#11 struct packing and CK_ULONG width
Why does the second verification pass see empty content?
Because CMS_verify reads the detached content BIO to end of file, and a BIO that has been read is not rewound for you. Verifying in two passes is a reasonable design, first the cryptographic signature alone with chain evaluation suppressed, then the full evaluation, and it fails in an unusually deceptive way if both passes share one BIO
The second pass gets zero bytes of content. In detached mode that is not an error, because an empty content buffer is a legal input. The digest simply does not match, and the failure surfaces as a chain-building failure rather than a content failure, which sends you off to inspect certificates and trust stores whilst the actual problem is a stream position. Rebuild the memory BIO with BIO_new_mem_buf for every pass. It costs one allocation and removes the possibility entirely
What the no-verify flag does and does not suppress
CMS_NO_SIGNER_CERT_VERIFY suppresses the chain evaluation, not the signer certificate lookup. Internally OpenSSL resolves and attaches the signer certificates before it consults the flag, so after a first pass carrying that flag the signer is already available and its algorithm identifiers can be read straight away. There is no need to run a second full verification just to obtain the signer certificate, which is what the flag name tempts you into assuming
One ownership rule goes with that. The signer reference belongs to the CMS structure and must not be freed independently. It is valid for as long as the structure is, and freeing it produces a corruption whose symptom appears somewhere else entirely, usually during cleanup of an unrelated object
Why does turning on CRL checking reject every signature?
Because OpenSSL checks CRLs only against what the store already holds and fetches nothing on its own. It does not follow CRL distribution points and it does not speak OCSP. Set X509_V_FLAG_CRL_CHECK on a store with no CRLs in it and every chain fails with an inability to obtain a certificate CRL. The result looks like revocation checking working and finding problems. It is revocation checking never running at all
The backend therefore sets the flag only when ConfigureSslCrls has actually supplied at least one CRL. Without one, RevocationStatus comes back as pcvsUnsupported, which is an honest statement that the question was not answered. For the same reason OnlineRetrieval has no effect on this backend and no pcvstOnlineRetrieval checkpoint is emitted: there is no fetching path to report progress from
This is a design position worth defending in general. A validator that cannot check revocation should say so. Reporting an unchecked certificate as not revoked is the single most common way signature validation tools mislead their users, and it is exactly the class of confusion explored in why validators reject PAdES signatures
// Checkpoints let a UI show which stage is running, and tell you which
// stages a backend actually performs
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;
// Read the three verdicts separately; they are allowed to disagree
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');
Binding to a library you cannot pin
OpenSSL renamed its stack accessors between 1.0 and 1.1, so the same logical function has two possible export names depending on the build the host happens to have. The binding resolves the newer name first and falls back to the older one, and only records a missing symbol when neither resolves. That is the right shape for any dynamic binding against a library you do not ship: prefer current names, tolerate historical ones, and report only genuine absence
SslMissingSymbols is what turns a failed load into a diagnosable event. A non-empty result on a host that clearly has libcrypto installed means the installed version is older than the API this build targets, which is a completely different support conversation from a library that is missing. ConfigureSslLibraryPath covers the other common case, a host with several OpenSSL builds where the one on the default search path is not the one you want
Choosing a backend per platform
The practical arrangement is to select at startup and record which one answered. On Windows, the platform backend integrates with the certificate stores an enterprise already manages, which is normally what you want. On macOS the Keychain backend fits the same reasoning and is described in verifying signatures with SecTrust on macOS. OpenSSL is the portable option, and it is also the right choice when you need a validation policy that is identical across platforms rather than one that follows each platform trust store
Whichever you install, log PadesCmsVerificationBackendName next to every verdict you record. A stored validation result without the backend that produced it cannot be reproduced later, because the three status values mean subtly different things depending on which stack answered. The signature-inspection layer on top of all this, including how PAdES levels are reported, is covered in inspecting PDF digital signatures and PAdES levels
All of it ships as source with the PDFium Delphi component, which matters here more than usual: for a signature validator, being able to read exactly which flags a backend sets and which checks it skips is not a nice-to-have, it is the only way to know what a green tick in your application actually claims