Műszaki cikk

PDF-aláírások ellenőrzése macOS-en SecTrust-tal Delphiben

A PDFium Delphi Component macOS-en a TPdfKeychainCmsVerifier segítségével ellenőrzi a PDF-aláírásokat, egy Apple CMSDecoder-re és SecTrustra épülő CMS-verifikációs backenddel, nem kézzel parsolt CMS-sel. A ConfigureKeychainCmsVerifier telepíti, egyetlen CMSDecoderCopySignerStatus hívás pedig visszaadja az aláírás verdictjét, egy SecTrust handle-t és egy tanúsítvány-eredménykódot, pontosan azt a két oszlopot, amelyet a TPdfCmsVerifyResult Windowson már hordozott

A munkát kiváltó helyzet hétköznapi és gyakori. Egy dokumentumarchívum Lazarus-buildje Macen fut, megnyit egy aláírt szerződést, és minden aláírásra pcsUnsupported érkezik. A fájllal semmi baj. Az aláírás-ellenőrzésnek egyszerűen nem volt backendje Windowson kívül, a PAdES-validator pedig backend hiányában nem találgatott. A PDFiumPas v3.111.0-ban az IPdfCmsVerifier és a ConfigurePadesCmsVerifier megnyitotta a varratot, a v3.113.0 pedig macOS-en betöltötte. A port érdekes része nem a plumbing, hanem három hely, ahol az Apple API nem ugyanúgy néz ki, mint a windowsos

Miért fed le egy PDF-aláírás két byte-range-et?

Azért, mert az aláírás nem fedheti le azokat a byte-okat, amelyek magát hordozzák. Az ISO 32000-1 §12.8.1 a CMS SignedData blobot a signature dictionary /Contents stringjébe teszi, a signed extentet pedig a /ByteRange írja le, offset- és hossz-párok sorozataként, amely a lyuk két oldalán mindent lefed. Két szegmens, középen egy rés, minden platformon

A platformok eltérnek abban, hogyan jut ez a két szegmens a crypto layerbe, és az eltérés memóriába kerül. Windowson a CryptVerifyDetachedMessageSignature pointerek és hosszok tömbjét fogadja, ezért mindkét span mehet úgy, ahogy a bufferben fekszik, duplikáció nélkül. Az Apple CMSDecoderSetDetachedContent egyetlen CFData-t fogad, multi-segment alakja nincs, ezért a macOS backend összefűzi a két tartományt egy folytonos bufferbe a dekódolás előtt. Ez a signed byte-ok teljes második másolata. Egy 400 MB-os szkennelt archívumnál ez valódi memória-csúcs, a dokumentummal együtt skálázódik, nem az aláírással, és nincs másik API, amelyhez nyúlhatnál. A batch workert ennek tudatában méretezd, ne egy ügyfél gépén fedezd fel

Egy hívás tölti ki a TPdfCmsVerifyResult két oszlopát

A CMSDecoderCopySignerStatus szokatlanul bőkezű Security.framework belépési pont: egy hívás visszaadja a signer státuszát, az általa felépített lánc SecTrustRef-jét, valamint a tanúsítványértékelés OSStatus-át. Ezek közvetlenül abba a rekordba kerülnek, amelyet a PAdES-validator már fogyaszt, a signer státusza SignatureStatus, a tanúsítvány eredménye TrustStatus lesz, a nyers értékek pedig SignatureError és TrustError alatt megmaradnak, így egy support-ticket számot idézhet jelző helyett. A hívók közvetlenül soha nem érintik az IPdfCmsVerifier-t — a ValidatePadesCompliance és ValidatePadesTrust minden verifikációt azon a backenden keresztül irányít, amelyik telepítve van, ezért a TPadesSignatureValidation-t olvasó kód bájtról bájtra ugyanaz mindkét platformon, ahogy azt a PDF signature dictionaryk és PAdES-szintek Delphiben való vizsgálatáról szóló útmutató is leírja

