Odborný článok

Podpisovanie PAdES identitou z macOS Keychain v Delphi

PDFium VCL podpisuje PAdES dokumenty súkromným kľúčom uloženým v macOS Keychain cez backend, ktorý resolveuje každý symbol Security a CoreFoundation za behu cez dlopen a dlsym. Nič nie je viazané v čase linkovania, takže preklep v názve symbolu sa prejaví ako KeychainAvailable vracajúce False a ako KeychainMissingSymbols vymenujúci vinníka, nie ako linková chyba alebo pád

Túto voľbu vynútilo nepríjemné obmedzenie a spôsob, akým bolo ošetrené, sa dá zovšeobecniť. Unita vznikla na stroji bez macOS SDK, takže každý názov frameworkového symbolu a každá konštanta pochádzali z dokumentácie a nič sa nedalo skontrolovať proti hlavičkovému súboru. Zlá reakcia na takú situáciu je písať kód opatrne a dúfať. Tá správna je zariadiť, aby sa nevyhnutné chyby prihlásili samy v čo najľahšie dohľadateľnej podobe

Prečo je dynamické viazanie správna voľba aj na cieľovej platforme

Pretože premieňa triedu zlyhaní, ktoré zastavia program, na triedu zlyhaní, ktoré sa prihlásia samy. Staticky linkovaný odkaz na framework, ktorý je zlý, zlyhá pri linkovaní na cieli a nikde inde sa nezlinkuje. Dynamicky viazaný zlý symbol vyprodukuje nedostupný backend a zoznam nevyriešených názvov, a prvé spustenie na Macu zmení otázku prečo je to nedostupné na jediný riadok vymenujúci preklep

Je tu aj druhý prínos, ktorý sa vypláca denne, nie raz. Pretože unita nezlinkuje žiadne frameworky, skompiluje sa na každej platforme, takže obyčajný Windows build naďalej kontroluje jej syntax, typy a uses klauzulu. Unita, ktorá sa kompiluje len na platforme, ktorú nikto v tíme nemá, je unita bez jediného prekladača, ktorý by sa na ňu pozrel, a ticho chátra pri každom refaktore zdieľaného typu

uses
  FPdfCrypto, FPdfCryptoMac;

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

  ConfigureKeychainSignerProvider;   // inštaluje sa ako PAdES podpisový backend
  ConfigureKeychainCmsVerifier;      // a zároveň ako overovací backend

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

  Options := TPadesSignerOptions.Default;
  Options.CertificateThumbprint := 'B1 3F 9C ...';   // SHA-1, ľubovoľná veľkosť písmen
  Options.PaddingScheme := psRsaPss;
end;

Dva druhy exportovaných symbolov, dva spôsoby, ako ich čítať

Toto je jediný najmenej zrozumiteľný detail celého viazania a pomýliť sa v ňom znamená čisto sa skompilovať a zlyhať až za behu. CoreFoundation a Security exportujú cez rovnaké volanie dlsym dve veci kategoricky odlišného druhu a kód musí vedieť, ktorý je ktorý

Menované konštanty, ako sú kľúče tried položiek keychainu či CoreFoundation boolean singletony, sú exportované premenné, ktorých obsahom je CFStringRef alebo CFBooleanRef, ktorý chcete. dlsym vráti adresu tej premennej, takže musíte raz dereferencovať, aby ste získali hodnotu. Štruktúry callback tabuliek, ako sú callbacky kľúčov a hodnôt slovníka, sú exportované štruktúry a dlsym vráti adresu štruktúry, čo je presne ten pointer, ktorý funkcia na vytváranie slovníka očakáva. Keď dereferencujete túto, odovzdáte prvé strojové slovo štruktúry, ako keby to bol pointer

Ani jedna z omylov neprodukuje compile error a ani jedna neprodukuje zrozumiteľnú runtime chybu. Dostanete garbage pointer, ktorý zlyhá niekde po prúde. Spôsob, ako urobiť rozlišovanie neomyliteľným, je prestať sa spoliehať na pamätanie si: dve helper funkcie, jedna viaže a dereferencuje a druhá viaže a nedereferencuje, takže miesto volania deklaruje, aký druh symbolu žiada, a helper vynúti zvyšok

