Artykuł techniczny

Szyfrowanie PDF certyfikatami w Delphi: RSA-OAEP i ECDH

PDF zaszyfrowany certyfikatami niesie pojedynczą wartość /P w słowniku szyfrowania, niezależnie od tego, dla kogo plik został zapisany; w plikach budowanych dalej w tym artykule to -64. Trzech odbiorców z trzema różnymi zestawami uprawnień dzieli tę jedną liczbę. Kod odpowiadający na pytanie "czy ten użytkownik może drukować?" z /P czyta placeholder, bo pod handlerem bezpieczeństwa klucza publicznego prawdziwe słowo uprawnień podróżuje wewnątrz zaszyfrowanej koperty każdego odbiorcy i pojawia się dopiero, gdy klucz prywatny tego odbiorcy ją otworzy

Ten projekt to powód, dla którego szyfrowanie certyfikatami jest warte trudu. Kwartalny pakiet audytowy idzie do trzech zewnętrznych recenzentów; prawnicy chcą, by wszyscy go przeczytali, tylko jeden może go wydrukować, a nikt nie chce hasła siedzącego w wątku mailowym obok załącznika. Hasło tego nie wyrazi. Certyfikaty tak: każdy odbiorca otwiera plik kluczem, który już ma, i każda koperta niesie własne słowo uprawnień

Czym szyfrowanie PDF certyfikatami różni się od hasła?

PDF zaszyfrowany kluczem publicznym wyprowadza klucz pliku z losowego ziarna plus dokładnych bajtów każdej koperty odbiorcy, i nic, co wpisuje człowiek, nie wchodzi do wyliczenia. Handler jest specyfikowany w ISO 32000-1 §7.6.4 (§7.6.5 w ISO 32000-2), a koperty to struktury CMS EnvelopedData z RFC 5652. HotPDF zapisuje /Filter /Adobe.PubSec z /SubFilter /adbe.pkcs7.s5. Dla AES-256 znaczy to /V 5, wpis /DefaultCryptFilter pod /CF z /CFM /AESV3 i tablicę /Recipients umieszczoną wewnątrz tego filtra kryptograficznego, a nie w słowniku najwyższego poziomu

Każda koperta szyfruje 24 bajty: 20-bajtowe ziarno, po którym następuje 32-bitowe słowo uprawnień tego odbiorcy. Przy wczytywaniu czytnik odpina jedną kopertę, odzyskuje ziarno i haszuje ziarno razem z każdą kopertą w kolejności /Recipients (SHA-256 dla AES-256, SHA-1 dla starszych szyfrów), by odbudować klucz pliku. Recenzent B nigdy nie dowie się, co może recenzent A, a czytnik B nadal haszuje bajty koperty A, więc cała tablica musi przetrwać bajt po bajcie. Jeśli nadal wybierasz między tym modelem a zwykłymi hasłami, przewodnik szyfrowania hasłem AES-256 i flag uprawnień opisuje drugą stronę tego kompromisu

Diagram szyfrowania kluczem publicznym w HotPDF: EnablePubKeyEncryption ustala 20-bajtowe ziarno, każda koperta CMS EnvelopedData szyfruje te 20 bajtów plus jedno 32-bitowe słowo uprawnień wewnątrz /Filter /Adobe.PubSec z /SubFilter /adbe.pkcs7.s5 i /CFM /AESV3, a czytnik odpina jedną kopertę, odzyskuje ziarno i haszuje je z każdym wpisem /Recipients w kolejności tablicy, by odbudować klucz pliku
Wartość /P w słowniku szyfrowania to tylko miejsce na znak, bo prawdziwe uprawnienia podróżują wewnątrz każdej koperty, a nic downstream nie wolno przestawiać ani przekodowywać tablicy, po której biegnie skrót

Zapisywanie odbiorców RSA przez EnablePubKeyEncryption

Dla certyfikatów RSA sekwencja jest krótka: EnablePubKeyEncryption z aes256, potem po jednym wywołaniu AddPubKeyRecipientCertificate na certyfikat w DER, wszystko przed BeginDoc. Helper buduje kopertę RSAES-OAEP w procesie. Jego dwa ostatnie parametry wybierają skrót OAEP i skrót MGF1 z THPDFRSAOAEPHash (rohSHA256, rohSHA384 albo rohSHA512), oba domyślnie rohSHA256, a zawartość koperty jest szyfrowana AES-256-CBC

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFCrypt, HPDFRSA;

