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

Проверка PDF-подписей на macOS через SecTrust в Delphi

PDFium Delphi Component проверяет PDF-подписи на macOS через TPdfKeychainCmsVerifier — backend CMS verification на Apple CMSDecoder и SecTrust, а не на ручном разборе CMS. ConfigureKeychainCmsVerifier устанавливает его, а один вызов CMSDecoderCopySignerStatus возвращает verdict подписи, SecTrust handle и result code сертификата — ровно ту пару колонок, которую TPdfCmsVerifyResult уже несла в Windows

Сценарий, заставивший взяться за работу, скучен и типичен. Lazarus build архива документов работает на Mac, открывает подписанный контракт, и каждая подпись возвращается как pcsUnsupported. С файлом всё в порядке. За пределами Windows просто не было backend для verification, а PAdES validator отказывался угадывать при его отсутствии. Версия PDFiumPas 3.111.0 открыла seam через IPdfCmsVerifier и ConfigurePadesCmsVerifier, а версия 3.113.0 заполнила его на macOS. Интерес в этом port не в plumbing, а в трёх местах, где Apple API имеет не такую форму, как Windows API

Почему PDF-подпись охватывает два диапазона байт?

Потому что подпись не может охватывать байты, в которых сама находится. ISO 32000-1 §12.8.1 помещает CMS SignedData blob в строку /Contents signature dictionary и описывает signed extent через /ByteRange — набор пар offset и length, покрывающих всё по обе стороны от этой дыры. На каждой платформе это два сегмента и один gap в середине

Платформы расходятся в способе доставки этих сегментов в crypto layer, и это расхождение стоит памяти. В Windows CryptVerifyDetachedMessageSignature принимает массив pointers и lengths, поэтому оба span передаются как лежат в buffer и ничего не дублируется. Apple CMSDecoderSetDetachedContent принимает один CFData и не имеет multi-segment формы, поэтому macOS backend перед декодированием конкатенирует два диапазона в contiguous buffer. Это полная вторая копия подписанных байт. В scanned archive на 400 МБ это настоящий memory peak, он растёт вместе с документом, а не с подписью, и другого API для обхода нет. Планируйте batch worker с этим в уме, а не обнаруживайте ограничение на машине заказчика

Один вызов заполняет две колонки TPdfCmsVerifyResult

CMSDecoderCopySignerStatus необычно щедр для entry point Security.framework: один вызов возвращает status signer, SecTrustRef построенной им chain и OSStatus оценки сертификата. Они напрямую попадают в record, который уже потребляет PAdES validator: signer status становится SignatureStatus, certificate result — TrustStatus, а raw values сохраняются в SignatureError и TrustError, чтобы в support ticket можно было привести число, а не прилагательное. Вызывающие никогда сами не трогают IPdfCmsVerifierValidatePadesCompliance и ValidatePadesTrust направляют каждую verification через установленный backend, поэтому код, читающий TPadesSignatureValidation, побайтно одинаков на обеих платформах, как описано в разборе инспекции PDF signature dictionaries и уровней PAdES в Delphi

uses
  FPdfCrypto, FPdfCryptoMac, FPdfPades;

procedure InstallMacVerifier;
begin
  // Signing и verification разрешают разные 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 сообщает о действительной подписи?

Потому что Apple придаёт этому значению более узкий смысл, чем подсказывает имя: сама подпись проверена, а установить удалось только не всю certificate chain. Поэтому TPdfKeychainCmsVerifier отображает kCMSSignerInvalidCert в pcvsValid колонки SignatureStatus и выводит проблему сертификата через TrustStatus, где и должна находиться проблема chain. Если включить её в verdict подписи, компонент сообщит оператору, что нетронутый документ был изменён, а это худшая ложная тревога, которую может поднять signature validator

function MapSignerStatus(Status: LongWord): TPdfCmsVerifyStatus;
begin
  case Status of
  kCMSSignerValid:
    Result:= pcvsValid;
  // Подпись прошла проверку, а chain — нет; trust
  // status сообщает об этом отдельно
  kCMSSignerInvalidCert:
    Result:= pcvsValid;
  kCMSSignerInvalidSignature, kCMSSignerUnsigned:
    Result:= pcvsInvalid;
  else
    Result:= pcvsIndeterminate;
  end;
end;

