Artykuł techniczny

Podpisywanie PAdES tożsamością z Keychain macOS w Delphi

PDFium VCL podpisuje dokumenty PAdES kluczem prywatnym trzymanym w Keychain macOS przez backend, który rozwiązuje każdy symbol Security i CoreFoundation w runtime przez dlopen i dlsym. Nic nie jest bindowane w czasie linkowania, co znaczy, że literówka w nazwie symbolu wychodzi jako KeychainAvailable zwracające False i KeychainMissingSymbols wskazujące winowajcę, a nie jako błąd linkera albo crash

Ten wybór został wymuszony niewygodnym ograniczeniem, a sposób jego obsłużenia się uogólnia. Unit był pisany na maszynie bez SDK macOS, więc każda nazwa symbolu frameworku i każda stała pochodziła z dokumentacji i nic z tego nie dało się sprawdzić względem nagłówka. Złą odpowiedzią na taką sytuację jest pisać kod starannie i mieć nadzieję. Właściwą jest zaaranżować, by nieuniknione pomyłki same się zgłaszały w najłatwiejszej do zlokalizowania postaci

Dlaczego bindowanie dynamiczne to trafny ruch nawet na platformie docelowej

Bo zamienia klasę awarii zatrzymujących program w klasę awarii, które same się zgłaszają. Źle napisana statycznie linkowana referencja do frameworka polegnie w czasie linkowania na targecie i nigdzie indziej się nie zlinkuje. Źle zbindowana dynamicznie produkuje niedostępny backend i listę nierozwiązanych nazw, a pierwsze odpalenie na Macu zamienia pytanie dlaczego to niedostępne w jedną linię wskazującą literówkę

Jest druga korzyść, która spłaca się codziennie, a nie raz. Ponieważ unit nie linkuje żadnych frameworków, kompiluje się na każdej platformie, więc zwykły build Windows wciąż sprawdza jego składnię, typy i klauzulę uses. Unit kompilujący się wyłącznie na platformie, której nikt w zespole nie ma, to unit bez żadnego kompilatora patrzącego mu na ręce i cichnie z każdym refaktorem współdzielonego typu

uses
  FPdfCrypto, FPdfCryptoMac;

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

  ConfigureKeychainSignerProvider;   // instalacja jako backend podpisujący PAdES
  ConfigureKeychainCmsVerifier;      // i jako backend weryfikacji

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

  Options := TPadesSignerOptions.Default;
  Options.CertificateThumbprint := 'B1 3F 9C ...';   // SHA-1, dowolna wielkość liter
  Options.PaddingScheme := psRsaPss;
end;

Dwa rodzaje eksportowanych symboli, dwa sposoby ich czytania

To najbardziej mylący pojedynczy detal w całym bindingu i odwrócenie go kompiluje się czysto, a polega w runtime. CoreFoundation i Security eksportują dwie kategorycznie różne rzeczy tym samym wywołaniem dlsym i kod musi wiedzieć, które jest które

Nazwane stałe, jak klucze klas elementów keychain i booleanowe singletony CoreFoundation, to eksportowane zmienne, których zawartością jest CFStringRef albo CFBooleanRef, którego chcesz. dlsym zwraca adres tej zmiennej, więc musisz zdereferencjonować raz, by dostać wartość. Struktury tabel callbacków, jak callbacki klucza i wartości słownika, to eksportowane struktury i dlsym zwraca adres struktury, czyli dokładnie ten wskaźnik, którego oczekuje funkcja tworząca słownik. Zdereferencjonujesz ten i podajesz pierwsze maszynowe słowo struktury tak, jakby było wskaźnikiem

Żadna z tych pomyłek nie daje błędu kompilacji i żadna nie daje jasnego błędu runtime. Dostajesz śmieciowy wskaźnik, który polegnie gdzieś w dole. Sposób, by tę różnicę nie dało się pomylić, to przestać polegać na pamiętaniu: dwie funkcje pomocnicze, jedna bindująca i dereferencjonująca, druga bindująca bez dereferencji, więc miejsce wywołania deklaruje, jakiego rodzaju symbolu prosi, a helper egzekwuje resztę