uses
  FPdfCrypto, FPdfCryptoMac, FPdfPades;

procedure InstallMacVerifier;
begin
  // Az aláírás és az ellenőrzés külön framework-szimbólumokat old fel,
  // ezért az egyik jelen lehet, miközben a másik hiányzik
  if not KeychainVerificationAvailable then
    raise Exception.CreateFmt('Security.framework symbols missing: %s',
      [KeychainMissingSymbols]);

  ConfigureKeychainCmsVerifier;

  // A PadesCmsVerificationBackendName most ezt adja: 'macOS Security.framework'
  if not PadesCmsVerificationAvailable then
    raise Exception.Create('No CMS verification backend is installed');
end;

Miért jelent a kCMSSignerInvalidCert érvényes aláírást?

Azért, mert az Apple ennek az értéknek a neve által sugalltnál szűkebb jelentést ad: maga az aláírás érvényesült, csak a tanúsítványláncot nem sikerült felépíteni. A TPdfKeychainCmsVerifier ezért a kCMSSignerInvalidCert-et a SignatureStatus oszlopban pcvsValid-re képezi, a tanúsítványproblémát pedig a TrustStatuson keresztül hagyja felszínre kerülni, ahol a lánc hibája helyet kap. Ha ezt az aláírás verdictjébe gyúrnád, a komponens azt mondaná az operátornak, hogy egy érintetlen dokumentum módosult, ez pedig a signature validator által adható legrosszabb téves riasztás

function MapSignerStatus(Status: LongWord): TPdfCmsVerifyStatus;
begin
  case Status of
  kCMSSignerValid:
    Result:= pcvsValid;
  // Az aláírás érvényesült, csak a lánc nem; ezt külön a trust
  // status jelenti
  kCMSSignerInvalidCert:
    Result:= pcvsValid;
  kCMSSignerInvalidSignature, kCMSSignerUnsigned:
    Result:= pcvsInvalid;
  else
    Result:= pcvsIndeterminate;
  end;
end;

A két státuszt rendezett párként olvasd, és a reporting logika magától felépül. A SignatureStatus = pcvsValid és TrustStatus = pcvsInvalid olyan dokumentumot jelent, amelynek byte-jai épek, de a kibocsátót az adott Mac nem bízza meg: hiányzik egy anchor a Keychainből, lejárt egy intermediate, vagy offline nem fejezhető be a lánc. Ez operátori policy-kérdés, nem dokumentumintegritási kérdés, és pontosan ez a különbség áll a kriptográfiailag ép PAdES-aláírásokat elutasító validatorokról szóló jegyzet legtöbb esete mögött

Hol ellenőriz ténylegesen revokációt a macOS?

A trust evaluation belsejében, ezért követi a TPdfCmsVerifyResult.RevocationStatus a TrustStatus-t, ahelyett hogy saját verdictet hordozna. A SecPolicyCreateRevocation policyt hoz létre, ez a policy bekerül a SecPolicyCreateBasicX509 mellé a CMSDecoderCopySignerStatus-nak átadott tömbbe, az OCSP- vagy CRL-munka pedig ott történik, ahol a lánc felépül. Külön válasz nem jön vissza, ezért egy külön revokációs verdict kitalálása lenne. A tömbhöz egy kis tulajdonlási szabály is tartozik: a CFArrayCreate mindkét policyt retaineli, ezért a két lokális referenciát azonnal fel lehet szabadítani, az egyetlen policyt tartalmazó eset pedig teljesen elhagyja a tömböt, és közvetlenül adja át a policyt, amit az API szintén elfogad

