Technisch artikel

PDF-certificaatversleuteling in Delphi: RSA-OAEP en ECDH

HotPDF versleutelt een PDF voor specifieke certificaathouders via de ISO 32000 public-key security handler: EnablePubKeyEncryption neemt een random seed van 20 bytes, en elke ontvanger krijgt zijn eigen CMS-envelope, gebouwd door AddPubKeyRecipientCertificate voor RSA-sleutels (RSA-OAEP key transport) of AddPubKeyAgreementRecipientWithSecret voor elliptische-curve-sleutels (ECDH op P-256, P-384, P-521, X25519 of X448). Niemand deelt een wachtwoord; wie een passende privésleutel bezit opent het bestand

De use case is altijd wel een variant op hetzelfde verhaal. Een kwartaalauditpakket gaat naar drie externe reviewers, legal wil dat ze het alle drie lezen, slechts één van hen mag het printen, en niemand wil een wachtwoord in een e-mailthread slingeren naast de bijlage. Wachtwoordversleuteling kan dat niet uitdrukken. Certificaatversleuteling wel, want elke ontvanger ontgrendelt het document met een sleutel die hij al bezit, en elke ontvanger kan een eigen permissionset in zijn eigen envelope meedragen

Hoe verschilt certificaatgebaseerde PDF-versleuteling van een wachtwoord?

Een met public key versleutelde PDF leidt zijn file key af uit een random seed plus de exacte bytes van elke ontvanger-envelope, niet uit iets wat een mens intoetst. De handler staat beschreven in ISO 32000-1 §7.6.4 (§7.6.5 in ISO 32000-2), en de envelopes zijn CMS-EnvelopedData-structuren zoals gedefinieerd in RFC 5652. HotPDF schrijft /Filter /Adobe.PubSec met /SubFilter /adbe.pkcs7.s5; bij AES-256 betekent dat /V 5 en een /DefaultCryptFilter-entry onder /CF met /CFM /AESV3, en de /Recipients-array woont in die crypt filter. Elke envelope versleutelt 24 bytes: de seed van 20 bytes gevolgd door het 32-bit-permissionword van die ontvanger. De /P-waarde in de encryption-dictionary is alleen een placeholder, want de echte permissies reizen binnenin elke envelope. Bij het laden pakt een reader één envelope uit, herstelt de seed, en hasht de seed samen met elke envelope in /Recipients-volgorde (SHA-256 voor AES-256, SHA-1 voor de oudere ciphers) om de file key opnieuw op te bouwen. Twijfelt u nog tussen dit model en gewone wachtwoorden, dan behandelt de gids over AES-256-wachtwoordversleuteling en permissionflags de andere kant van die afweging

Diagram van public-key-versleuteling in HotPDF: EnablePubKeyEncryption zet een seed van 20 bytes vast, elke CMS-EnvelopedData-envelope versleutelt die 20 bytes plus één 32-bit-permissionword binnen /Filter /Adobe.PubSec met /SubFilter /adbe.pkcs7.s5 en /CFM /AESV3, en de reader pakt één envelope uit, herstelt de seed en hasht haar met elke /Recipients-entry in arrayvolgorde om de file key te herbouwen
De /P-waarde in de encryption-dictionary is alleen een placeholder want de echte permissies reizen binnenin elke envelope, en niets stroomafwaarts mag de array waarover de digest loopt herordenen of herencoderen

RSA-ontvangers schrijven met EnablePubKeyEncryption

Voor RSA-certificaten roept u EnablePubKeyEncryption aan met aes256, en daarna AddPubKeyRecipientCertificate één keer per DER-gecodeerd certificaat vóór BeginDoc. De helper bouwt in-process een RSAES-OAEP-envelope met THPDFRSAOAEPHash-waarden voor de OAEP-digest en de MGF1-digest (rohSHA256, rohSHA384 of rohSHA512), en versleutelt de envelope-inhoud met 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);                      // precies 20 bytes, ook voor AES-256
  AESGenerateRandomBytes(@Seed[1], Length(Seed));
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := OutFile;
    Pdf.EnablePubKeyEncryption(Seed, aes256, True);   // default keytype is aes128
    // Reviewer A mag printen; reviewer B mag alleen lezen en extraheren
    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;

