Technický článek

Ověření podpisů PDF na macOS přes SecTrust v Delphi

PDFium Delphi Component ověřuje podpisy PDF na macOS přes TPdfKeychainCmsVerifier, backend ověřování CMS postavený na Apple CMSDecoder a SecTrust místo ručního parsování CMS. ConfigureKeychainCmsVerifier ho nainstaluje a jediné volání CMSDecoderCopySignerStatus vrátí verdikt podpisu, handle SecTrust a kód výsledku certifikátu, tedy přesně dvojici sloupců, kterou TPdfCmsVerifyResult už nesl na Windows

Scénář, který práci vynutil, je nudný a běžný. Lazarus build archivu dokumentů běží na Macu, otevře podepsanou smlouvu a každý podpis se vrátí jako pcsUnsupported. Se souborem nic není. Ověřování podpisu prostě nemělo backend mimo Windows a PAdES validator odmítl hádat, když žádný nebyl. Verze 3.111.0 v PDFiumPas otevřela seam přes IPdfCmsVerifier a ConfigurePadesCmsVerifier; verze 3.113.0 ho na macOS doplnila. Zajímavá část portu není plumbing, ale tři místa, kde Apple API nemá stejný tvar jako windowsová

Proč podpis PDF pokrývá dva rozsahy bajtů

Protože podpis nemůže pokrývat bajty, které ho samy obsahují. ISO 32000-1 §12.8.1 ukládá blob CMS SignedData do stringu /Contents v dictionary podpisu a popisuje podepsaný rozsah přes /ByteRange, sadu dvojic offset a length pokrývajících vše na obou stranách této díry. Dva segmenty, jedna mezera uprostřed, na každé platformě

Platformy se liší v tom, jak tyto segmenty dostanou do kryptografické vrstvy, a rozdíl stojí paměť. Na Windows přijímá CryptVerifyDetachedMessageSignature pole pointerů a délek, takže oba spany předáte tak, jak leží v bufferu, a nic se neduplikuje. Apple CMSDecoderSetDetachedContent přijímá jeden CFData a nemá formu pro více segmentů, takže macOS backend oba rozsahy před dekódováním spojí do souvislého bufferu. Je to plná druhá kopie podepsaných bajtů. U skenovaného archivu o 400 MB jde o skutečný paměťový vrchol, roste s dokumentem, nikoli s podpisem, a žádné alternativní API není po ruce. Batch worker podle toho dimenzujte, místo abyste to zjistili na stroji zákazníka

Jedno volání naplní dva sloupce TPdfCmsVerifyResult

CMSDecoderCopySignerStatus je pro entry point Security.framework neobvykle štědré: jedno volání vrací status signera, SecTrustRef pro řetězec, který sestavilo, a OSStatus pro vyhodnocení certifikátu. Tyto hodnoty jdou přímo do recordu, který PAdES validator už spotřebovává, přičemž status signera se stane SignatureStatus, výsledek certifikátu TrustStatus a raw hodnoty zůstanou v SignatureError a TrustError, takže support ticket může citovat číslo místo přídavného jména. Volající se IPdfCmsVerifier nikdy sami nedotknou — ValidatePadesCompliance a ValidatePadesTrust vedou každé ověření přes nainstalovaný backend, takže kód čtoucí TPadesSignatureValidation je na obou platformách bajt za bajtem stejný, jak popisuje návod k inspekci dictionary podpisů PDF a úrovní PAdES v Delphi

uses
  FPdfCrypto, FPdfCryptoMac, FPdfPades;

procedure InstallMacVerifier;
begin
  // Podpis a ověřování resolveují různé framework symboly, takže
  // jeden může být přítomen, zatímco druhý není
  if not KeychainVerificationAvailable then
    raise Exception.CreateFmt('Security.framework symbols missing: %s',
      [KeychainMissingSymbols]);

  ConfigureKeychainCmsVerifier;

  // PadesCmsVerificationBackendName nyní vrací 'macOS Security.framework'
  if not PadesCmsVerificationAvailable then
    raise Exception.Create('No CMS verification backend is installed');
end;

Proč kCMSSignerInvalidCert hlásí platný podpis

Protože Apple této hodnotě přisuzuje užší význam, než napovídá její jméno: samotný podpis se ověřil a nepodařilo se pouze sestavit řetěz certifikátů. TPdfKeychainCmsVerifier proto mapuje kCMSSignerInvalidCert na pcvsValid ve sloupci SignatureStatus a problém certifikátu nechá vystoupit přes TrustStatus, kam problém řetězce patří. Slít ho do verdiktu podpisu by způsobilo, že komponenta operátorovi oznámí změnu dokumentu, jehož bajty jsou nedotčené, což je nejhorší false alarm, který může validator podpisu vyvolat

function MapSignerStatus(Status: LongWord): TPdfCmsVerifyStatus;
begin
  case Status of
  kCMSSignerValid:
    Result:= pcvsValid;
  // Podpis se ověřil a selhal jen řetězec, což trust status
  // reportuje samostatně
  kCMSSignerInvalidCert:
    Result:= pcvsValid;
  kCMSSignerInvalidSignature, kCMSSignerUnsigned:
    Result:= pcvsInvalid;
  else
    Result:= pcvsIndeterminate;
  end;
