Technický článek

Ověřování PDF podpisů přes OpenSSL v PDFium VCL

PDFium VCL bere CMS verifikaci jako vyměnitelný backend za interface IPdfCmsVerifier, takže PAdES validátor může běžet na Windows přes CryptoAPI, na macOS přes Keychain a kdekoli, kde je OpenSSL, přes ConfigureSslCmsVerifier. Interface je malý. Tři chování OpenSSL pod ním produkují sebejistě špatné odpovědi, pokud ho implementujete naivně

Motivace je jasná, jakmile Delphi aplikace opustí Windows. Validace podpisů je jedna z mála oblastí, kde platformní crypto stack není implementační detail: rozhoduje, které certifikáty jsou důvěryhodné, které algoritmy existují a co znamená revokace. Natvrdo zapsat jednu znamená, že kód neportuje. Abstrahovat ji špatně znamená, že každá platforma hlásí jinak tvarovanou odpověď, kterou si volající nemůže srovnat

Co musí abstrakce doopravdy nést

Dvě podoby verifikace a tři nezávislé verdikty. PDF podpis je detached: podepsaný obsah jsou dva bajtové rozsahy po stranách díry /Contents, takže VerifyDetached bere dva segmenty, ne jeden buffer. Timestamp token je attached, nese vlastní obsah, takže VerifyAttached bere jen DER

Výsledek se dělí na tři statusy, protože odpovídají na tři různé otázky a můžou si protiřečit. SignatureStatus říká, zda bajty podepsal klíč v certifikátu podepisujícího. TrustStatus říká, zda ten certifikát chainuje k něčemu, čemu věříte. RevocationStatus říká, zda byl certifikát stále platný v relevantní době. Dokument s matematicky dokonalým podpisem od certifikátu, o němž jste nikdy neslyšeli, je validní, nedůvěryhodný a neznámý a slepit tohle do jediného booleanu je způsob, jak validátory končí tím, že lžou uživatelům

uses
  FPdfCrypto, FPdfCryptoSsl;

var
  Options: TPdfCmsVerifyOptions;
begin
  if not SslAvailable then
    raise Exception.Create('libcrypto not usable: ' + SslMissingSymbols);

  ConfigureSslTrustAnchors(LoadCorporateRoots);   // DER, může být prázdné
  ConfigureSslCrls(LoadFreshCrls);                // DER, může být prázdné
  ConfigureSslCmsVerifier;                        // instaluje backend

  Writeln('backend  : ', PadesCmsVerificationBackendName);
  Writeln('library  : ', SslLibraryPath, ' ', SslLibraryVersion);
  Writeln('ABI      : ', SslAbiLayout);           // ulong=<n> long=<n>

  Options := TPdfCmsVerifyOptions.Default;
  Options.CheckRevocation := True;
  Options.CollectChainCertificates := True;
end;

SslAbiLayout vypadá jako kuriozita a není. Každý error kód OpenSSL a každý store flag překračuje hranici jako C unsigned long, který je na Windows čtyři bajty a na Linuxu a macOS osm. Deklarovat ho jako fixní 32bit typ znamená, že kód funguje na Windows a pak na LP64 tiše čte polovinu hodnoty. Hlásit předpokládané šířky jako string, na který se v testu můžete assertnout, promění celou třídu platformního ABI driftu v jednořádkovou kontrolu. Kdo kdy procházel stejný problém s CK_ULONG v bindingu PKCS#11, okamžitě ho pozná; tenhle příběh je v článku PKCS#11 struct packing a šířka CK_ULONG

Proč druhý průchod verifikace vidí prázdný obsah?

Protože CMS_verify čte detached content BIO až do konce souboru a BIO, které bylo čteno, vám nikdo nezaskočí zpět. Verifikovat ve dvou průchodech je rozumný návrh, nejdřív samotný kryptografický podpis s potlačeným vyhodnocením chainu, pak plné vyhodnocení, a selže to neobvykle klamavým způsobem, sdílí-li oba průchody jedno BIO

Druhý průchod dostane nula bajtů obsahu. V detached módu to není chyba, protože prázdný content buffer je legální vstup. Digest se prostě neschoduje a selhání se projeví jako selhání stavby chainu, ne jako selhání obsahu, což vás pošle zkoumat certifikáty a trust store, zatímco skutečný problém je pozice ve streamu. Postavte memory BIO přes BIO_new_mem_buf pro každý průchod znovu. Stojí jednu alokaci a možnost odstraní úplně

Co flag no-verify potlačí a co ne

CMS_NO_SIGNER_CERT_VERIFY potlačuje vyhodnocení chainu, ne dohledání certifikátu podepisujícího. Interně resolveuje a připojuje OpenSSL certifikáty podepisujícího dřív, než flag konzultuje, takže po prvním průchodu nesoucím tenhle flag je podepisující už dostupný a jeho algorithm identifiery lze číst hned. Není potřeba pouštět druhou plnou verifikaci jen kvůli získání certifikátu podepisujícího, což je to, do čeho vás jméno flagu láká

Jedno ownership pravidlo s tím souvisí. Reference na podepisujícího patří CMS struktuře a nesmí se uvolňovat samostatně. Platí, dokud platí struktura, a její uvolnění vyprodukuje corrupt, jejíž symptom se objeví úplně jinde, obvykle při cleanupu nesouvisejícího objektu

Proč zapnutí kontroly CRL odmítne každý podpis?

Protože OpenSSL kontroluje CRL jen proti tomu, co store už drží, a nic vlastními silami nestahuje. Nenasleduje CRL distribution points a nemluví OCSP. Nastavte X509_V_FLAG_CRL_CHECK na store bez jediného CRL a každý chain selže s neschopností získat certifikátové CRL. Výsledek vypadá jako revokační kontrola, která funguje a nachází problémy. Je to revokační kontrola, která nikdy vůbec neběžela