Drie details in die listing zijn dragend. Ten eerste staat de seedlengte vast op 20 bytes voor elk keytype, AES-256 incluis; EnablePubKeyEncryption gooit bij elke andere lengte. Ten tweede staat EnablePubKeyEncryption standaard op aes128, en allebei de certificaathelpers weigeren te draaien tenzij het keytype aes256 is, dus het vergeten van het tweede argument levert de exception "certificate envelopes require aes256" op. De legacy-ciphers (k40, k128, aes128) werken nog, maar alleen via AddPubKeyRecipient met een envelope die u elders zelf bouwde. Ten derde is AES-256-public-key-versleuteling een PDF 2.0-feature, dus HotPDF verhoogt de documentversie automatisch naar 2.0. Met StrictVersionLock gezet op een lagere versie keert EnablePubKeyEncryption terug zonder iets in te schakelen, en het falen duikt pas op de volgende regel op als "call EnablePubKeyEncryption first". Versleuteling wisselen tijdens een incrementele update gooit meteen een EInvalidOpException

ECDH-ontvangers toevoegen: P-256, P-384, P-521, X25519 en X448

Voor elliptische-curve-certificaten schrijft AddPubKeyAgreementRecipientWithSecret een CMS key-agreement-ontvanger (KeyAgreeRecipientInfo, de KARI-structuur uit RFC 5753, met het X25519- en X448-profiel uit RFC 8418) en berekent hij het ECDH shared secret in-process. De curve kiest u met een THPDFPubKeyAgreementScheme-waarde: pkasECDHP256, pkasECDHP384, pkasECDHP521, pkasX25519 of pkasX448. Het scheme moet matchen met de sleutel in het certificaat, anders gooit de aanroep "Certificate key does not match the requested agreement scheme". Onder de motorkap krijgt elke envelope een verse random UKM van 32 bytes, een met de stdDH-KDF afgeleide key-encryption key (SHA-256 voor P-256 en X25519, SHA-384 voor P-384, SHA-512 voor P-521 en X448), en een AES-256 key wrap zoals gedefinieerd in RFC 3394. Het shared secret zelf komt uit pure Pascal-curvecode, zonder dat er een platform-cryptoprovider aan te pas komt; het artikel over pure Pascal NIST-curve-aritmetiek legt uit hoe die laag is gebouwd en geverifieerd. Voor de Montgomery-curves kan het hele ephemere sleutelpaar lokaal worden gegenereerd:

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

procedure AddLegalRecipient(Pdf: THotPDF);
var
  Scalar, OriginatorPublic: TBytes;
begin
  // Verse ephemere scalar per envelope; clamping gebeurt binnen de ladder
  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: alleen betekenisvol voor de NIST-curves
  finally
    HPDFSecureClearBytes(Scalar);
  end;
end;

De NIST-curves vragen meer van de aanroeper. HotPDF levert public-key-helpers alleen voor X25519 en X448 (HPDFX25519PublicFromScalar, HPDFX448PublicFromScalar), dus voor P-256, P-384 en P-521 genereert u het ephemere sleutelpaar met eigen tooling en geeft u een big-endian scalar van precies de veldgrootte (32, 48 of 66 bytes) mee plus het bijpassende ongecomprimeerde 0x04||X||Y-punt als OriginatorPublicKey. HotPDF valideert het ontvangerpunt tegen de curvevergelijking, maar hij kan niet controleren dat uw originator public key werkelijk bij uw scalar hoort. Niet-passende helften leveren nog steeds een keurig gevormde envelope op die geen enkele ontvanger kan openen, en daarom hoort een round-trip-load in uw testsuite, niet alleen een controle op bestandsgrootte

