The PDFium Delphi Component verifies PDF signatures on macOS through TPdfKeychainCmsVerifier, a CMS verification backend built on Apple CMSDecoder and SecTrust instead of on hand-parsed CMS. ConfigureKeychainCmsVerifier installs it, and a single CMSDecoderCopySignerStatus call hands back the signature verdict, a SecTrust handle and a certificate result code, which is exactly the pair of columns TPdfCmsVerifyResult already carried on Windows
The scenario that forced the work is dull and common. A Lazarus build of a document archive runs on a Mac, opens a signed contract, and every signature comes back pcsUnsupported. Nothing is wrong with the file. Signature verification simply had no backend outside Windows, and the PAdES validator refused to guess in the absence of one. Version 3.111.0 of PDFiumPas opened the seam with IPdfCmsVerifier and ConfigurePadesCmsVerifier; version 3.113.0 filled it on macOS. The interesting part of that port is not the plumbing, it is the three places where the Apple API does not have the same shape as the Windows one
Why does a PDF signature cover two byte ranges?
Because a signature cannot cover the bytes that hold it. ISO 32000-1 §12.8.1 puts the CMS SignedData blob in the /Contents string of the signature dictionary and describes the signed extent with /ByteRange, a set of offset and length pairs covering everything on either side of that hole. Two segments, one gap in the middle, on every platform
The platforms disagree about how those segments reach the crypto layer, and the disagreement costs memory. On Windows, CryptVerifyDetachedMessageSignature accepts an array of pointers and lengths, so both spans go in as they lie in the buffer and nothing is duplicated. Apple CMSDecoderSetDetachedContent accepts one CFData and has no multi-segment form, so the macOS backend concatenates the two ranges into a contiguous buffer before decoding. That is a full second copy of the signed bytes. On a 400 MB scanned archive it is a genuine memory peak, it scales with the document rather than with the signature, and there is no alternative API to reach for. Size the batch worker accordingly instead of discovering this on a customer machine
One call fills two columns of TPdfCmsVerifyResult
CMSDecoderCopySignerStatus is unusually generous for a Security.framework entry point: one call returns the signer status, a SecTrustRef for the chain it built, and an OSStatus for the certificate evaluation. Those land directly in the record the PAdES validator already consumes, with the signer status becoming SignatureStatus, the certificate result becoming TrustStatus, and the raw values preserved in SignatureError and TrustError so a support ticket can quote a number rather than an adjective. Callers never touch IPdfCmsVerifier themselves — ValidatePadesCompliance and ValidatePadesTrust route every verification through whichever backend is installed, so the code reading TPadesSignatureValidation is byte-for-byte the same on both platforms, as described in the walkthrough on inspecting PDF signature dictionaries and PAdES levels in Delphi
uses
FPdfCrypto, FPdfCryptoMac, FPdfPades;
procedure InstallMacVerifier;
begin
// Signing and verification resolve different framework symbols, so one
// can be present while the other is not
if not KeychainVerificationAvailable then
raise Exception.CreateFmt('Security.framework symbols missing: %s',
[KeychainMissingSymbols]);
ConfigureKeychainCmsVerifier;
// PadesCmsVerificationBackendName now answers 'macOS Security.framework'
if not PadesCmsVerificationAvailable then
raise Exception.Create('No CMS verification backend is installed');
end;
Why does kCMSSignerInvalidCert report a valid signature?
Because Apple assigns that value a narrower meaning than its name suggests: the signature itself verified and only the certificate chain could not be established. TPdfKeychainCmsVerifier therefore maps kCMSSignerInvalidCert to pcvsValid in the SignatureStatus column and lets the certificate problem surface through TrustStatus, where a chain problem belongs. Folding it into the signature verdict would make the component tell an operator that an untampered document had been modified, which is the single worst false alarm a signature validator can raise
function MapSignerStatus(Status: LongWord): TPdfCmsVerifyStatus;
begin
case Status of
kCMSSignerValid:
Result:= pcvsValid;
// The signature verified and only the chain did not, which the trust
// status reports on its own
kCMSSignerInvalidCert:
Result:= pcvsValid;
kCMSSignerInvalidSignature, kCMSSignerUnsigned:
Result:= pcvsInvalid;
else
Result:= pcvsIndeterminate;
end;
end;
Read the two statuses as an ordered pair and the reporting logic writes itself. SignatureStatus = pcvsValid together with TrustStatus = pcvsInvalid describes a document whose bytes are intact and whose issuer this particular Mac does not trust: an anchor missing from the Keychain, an expired intermediate, a chain that cannot be completed offline. That is an operator policy question, not a document integrity question, and the distinction is exactly the one behind most of the cases in the note on why validators reject PAdES signatures that are cryptographically sound
Where does macOS actually check revocation?
Inside the trust evaluation, which is why TPdfCmsVerifyResult.RevocationStatus follows TrustStatus rather than carrying a verdict of its own. SecPolicyCreateRevocation produces a policy, that policy joins SecPolicyCreateBasicX509 in the array passed to CMSDecoderCopySignerStatus, and the OCSP or CRL work happens where the chain is built. No separate answer comes back, so reporting one would mean inventing it. The array itself carries a small ownership rule worth naming: CFArrayCreate retains both policies, so the two local references are released immediately afterwards, while the single-policy case skips the array entirely and passes the policy directly, a form the API also accepts
Offline operation is an explicit flag rather than an accident of connectivity. When TPdfCmsVerifyOptions.OnlineRetrieval is False the backend adds kSecRevocationNetworkAccessDisabled, confining the evaluation to responses already cached on the machine, and the checkpoint callback still fires pcvstCryptographicSignature, pcvstChainBuild and pcvstRevocationCheck in the same order the Windows backend reports them. Application code sets all of this through the higher-level options record
var
Options: TPadesTrustValidationOptions;
Report: TPadesValidationResult;
Stream: TFileStream;
begin
Options:= TPadesTrustValidationOptions.Default;
Options.CheckRevocation:= True;
Options.NetworkPolicy:= ptnpOffline; // cached responses only
Options.CheckTimeStamps:= True;
Stream:= TFileStream.Create('contract.pdf', fmOpenRead or fmShareDenyWrite);
try
Report:= ValidatePadesTrust(Stream, Options);
finally
Stream.Free;
end;
if Report.SignatureCount= 0 then
Log('No signature dictionary in this document')
else if Report.Signatures[0].CmsSignatureStatus <> pcsValid then
Log('Document integrity failed')
else if Report.Signatures[0].CertificateTrustStatus <> pcsValid then
Log('Bytes intact, chain not trusted on this Mac');
end;
Get versus copy: the release that fails somewhere else
SecTrustGetCertificateAtIndex has get semantics and the reference it returns must never be released, while CMSDecoderCopySignerCert and SecCertificateCopyData, sitting a few lines away in the same routine, have copy semantics and must be. Core Foundation encodes the whole rule in one verb of the function name and the type system enforces none of it. Release the borrowed reference and nothing goes wrong at the call site: the trust object simply becomes unsound, and the crash arrives later somewhere that has no visible connection to certificate chains
ChainCount:= _SecTrustGetCertificateCount(Trust);
SetLength(Result.ChainCertificates, ChainCount);
for I:= 0 to ChainCount- 1 do
begin
// Get semantics: this reference is borrowed and is not released here
Cert:= _SecTrustGetCertificateAtIndex(Trust, I);
if Cert= nil then
Continue;
// Copy semantics: this one is owned and must go back
CertData:= _SecCertificateCopyData(Cert);
if CertData= nil then
Continue;
try
Result.ChainCertificates[I]:= CFDataToBytes(CertData);
finally
_CFRelease(CertData);
end;
end;
What does the verifier guarantee when no backend answers?
That the answer is unsupported, never a quiet pass. Where ConfigurePadesCmsVerifier has installed nothing and the platform default cannot help, TPdfCmsVerifyResult comes back with every column set to unavailable and the PAdES validator maps that onto pcsUnsupported, so a build with no crypto backend reports honestly rather than claiming anything about the signature. The macOS binding is deliberately conservative in the same direction: Security.framework and CoreFoundation are reached by dlopen and dlsym, so an absent framework or a symbol name this binding got wrong shows up as KeychainVerificationAvailable returning False with KeychainMissingSymbols naming the offender, not as a link failure and not as a wrong verdict. That is the same fail-closed posture the component takes when it goes looking for the native library, described in the piece on loading the PDFium native library on any target
Signature verification is the part of a PDF stack where being wrong quietly is worse than being unavailable loudly, and macOS gives you an API generous enough to make both outcomes easy to reach. Concatenate the byte ranges and accept the copy, keep the signature verdict and the chain verdict in separate columns, respect the get and copy verbs, and let a missing backend say so. If you are moving a Delphi or Free Pascal document workflow onto the Mac and need PAdES signing and validation on both sides, the PDFium Delphi Component ships the Keychain backend alongside the Windows one behind a single interface