Artykuł techniczny

Weryfikowanie podpisów PDF na macOS z SecTrust w Delphi

Komponent PDFium dla Delphi weryfikuje podpisy PDF na macOS przez TPdfKeychainCmsVerifier, backend weryfikacji CMS zbudowany na Apple CMSDecoder i SecTrust zamiast na ręcznie parsowanym CMS. ConfigureKeychainCmsVerifier go instaluje, a pojedyncze wywołanie CMSDecoderCopySignerStatus zwraca werdykt podpisu, uchwyt SecTrust i kod wyniku certyfikatu, czyli dokładnie tę parę kolumn, którą TPdfCmsVerifyResult już niósł w Windows

Przypadek, który wymusił tę pracę, jest nudny i częsty. Build Lazarusa archiwum dokumentów działa na Macu, otwiera podpisaną umowę, a każdy podpis wraca jako pcsUnsupported. Z plikiem nic nie jest nie tak. Weryfikacja podpisu po prostu nie miała backendu poza Windows, a walidator PAdES odmawiał zgadywania pod jego nieobecność. Wersja 3.111.0 PDFiumPas otworzyła miejsce przez IPdfCmsVerifier i ConfigurePadesCmsVerifier, a wersja 3.113.0 wypełniła je na macOS. Ciekawy element portu to nie plumbing, lecz trzy miejsca, w których API Apple ma inny kształt niż windowsowe

Dlaczego podpis PDF obejmuje dwa zakresy bajtów?

Ponieważ podpis nie może obejmować bajtów, które go przechowują. ISO 32000-1 §12.8.1 umieszcza blob CMS SignedData w łańcuchu /Contents słownika podpisu i opisuje podpisany zakres przez /ByteRange, czyli zestaw par offsetu i długości obejmujących wszystko po obu stronach tej dziury. Dwa segmenty, jedna luka pośrodku, na każdej platformie

Platformy różnią się tym, jak te segmenty trafiają do warstwy kryptograficznej, a różnica kosztuje pamięć. W Windows CryptVerifyDetachedMessageSignature przyjmuje tablicę wskaźników i długości, więc oba zakresy przekazuje się tak, jak leżą w buforze, bez duplikowania. Apple CMSDecoderSetDetachedContent przyjmuje jedno CFData i nie ma wariantu wielosegmentowego, więc backend macOS konkatenatuje oba zakresy do ciągłego bufora przed dekodowaniem. To pełna druga kopia podpisanych bajtów. W skanowanym archiwum 400 MB jest to prawdziwy szczyt pamięci, skaluje się z dokumentem, a nie z podpisem, i nie ma alternatywnego API, po które można sięgnąć. Odpowiednio wymiaruj worker batcha zamiast odkrywać to na maszynie klienta

Jedno wywołanie wypełnia dwie kolumny TPdfCmsVerifyResult

CMSDecoderCopySignerStatus jest niezwykle hojnym punktem wejścia Security.framework: jedno wywołanie zwraca status podpisującego, SecTrustRef dla zbudowanego łańcucha oraz OSStatus wyniku oceny certyfikatu. Wartości trafiają bezpośrednio do rekordu konsumowanego już przez walidator PAdES: status podpisującego staje się SignatureStatus, wynik certyfikatu staje się TrustStatus, a surowe wartości zachowują się w SignatureError i TrustError, aby zgłoszenie wsparcia mogło cytować liczbę zamiast przymiotnika. Wywołujący nigdy nie dotykają sami IPdfCmsVerifierValidatePadesCompliance i ValidatePadesTrust kierują każdą weryfikację przez zainstalowany backend, więc kod odczytujący TPadesSignatureValidation jest bajt w bajt taki sam na obu platformach, jak opisano w instrukcji inspekcji słowników podpisu PDF i poziomów PAdES w Delphi

uses
  FPdfCrypto, FPdfCryptoMac, FPdfPades;

procedure InstallMacVerifier;
begin
  // Podpisywanie i weryfikacja rozwiązują różne symbole frameworka, więc jeden
  // może być obecny, gdy drugiego nie ma
  if not KeychainVerificationAvailable then
    raise Exception.CreateFmt('Security.framework symbols missing: %s',
      [KeychainMissingSymbols]);

  ConfigureKeychainCmsVerifier;

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

Dlaczego kCMSSignerInvalidCert zgłasza poprawny podpis?

Ponieważ Apple przypisuje tej wartości węższe znaczenie, niż sugeruje jej nazwa: sam podpis został zweryfikowany i tylko łańcucha certyfikatów nie udało się ustanowić. TPdfKeychainCmsVerifier mapuje więc kCMSSignerInvalidCert na pcvsValid w kolumnie SignatureStatus, a problem certyfikatu ujawnia przez TrustStatus, gdzie problem łańcucha należy. Włączenie go do werdyktu podpisu sprawiłoby, że komponent powiedziałby operatorowi, iż nietknięty dokument został zmodyfikowany, co jest najgorszym pojedynczym fałszywym alarmem, jaki może podnieść walidator podpisu

function MapSignerStatus(Status: LongWord): TPdfCmsVerifyStatus;
begin
  case Status of
  kCMSSignerValid:
    Result:= pcvsValid;
  // Podpis został zweryfikowany, a tylko łańcuch nie, co osobno
  // raportuje status zaufania
  kCMSSignerInvalidCert:
    Result:= pcvsValid;
  kCMSSignerInvalidSignature, kCMSSignerUnsigned:
    Result:= pcvsInvalid;
  else
    Result:= pcvsIndeterminate;
  end;
end;