procedure WriteAuditPack(const OutFile: string);
var
  Pdf: THotPDF;
  Seed: AnsiString;
begin
  SetLength(Seed, 20);                      // dokładnie 20 bajtów, nawet dla AES-256
  AESGenerateRandomBytes(@Seed[1], Length(Seed));
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := OutFile;
    Pdf.EnablePubKeyEncryption(Seed, aes256, True);   // domyślny typ klucza to aes128
    // Recenzent A może drukować; recenzent B może tylko czytać i wyodrębniać
    Pdf.AddPubKeyRecipientCertificate(TFile.ReadAllBytes('reviewer-a.cer'),
      [prPrint, prPrint12bit, prExtractContent], rohSHA256, rohSHA256);
    Pdf.AddPubKeyRecipientCertificate(TFile.ReadAllBytes('reviewer-b.cer'),
      [prExtractContent]);
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(72, 720, 0, 'Q3 audit pack');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Trzy szczegóły w tym listingu mają ciężar. Ziarno ma 20 bajtów dla każdego typu klucza, AES-256 włącznie, i EnablePubKeyEncryption rzuca przy każdej innej długości. Typ klucza domyślnie to aes128, podczas gdy oba helpery certyfikatowe upierają się przy aes256, więc pominięcie drugiego argumentu sprawia, że pierwsze wywołanie AddPubKeyRecipientCertificate rzuca "certificate envelopes require aes256". Starsze szyfry (k40, k128, aes128) pozostają dostępne przez AddPubKeyRecipient, który przyjmuje kompletną kopertę DER zbudowaną przez inne narzędzia

Trzeci szczegół to wersja. AES-256 pod handlerem klucza publicznego to funkcja PDF 2.0, i HotPDF sam podnosi wersję dokumentu do 2.0. Przy ustawionym StrictVersionLock na niższej wersji to podbicie nie jest dozwolone: EnablePubKeyEncryption wraca bez włączania czegokolwiek, a błąd wychodzi linię później, gdy helper odbiorcy zgłasza "call EnablePubKeyEncryption first". Ten komunikat wysyła ludzi szukać błędu kolejności wywołań. Sprawdź zamiast tego blokadę wersji. Zmiana szyfrowania w trakcie aktualizacji przyrostowej jest odrzucana wprost z EInvalidOpException

Dodawanie odbiorców ECDH na P-256, P-384, P-521, X25519 i X448

Certyfikaty krzywych eliptycznych przechodzą przez AddPubKeyAgreementRecipientWithSecret, które zapisuje odbiorcę uzgodnienia klucza CMS (KeyAgreeRecipientInfo z RFC 5753, z profilem X25519 i X448 z RFC 8418) i liczy wspólny sekret ECDH w procesie. Wartość THPDFPubKeyAgreementScheme nazywa krzywą: pkasECDHP256, pkasECDHP384, pkasECDHP521, pkasX25519 albo pkasX448. Schemat musi pasować do algorytmu klucza certyfikatu, krzywej i długości klucza, inaczej wywołanie rzuca "Certificate key does not match the requested agreement scheme"

Każda koperta dostaje świeże losowe 32-bajtowe UKM, klucz szyfrujący klucz z KDF stdDH (SHA-256 dla P-256 i X25519, SHA-384 dla P-384, SHA-512 dla P-521 i X448) i zawinięcie klucza AES-256 z RFC 3394. Arytmetyka krzywych to czysty Pascal bez platformowego dostawcy kryptografii pod spodem; artykuł o arytmetyce krzywych NIST w czystym Pascalu przechodzi przez to, jak ta warstwa jest złożona. Na krzywych Montgomery cały efemeryczny duet kluczy można wygenerować lokalnie:

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFCrypt, HPDFPubSec,
  HPDFKeyAgreement;

procedure AddLegalRecipient(Pdf: THotPDF);
var
  Scalar, OriginatorPublic: TBytes;
