PDFium Delphi Component перевіряє PDF signatures на macOS через TPdfKeychainCmsVerifier — CMS verification backend на базі Apple CMSDecoder та SecTrust, а не hand-parsed CMS. ConfigureKeychainCmsVerifier встановлює його, а один call CMSDecoderCopySignerStatus повертає signature verdict, SecTrust handle і certificate result code — саме ту pair columns, яку TPdfCmsVerifyResult уже мав у Windows
Сценарій, що змусив зробити цю роботу, буденний і поширений. Lazarus build document archive працює на Mac, відкриває signed contract, і кожна signature повертається як pcsUnsupported. З file нічого не було не так. Signature verification просто не мала backend поза Windows, а PAdES validator відмовлявся вгадувати за його відсутності. Version 3.111.0 PDFiumPas відкрив seam через IPdfCmsVerifier і ConfigurePadesCmsVerifier; version 3.113.0 заповнила його на macOS. Цікава частина port — не plumbing, а три місця, де Apple API має не таку shape, як Windows one
Чому PDF signature охоплює два byte ranges?
Тому що signature не може охопити bytes, які її містять. ISO 32000-1 §12.8.1 поміщає CMS SignedData blob у /Contents string signature dictionary і описує signed extent через /ByteRange — набір offset і length pairs, які охоплюють усе по обидва боки цієї hole. На кожній platform два segments і один gap посередині
Platforms не погоджуються щодо того, як ці segments потрапляють у crypto layer, і disagreement коштує memory. У Windows CryptVerifyDetachedMessageSignature приймає array pointers та lengths, тому обидва spans подаються як лежать у buffer, без duplication. Apple CMSDecoderSetDetachedContent приймає один CFData і не має multi-segment form, тому macOS backend конкатенує два ranges у contiguous buffer перед decoding. Це full second copy signed bytes. На 400 MB scanned archive це справжній memory peak, він масштабується разом із document, а не signature, і альтернативного API немає. Плануйте batch worker відповідно, а не виявляйте це на customer machine
Один call заповнює дві columns TPdfCmsVerifyResult
CMSDecoderCopySignerStatus незвично щедрий для Security.framework entry point: один call повертає signer status, SecTrustRef для побудованого ним chain і OSStatus для certificate evaluation. Вони безпосередньо потрапляють у record, який PAdES validator уже споживає: signer status стає SignatureStatus, certificate result — TrustStatus, а raw values зберігаються у SignatureError та TrustError, щоб support ticket міг процитувати number, а не adjective. Callers ніколи самі не торкаються IPdfCmsVerifier — ValidatePadesCompliance і ValidatePadesTrust спрямовують кожну verification через встановлений backend, тому code, який читає TPadesSignatureValidation, byte-for-byte однаковий на обох platforms, як описано у walkthrough про inspection PDF signature dictionaries та PAdES levels у Delphi
uses
FPdfCrypto, FPdfCryptoMac, FPdfPades;
procedure InstallMacVerifier;
begin
// Signing і verification resolve різні framework symbols, тому
// один може бути присутнім, коли іншого немає
if not KeychainVerificationAvailable then
raise Exception.CreateFmt('Security.framework symbols missing: %s',
[KeychainMissingSymbols]);
ConfigureKeychainCmsVerifier;
// PadesCmsVerificationBackendName тепер відповідає 'macOS Security.framework'
if not PadesCmsVerificationAvailable then
raise Exception.Create('No CMS verification backend is installed');
end;
Чому kCMSSignerInvalidCert повідомляє про valid signature?
Тому що Apple надає цьому value вужче meaning, ніж натякає name: signature сама verified, а встановити certificate chain не вдалося. Тому TPdfKeychainCmsVerifier map-ить kCMSSignerInvalidCert у pcvsValid у column SignatureStatus і дозволяє certificate problem проявитися через TrustStatus, де chain problem і має бути. Згорнути це в signature verdict означало б змусити component сказати operator-у, що не tampered document було modified, а це найгірший false alarm, який signature validator може підняти
function MapSignerStatus(Status: LongWord): TPdfCmsVerifyStatus;
begin
case Status of
kCMSSignerValid:
Result:= pcvsValid;
// Signature verified, але chain — ні; це окремо повідомляє trust
// status
kCMSSignerInvalidCert:
Result:= pcvsValid;
kCMSSignerInvalidSignature, kCMSSignerUnsigned:
Result:= pcvsInvalid;
else
Result:= pcvsIndeterminate;
end;
end;
Читайте два statuses як ordered pair — і reporting logic сама стане очевидною. SignatureStatus = pcvsValid разом із TrustStatus = pcvsInvalid описує document, чиї bytes intact, але issuer якого ця конкретна Mac не trust-ить: anchor missing у Keychain, expired intermediate або chain, який неможливо complete offline. Це operator policy question, а не document integrity question, і саме ця distinction стоїть за більшістю випадків у нотатці про те, чому validators reject PAdES signatures, які cryptographically sound
Де macOS насправді перевіряє revocation?
Усередині trust evaluation, і саме тому TPdfCmsVerifyResult.RevocationStatus іде після TrustStatus, а не несе власний verdict. SecPolicyCreateRevocation створює policy, ця policy joins SecPolicyCreateBasicX509 в array, переданому до CMSDecoderCopySignerStatus, а OCSP або CRL work відбувається там, де будується chain. Окремої відповіді не повертається, тож повідомити її означало б invent-ити. У самого array є маленьке ownership rule, яке варто назвати: CFArrayCreate retains обидві policies, тому два local references release-яться одразу після цього, а single-policy case взагалі пропускає array і передає policy напряму — form, яку API також приймає
Offline operation — explicit flag, а не випадковий наслідок connectivity. Коли TPdfCmsVerifyOptions.OnlineRetrieval дорівнює False, backend додає kSecRevocationNetworkAccessDisabled, обмежуючи evaluation responses, уже cached на machine, а checkpoint callback усе ще fires pcvstCryptographicSignature, pcvstChainBuild та pcvstRevocationCheck у тому самому order, у якому їх повідомляє Windows backend. Application code встановлює все це через higher-level options record
var
Options: TPadesTrustValidationOptions;
Report: TPadesValidationResult;
Stream: TFileStream;
begin
Options:= TPadesTrustValidationOptions.Default;
Options.CheckRevocation:= True;
Options.NetworkPolicy:= ptnpOffline; // лише cached responses
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 проти copy: release, який провалюється десь в іншому місці
SecTrustGetCertificateAtIndex має get semantics, і reference, яку він повертає, ніколи не можна release-ити, тоді як CMSDecoderCopySignerCert і SecCertificateCopyData, розташовані за кілька lines у тому самому routine, мають copy semantics і мусять бути release-нуті. Core Foundation кодує все правило в одному verb function name, а type system нічого не enforce-ить. Release borrowed reference — і на call site нічого не трапиться: trust object просто стане unsound, а crash прийде пізніше в місці, яке не має visible connection із certificate chains
ChainCount:= _SecTrustGetCertificateCount(Trust);
SetLength(Result.ChainCertificates, ChainCount);
for I:= 0 to ChainCount- 1 do
begin
// Get semantics: reference borrowed, тут не release-ити
Cert:= _SecTrustGetCertificateAtIndex(Trust, I);
if Cert= nil then
Continue;
// Copy semantics: ця reference owned і має бути release-нута
CertData:= _SecCertificateCopyData(Cert);
if CertData= nil then
Continue;
try
Result.ChainCertificates[I]:= CFDataToBytes(CertData);
finally
_CFRelease(CertData);
end;
end;
Що гарантує verifier, коли жоден backend не відповідає?
Що відповідь буде unsupported, а не quiet pass. Коли ConfigurePadesCmsVerifier нічого не встановив і platform default не може допомогти, TPdfCmsVerifyResult повертається з кожною column, set у unavailable, а PAdES validator map-ить це в pcsUnsupported, тому build без crypto backend чесно звітує, а не робить claims про signature. macOS binding навмисно такий самий conservative: Security.framework і CoreFoundation досягаються через dlopen і dlsym, тому absent framework або symbol name, який binding неправильно вказав, проявляється як KeychainVerificationAvailable = False із KeychainMissingSymbols, що називає culprit, а не як link failure і не як wrong verdict. Це та сама fail-closed posture, яку component застосовує, шукаючи native library, описана в матеріалі про loading PDFium native library на будь-якому target
Signature verification — частина PDF stack, де тихо бути wrong гірше, ніж голосно бути unavailable, а macOS дає API, достатньо generous, щоб легко досягти обох outcomes. Concatenate byte ranges і прийміть copy, тримайте signature verdict та chain verdict в окремих columns, поважайте verbs get і copy, і дозвольте missing backend так і сказати. Якщо ви переносите Delphi або Free Pascal document workflow на Mac і потребуєте PAdES signing та validation з обох боків, PDFium Delphi Component постачає Keychain backend поруч із Windows one за єдиним interface