Diagram van de ECDH agreement in HotPDF: AddPubKeyAgreementRecipientWithSecret leidt het shared secret af met pure Pascal-curvecode, mengt een verse UKM van 32 bytes door de stdDH-KDF met SHA-256 voor P-256 en X25519, SHA-384 voor P-384, SHA-512 voor P-521 en X448, en wrapt daarna de content key met de RFC 3394 AES-256 key wrap tot de KeyAgreeRecipientInfo-envelope
De schemawaarde van pkasECDHP256 tot pkasX448 moet matchen met de certificaatsleutel, en niet-passende scalar- en public point-helften leveren nog steeds een keurig gevormde envelope op die geen enkele ontvanger kan openen

Waarom maakt de volgorde van /Recipients uit?

De volgorde van /Recipients telt omdat de file key een digest is over de seed en elke envelope in arrayvolgorde, dus writer en reader moeten dezelfde bytes in dezelfde volgorde hashen. HotPDF houdt envelopes in de volgorde waarin u ze toevoegt en schrijft ze onveranderd weg, wat betekent dat u ontvangers in elke gewenste volgorde mag toevoegen, maar niets stroomafwaarts die array mag herordenen, herencoderen of "opschonen". De meeste echte bugs op dit terrein waren een variatie op dat thema, waarbij twee kanten iets verschillende bytes hashten:

  • Dynamische arrays in een TList bewaren via Add houdt alleen een raw pointer vast terwijl de reference count bij de lokale variabele blijft. De volgende SetLength geeft de buffer vrij en kan hem hergebruiken, dus elke slot begon naar de laatste envelope te aliasen en bestanden met meerdere ontvangers leidden de verkeerde key af. De fix is een owned kopie bewaren met List.Add(Pointer(System.Copy(Bytes)))
  • Het uitpakken van envelopes parseert de DER in place, en de key-recovery-pas hashte oorspronkelijk diezelfde live arrays. De reader snapshot nu pristine kopieën van elke envelope voordat een uitpakactie ze aanraakt, en de digest loopt over de snapshots
  • Binaire DER die door een Unicode-TStringList gaat krijgt bytes op of boven $80 hergecodeerd door de codepage, dus HotPDF bewaart envelopes intern als hex-tekst
  • Versleutelde en binaire strings moeten als hex-strings worden weggeschreven. Een literal string ondervalt end-of-line-normalisatie, waarbij CR, LF en CRLF allemaal één LF worden (ISO 32000-1 §7.3.4.2), en dat herschrijft ciphertext geruisloos. HotPDF schrijft elke /Recipients-entry als hex-string en vrijwaart haar van stringversleuteling, want elke reader heeft de envelopes nodig voordat hij enige key bezit
  • De eerste byte van een DER-BIT STRING telt ongebruikte bits en moet nul zijn voor byte-uitgelijnde sleutels. Haar ongeïnitialiseerd laten na SetLength schreef wat er toevallig op de stack stond, en een strenge unwrapper weigerde de originator key, dus een bestand kon af en toe weigeren te openen met precies de key waarvoor het was geschreven
  • Als dezelfde key nog steeds niet kan decoderen, vergelijk dan laag voor laag: de file key, daarna de ciphertext-prefix (de IV), daarna de object key, daarna de plaintext. De bug zit vlak na de eerste laag waar het verschil zit

Hoe opent u een certificaatversleutelde PDF met een privésleutel?

Om een certificaatversleutelde PDF te openen registreert u het privésleutelmateriaal vóór het aanroepen van LoadFromFile, want HotPDF herstelt de file key tijdens de structurele pas. Wijs een met HPDFParsePFX geparsde RSA- of EC-sleutel toe aan PubSecKeyMaterial, voeg extra RSA-sleutels toe met AddPubSecKeyMaterial, en registreer kale ECDH-scalars met AddPubSecAgreementKeyMaterial(CurveOID, PrivateScalar, OwnPublicPoint), met de constanten HPDFOIDX25519, HPDFOIDX448, HPDFOIDECP256, HPDFOIDECP384 of HPDFOIDECP521. De NIST-curves vereisen het eigen ongecomprimeerde public point van de ontvanger; de Montgomery-curves negeren het

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

