Tehnički članak

Provera PDF potpisa na macOS-u uz SecTrust u Delphi-ju

PDFium Delphi Component proverava PDF potpise na macOS-u preko TPdfKeychainCmsVerifier, CMS verification backend-a izgrađenog na Apple CMSDecoder i SecTrust-u umesto na ručno parsiranom CMS-u. ConfigureKeychainCmsVerifier ga instalira, a jedan poziv CMSDecoderCopySignerStatus vraća presudu o potpisu, SecTrust handle i result code sertifikata, upravo par kolona koje je TPdfCmsVerifyResult već nosio na Windows-u

Situacija koja je pokrenula posao dosadna je i česta. Lazarus build arhive dokumenata radi na Mac-u, otvara potpisan ugovor i svaki potpis vraća kao pcsUnsupported. Sa fajlom nije ništa pogrešno. Verifikacija potpisa jednostavno nije imala backend izvan Windows-a, a PAdES validator je odbijao da nagađa kada ga nema. Verzija 3.111.0 PDFiumPas-a otvorila je seam preko IPdfCmsVerifier i ConfigurePadesCmsVerifier; verzija 3.113.0 popunila ga je na macOS-u. Zanimljiv deo porta nije plumbing, već tri mesta na kojima Apple API nema isti oblik kao Windows API

Zašto PDF potpis pokriva dva opsega bajtova?

Zato što potpis ne može da pokrije bajtove koji ga drže. ISO 32000-1 §12.8.1 smešta CMS SignedData blob u /Contents string signature dictionary-ja i opisuje potpisani opseg pomoću /ByteRange, skupa parova offset-a i dužine koji pokrivaju sve sa obe strane te rupe. Dva segmenta i jedna rupa u sredini, na svakoj platformi

Platforme se ne slažu oko načina na koji ti segmenti stižu do crypto sloja i to neslaganje košta memoriju. Na Windows-u CryptVerifyDetachedMessageSignature prihvata niz pokazivača i dužina, pa oba raspona ulaze onako kako leže u baferu i ništa se ne duplira. Apple CMSDecoderSetDetachedContent prihvata jedan CFData i nema oblik sa više segmenata, pa macOS backend konkatenira dva opsega u susedni bafer pre dekodiranja. To je puna druga kopija potpisanih bajtova. Na skeniranoj arhivi od 400 MB to je stvarni memorijski vrh, skalira se sa dokumentom, a ne sa potpisom i nema alternativnog API-ja koji biste mogli da pozovete. Zato dimenzionišite batch worker prema tome umesto da ovo otkrijete na mašini klijenta

Jedan poziv puni dve kolone TPdfCmsVerifyResult

CMSDecoderCopySignerStatus neobično je darežljiv za Security.framework entry point: jedan poziv vraća status potpisnika, SecTrustRef za lanac koji je izgradio i OSStatus za evaluaciju sertifikata. Oni direktno padaju u zapis koji PAdES validator već troši, pri čemu status potpisnika postaje SignatureStatus, rezultat sertifikata postaje TrustStatus, a sirove vrednosti ostaju u SignatureError i TrustError, pa support ticket može da citira broj umesto prideva. Pozivaoci nikada sami ne dodiruju IPdfCmsVerifierValidatePadesCompliance i ValidatePadesTrust svaku verifikaciju šalju kroz backend koji je instaliran, pa je kod koji čita TPadesSignatureValidation bajtovski isti na obe platforme, kako je opisano u vodiču o inspekciji PDF signature dictionary-ja i PAdES nivoa u Delphi-ju

uses
  FPdfCrypto, FPdfCryptoMac, FPdfPades;

procedure InstallMacVerifier;
begin
  // Potpisivanje i verifikacija razrešavaju različite framework simbole,
  // pa jedan može postojati dok drugi ne postoji
  if not KeychainVerificationAvailable then
    raise Exception.CreateFmt('Security.framework symbols missing: %s',
      [KeychainMissingSymbols]);

  ConfigureKeychainCmsVerifier;

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

Zašto kCMSSignerInvalidCert prijavljuje validan potpis?

Zato što Apple toj vrednosti daje uže značenje nego što njeno ime sugeriše: sam potpis je verifikovan, a samo lanac sertifikata nije mogao da se uspostavi. TPdfKeychainCmsVerifier zato mapira kCMSSignerInvalidCert na pcvsValid u koloni SignatureStatus i pušta da problem sertifikata izađe kroz TrustStatus, gde problem lanca i pripada. Sabiti ga u presudu o potpisu značilo bi da komponenta operateru kaže da je netaknut dokument menjan, što je najgori lažni alarm koji validator potpisa može da podigne

function MapSignerStatus(Status: LongWord): TPdfCmsVerifyStatus;
begin
  case Status of
  kCMSSignerValid:
    Result:= pcvsValid;
  // Potpis je verifikovan, a samo lanac nije, što status poverenja
  // prijavljuje zasebno
  kCMSSignerInvalidCert:
    Result:= pcvsValid;
  kCMSSignerInvalidSignature, kCMSSignerUnsigned:
    Result:= pcvsInvalid;
  else
    Result:= pcvsIndeterminate;
  end;
end;