Diagram backendu Keychain macOS w PDFium VCL rozwiązującego symbole Security i CoreFoundation przez dlsym: kSecClass to eksportowana zmienna, którą BindConstant dereferencjonuje raz, by zdobyć wartość CFStringRef, natomiast kCFTypeDictionaryKeyCallBacks to eksportowana struktura, którą BindStruct podaje przez adres, a pomieszanie tych dwóch reguł daje śmieciowe wskaźniki w dole strumienia
Jedno wywołanie dlsym zwraca dwie kategorycznie różne rzeczy: adres zmiennej trzymającej CFTypeRef i adres struktury callbacków. Dwa helpery podejmują decyzję dereferencjonować albo nie w miejscu bindowania, zamiast w pamięci
// Eksportowana zmienna: dlsym daje adres zmiennej trzymającej
// CFTypeRef, więc dereferencjonuj raz
FSecClassKey := BindConstant(SecurityLib, 'kSecClass');

// Eksportowana struktura: dlsym daje adres STRUKTURY, czyli dokładnie to,
// czego chce API. Nie dereferencjonuj
FKeyCallbacks := BindStruct(CoreFoundationLib,
  'kCFTypeDictionaryKeyCallBacks');

Dlaczego podpis RSA-PSS potrzebuje dwóch osobnych fallbacków?

Bo algorytm może być nieobecny na dwa niezależne sposoby i tylko jeden z nich to pytanie o wersję. Stała algorytmu podpisującego digest PSS pojawiła się w macOS 10.13, więc na starszym systemie symbolu po prostu nie ma i binding dostaje nil. To jest sprawdzenie wersji. Osobno, na systemie, gdzie stała istnieje, konkretny klucz może ją mimo wszystko odrzucić i framework odpowiada na to pytanie przez SecKeyIsAlgorithmSupported dla tego klucza. Klucz wsparty sprzętowo albo klucz z restrykcyjnymi atrybutami może odmówić PSS, podczas gdy programowy klucz na tej samej maszynie go przyjmie

Obie ścieżki muszą prowadzić do tego samego fallbacku: przełącz na PKCS#1 v1.5. A krytyczna część jest taka, że fallback musi zmienić także identyfikator algorytmu zapisywany do struktury CMS, nie tylko wywołanie podpisujące. Wyemitowanie identyfikatora algorytmu PSS przy faktycznej produkcji podpisu v1.5 daje dokument, który każdy weryfikator odrzuci w prostej linii, co jest ściśle gorsze niż zgłoszenie, że PSS nie jest wspierany. Downgrade jest akceptowalny, niezgodność między tym, co deklarujesz, a tym, co zrobiłeś — nie, i to jest ogólna reguła dla kodu podpisów, a nie kaprys macOS. Implikacje na poziomie podpisu rozkłada w artykule podpisywanie PDF-ów z PAdES B-B

Łańcuch decyzyjny pokazujący, dlaczego podpisywanie RSA-PSS w backendzie Keychain PDFium VCL potrzebuje dwóch niezależnych fallbacków: dlsym zwraca nil dla stały podpisującej digest na wersjach macOS przed 10.13, SecKeyIsAlgorithmSupported może odmówić kluczowi wspartemu sprzętowo, a obie bramki leją się w ten sam downgrade do PKCS#1 v1.5, którego identyfikator algorytmu CMS musi zmienić się razem z nim
PSS może być niedostępny dwukrotnie: raz per wersja macOS i raz per klucz, i tylko bramka wersji jest pytaniem systemowym. Obie bramki leją się w ten sam downgrade do v1.5, a identyfikator CMS podąża

Kodowanie podpisu ECDSA i odwrócenie warte odnotowania

Ścieżka krzywych eliptycznych nie potrzebuje na macOS żadnej konwersji w ogóle i to jest odwrotność tego, czego wymaga binding PKCS#11. Algorytm podpisujący digest dla ECDSA w frameworku Security zwraca podpis już w postaci X9.62 DER, czyli dokładnie to, czego chce CMS. Token PKCS#11 zwraca zamiast tego surową parę P1363 o stałej szerokości, którą trzeba przekodować, zanim trafi do struktury podpisu