Odczytaj oba statusy jako uporządkowaną parę, a logika raportowania napisze się sama. SignatureStatus = pcvsValid razem z TrustStatus = pcvsInvalid opisuje dokument, którego bajty są nienaruszone, ale którego wystawcy ten konkretny Mac nie ufa: kotwica brakuje w Keychain, wygasł pośredni certyfikat albo łańcucha nie da się uzupełnić offline. To pytanie o politykę operatora, a nie o integralność dokumentu, i dokładnie to rozróżnienie stoi za większością przypadków w notatce o tym, dlaczego walidatory odrzucają kryptograficznie poprawne podpisy PAdES

Gdzie macOS faktycznie sprawdza odwołanie certyfikatu?

Wewnątrz oceny zaufania, dlatego TPdfCmsVerifyResult.RevocationStatus następuje po TrustStatus, zamiast przenosić własny werdykt. SecPolicyCreateRevocation tworzy politykę, ta polityka dołącza do SecPolicyCreateBasicX509 w tablicy przekazywanej do CMSDecoderCopySignerStatus, a praca OCSP albo CRL odbywa się podczas budowania łańcucha. Nie wraca osobna odpowiedź, więc jej raportowanie oznaczałoby wymyślanie. Sama tablica niesie małą regułę własności, którą warto nazwać: CFArrayCreate zatrzymuje obie polityki, więc dwie lokalne referencje są natychmiast zwalniane, a przypadek jednej polityki pomija tablicę całkowicie i przekazuje politykę bezpośrednio, w formie również akceptowanej przez API

Praca offline jest jawną flagą, a nie przypadkiem wynikającym z łączności. Gdy TPdfCmsVerifyOptions.OnlineRetrieval ma wartość False, backend dodaje kSecRevocationNetworkAccessDisabled, ograniczając ocenę do odpowiedzi już zapisanych w cache na maszynie, a callback checkpointu nadal wywołuje pcvstCryptographicSignature, pcvstChainBuild i pcvstRevocationCheck w tej samej kolejności, w jakiej raportuje je backend Windows. Kod aplikacji ustawia to wszystko przez rekord opcji wyższego poziomu

var
  Options: TPadesTrustValidationOptions;
  Report: TPadesValidationResult;
  Stream: TFileStream;
begin
  Options:= TPadesTrustValidationOptions.Default;
  Options.CheckRevocation:= True;
  Options.NetworkPolicy:= ptnpOffline;   // tylko odpowiedzi z cache
  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 kontra copy: zwolnienie, które wybucha gdzie indziej

SecTrustGetCertificateAtIndex ma semantykę get i zwrócona przez nie referencja nigdy nie może być zwalniana, podczas gdy CMSDecoderCopySignerCert i SecCertificateCopyData, znajdujące się kilka linii dalej w tej samej rutynie, mają semantykę copy i muszą być zwalniane. Core Foundation koduje całą regułę w jednym czasowniku nazwy funkcji, a system typów nie wymusza żadnej z niej. Zwalniaj pożyczoną referencję, a w miejscu wywołania nic się nie stanie: obiekt zaufania po prostu stanie się niespójny, a awaria nadejdzie później, w miejscu pozbawionym widocznego związku z łańcuchami certyfikatów

ChainCount:= _SecTrustGetCertificateCount(Trust);
SetLength(Result.ChainCertificates, ChainCount);
for I:= 0 to ChainCount- 1 do
begin
  // Semantyka get: ta referencja jest pożyczona i tutaj się jej nie zwalnia
  Cert:= _SecTrustGetCertificateAtIndex(Trust, I);
  if Cert= nil then
    Continue;
  // Semantyka copy: ta referencja jest własnością kodu i musi wrócić
  CertData:= _SecCertificateCopyData(Cert);
  if CertData= nil then
    Continue;
  try
    Result.ChainCertificates[I]:= CFDataToBytes(CertData);
  finally
    _CFRelease(CertData);
  end;
end;

Co gwarantuje verifier, gdy żaden backend nie odpowiada?

Że odpowiedź jest nieobsługiwana, nigdy po cichu pozytywna. Gdy ConfigurePadesCmsVerifier niczego nie zainstalowało, a domyślna platformy nie może pomóc, TPdfCmsVerifyResult wraca ze wszystkimi kolumnami ustawionymi jako niedostępne, a walidator PAdES mapuje to na pcsUnsupported, więc build bez backendu kryptograficznego raportuje uczciwie zamiast twierdzić cokolwiek o podpisie. Wiązanie macOS jest celowo konserwatywne w tym samym kierunku: Security.framework i CoreFoundation są osiągane przez dlopen i dlsym, więc brak frameworka albo nazwa symbolu pomylona przez to wiązanie ujawnia się jako KeychainVerificationAvailable zwracające False z KeychainMissingSymbols nazywającym winowajcę, a nie jako błąd linkowania i nie jako zły werdykt. To ta sama postawa fail-closed, którą komponent stosuje przy szukaniu biblioteki natywnej, opisana w tekście o ładowaniu natywnej biblioteki PDFium na dowolnym celu

Weryfikacja podpisu to ta część stosu PDF, w której cicha pomyłka jest gorsza niż głośna niedostępność, a macOS daje API dość hojne, by łatwo osiągnąć oba wyniki. Połącz zakresy bajtów i zaakceptuj kopię, trzymaj werdykt podpisu oraz werdykt łańcucha w osobnych kolumnach, szanuj czasowniki get i copy, a brakujący backend pozwól zgłosić. Jeśli przenosisz workflow dokumentów Delphi albo Free Pascal na Maca i potrzebujesz podpisywania oraz walidacji PAdES po obu stronach, komponent PDFium dla Delphi dostarcza backend Keychain obok windowsowego za jednym interfejsem