Čitajte dva statusa kao uređeni par i logika izveštavanja sama se ispisuje. SignatureStatus = pcvsValid zajedno sa TrustStatus = pcvsInvalid opisuje dokument čiji su bajtovi netaknuti, ali čijem izdavaocu ovaj konkretni Mac ne veruje: anchor nedostaje u Keychain-u, međupotpis je istekao ili lanac ne može da se dovrši offline. To je pitanje politike operatera, a ne integriteta dokumenta, i upravo je to razlika iza većine slučajeva u belešci o tome zašto validator odbija kriptografski ispravne PAdES potpise

Gde macOS zaista proverava opoziv?

Unutar evaluacije poverenja, zbog čega TPdfCmsVerifyResult.RevocationStatus sledi TrustStatus, a ne nosi sopstvenu presudu. SecPolicyCreateRevocation proizvodi policy, taj policy se pridružuje SecPolicyCreateBasicX509 u nizu prosleđenom u CMSDecoderCopySignerStatus, a OCSP ili CRL posao odvija se tamo gde se gradi lanac. Ne vraća se nikakav zaseban odgovor, pa bi njegovo prijavljivanje značilo da ga izmišljamo. Sam niz nosi malo pravilo vlasništva koje vredi imenovati: CFArrayCreate zadržava obe policy reference, pa se dve lokalne reference odmah oslobađaju, dok slučaj sa jednom politikom uopšte preskače niz i policy predaje direktno, što je oblik koji API takođe prihvata

Offline rad je eksplicitna zastavica, a ne slučajnost konektivnosti. Kada je TPdfCmsVerifyOptions.OnlineRetrieval False, backend dodaje kSecRevocationNetworkAccessDisabled, ograničavajući evaluaciju na odgovore već keširane na mašini, a checkpoint callback i dalje okida pcvstCryptographicSignature, pcvstChainBuild i pcvstRevocationCheck istim redom kojim ih Windows backend prijavljuje. Aplikacioni kod sve ovo postavlja kroz option record višeg nivoa

var
  Options: TPadesTrustValidationOptions;
  Report: TPadesValidationResult;
  Stream: TFileStream;
begin
  Options:= TPadesTrustValidationOptions.Default;
  Options.CheckRevocation:= True;
  Options.NetworkPolicy:= ptnpOffline;   // samo keširani odgovori
  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 naspram copy: oslobađanje koje kasni na drugom mestu

SecTrustGetCertificateAtIndex ima get semantiku i referenca koju vraća nikada ne sme da se oslobodi, dok CMSDecoderCopySignerCert i SecCertificateCopyData, nekoliko linija dalje u istoj rutini, imaju copy semantiku i moraju da se oslobode. Core Foundation ceo ugovor kodira jednom rečju u imenu funkcije, a sistem tipova ne sprovodi ništa od toga. Oslobodite pozajmljenu referencu i na mestu poziva se ništa ne mora desiti: trust objekat jednostavno postane neispravan, a pad stiže kasnije, na mestu koje nema vidljivu vezu sa lancima sertifikata

ChainCount:= _SecTrustGetCertificateCount(Trust);
SetLength(Result.ChainCertificates, ChainCount);
for I:= 0 to ChainCount- 1 do
begin
  // Get semantika: ova referenca je pozajmljena i ovde se ne oslobađa
  Cert:= _SecTrustGetCertificateAtIndex(Trust, I);
  if Cert= nil then
    Continue;
  // Copy semantika: ova referenca je vlasnička i mora da se vrati
  CertData:= _SecCertificateCopyData(Cert);
  if CertData= nil then
    Continue;
  try
    Result.ChainCertificates[I]:= CFDataToBytes(CertData);
  finally
    _CFRelease(CertData);
  end;
end;

Šta verifier garantuje kada nijedan backend ne odgovori?

Da je odgovor unsupported, nikada tihi prolaz. Tamo gde ConfigurePadesCmsVerifier nije instalirao ništa, a podrazumevana platforma ne može da pomogne, TPdfCmsVerifyResult vraća se sa svim kolonama postavljenim na unavailable, a PAdES validator to mapira na pcsUnsupported, pa build bez crypto backend-a pošteno prijavljuje stanje umesto da tvrdi bilo šta o potpisu. macOS binding namerno je konzervativan u istom smeru: do Security.framework-a i CoreFoundation-a dolazi preko dlopen i dlsym, pa se odsutan framework ili ime simbola koje je ovaj binding pogrešio pojavljuje kao KeychainVerificationAvailable koje vraća False, uz KeychainMissingSymbols koji imenuje krivca, a ne kao link failure i ne kao pogrešna presuda. To je isti fail-closed stav koji komponenta zauzima kada traži native library, opisan u tekstu o učitavanju PDFium native biblioteke na bilo kom cilju

Verifikacija potpisa jeste deo PDF stack-a u kojem je tiha greška gora od glasne nedostupnosti, a macOS vam daje API dovoljno darežljiv da oba ishoda budu lako dostupna. Konkatenirajte byte range-ove i prihvatite kopiju, držite presudu o potpisu i presudu o lancu u odvojenim kolonama, poštujte get i copy glagole i pustite da nedostajući backend to kaže. Ako Delphi ili Free Pascal workflow dokumenata premeštate na Mac i treba vam PAdES potpisivanje i validacija na obe strane, PDFium Delphi Component isporučuje Keychain backend zajedno sa Windows backend-om iza jednog interfejsa