Teknisk artikel

PDF-certifikatkryptering i Delphi: RSA-OAEP och ECDH

HotPDF krypterar en PDF för specifika certifikatinnehavare genom ISO 32000:s public-key security handler: EnablePubKeyEncryption tar ett slumpmässigt frö på 20 byte, och varje mottagare får sitt eget CMS-kuvert, byggt av AddPubKeyRecipientCertificate för RSA-nycklar (RSA-OAEP key transport) eller AddPubKeyAgreementRecipientWithSecret för elliptiska kurvnycklar (ECDH på P-256, P-384, P-521, X25519 eller X448). Ingen delar ett lösenord; den som håller en matchande privat nyckel öppnar filen

Användningsfallet är alltid någon variant av samma historia. Ett kvartals revisionspaket går till tre externa granskare, juristen vill att var och en av dem läser det, bara en av dem får skriva ut det, och ingen vill ha ett lösenord liggande i en e-posttråd bredvid bilagan. Lösenordskryptering kan inte uttrycka det. Certifikatkryptering kan det, eftersom varje mottagare låser upp dokumentet med en nyckel hen redan håller, och varje mottagare kan bära en annan behörighetsuppsättning inuti sitt eget kuvert

Hur skiljer sig certifikatsbaserad PDF-kryptering från ett lösenord?

En public-key-krypterad PDF härleder sin filnyckel från ett slumpmässigt frö plus de exakta bytena av varje mottagarkuvert, inte från något en människa skriver. Handlern beskrivs i ISO 32000-1 §7.6.4 (§7.6.5 i ISO 32000-2), och kuverten är CMS-EnvelopedData-strukturer enligt RFC 5652. HotPDF skriver /Filter /Adobe.PubSec med /SubFilter /adbe.pkcs7.s5; för AES-256 betyder det /V 5 och en /DefaultCryptFilter-post under /CF med /CFM /AESV3, och /Recipients-arrayen bor inuti det crypt-filtret. Varje kuvert krypterar 24 byte: fröet på 20 byte följt av den mottagarens 32-bitars behörighetsord. /P-värdet i krypteringsordboken är bara en platshållare, för att de riktiga behörigheterna färdas inuti varje kuvert. Vid inläsning vecklar en läsare upp ett kuvert, återvinner fröet och hashar fröet tillsammans med varje kuvert i /Recipients-ordning (SHA-256 för AES-256, SHA-1 för de äldre chifrren) för att bygga om filnyckeln. Om du fortfarande väger den modellen mot vanliga lösenord täcker guiden om AES-256-lösenordskryptering och behörighetsflaggor den andra sidan av den avvägningen

HotPDF-diagram för public-key-kryptering: EnablePubKeyEncryption fastställer ett 20-byte-frö, varje CMS EnvelopedData-kuvert krypterar de 20 bytena plus ett 32-bitars behörighetsord inuti /Filter /Adobe.PubSec med /SubFilter /adbe.pkcs7.s5 och /CFM /AESV3, och läsaren vecklar upp ett kuvert, återvinner fröet och hashar det med varje /Recipients-post i arrayordning för att bygga om filnyckeln
/P-värdet i krypteringsordboken är bara en platshållare eftersom de riktiga behörigheterna färdas inuti varje kuvert, och ingenting nedströms får ordna om eller koda om arrayen som digesten löper över

Skriva RSA-mottagare med EnablePubKeyEncryption

För RSA-certifikat anropar du EnablePubKeyEncryption med aes256 och sedan AddPubKeyRecipientCertificate en gång per DER-kodat certifikat före BeginDoc. Hjälpfunktionen bygger ett RSAES-OAEP-kuvert i processen med THPDFRSAOAEPHash-värden för OAEP-digesten och MGF1-digesten (rohSHA256, rohSHA384 eller rohSHA512), och krypterar kuvertinnehållet med 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);                      // exakt 20 byte, även för AES-256
  AESGenerateRandomBytes(@Seed[1], Length(Seed));
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := OutFile;
    Pdf.EnablePubKeyEncryption(Seed, aes256, True);   // standardnyckeltypen är aes128
    // Granskare A får skriva ut; granskare B får bara läsa och extrahera
    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;

Tre detaljer i den listningen bär hela vikten. För det första är frölängden låst till 20 byte för varje nyckeltyp, AES-256 inkluderat; EnablePubKeyEncryption kastar vid varje annan längd. För det andra är standardvärdet för EnablePubKeyEncryption aes128, och båda certifikathjälpfunktionerna vägrar köra om inte nyckeltypen är aes256, så att glömma det andra argumentet ger dig undantaget "certificate envelopes require aes256". De äldre chifrren (k40, k128, aes128) fungerar fortfarande, men bara genom AddPubKeyRecipient med ett kuvert du byggt någon annanstans. För det tredje är AES-256 public-key-kryptering en PDF 2.0-funktion, så HotPDF höjer dokumentversionen till 2.0 automatiskt. Med StrictVersionLock satt på en lägre version returnerar EnablePubKeyEncryption utan att aktivera något, och felet dyker först upp på nästa rad som "call EnablePubKeyEncryption first". Att byta kryptering under en inkrementell uppdatering kastar EInvalidOpException direkt

