Technický článek

Podepisování PAdES identitou z macOS Keychain v Delphi

PDFium VCL podepisuje PAdES dokumenty privátním klíčem drženým v macOS Keychain přes backend, který resolveuje každý symbol Security a CoreFoundation za běhu přes dlopen a dlsym. Nic není vázané v link čase, takže překlepnuté jméno symbolu se projeví jako KeychainAvailable vracející False a KeychainMissingSymbols pojmenující pachatele, místo jako linker error nebo crash

Volba byla vynucená nepříjemným omezením a způsob, jakým se to ošetřilo, se zobecňuje. Unit se psala na stroji bez macOS SDK, takže každé jméno framework symbolu a každá konstanta přišla z dokumentace a nic z toho nešlo zkontrolovat proti headeru. Špatná odpověď na tuhle situaci je napsat kód pečlivě a doufat. Správná je zařídit, aby nevyhnutelné chyby samy ohlásily sebe v nejlépe dohledatelné podobě

Proč je dynamické vázání správná volba i na cílové platformě

Protože převádí třídu selhání, která zastaví program, na třídu selhání, která se ohlásí sama. Špatná staticky linkovaná reference na framework selže v link čase na cíli a nikde jinde nelinkuje. Špatná dynamicky vázaná vyprodukuje nedostupný backend a seznam neresolvovaných jmen a první běh na Macu změní otázku z proč je tohle nedostupné na jedinou řádku pojmenující překlep

Je tu druhý přínos, který se vyplácí denně, ne jednou. Protože unit nelinkuje žádné frameworky, kompiluje se na každé platformě, takže obyčejný Windows build dál kontroluje její syntaxi, typy i uses clause. Unit, která se kompiluje jen na platformě, kterou nemá nikdo v týmu, je unit bez kompilátoru, který by se na ni díval, a tiše chátrá s každým refaktorem sdíleného typu

uses
  FPdfCrypto, FPdfCryptoMac;

var
  Options: TPadesSignerOptions;
begin
  if not KeychainAvailable then
    raise Exception.Create('Keychain backend unavailable, unresolved: ' +
      KeychainMissingSymbols);

  ConfigureKeychainSignerProvider;   // nainstalovat jako PAdES signer backend
  ConfigureKeychainCmsVerifier;      // a jako verification backend

  Writeln('signer backend  : ', PadesCryptoBackendName);
  Writeln('verify backend  : ', PadesCmsVerificationBackendName);

  Options := TPadesSignerOptions.Default;
  Options.CertificateThumbprint := 'B1 3F 9C ...';   // SHA-1, libovolná velikost písmen
  Options.PaddingScheme := psRsaPss;
end;

Dva druhy exportovaných symbolů, dva způsoby čtení

Tohle je jediný nejzmatenější detail celého vázání a mít to obráceně se zkompiluje čistě a selže za běhu. CoreFoundation a Security exportují dvě kategoricky různé věci stejným voláním dlsym a kód musí vědět, která je která

Pojmenované konstanty jako klíče item class keychainu a CoreFoundation boolean singlefony jsou exportované proměnné, jejichž obsahem je CFStringRef nebo CFBooleanRef, který chcete. dlsym vrací adresu té proměnné, takže musíte jednou dereferencovat, abyste hodnotu získali. Struktury callback tabulek jako dictionary key a value callbacky jsou exportované struktury a dlsym vrací adresu struktury, což je přesně pointer, který očekává funkce zakládající dictionary. Dereferencujete-li tenhle, předáte první machine word struktury, jako by to byl pointer

Ani jedna chyba nevyprodukuje compile error a ani jedna nevyprodukuje jasný runtime error. Dostanete garbage pointer, který selže někde po proudu. Způsob, jak udělat tohle rozlišení nezkazitelným, je přestat spoléhat na pamatování: dvě helper funkce, jedna váže a dereferencuje a druhá váže a nedereferencuje, takže místo volání deklaruje, jaký druh symbolu si žádá, a helper vynutí zbytek

Diagram backendu macOS Keychain v PDFium VCL resolvujícího symboly Security a CoreFoundation přes dlsym: kSecClass je exportovaná proměnná, kterou BindConstant jednou dereferencuje, aby získal hodnotu CFStringRef, zatímco kCFTypeDictionaryKeyCallBacks je exportovaná struktura, kterou BindStruct předává adresou, a promíchání obou pravidel dá garbage pointery po proudu
Jedno volání dlsym vrací dvě kategoricky různé věci: adresu proměnné držící CFTypeRef a adresu callback struktury. Dva helpery udělají rozhodnutí dereferencovat-či-ne na místě vázání místo v paměti
// Exportovaná proměnná: dlsym dá adresu proměnné držící CFTypeRef,
// takže jednou dereferencujte
FSecClassKey := BindConstant(SecurityLib, 'kSecClass');

// Exportovaná struktura: dlsym dá adresu struktury SAMOTNÉ, což je
// to, co API chce. Nedereferencujte
FKeyCallbacks := BindStruct(CoreFoundationLib,
  'kCFTypeDictionaryKeyCallBacks');

Proč potřebuje podpis RSA-PSS dva oddělené fallbacky?

Protože algoritmus může chybět dvěma nezávislými způsoby a jen jeden z nich je otázka verze. Konstanta PSS digest-signing algoritmu se objevila v macOS 10.13, takže na starším systému symbol prostě není a binding dostane nil. To je version check. Samostatně: na systému, kde konstanta existuje, může konkrétní klíč stejně odmítnout a framework odpoví na tuhle otázku přes SecKeyIsAlgorithmSupported pro daný klíč. Hardwarově podložený klíč nebo klíč s restriktivními atributy může PSS odmítnout, zatímco softwarový klíč na témže stroji ho přijme

