Технічна стаття

Перевірка 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 — саме ту 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 ніколи самі не торкаються IPdfCmsVerifierValidatePadesCompliance і 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