Lägga till ECDH-mottagare: P-256, P-384, P-521, X25519 och X448

För elliptiska kurvcertifikat skriver AddPubKeyAgreementRecipientWithSecret en CMS key-agreement-mottagare (KeyAgreeRecipientInfo, KARI-strukturen från RFC 5753, med X25519- och X448-profilen från RFC 8418) och beräknar den delade ECDH-hemligheten i processen. Du väljer kurvan med ett THPDFPubKeyAgreementScheme-värde: pkasECDHP256, pkasECDHP384, pkasECDHP521, pkasX25519 eller pkasX448. Schemat måste matcha nyckeln i certifikatet, annars kastar anropet "Certificate key does not match the requested agreement scheme". Under huven får varje kuvert en färsk slumpmässig UKM på 32 byte, en nyckelkrypteringsnyckel härledd med stdDH-KDF:n (SHA-256 för P-256 och X25519, SHA-384 för P-384, SHA-512 för P-521 och X448) och en AES-256 key wrap enligt RFC 3394. Den delade hemligheten i sig kommer från ren Pascal-kurvkod, utan någon plattformskryptoprovider inblandad; artikeln om ren Pascal NIST-kurvaritmetik förklarar hur det lagret byggdes och verifierades. För Montgomery-kurvorna kan hela det efemära nyckelparet genereras lokalt:

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

procedure AddLegalRecipient(Pdf: THotPDF);
var
  Scalar, OriginatorPublic: TBytes;
begin
  // Färsk efemär skalär per kuvert; klampning sker inne i stegen
  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: endast meningsfull för NIST-kurvorna
  finally
    HPDFSecureClearBytes(Scalar);
  end;
end;

NIST-kurvorna kräver mer av anroparen. HotPDF levererar public-key-hjälpare bara för X25519 och X448 (HPDFX25519PublicFromScalar, HPDFX448PublicFromScalar), så för P-256, P-384 och P-521 genererar du det efemära nyckelparet med egna verktyg och skickar en big-endian-skalär med exakt fältstorleken (32, 48 eller 66 byte) plus den matchande okomprimerade punkten 0x04||X||Y som OriginatorPublicKey. HotPDF validerar mottagarpunkten mot kurvekvationen, men den kan inte kontrollera att din originator publika nyckel faktiskt hör till din skalär. Omaka halvor ger ändå ett helt välbildat kuvert som ingen mottagare kan öppna, vilket är varför en rundtur med inläsning hör hemma i din testsvit, inte bara en filstorlekskontroll

HotPDF-diagram för ECDH-överenskommelse: AddPubKeyAgreementRecipientWithSecret härleder den delade hemligheten med ren Pascal-kurvkod, blandar en färsk UKM på 32 byte genom stdDH-KDF:n med SHA-256 för P-256 och X25519, SHA-384 för P-384, SHA-512 för P-521 och X448, och lindar sedan innehållsnyckeln med RFC 3394 AES-256 key wrap för att bygga KeyAgreeRecipientInfo-kuvertet
Schemavärdet från pkasECDHP256 till pkasX448 måste matcha certifikatnyckeln, och omaka halvor av skalär och publik punkt ger ändå ett välbildat kuvert som ingen mottagare kan öppna

Varför spelar /Recipients-ordningen roll?

Ordningen på /Recipients spelar roll för att filnyckeln är en digest över fröet och varje kuvert i arrayordning, så skrivare och läsare måste hasha samma byte i samma sekvens. HotPDF behåller kuverten i den ordning du lägger till dem och skriver dem oförändrade, vilket betyder att du kan lägga till mottagare i vilken ordning du vill, men ingenting nedströms får ordna om, koda om eller "städa upp" den arrayen. De flesta av de riktiga buggarna på det här området var en variation på det temat, där två sidor hashade något olika byte:

  • Att lagra dynamiska arrayer i en TList via Add behåller bara en rå pekare medan referensräknaren stannar hos den lokala variabeln. Nästa SetLength frigör bufferten och kan återanvända den, så varje plats slutade aliasa sista kuvertet och filer med flera mottagare härledde fel nyckel. Fixen är att lagra en ägd kopia med List.Add(Pointer(System.Copy(Bytes)))
  • Kuvertuppackning tolkar DER:en på plats, och nyckelåtervinningsgenomgången hashade ursprungligen samma levande arrayer. Läsaren tar nu ögonblicksbilder av rena kopior av varje kuvert innan någon uppackning rör dem, och digesten löper över ögonblicksbilderna
  • Binär DER som passerar en Unicode-TStringList får byte på eller över $80 omkodade av kodsidan, så HotPDF lagrar kuverten som hex-text internt
  • Krypterade och binära strängar måste skrivas som hex-strängar. En literal sträng är underkastad radslutnormalisering, där CR, LF och CRLF alla blir en enda LF (ISO 32000-1 §7.3.4.2), och det skriver om chiffertexten tyst. HotPDF skickar ut varje /Recipients-post som en hex-sträng och undantar den från strängkryptering, eftersom varje läsare behöver kuverten innan den håller någon nyckel
  • Första byten i en DER BIT STRING räknar oanvända bitar och måste vara noll för byte-justerade nycklar. Att lämna den oinitierad efter SetLength skrev vad som helst som låg på stacken, och en strikt uppackare avvisade originatornyckeln, så en fil kunde ibland vägra öppnas med just den nyckel den skrivits för
  • När samma nyckel ändå inte kan dekryptera, jämför lager för lager: filnyckeln, sedan chiffertext-prefixet (IV:en), sedan objektnyckeln, sedan klartexten. Buggen bor precis efter det första lagret som inte stämmer