begin
  // Świeży efemeryczny skalar na kopertę; przycinanie dzieje się w drabince
  SetLength(Scalar, 32);
  AESGenerateRandomBytes(@Scalar[0], Length(Scalar));
  try
    OriginatorPublic := HPDFX25519PublicFromScalar(Scalar);
    Pdf.AddPubKeyAgreementRecipientWithSecret(
      TFile.ReadAllBytes('legal-x25519.cer'),
      [prPrint, prExtractContent], pkasX25519,
      OriginatorPublic, Scalar,
      []);   // OwnPublicPoint: ma znaczenie tylko dla krzywych NIST
  finally
    HPDFSecureClearBytes(Scalar);
  end;
end;

Krzywe NIST więcej wymagają od wywołującego. HotPDF wyprowadza klucze publiczne inicjatora tylko dla X25519 i X448 (HPDFX25519PublicFromScalar, HPDFX448PublicFromScalar), więc dla P-256, P-384 i P-521 efemeryczny duet pochodzi z twojego własnego narzędzia. Przekaż skalar big-endian dokładnie rozmiaru pola (32, 48 albo 66 bajtów) i pasujący punkt bez kompresji 0x04||X||Y (65, 97 albo 133 bajty) jako OriginatorPublicKey, i podaj ten sam punkt do OwnPublicPoint, parametru zarezerwowanego dla schematów NIST. Złe długości rzucają od razu. Punkt odbiorcy wzięty z certyfikatu jest sprawdzany względem równania krzywej, zanim ruszy jakiekolwiek mnożenie

Czego żadna biblioteka nie sprawdzi, to czy twój klucz publiczny inicjatora należy do twojego skalara. Niedopasowane połówki i tak wyprodukują dobrze uformowaną kopertę. Zapisujący wyprowadza klucz ze skalaru razy punkt odbiorcy, odbiorca wyprowadza swój z klucza prywatnego razy zły punkt inicjatora, a kontrola integralności RFC 3394 po stronie odbiorcy jest pierwszą, która to zauważy, gdy plik jest już wysłany. Podróż w obie strony na testowym kluczu na tej samej krzywej należy do twojego builda; udane EndDoc i wiarygodny rozmiar pliku nie dowodzą tu niczego

Właśnie ten krok parowania sprawia, że wybór krzywej ma w praktyce znaczenie. Jeśli kontrolujesz wydawanie certyfikatów, X25519 to krzywa łatwiejsza do zrobienia dobrze z Delphi: jeden losowy skalar 32 bajty, jedno wywołanie helpera, żaden punkt do trzymania w synchronizacji ręcznie. Krzywe NIST zasługują na swoje miejsce, gdy certyfikaty odbiorców pochodzą z PKI wydającej wyłącznie P-256 albo P-384, i wtedy podróż w obie strony przestaje być opcjonalna

Diagram uzgadniania ECDH w HotPDF: AddPubKeyAgreementRecipientWithSecret wyprowadza wspólny sekret czystym kodem Pascal krzywych, miesza świeże 32-bajtowe UKM przez KDF stdDH z SHA-256 dla P-256 i X25519, SHA-384 dla P-384, SHA-512 dla P-521 i X448, po czym zawija klucz zawartości zawinięciem klucza AES-256 z RFC 3394, budując kopertę KeyAgreeRecipientInfo
Wartość schematu od pkasECDHP256 po pkasX448 musi pasować do klucza certyfikatu, a niedopasowane połówki skalara i publicznego punktu i tak dadzą poprawną kopertę, której żaden odbiorca nie otworzy

Dlaczego kolejność /Recipients ma znaczenie?