Backend proto nastavuje flag jen tehdy, když ConfigureSslCrls doopravdy dodal aspoň jedno CRL. Bez něj se RevocationStatus vrátí jako pcvsUnsupported, což je upřímné tvrzení, že otázka nezodpovězena byla. Ze stejného důvodu nemá OnlineRetrieval na tenhle backend žádný efekt a neemituje se žádný checkpoint pcvstOnlineRetrieval: není tu žádná stahovací cesta, ze které by se hlásil progres

Diagram OpenSSL CMS verifieru v PDFium VCL se třemi pastmi: sdílené content BIO čtené do konce souboru nechá druhý průchod verifikace s nula bajty, CMS_NO_SIGNER_CERT_VERIFY potlačí vyhodnocení chainu, ne dohledání podepisujícího, a kontrola CRL na prázdném store odmítne každý chain, aniž by revokace kdy běžela
Každá past dá sebejistě špatný verdikt: pozice ve streamu se vydává za selhání důvěry, flag no-verify potlačuje míň, než jeho jméno naznačuje, a nikdy neběžší revokace vypadá jako revokace, která našla problémy

Tohle je designová pozice, která stojí za obhajobu obecně. Validátor, který nedokáže zkontrolovat revokaci, by to měl říct. Hlásit nezkontrolovaný certifikát jako nerevokovaný je ten nejčastější způsob, jakým nástroje na validaci podpisů klamou své uživatele, a je to přesně ta třída zmatení, kterou rozebírá článek proč validátory odmítají PAdES podpisy

// Checkpointy umožní UI ukázat, která stage běží, a řeknou vám,
// které stage backend doopravdy provádí
type
  TSignatureProbe = class
    procedure Checkpoint(Stage: TPdfCmsVerifyStage);
  end;

procedure TSignatureProbe.Checkpoint(Stage: TPdfCmsVerifyStage);
begin
  case Stage of
    pcvstCryptographicSignature: Status('checking the signature');
    pcvstChainBuild:             Status('building the certificate chain');
    pcvstOnlineRetrieval:        Status('fetching validation data');
    pcvstRevocationCheck:        Status('checking revocation');
  end;
end;

// Tři verdikty čtěte odděleně; smí si protiřečit
if Result.SignatureStatus = pcvsValid then
  case Result.TrustStatus of
    pcvsValid:         Report('signed and trusted');
    pcvsInvalid:       Report('signed, chain rejected');
    pcvsUnsupported,
    pcvsIndeterminate: Report('signed, trust not established');
  end;
if Result.RevocationStatus = pcvsUnsupported then
  Report('revocation was not checked on this backend');

Vázání na knihovnu, kterou nemůžete zafixovat

OpenSSL přejmenovalo své stack accessory mezi 1.0 a 1.1, takže tatáž logická funkce má dvě možná exportní jména podle buildu, který host zrovna má. Binding resolveuje nejdřív novější jméno a spadne zpět na starší a chybějící symbol zaznamená, jen když nevyjde ani jedno. To je správná podoba pro jakékoli dynamické vázání na knihovnu, kterou nedodáváte: preferujte současná jména, tolerujte historická a hlásit jen skutečnou absenci

SslMissingSymbols je to, co ze selhaného loadu udělá diagnostikovatelnou událost. Neprázdný výsledek na hostu, který zjevně má libcrypto nainstalované, znamená, že nainstalovaná verze je starší než API, na které tenhle build míří, což je úplně jiný support rozhovor než chybějící knihovna. ConfigureSslLibraryPath pokrývá druhý běžný případ, host s několika OpenSSL buildy, kde ten na defaultní search path není ten, který chcete

Výběr backendu per platforma

Praktická úprava je vybrat při startu a zaznamenat, který odpověděl. Na Windows se platformní backend integruje s certificate store, které podnik už spravuje, což je normálně to, co chcete. Na macOS sedí Keychain backend do téže úvahy a popisuje ho článek ověřování podpisů se SecTrust na macOS. OpenSSL je přenosná volba a je to taky správná volba, když potřebujete validační politiku identickou napříč platformami, místo jedné, která následuje trust store každé platformy

Diagram abstrakce IPdfCmsVerifier v PDFium VCL nesoucí VerifyDetached přes dva bajtové rozsahy kolem díry Contents a VerifyAttached pro timestamp tokeny, tři nezávislé verdikty SignatureStatus, TrustStatus a RevocationStatus a per-platform backendy vybírané při startu přes CryptoAPI, SecTrust nebo ConfigureSslCmsVerifier
Interface nese dvě podoby verifikace a tři verdikty, protože odpovídají na různé otázky a můžou si protiřečit, a nainstalovaný backend se zaznamenává vedle každého verdiktu, takže uložené výsledky jdou reprodukovat

Cokoli nainstalujete, logujte PadesCmsVerificationBackendName vedle každého verdiktu, který zaznamenáte. Uložený validační výsledek bez backendu, který ho vyprodukoval, nejde později reprodukovat, protože tři statusové hodnoty znamenají subtílně různé věci podle toho, který stack odpověděl. Vrstvu inspekce podpisů nad tímhle vším, včetně toho, jak se hlásí PAdES úrovně, pokrývá článek inspekce PDF digitálních podpisů a PAdES úrovní

Všechno tohle se dodává jako source s PDFium Delphi komponentou, což je tady důležitější než obvykle: pro validátor podpisů je možnost přečíst si přesně, které flagy backend nastavuje a které kontroly přeskakuje, ne nice-to-have, ale jediný způsob, jak vědět, co zelená fajfka ve vaší aplikaci doopravdy tvrdí