Az offline működés explicit flag, nem a kapcsolat véletlen hiánya. Ha a TPdfCmsVerifyOptions.OnlineRetrieval False, a backend hozzáadja a kSecRevocationNetworkAccessDisabled értéket, így az ellenőrzés a gépen már cache-elt válaszokra korlátozódik, a checkpoint callback pedig ugyanabban a sorrendben adja a pcvstCryptographicSignature, pcvstChainBuild és pcvstRevocationCheck állapotot, ahogy a Windows backend jelenti. Az alkalmazáskód mindezt a magasabb szintű options rekordon keresztül állítja be

var
  Options: TPadesTrustValidationOptions;
  Report: TPadesValidationResult;
  Stream: TFileStream;
begin
  Options:= TPadesTrustValidationOptions.Default;
  Options.CheckRevocation:= True;
  Options.NetworkPolicy:= ptnpOffline;   // csak cache-elt válaszok
  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 kontra copy: a release, amely máshol bukik el

A SecTrustGetCertificateAtIndex get szemantikájú, az általa visszaadott referenciát pedig soha nem szabad felszabadítani, miközben a néhány sorral odébb lévő CMSDecoderCopySignerCert és SecCertificateCopyData copy szemantikájú, ezért ezeket igen. A Core Foundation az egész szabályt egyetlen igével kódolja a függvény nevében, a típusrendszer viszont semmit nem kényszerít ki belőle. Ha a kölcsönzött referenciát felszabadítod, a hívási helyen semmi nem romlik el: a trust objektum egyszerűen érvénytelenné válik, az összeomlás pedig később, egy olyan helyen érkezik, amelynek látszólag nincs kapcsolata a certificate chainnel

ChainCount:= _SecTrustGetCertificateCount(Trust);
SetLength(Result.ChainCertificates, ChainCount);
for I:= 0 to ChainCount- 1 do
begin
  // Get szemantika: ez a referencia kölcsönzött, itt nem szabadítani
  Cert:= _SecTrustGetCertificateAtIndex(Trust, I);
  if Cert= nil then
    Continue;
  // Copy szemantika: ez a referencia saját, vissza kell adni
  CertData:= _SecCertificateCopyData(Cert);
  if CertData= nil then
    Continue;
  try
    Result.ChainCertificates[I]:= CFDataToBytes(CertData);
  finally
    _CFRelease(CertData);
  end;
end;

Mit garantál a verifier, ha egyetlen backend sem válaszol?

Azt, hogy az eredmény unsupported, soha nem csendes pass. Ha a ConfigurePadesCmsVerifier nem telepített semmit, és a platform default sem segít, a TPdfCmsVerifyResult minden oszlopa unavailable értékkel érkezik, a PAdES-validator pedig ezt pcsUnsupported-ra képezi, így a crypto backend nélküli build őszintén jelent, nem állít valamit az aláírásról. A macOS-binding ugyanilyen konzervatív: a Security.frameworket és a CoreFoundationt dlopen és dlsym segítségével éri el, így egy hiányzó framework vagy egy elrontott szimbólumnév a KeychainVerificationAvailable False eredményében jelenik meg, a KeychainMissingSymbols pedig megnevezi a vétkest, nem linkhiba és nem rossz verdict formájában. Ez ugyanaz a fail-closed hozzáállás, amelyet a komponens a natív library keresésekor követ, az PDFium natív library bármely célon való betöltéséről szóló cikkben leírva

A signature verification a PDF-stack azon része, ahol a csendes tévedés rosszabb a hangos elérhetetlenségnél, a macOS pedig olyan API-t ad, amellyel mindkét kimenet könnyen elérhető. Fűzd össze a byte-range-eket, fogadd el a másolat árát, tartsd külön az aláírás és a chain verdictjét, tartsd tiszteletben a get és copy igéket, és hagyd, hogy egy hiányzó backend ezt kimondja. Ha Delphi vagy Free Pascal dokumentum-workflow-t viszel Macre, és mindkét oldalon PAdES-aláírásra és validációra van szükséged, a PDFium Delphi Component a Keychain backendet ugyanazon interface mögött szállítja a windowsos mellett