Hur öppnar du en certifikatkrypterad PDF med en privat nyckel?

För att öppna en certifikatkrypterad PDF registrerar du det privata nyckelmaterialet innan du anropar LoadFromFile, eftersom HotPDF återvinner filnyckeln under det strukturella passet. Tilldela en RSA- eller EC-nyckel tolkad med HPDFParsePFX till PubSecKeyMaterial, lägg till fler RSA-nycklar med AddPubSecKeyMaterial och registrera råa ECDH-skalärer med AddPubSecAgreementKeyMaterial(CurveOID, PrivateScalar, OwnPublicPoint), med konstanterna HPDFOIDX25519, HPDFOIDX448, HPDFOIDECP256, HPDFOIDECP384 eller HPDFOIDECP521. NIST-kurvorna kräver mottagarens egen okomprimerade publika punkt; Montgomery-kurvorna ignorerar den

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);
    // Valfritt: plocka kuvertet direkt i stället för att prova alla
    Reader.PubSecRecipientQuery :=
      function(Context: Pointer; RecipientCount: Integer): Integer
      begin
        Result := -1;   // -1 = prova alla kuvert i ordning
      end;
    Reader.LoadFromFile('audit-pack.pdf', '');
    Writeln('Pages: ', Reader.GetLoadedPageCount);
  finally
    Reader.Free;
  end;
end;

Utan callback provar HotPDF varje kuvert mot varje registrerad nyckel: primärnyckeln först, sedan varje extra RSA-nyckel, sedan EC-materialet. PubSecRecipientQuery tar emot kuvertantalet och returnerar ett 0-baserat index eller -1, och ett index utanför arrayen kastar ett undantag i stället för att klämmas. Observera att AddPubSecKeyMaterial bara accepterar RSA-material (den kräver en modul och en privat exponent), så EC-nycklar hör hemma i PubSecKeyMaterial eller AddPubSecAgreementKeyMaterial. När ingen nyckel vecklar upp något kuvert returnerar återvinningssteget utan filnyckel i stället för att kasta, så kontrollera att innehållet du förväntar dig faktiskt dekrypterades i stället för att lita på att inläsningsanropet returnerade

HotPDF-diagram för inläsning av privat nyckel: PubSecKeyMaterial bär primärnyckeln RSA eller EC från HPDFParsePFX, AddPubSecKeyMaterial lägger bara till RSA-nycklar, AddPubSecAgreementKeyMaterial registrerar råa ECDH-skalärer under kurv-OID:na HPDFOIDX25519 till HPDFOIDP521, och vid LoadFromFile provar providern primärnyckeln, sedan varje extra RSA-nyckel, sedan EC-materialet mot varje kuvert
När ingen nyckel vecklar upp något kuvert returnerar återvinningssteget utan filnyckel i stället för att kasta, så kontrollera att innehållet faktiskt dekrypterades eller fäst kuvertet genom PubSecRecipientQuery

Vad HotPDF inte garanterar

HotPDF garanterar att dess egen skrivare och läsare är överens byte för byte, och den bygger kuvert som följer de CMS-strukturer som citerats ovan. Den garanterar inte att varje PDF-viewer öppnar varje kombination. Stödet för RSA-OAEP key transport och för X25519- eller X448-mottagare varierar mellan läsare och versioner, och vi har inte publicerat kompatibilitetsresultat för de kombinationerna. Om ett dokument måste öppnas i en specifik viewer, kryptera en testfil för ett testcertifikat av samma nyckeltyp och öppna den där innan du binder dig för ett schema. Behörigheterna som bärs i kuvertet förblir policy som konform programvara hedrar, exakt som under lösenordskryptering. Frökvaliteten är också ditt ansvar: AESGenerateRandomBytes finns för det jobbet, och HotPDF raderar sin kopia av fröet så snart filnyckeln härletts. Om du också behöver en sträng, ström eller bilaga som använder ett annat crypt-filter visar policyguiden för crypt-filtren StmF, StrF och EFF vilka filternamn public-key-handlern accepterar

Certifikatkryptering, RSA-OAEP- och ECDH-mottagarkuvert och inläsning av privat nyckel kommer alla i HotPDF Delphi PDF-komponenten, jämte lösenordskryptering, digitala signaturer och resten av ISO 32000-verktygslådan för Delphi och C++Builder