Kolejność /Recipients ma znaczenie, bo klucz pliku to skrót z ziarna, po którym następuje każda koperta w kolejności tablicy, więc zapisujący i czytający muszą haszować identyczne bajty w identycznej sekwencji. HotPDF trzyma koperty w kolejności dodawania i zapisuje je bez zmian, co zostawia ci wolność wyboru dowolnej kolejności. To, co dzieje się z tablicą potem, jest ryzykowną częścią. Narzędzia postprocessingu, pipeline'y podpisywania i własnoręczne budowniczowie kopert to miejsca, gdzie to się psuje, a awarie mają jeden kształt — dwie strony haszujące lekko różne bajty:

  • Beztypowa TList Delphi przechowuje surowy wskaźnik. Zbieranie kopert przez List.Add(Pointer(Bytes)) zostawia liczbę referencji zmiennej lokalnej, więc następne SetLength na tej zmiennej zwalnia albo przejmuje bufor, i każdy slot może skończyć aliasując ostatnią kopertę. TList<TBytes> albo zwykła TArray<TBytes> trzyma referencje zliczone
  • Parser DER pracujące w miejscu modyfikują bufor, który parsują. Hashuj nietkniętą kopię każdej koperty, wziętą, zanim jakakolwiek próba odpinania ją dotknie
  • Unicode'owy TStringList to nie miejsce dla binarnego DER: bajty od $80 w górę są re-kodowane przez stronę kodową przy wejściu. Trzymaj koperty w TBytes albo najpierw zakoduj je szesnastkowo
  • Szyfrogram należy do łańcuchów hex. Wewnątrz łańcucha literałowego CR, LF i CRLF czytają się z powrotem jako pojedynczy LF (ISO 32000-1 §7.3.4.2), więc koperta zawierająca akurat $0D $0A traci bajt. HotPDF zapisuje każdy wpis /Recipients jako łańcuch szesnastkowy i nie poddaje go szyfrowaniu łańcuchów, bo czytnik potrzebuje kopert, zanim trzyma jakikolwiek klucz
  • Pierwszy bajt zawartości DER BIT STRING liczy nieużywane bity i musi być $00 dla kluczy wyrównanych do bajtu. Ręcznie pisany DER, który wyrzuca ten bajt i wkłada punkt 0x04||X||Y prosto do zawartości, przesuwa cały klucz o jeden bajt, a surowy parser odrzuca klucz inicjatora wprost
  • Gdy klucz, który powinien działać, nadal nie potrafi odszyfrować, porównuj warstwa po warstwie, w tej kolejności: klucz pliku, IV na początku szyfrogramu, klucz per obiekt, tekst jawny. Wada siedzi zaraz za pierwszą warstwą, która się nie zgadza

Wczytywanie PDF zaszyfrowanego certyfikatami z kluczami prywatnymi

Klucze prywatne muszą być zarejestrowane, zanim ruszy LoadFromFile, bo HotPDF odzyskuje klucz pliku podczas przebiegu strukturalnego. Tożsamości RSA sparsowane przez HPDFParsePFX idą do PubSecKeyMaterial, a kolejne klucze RSA do AddPubSecKeyMaterial, które odrzuca materiał bez modulusa i wykładnika prywatnego. Oba te sloty kluczy są dopasowywane po modulusie RSA i wykładniku prywatnym, więc klucze EC, włącznie z pochodzącymi z PFX, biorą trzecią drogę: AddPubSecAgreementKeyMaterial(CurveOID, PrivateScalar, OwnPublicPoint), z jedną ze stałych HPDFOIDX25519, HPDFOIDX448, HPDFOIDECP256, HPDFOIDECP384 albo HPDFOIDECP521. Krzywe NIST wymagają tam własnego punktu publicznego odbiorcy bez kompresji; krzywe Montgomery go ignorują

Tożsamość EC przychodząca jako plik PFX pasuje do tego samego wywołania. HPDFParsePFX zgłasza ją z KeyAlgorithm = pfxkaECDSA (włącznie z tożsamościami X25519 i X448) i wypełnia CurveOID, prywatny skalar w PrivateKey i punkt certyfikatu w PublicKey, co mapuje się wprost na trzy parametry

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFPFX, HPDFKeyAgreement;

procedure OpenAuditPack(const LegalScalar: TBytes);
var
  Reader: THotPDF;
  EcKey: THPDFPFXKeyMaterial;
begin
  Reader := THotPDF.Create(nil);
  try
    Reader.AutoLaunch := False;
    // tożsamość RSA staje się kluczem głównym
    Reader.PubSecKeyMaterial :=
      HPDFParsePFX(TFile.ReadAllBytes('reviewer-a.pfx'), 'pfx-password');
    // tożsamość EC z PFX: krzywa, skalar i punkt idą na listę uzgodnienia
    EcKey := HPDFParsePFX(TFile.ReadAllBytes('reviewer-c-p256.pfx'), 'pfx-password');
    if EcKey.KeyAlgorithm = pfxkaECDSA then
      Reader.AddPubSecAgreementKeyMaterial(EcKey.CurveOID,
        EcKey.PrivateKey, EcKey.PublicKey);
    // surowy skalar X25519 trzymany przez zespół prawny; punkt niepotrzebny
    Reader.AddPubSecAgreementKeyMaterial(HPDFOIDX25519, LegalScalar, nil);
    Reader.LoadFromFile('audit-pack.pdf', '');
    Writeln('Pages: ', Reader.GetLoadedPageCount);
  finally
    Reader.Free;
  end;