end;

Čtěte oba statusy jako uspořádanou dvojici a reportovací logika se napíše sama. SignatureStatus = pcvsValid společně s TrustStatus = pcvsInvalid popisuje dokument, jehož bajty jsou neporušené, ale jehož vydavateli tento konkrétní Mac nedůvěřuje: anchor chybí v Keychainu, intermediát je prošlý nebo řetězec nelze dokončit offline. To je otázka policy operátora, nikoli integrity dokumentu, a právě toto rozlišení stojí za většinou případů v poznámce o tom, proč validátory odmítají kryptograficky správné PAdES podpisy

Kde macOS skutečně kontroluje revokaci

Uvnitř trust evaluation, proto TPdfCmsVerifyResult.RevocationStatus následuje TrustStatus místo vlastního verdiktu. SecPolicyCreateRevocation vytvoří policy, tato policy se připojí k SecPolicyCreateBasicX509 v poli předávaném do CMSDecoderCopySignerStatus a práce s OCSP nebo CRL proběhne tam, kde se sestavuje chain. Samostatná odpověď se nevrací, takže její reportování by znamenalo si ji vymyslet. Samotné pole nese malé vlastnické pravidlo, které stojí za jméno: CFArrayCreate retainuje obě policy, takže dvě lokální reference se hned potom uvolní, zatímco případ s jedinou policy pole úplně obejde a předá policy přímo, což API také přijímá

Offline režim je explicitní flag, nikoli náhoda konektivity. Když je TPdfCmsVerifyOptions.OnlineRetrieval False, backend přidá kSecRevocationNetworkAccessDisabled, omezí evaluation na odpovědi už cachované v počítači a checkpoint callback stále vyvolá pcvstCryptographicSignature, pcvstChainBuild a pcvstRevocationCheck ve stejném pořadí, v jakém je reportuje windowsový backend. Aplikace to nastaví přes options record vyšší úrovně

var
  Options: TPadesTrustValidationOptions;
  Report: TPadesValidationResult;
  Stream: TFileStream;
begin
  Options:= TPadesTrustValidationOptions.Default;
  Options.CheckRevocation:= True;
  Options.NetworkPolicy:= ptnpOffline;   // pouze cachované odpovědi
  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 proti copy: release, který selže někde jinde

SecTrustGetCertificateAtIndex má get sémantiku a reference, kterou vrací, se nikdy nesmí uvolnit, zatímco CMSDecoderCopySignerCert a SecCertificateCopyData, sedící o pár řádků dál ve stejné rutině, mají copy sémantiku a uvolnit se musí. Core Foundation kóduje celé pravidlo jediným slovesem ve jménu funkce a type system nic nevynucuje. Uvolněte borrowed reference a v call site se nic nestane: trust object se prostě stane nekonzistentním a crash přijde později na místě, které nemá viditelnou souvislost s certificate chain

ChainCount:= _SecTrustGetCertificateCount(Trust);
SetLength(Result.ChainCertificates, ChainCount);
for I:= 0 to ChainCount- 1 do
begin
  // Get sémantika: tato reference je borrowed a zde se neuvolňuje
  Cert:= _SecTrustGetCertificateAtIndex(Trust, I);
  if Cert= nil then
    Continue;
  // Copy sémantika: tato reference je vlastněná a musí se vrátit
  CertData:= _SecCertificateCopyData(Cert);
  if CertData= nil then
    Continue;
  try
    Result.ChainCertificates[I]:= CFDataToBytes(CertData);
  finally
    _CFRelease(CertData);
  end;
end;

Co verifier zaručí, když žádný backend neodpoví

Že odpověď bude unsupported, nikdy tichý pass. Když ConfigurePadesCmsVerifier nic nenainstaloval a platformní default nepomůže, TPdfCmsVerifyResult se vrátí se všemi sloupci unavailable a PAdES validator to namapuje na pcsUnsupported, takže build bez kryptografického backendu poctivě oznámí, že nic neví o podpisu. macOS binding je konzervativní stejným směrem: k Security.framework a CoreFoundation se přistupuje přes dlopen a dlsym, takže chybějící framework nebo špatně zapsané jméno symbolu se projeví jako KeychainVerificationAvailable vracející False s KeychainMissingSymbols pojmenovávajícím viníka, nikoli jako link failure a nikoli jako chybný verdikt. Je to stejný fail-closed postoj, který komponenta používá při hledání nativní knihovny, popsaný v článku o načítání nativní knihovny PDFium na libovolném cíli

Ověřování podpisu je část PDF stacku, kde je potichu chybný výsledek horší než hlasitá nedostupnost a macOS nabízí API dostatečně štědré na to, aby obou stavů šlo snadno dosáhnout. Spojte byte ranges a přijměte kopii, držte verdikt podpisu a verdikt chainu v oddělených sloupcích, respektujte slovesa get a copy a nechte chybějící backend říct pravdu. Pokud přesouváte workflow dokumentů v Delphi nebo Free Pascalu na Mac a potřebujete PAdES signing i validation na obou stranách, PDFium Delphi Component dodává Keychain backend vedle windowsového za jediným rozhraním