Dwa backendy implementujące ten sam interfejs potrzebują więc przeciwnego traktowania dla tego samego algorytmu i żaden nie jest w błędzie. To dokładnie ten rodzaj różnicy, który abstrakcja musi wchłonąć, a nie wystawiać: warstwa PAdES prosi providera o podpis, a konwencje kodowania zostają w środku providera. Jeśli wyciekną w górę, każdy wołający skończy z warunkowym per backend. Ten sam kształt pojawia się w historii zdalnego podpisywania opisanej w artykule zdalne sesje podpisywania PAdES względem HSM

Porównanie kodowania podpisu ECDSA między dwoma backendami podpisującego PAdES w PDFium VCL: framework Security Keychain macOS zwraca X9.62 DER, który CMS przyjmuje bez żadnej konwersji, podczas gdy token PKCS#11 zwraca surową parę P1363 o stałej szerokości, którą trzeba przekodować, więc ResolvePadesSigner trzyma konwencje kodowania wewnątrz providera
Ten sam interfejs ECDSA potrzebuje przeciwnego traktowania per backend: Security wręcza gotowy DER, a token PKCS#11 surowe P1363, więc konwersja mieszka wewnątrz providera, a wołający nigdy nie widzą warunkowego per backend
// Interfejs providera jest ten sam na każdej platformie, więc wybór to
// decyzja startowa, a nie per wywołanie
{$IFDEF DARWIN}
  if KeychainAvailable then
    ConfigureKeychainSignerProvider;
{$ENDIF}
{$IFDEF MSWINDOWS}
  // Provider CNG Windows jest instalowany przez unit platformowy
{$ENDIF}

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

// Stąd kod podpisujący jest neutralny platformowo
Signer := ResolvePadesSigner(Options);

Reguły zliczania referencji oddalone o trzy linie

Zarządzanie pamięcią Core Foundation podąża za konwencjami nazewniczymi, a pułapka polega na tym, że funkcje o różnych konwencjach pojawiają się obok siebie w tym samym krótkim bloku. Funkcja, która bierze certyfikat z obiektu trust, zwraca pożyczoną referencję, której nie wolno zwalniać. Funkcje, które kopiują certyfikat podpisującego albo kopiują jego dane, zwracają referencje posiadane, które trzeba zwolnić. Trzy wywołania pod rząd, dwie reguły własności, a zwolnienie pożyczonej nie polegnie w tej linii. Psuje licznik retain i zbija coś niezwiązanego później

Mitygacją jest czytanie czasownika w nazwie każdej funkcji frameworka przed napisaniem sprzątania, za każdym razem, bez wyjątku. To odpowiednik CoreFoundation sprawdzania, czy API zwraca kopię, czy widok, a koszt pomyłki to przerywany crash, a nie błąd

Czego ten backend nie twierdzi

Nigdy nie chodził na macOS w chwili pisania i powiedzenie tego wprost jest pożyteczniejsze niż sugerowane zapewnienie. Demonstracyjnie prawdziwe jest węższe i wciąż cenne: unit kompiluje się na Windows w ramach codziennego builda, każdy symbol frameworka jest bindowany po nazwie w runtime z wyliczonymi porażkami, a logika wyboru algorytmu, łącznie z oboma fallbackami PSS, to zwykły Pascal, który można recenzować i o nim rozumować. Pierwsze odpalenie na Macu albo zadziała, albo wyprodukuje listę nazw do poprawienia

Odpowiednik weryfikacyjny, który używa dekodera CMS wyższego poziomu zamiast składania struktury CMS ręcznie, jest opisany w artykule weryfikacja podpisów PDF na macOS z SecTrust i dzieli tę samą infrastrukturę bindingu i to samo podejście diagnostyczne

Przenośny pomysł stąd dotyczy rozmieszczania ryzyka, a nie macOS. Gdy musisz pisać kod względem interfejsu, którego nie możesz zweryfikować, wybierz konstrukcję, w której pomyłki są najtańsze do zlokalizowania. Bindowanie dynamiczne z jawną listą nierozwiązanych nazw zamienia dwadzieścia niemożliwych do zweryfikowania założeń w jedną linię diagnostyczną. Oba backendy są dostarczane jako źródła z komponentem PDFium dla Delphi, więc jeśli nazwa symbolu jednak wymaga poprawki, to zmiana jednej linii we własnym drzewie, a nie ticket na support