Obě cesty musejí vést ke stejnému fallbacku: přepnout na PKCS#1 v1.5. A kritická část je, že fallback musí změnit i algorithm identifier zapsaný do CMS struktury, ne jen podpisové volání. Emitovat PSS algorithm identifier, zatímco doopravdy vzniká v1.5 podpis, dá dokument, který každý verifier odmítne čistě, což je striktně hůř než nahlásit, že PSS není podporovaný. Downgrade je přijatelný, nesoulad mezi tím, co deklarujete, a tím, co jste udělali, není, a tohle je obecné pravidlo pro podpisový kód, ne zvláštnost macOS. Důsledky na úrovni podpisu rozebírá článek podepisování PDF s PAdES B-B

Rozhodovací řetěz ukazující, proč podepisování RSA-PSS v Keychain backendu PDFium VCL potřebuje dva nezávislé fallbacky: dlsym vrací nil pro konstantu digest-signing na verzích macOS před 10.13, SecKeyIsAlgorithmSupported může odmítnout hardwarově podložený klíč a obě brány ústí do téhož downgradu PKCS#1 v1.5, jehož CMS algorithm identifier se musí změnit s ním
PSS může být nedostupné dvakrát, jednou na verzi macOS a jednou na klíč, a jen version brána je systémová otázka. Obě brány ústí do téhož downgradu v1.5 a CMS identifier následuje

Encoding podpisu ECDSA a obrácení, které stojí za zmínku

Elliptic-curve cesta nepotřebuje na macOS vůbec žádnou konverzi, což je opak toho, co vyžaduje binding PKCS#11. Digest-signing algoritmus Security frameworku pro ECDSA vrací podpis už ve formě X9.62 DER, což je přesně to, co CMS chce. Token PKCS#11 místo toho vrací surový fixně široký pár P1363, který se musí překódovat, než vejde do podpisové struktury

Dva backendy implementující stejné interface tedy potřebují opačné zacházení pro tentýž algoritmus a ani jeden není špatně. Tohle je přesně ten druh rozdílu, který musí abstrakce pojíst, ne vystavit: PAdES vrstva žádá providera o podpis a encoding konvence zůstávají uvnitř providera. Proteknou-li nahoru, každý volající skončí s per-backend conditionalem. Tatáž podoba se objevuje v příběhu vzdáleného podepisování popsaném v článku vzdálené PAdES signing sessions proti HSM

Srovnání encodingu ECDSA podpisu napříč dvěma backendy PAdES signeru PDFium VCL: Security framework macOS Keychain vrací X9.62 DER, který CMS přijme s nulovou konverzí, zatímco token PKCS#11 vrací surový fixně široký pár P1363, který se musí překódovat, takže ResolvePadesSigner drží encoding konvence uvnitř providera
Stejné ECDSA interface potřebuje per backend opačné zacházení: Security podává hotový DER, zatímco token PKCS#11 podává surový P1363, takže konverze bydlí uvnitř providera a volající nikdy nevidí per-backend conditional
// Provider interface je na každé platformě stejný, takže výběr je
// rozhodnutí při startu, ne při každém volání
{$IFDEF DARWIN}
  if KeychainAvailable then
    ConfigureKeychainSignerProvider;
{$ENDIF}
{$IFDEF MSWINDOWS}
  // Windows CNG provider instaluje platformní unit
{$ENDIF}

if not PadesCryptoAvailable then
  raise Exception.Create('no signing backend on this platform');

// Odtud dál je podpisový kód platformně neutrální
Signer := ResolvePadesSigner(Options);

Pravidla reference countingu tři řádky od sebe

Správa paměti Core Foundation se řídí naming konvencemi a past je v tom, že funkce s různými konvencemi se objevují vedle sebe v témže krátkém bloku. Funkce, která getuje certifikát z trust objektu, vrací vypůjčenou referenci, kterou nesmíte uvolnit. Funkce, které copy certifikát podepisujícího nebo kopírují jeho data, vrací vlastněné reference, které uvolnit musíte. Tři volání za sebou, dvě ownership pravidla a uvolnění vypůjčené neselže na tom řádku. Pokazí retain count a později srazí něco úplně jiného

Zmírnění je číst sloveso ve jméně každé framework funkce před psaním cleanupu, pokaždé, bez výjimky. Je to CoreFoundation obdoba kontroly, zda API vrací kopii, nebo view, a cena omylu je přerušovaný crash, ne chyba

Co tenhle backend netvrdí

V době psaní nikdy nebežel na macOS a říct to napřímo je užitečnější než naznačená pojistka. Prokazatelná pravda je užší a pořád hodnotná: unit se kompiluje na Windows v rámci denního buildu, každý framework symbol se váže jménem za běhu s vypsanými selháními a logika výběru algoritmu včetně obou PSS fallbacků je obyčejný Pascal, který se dá revidovat a o němž se dá uvažovat. První běh na Macu buď funguje, nebo vyprodukuje seznam jmen k opravě

Verifikační protějšek, který používá výše postavený CMS decoder místo ručního skládání CMS struktury, pokrývá článek ověřování PDF podpisů na macOS se SecTrust a sdílí tutéž binding infrastrukturu a tentýž diagnostický přístup

Přenositelný nápad tady je o umístění rizika, ne o macOS. Když musíte psát kód proti interface, který nemůžete ověřit, vyberte konstrukci, kde jsou chyby nejlevnější na dohledání. Dynamické vázání s explicitním seznamem neresolvovaných jmen promění dvacet neověřitelných předpokladů v jednu diagnostickou řádku. Oba backendy se dodávají jako source s PDFium Delphi komponentou, takže potřebuje-li se jméno symbolu opravit, je to změna jednoho řádku ve vašem vlastním stromu, ne support ticket