Diagram macOS Keychain backendu PDFium VCL resolveujúceho symboly Security a CoreFoundation cez dlsym: kSecClass je exportovaná premenná, ktorú BindConstant raz dereferencuje na získanie hodnoty CFStringRef, zatiaľ čo kCFTypeDictionaryKeyCallBacks je exportovaná štruktúra, ktorú BindStruct podáva adresou, a pomiešanie oboch pravidiel vyprodukuje garbage pointery ďalej v prúde
Jedno volanie dlsym vracia dve veci kategoricky odlišného druhu: adresu premennej držiacej CFTypeRef a adresu callback štruktúry. Dve helper funkcie urobia rozhodnutie dereferencovať či nie na mieste viazania, nie v pamäti
// Exportovaná premenná: dlsym dáva adresu premennej držiacej
// CFTypeRef, takže raz dereferencujte
FSecClassKey := BindConstant(SecurityLib, 'kSecClass');

// Exportovaná štruktúra: dlsym dáva adresu ŠTRUKTÚRY, čo je presne
// to, čo API chce. Nedereferencujte
FKeyCallbacks := BindStruct(CoreFoundationLib,
  'kCFTypeDictionaryKeyCallBacks');

Prečo potrebuje podpis RSA-PSS dva samostatné fallbacky?

Pretože algoritmus môže chýbať dvoma nezávislými spôsobmi a len jeden z nich je otázka verzie. Konštanta PSS digest-signing algoritmu sa objavila v macOS 10.13, takže na staršom systéme symbol jednoducho nie je a viazanie dostane nil. To je kontrola verzie. Nezávisle na tom môže na systéme, kde konštanta existuje, konkrétny kľúč PSS aj tak odmietnuť, a framework odpovedá na túto otázku cez SecKeyIsAlgorithmSupported pre daný kľúč. Kľúč podložený hardvérom alebo kľúč s reštriktívnymi atribútmi môže PSS odmietnuť, kým softvérový kľúč na tom istom stroji ho prijme

Oba cesty musia viesť k tomu istému fallbacku: prepnúť na PKCS#1 v1.5. A kritická časť je, že fallback musí zmeniť aj identifikátor algoritmu zapísaný do CMS štruktúry, nielen podpisové volanie. Vyprodukovať identifikátor algoritmu PSS, kým reálne vzniká podpis v1.5, dáva dokument, ktorý každý overovač odmietne rovno, čo je striktne horšie než nahlásiť, že PSS nie je podporované. Downgrade je prijateľný, nesúlad medzi tým, čo deklarujete, a tým, čo ste urobili, nie, a to je všeobecné pravidlo pre podpisový kód, nie macOS kuriozita. Dôsledky na úrovni podpisu rozoberá článok podpisovanie PDF s PAdES B-B

Rozhodovacia reťaz ukazujúca, prečo podpisovanie RSA-PSS v Keychain backendu PDFium VCL potrebuje dva nezávislé fallbacky: dlsym vráti nil pre konštantu digest-signing algoritmu na verziách macOS pred 10.13, SecKeyIsAlgorithmSupported môže odmietnuť hardvérom podložený kľúč a oba zúženia smerujú do rovnakého downgradu na PKCS#1 v1.5, ktorého CMS identifikátor algoritmu sa musí zmeniť spolu s ním
PSS môže byť nedostupné dvakrát, raz podľa verzie macOS a raz podľa kľúča, a systémovou otázkou je len tá verziová. Obe zúženia smerujú do rovnakého downgradu na v1.5 a CMS identifikátor sa mení s ním

Kódovanie podpisov ECDSA a otočka, ktorá stojí za zmienku

Eliptická krivka nepotrebuje na macOS žiadnu konverziu, čo je presný opak toho, čo vyžaduje PKCS#11 viazanie. Digest-signing algoritmus Security frameworku pre ECDSA vracia podpis už v tvare X9.62 DER, čo je presne to, čo CMS chce. PKCS#11 token namiesto toho vracia surový fixed-width pár P1363, ktorý sa musí pred vložením do podpisovej štruktúry prekódovať

