Техническа статия

Проверка на PDF signatures в macOS със SecTrust и Delphi

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, точно двойката 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 API

Защо 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, които покриват всичко от двете страни на тази дупка. Два segments, един gap по средата, на всяка platform

Platform-ите не са съгласни как тези segments стигат до crypto layer-а и несъгласието струва memory. В Windows CryptVerifyDetachedMessageSignature приема array от pointers и lengths, така че двата spans влизат както лежат в buffer-а и нищо не се duplicate-ва. Apple CMSDecoderSetDetachedContent приема един CFData и няма multi-segment форма, така че macOS backend-ът concatenat-ва двата ranges в contiguous buffer, преди да decode-не. Това е пълно второ копие на signed bytes. При 400 MB scanned archive това е реален memory peak, scale-ва с document-а, а не със signature, и няма към какъв alternative API да посегнете. Оразмерете batch worker-а съответно, вместо да откриете това на customer machine

Един call попълва две columns на TPdfCmsVerifyResult

CMSDecoderCopySignerStatus е необичайно generous за 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. Caller-ите никога не докосват IPdfCmsVerifier сами — ValidatePadesCompliance и ValidatePadesTrust насочват всяка verification през backend-а, който е инсталиран, така че code-ът, който чете TPadesSignatureValidation, е byte-for-byte един и същ на двете platforms, както е описано в walkthrough-а за инспекция на 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 по-тясно значение, отколкото подсказва името: signature самата е verified и само certificate chain не е могъл да бъде established. Затова TPdfKeychainCmsVerifier map-ва kCMSSignerInvalidCert към pcvsValid в SignatureStatus column и оставя certificate problem-а да излезе през TrustStatus, където му е мястото. Сливането му в signature verdict би накарало component-а да каже на operator-а, че untampered 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, липсващ от Keychain, expired intermediate или chain, който не може да бъде completed offline. Това е operator policy question, а не document integrity question и точно това различие стои зад повечето случаи в бележката за защо validators отхвърлят PAdES signatures, които са cryptographically sound

Къде macOS всъщност проверява revocation?

Вътре в trust evaluation-а, затова TPdfCmsVerifyResult.RevocationStatus следва TrustStatus, вместо да носи собствен verdict. SecPolicyCreateRevocation произвежда policy, тази policy се join-ва с SecPolicyCreateBasicX509 в array-а, подаден на CMSDecoderCopySignerStatus, а OCSP или CRL work се случва там, където се изгражда chain-ът. Оттам не се връща отделен answer, така че да report-нете такъв би означавало да го измислите. Самият array носи малко ownership rule, което си струва да се назове: CFArrayCreate retain-ва и двете policies, така че двете local references се release-ват веднага след това, докато single-policy case прескача array-а и подава policy-то директно, форма, която API също приема

Offline operation е explicit flag, а не accident на connectivity. Когато TPdfCmsVerifyOptions.OnlineRetrieval е False, backend-ът добавя kSecRevocationNetworkAccessDisabled, ограничавайки evaluation-а до responses, вече cached на machine-а, а checkpoint callback-ът пак fire-ва pcvstCryptographicSignature, pcvstChainBuild и pcvstRevocationCheck в същия ред, в който Windows backend-ът ги report-ва. 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-ът, който fail-ва някъде другаде

SecTrustGetCertificateAtIndex има get semantics и reference-ът, който връща, никога не трябва да бъде released, докато CMSDecoderCopySignerCert и SecCertificateCopyData, които стоят няколко lines по-надолу в същата routine, имат copy semantics и трябва да бъдат. Core Foundation кодира цялото правило в една verb word от function name и type system-ът не налага нищо. Release-нете borrowed reference и на call site-а няма да се случи нищо: trust object-ът просто става unsound, а crash-ът пристига по-късно някъде, което няма видима връзка с 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: този е owned и трябва да бъде върнат
  CertData:= _SecCertificateCopyData(Cert);
  if CertData= nil then
    Continue;
  try
    Result.ChainCertificates[I]:= CFDataToBytes(CertData);
  finally
    _CFRelease(CertData);
  end;
end;

Какво гарантира verifier-ът, когато никой backend не отговори?

Че answer-ът е unsupported, никога quiet pass. Когато ConfigurePadesCmsVerifier не е инсталирал нищо и platform default-ът не може да помогне, TPdfCmsVerifyResult се връща с всяка column, зададена като unavailable, а PAdES validator-ът го map-ва към pcsUnsupported, така че build без crypto backend да докладва честно, вместо да твърди нещо за signature. macOS binding-ът е умишлено conservative в същата посока: Security.framework и CoreFoundation се достигат чрез dlopen и dlsym, така че absent framework или symbol name, който този binding е объркал, се проявява като KeychainVerificationAvailable, връщащ False с KeychainMissingSymbols, който назовава offender-а, а не като link failure и не като wrong verdict. Това е същият fail-closed posture, който component-ът прилага, когато търси native library, описан в текста за loading на PDFium native library на всеки target

Signature verification е частта от PDF stack-а, в която quiet wrong е по-лошо от loud unavailable, а macOS ви дава API, достатъчно generous, за да направи и двата outcomes лесни за достигане. Concatenate-вайте byte ranges и приемете copy-то, пазете signature verdict и chain verdict в отделни columns, уважавайте get и copy verbs и оставете missing backend-а да каже това. Ако премествате Delphi или Free Pascal document workflow към Mac и ви трябва PAdES signing и validation от двете страни, PDFium Delphi Component доставя Keychain backend-а заедно с Windows backend-а зад един interface