end;

Sam na sam, HotPDF próbuje każdej koperty wobec każdego zarejestrowanego klucza: klucza RSA głównego, potem każdego dodatkowego klucza RSA, potem materiału EC. PubSecRecipientQuery zawęża to do jednej koperty. Callback dostaje liczbę kopert i musi zwrócić indeks liczony od zera wewnątrz tablicy; jakakolwiek inna wartość, -1 włącznie, rzuca podczas wczytywania. By próbować każdą kopertę, zostaw właściwość nieprzypisaną. Pod Delphi typ to referencja do funkcji, więc zadziała metoda anonimowa, a pod FPC to zwykły wskaźnik funkcji, którego argument Context pochodzi z PubSecRecipientQueryContext

Jedno zachowanie zasługuje na osobny test. Gdy żaden zarejestrowany klucz nie odpina żadnej koperty, odzyskiwanie klucza kończy się bez klucza pliku i nie rzuca z tego powodu. Aseruj na zawartości, o której wiesz, że dokument ją trzyma, na przykład na łańcuchu tekstu albo wartości metadanych; wywołanie LoadFromFile, które wraca normalnie, pokazuje tylko, że struktura pliku się sparsowała

Diagram wczytywania kluczy prywatnych w HotPDF: PubSecKeyMaterial niesie główny klucz RSA albo EC z HPDFParsePFX, AddPubSecKeyMaterial dodaje wyłącznie klucze RSA, AddPubSecAgreementKeyMaterial rejestruje surowe skalary ECDH pod OID-ami krzywych od HPDFOIDX25519 do HPDFOIDP521, a przy LoadFromFile dostawca próbuje klucza głównego, potem każdego dodatkowego klucza RSA, potem materiału EC względem każdej koperty
Gdy żaden klucz nie odpina żadnej koperty, krok odzyskiwania wraca bez klucza pliku zamiast rzucać, więc zweryfikuj, że zawartość faktycznie się odszyfrowała, albo przypnij kopertę przez PubSecRecipientQuery

Co należy sprawdzić w docelowej przeglądarce?

Zapisujący i czytający HotPDF zgadzają się bajt po bajcie, a koperty podążają za strukturami CMS cytowanymi wyżej. Zewnętrzne przeglądarki to inne pytanie: wsparcie dla transportu klucza RSA-OAEP, a tym bardziej dla odbiorców X25519 lub X448, różni się między czytnikami i między wersjami tego samego czytnika. Jeśli dokument musi się otworzyć w konkretnej przeglądarce, zaszyfruj testowy plik dla testowego certyfikatu tego samego typu klucza i otwórz go tam, zanim zdecydujesz się na schemat. Gdy przeglądarka jest poza twoją kontrolą, jej lista wsparcia wybiera schemat, i to waży więcej niż argument wygody X25519 z wcześniejszej części

Uprawnienia kopert pozostają polityką, którą zgodne oprogramowanie honoruje, dokładnie tak samo jak przy szyfrowaniu hasłem. Jakość ziarna jest na tobie: AESGenerateRandomBytes istnieje właśnie do tej roboty, a HotPDF wyciera własną kopię ziarna, gdy klucz pliku został wyprowadzony. Jeśli łańcuch, strumień albo załącznik potrzebuje innego filtra kryptograficznego, przewodnik polityki filtrów kryptograficznych dla StmF, StrF i EFF pokazuje, jak go przypisać; handler klucza publicznego przyjmuje DefaultCryptFilter albo Identity

Koperty per odbiorca dla certyfikatów RSA i krzywych eliptycznych, wraz z wczytywaniem kluczy w drodze powrotnej, są częścią komponentu HotPDF PDF dla Delphi i C++Builder, obok szyfrowania hasłem i podpisów cyfrowych