Takže dva backendy implementujúce to isté rozhranie potrebujú pre ten istý algoritmus opačné zaobchádzanie a ani jeden nie je zlý. Presne takýto rozdiel musí abstrakcia pohltnúť namiesto toho, aby ho vystavovala: PAdES vrstva požiada providera o podpis a konvencie kódovania zostávajú vo vnútri providera. Ak unikajú nahor, každý volajúci končí s podmienkou podľa backendu. Rovnaký vzor sa objavuje v príbehu vzdialeného podpisovania popísanom v článku vzdialené PAdES podpisové relácie voči HSM

Porovnanie kódovania podpisov ECDSA naprieč dvomi backendmi PAdES podpisovania PDFium VCL: macOS Keychain Security framework vracia X9.62 DER, ktoré CMS prijme bez jedinej konverzie, zatiaľ čo PKCS#11 token vracia surový fixed-width pár P1363, ktorý sa musí prekódovať, takže ResolvePadesSigner drží konvencie kódovania vo vnútri providera
Rovnaké rozhranie ECDSA potrebuje podľa backendu opačné zaobchádzanie: Security odovzdá hotové DER, kým PKCS#11 token odovzdá surový P1363, takže konverzia býva vo vnútri providera a volajúci nikdy nevidia podmienku podľa backendu
// Rozhranie providera je na každej platforme rovnaké, takže výber je
// rozhodnutie pri štarte, nie pri každom volaní
{$IFDEF DARWIN}
  if KeychainAvailable then
    ConfigureKeychainSignerProvider;
{$ENDIF}
{$IFDEF MSWINDOWS}
  // Windows CNG provider inštaluje platformová unita
{$ENDIF}

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

// Odtiaľto je podpisový kód platformovo neutrálny
Signer := ResolvePadesSigner(Options);

Pravidlá referenčného počítania tri riadky od seba

Správa pamäte Core Foundation sa riadi konvenciami názvov a pascu je, že funkcie s odlišnými konvenciami stoja vedľa seba v tom istom krátkom bloku. Funkcia, ktorá získa certifikát z trust objektu, vracia požičanú referenciu, ktorá sa nesmie uvoľniť. Funkcie, ktoré kopírujú podpisový certifikát alebo jeho dáta, vracajú vlastnené referencie, ktoré sa uvoľniť musia. Tri volania za sebou, dve pravidlá vlastníctva a uvoľnenie požičanej na tom riadku nezlyhá. Pokazí retain count a neskôr zhodí niečo úplne nesúvisiace

Zmierňuje sa to čítaním slovesa v názve každej frameworkovej funkcie pred písaním upratovania, zakaždým, bez výnimky. Je to CoreFoundation ekvivalent kontroly, či API vracia kópiu alebo pohľad, a cena omylu je občasný pád namiesto chyby

Čo tento backend netvrdí

V čase písania nikdy nebežal na macOS a povedať to rovno je užitočnejšie než naznačené uistenie. Dôkazne pravda je užšia a stále cenná: unita sa kompiluje na Windowse ako súčasť denného buildu, každý frameworkový symbol sa viaže menom za behu s vymenovanými zlyhaniami a logika výberu algoritmov vrátane oboch PSS fallbackov je obyčajný Pascal, ktorý sa dá reviewovať a o ktorom sa dá uvažovať. Prvé spustenie na Macu buď funguje, alebo vyprodukuje zoznam názvov na opravu

Overovacia strana, ktorá používa CMS dekóder vyššej úrovne namiesto ručného skladania CMS štruktúry, je rozobratá v článku overovanie PDF podpisov na macOS s SecTrust a zdieľa rovnakú infraštruktúru viazania a rovnaký diagnostický prístup

Prenositeľná myšlienka je o umiestnení rizika, nie o macOS. Keď musíte písať kód proti rozhraniu, ktoré nedokážete overiť, zvoľte konštrukciu, kde sú chyby najlacnejšie na nájdenie. Dynamické viazanie s explicitným zoznamom nevyriešených názvov mení dvadsať neoveriteľných predpokladov na jeden diagnostický riadok. Oba backendy sa dodávajú ako zdroják s PDFium Delphi komponentom, takže ak sa názov symbolu musí opraviť, je to zmena jedného riadku vo vašom vlastnom strome, nie support ticket