Читайте два статуса как упорядоченную пару — и reporting logic становится очевидной. SignatureStatus = pcvsValid вместе с TrustStatus = pcvsInvalid описывает документ с целыми байтами, чей issuer этот Mac не признаёт доверенным: отсутствующий anchor в Keychain, просроченный intermediate, chain, которую нельзя завершить offline. Это вопрос operator policy, а не целостности документа, и именно это различие лежит за большинством случаев в заметке о том, почему validator-ы отвергают криптографически корректные PAdES-подписи

Где macOS на самом деле проверяет revocation?

Внутри trust evaluation, поэтому TPdfCmsVerifyResult.RevocationStatus следует за TrustStatus, а не содержит собственный verdict. SecPolicyCreateRevocation создаёт policy, эта policy присоединяется к SecPolicyCreateBasicX509 в массиве, переданном CMSDecoderCopySignerStatus, а работа OCSP или CRL выполняется там, где строится chain. Отдельного ответа не приходит, поэтому выдавать его означало бы его придумать. У самого массива есть небольшое правило ownership, которое стоит назвать: CFArrayCreate удерживает обе policies, поэтому две локальные references освобождаются сразу после этого, а вариант с одной policy вообще пропускает массив и передаёт policy напрямую — API тоже принимает такую форму

Offline operation — явный flag, а не случайность connectivity. Когда TPdfCmsVerifyOptions.OnlineRetrieval равен False, backend добавляет kSecRevocationNetworkAccessDisabled, ограничивая evaluation уже закэшированными на машине ответами, а checkpoint callback по-прежнему срабатывает для pcvstCryptographicSignature, pcvstChainBuild и pcvstRevocationCheck в том же порядке, в котором их сообщает Windows backend. Application code задаёт всё это через 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 нельзя освобождать никогда, тогда как CMSDecoderCopySignerCert и SecCertificateCopyData, находящиеся несколькими строками ниже в той же routine, имеют copy semantics и обязаны быть освобождены. Core Foundation кодирует всё правило в одном глаголе имени функции, но type system ничего не контролирует. Освободите borrowed reference — и в call site ничего не сломается: trust object просто станет несостоятельным, а crash придёт позже в месте, не имеющем видимой связи с certificate chains

ChainCount:= _SecTrustGetCertificateCount(Trust);
SetLength(Result.ChainCertificates, ChainCount);
for I:= 0 to ChainCount- 1 do
begin
  // Get semantics: эта reference borrowed и здесь не освобождается
  Cert:= _SecTrustGetCertificateAtIndex(Trust, I);
  if Cert= nil then
    Continue;
  // Copy semantics: эта reference принадлежит нам и должна быть возвращена
  CertData:= _SecCertificateCopyData(Cert);
  if CertData= nil then
    Continue;
  try
    Result.ChainCertificates[I]:= CFDataToBytes(CertData);
  finally
    _CFRelease(CertData);
  end;
end;

Что гарантирует verifier, если ни один backend не отвечает?

Ответ будет unsupported, а не тихий pass. Если ConfigurePadesCmsVerifier ничего не установил и platform default не может помочь, TPdfCmsVerifyResult возвращается со всеми колонками в состоянии unavailable, а PAdES validator отображает это в pcsUnsupported, поэтому build без crypto backend честно сообщает результат, а не делает заявление о подписи. macOS binding намеренно придерживается той же стороны: Security.framework и CoreFoundation подключаются через dlopen и dlsym, поэтому отсутствующий framework или неверное имя symbol в binding проявляются как KeychainVerificationAvailable со значением False и KeychainMissingSymbols, называющим виновника, а не как link failure и не как неверный verdict. Это тот же fail-closed подход, который компонент использует при поиске native library и который разобран в статье о загрузке native PDFium library на любой target

Verification подписи — часть PDF stack, где тихая ошибка хуже громкой недоступности, а macOS даёт достаточно щедрый API, чтобы легко получить оба исхода. Конкатенируйте byte ranges и примите стоимость копирования, держите verdict подписи и verdict chain в разных columns, соблюдайте разницу get и copy и позволяйте отсутствующему backend честно об этом сказать. Если вы переносите workflow документов на Delphi или Free Pascal на Mac и хотите signing и validation PAdES с обеих сторон, PDFium Delphi Component поставляет Keychain backend рядом с Windows backend под единым interface