procedure OpenAuditPack(const LegalScalar: TBytes);
var
  Reader: THotPDF;
begin
  Reader := THotPDF.Create(nil);
  try
    Reader.AutoLaunch := False;
    Reader.PubSecKeyMaterial :=
      HPDFParsePFX(TFile.ReadAllBytes('reviewer-a.pfx'), 'pfx-password');
    Reader.AddPubSecAgreementKeyMaterial(HPDFOIDX25519, LegalScalar, nil);
    // Optioneel: pak de envelope rechtstreeks in plaats van ze allemaal te proberen
    Reader.PubSecRecipientQuery :=
      function(Context: Pointer; RecipientCount: Integer): Integer
      begin
        Result := -1;   // -1 = probeer elke envelope op volgorde
      end;
    Reader.LoadFromFile('audit-pack.pdf', '');
    Writeln('Pages: ', Reader.GetLoadedPageCount);
  finally
    Reader.Free;
  end;
end;

Zonder callback probeert HotPDF elke envelope tegen elke geregistreerde sleutel: eerst de primaire sleutel, daarna elke extra RSA-sleutel, daarna het EC-materiaal. PubSecRecipientQuery ontvangt het aantal envelopes en geeft een 0-based index of -1 terug, en een index buiten de array gooit een exception in plaats van geclampt te worden. Bedenk dat AddPubSecKeyMaterial alleen RSA-materiaal accepteert (hij dringt aan op een modulus en een private exponent), dus EC-sleutels horen thuis in PubSecKeyMaterial of AddPubSecAgreementKeyMaterial. Als geen enkele key een envelope uitpakt keert de recoverystap terug zonder file key in plaats van te gooien, dus verifieer dat de inhoud die u verwacht echt gedecodeerd is in plaats van te vertrouwen op het terugkeren van de load-aanroep

Diagram van het laden van privésleutels in HotPDF: PubSecKeyMaterial draagt de primaire RSA- of EC-sleutel uit HPDFParsePFX, AddPubSecKeyMaterial voegt alleen RSA-sleutels toe, AddPubSecAgreementKeyMaterial registreert kale ECDH-scalars onder de curve-OIDs van HPDFOIDX25519 tot HPDFOIDP521, en bij LoadFromFile probeert de provider eerst de primaire sleutel, daarna elke extra RSA-sleutel, daarna het EC-materiaal tegen elke envelope
Als geen enkele key een envelope uitpakt keert de recoverystap terug zonder file key in plaats van te gooien, dus verifieer dat de inhoud echt gedecodeerd is of pin de envelope vast via PubSecRecipientQuery

Wat HotPDF niet garandeert

HotPDF garandeert dat zijn eigen writer en reader byte voor byte overeenkomen, en hij bouwt envelopes die de hierboven genoemde CMS-structuren volgen. Hij garandeert niet dat elke PDF-viewer elke combinatie opent. Ondersteuning voor RSA-OAEP key transport en voor X25519- of X448-ontvangers verschilt per reader en per versie, en we hebben geen compatibiliteitsresultaten voor die combinaties gepubliceerd. Moet een document in een specifieke viewer opengaan, versleutel dan een testbestand voor een testcertificaat van hetzelfde keytype en open het daar voordat u aan een scheme vasthoudt. Permissies die in de envelope reizen blijven policy die conformerende software eert, precies zoals bij wachtwoordversleuteling. De kwaliteit van de seed is ook uw verantwoordelijkheid: AESGenerateRandomBytes is er voor die klus, en HotPDF wist zijn kopie van de seed zodra de file key is afgeleid. Heeft u ook nog een string, stream of bijlage nodig die een andere crypt filter gebruikt, dan laat de crypt filter policy-gids voor StmF, StrF en EFF zien welke filternamen de public-key-handler accepteert

Certificaatversleuteling, RSA-OAEP- en ECDH-ontvanger-envelopes en het laden van privésleutels zitten allemaal in de HotPDF Delphi PDF component, naast wachtwoordversleuteling, digitale handtekeningen en de rest van de ISO 32000-toolset voor Delphi en C++Builder