Articol tehnic

Criptare PDF cu certificate în Delphi: RSA-OAEP și ECDH

HotPDF criptează un PDF pentru deținători de certificate anumiți prin handler-ul de securitate cu cheie publică din ISO 32000: EnablePubKeyEncryption primește un seed aleator de 20 de octeți, iar fiecare destinatar primește propriul envelope CMS, construit de AddPubKeyRecipientCertificate pentru chei RSA (transport de cheie RSA-OAEP) sau de AddPubKeyAgreementRecipientWithSecret pentru chei pe curbe eliptice (ECDH pe P-256, P-384, P-521, X25519 sau X448). Nimeni nu partajează o parolă; cine deține cheia privată potrivită deschide fișierul

Cazul de utilizare e întotdeauna câte o variantă a aceleiași povești. Un pachet de audit trimestrial merge la trei recenzori externi, juridicul vrea ca fiecare dintre ei să îl citească, doar unuia i se permite să îl tipărească, și nimeni nu vrea o parolă stând într-un fir de email lângă atașament. Criptarea cu parolă nu poate exprima asta. Criptarea cu certificate poate, pentru că fiecare destinatar deblochează documentul cu o cheie pe care o deține deja, iar fiecare destinatar poate cară un set de permisiuni diferit în propriul lui envelope

Cum diferă criptarea PDF pe bază de certificate de o parolă?

Un PDF criptat cu cheie publică își derivă file key-ul dintr-un seed aleator plus octeții exacți ai fiecărui envelope de destinatar, nu din nimic ce tastează o persoană. Handler-ul e descris în ISO 32000-1 §7.6.4 (§7.6.5 în ISO 32000-2), iar envelope-urile sunt structuri CMS EnvelopedData conform RFC 5652. HotPDF scrie /Filter /Adobe.PubSec cu /SubFilter /adbe.pkcs7.s5; pentru AES-256 înseamnă /V 5 și o intrare /DefaultCryptFilter sub /CF cu /CFM /AESV3, iar tabloul /Recipients trăiește în interiorul acelui crypt filter. Fiecare envelope criptează 24 de octeți: seed-ul de 20 de octeți urmat de cuvântul de permisiuni pe 32 de biți al destinatarului respectiv. Valoarea /P din dicționarul de criptare e doar un placeholder, pentru că permisiunile reale călătoresc în interiorul fiecărui envelope. La încărcare, un cititor desface un envelope, recuperează seed-ul și hash-uiește seed-ul împreună cu fiecare envelope în ordinea din /Recipients (SHA-256 pentru AES-256, SHA-1 pentru cifrurile mai vechi) ca să reconstruiască file key-ul. Dacă vă mai decideți între acest model și parolele obișnuite, ghidul de criptare AES-256 cu parolă și flag-uri de permisiuni acoperă cealaltă parte a compromisului

Diagrama criptării cu cheie publică în HotPDF: EnablePubKeyEncryption fixează un seed de 20 de octeți, fiecare envelope CMS EnvelopedData criptează acei 20 de octeți plus un cuvânt de permisiuni pe 32 de biți în interiorul /Filter /Adobe.PubSec cu /SubFilter /adbe.pkcs7.s5 și /CFM /AESV3, iar cititorul desface un envelope, recuperează seed-ul și îl hash-uiește cu fiecare intrare din /Recipients în ordinea tabloului pentru a reconstrui file key-ul
Valoarea /P din dicționarul de criptare e doar un placeholder pentru că permisiunile reale călătoresc în interiorul fiecărui envelope, iar nimic din aval nu are voie să reordoneze sau să re-encodeze tabloul peste care rulează digest-ul

Scrierea destinatarilor RSA cu EnablePubKeyEncryption

Pentru certificate RSA, apelați EnablePubKeyEncryption cu aes256, apoi apelați AddPubKeyRecipientCertificate o dată per certificat codat DER înainte de BeginDoc. Helper-ul construiește un envelope RSAES-OAEP în proces, cu valori THPDFRSAOAEPHash pentru digest-ul OAEP și digest-ul MGF1 (rohSHA256, rohSHA384 sau rohSHA512), și criptează conținutul envelope-ului cu 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);                      // exact 20 de octeți, chiar și pentru AES-256
  AESGenerateRandomBytes(@Seed[1], Length(Seed));
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := OutFile;
    Pdf.EnablePubKeyEncryption(Seed, aes256, True);   // tipul de cheie implicit e aes128
    // Reviewerul A poate tipări; reviewerul B poate doar citi și extrage
    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;

Trei detalii din listarea aceea poartă greutatea. Întâi, lungimea seed-ului e fixată la 20 de octeți pentru orice tip de cheie, AES-256 inclus; EnablePubKeyEncryption ridică excepție la orice altă lungime. Al doilea, EnablePubKeyEncryption are implicit aes128, iar ambii helper-i de certificate refuză să ruleze dacă tipul de cheie nu e aes256, deci uitarea celui de-al doilea argument vă aduce excepția „certificate envelopes require aes256”. Cifrurile legacy (k40, k128, aes128) funcționează în continuare, dar doar prin AddPubKeyRecipient cu un envelope construit în altă parte. Al treilea, criptarea cu cheie publică AES-256 e o funcționalitate PDF 2.0, deci HotPDF ridică versiunea documentului la 2.0 automat. Cu StrictVersionLock setat pe o versiune mai mică, EnablePubKeyEncryption se întoarce fără să activeze nimic, iar eșecul apare abia pe rândul următor ca „call EnablePubKeyEncryption first”. Schimbarea criptării în timpul unei actualizări incrementale ridică EInvalidOpException pe loc

Adăugarea destinatarilor ECDH: P-256, P-384, P-521, X25519 și X448

Pentru certificate pe curbe eliptice, AddPubKeyAgreementRecipientWithSecret scrie un destinatar CMS de key-agreement (KeyAgreeRecipientInfo, structura KARI din RFC 5753, cu profilul X25519 și X448 din RFC 8418) și calculează secretul partajat ECDH în proces. Alegeți curba cu o valoare THPDFPubKeyAgreementScheme: pkasECDHP256, pkasECDHP384, pkasECDHP521, pkasX25519 sau pkasX448. Schema trebuie să se potrivească cu cheia din certificat, altfel apelul ridică „Certificate key does not match the requested agreement scheme”. Sub capotă, fiecare envelope primește un UKM aleator proaspăt de 32 de octeți, o cheie de criptare a cheii derivată cu KDF-ul stdDH (SHA-256 pentru P-256 și X25519, SHA-384 pentru P-384, SHA-512 pentru P-521 și X448) și un AES-256 key wrap conform RFC 3394. Secretul partajat în sine vine din cod Pascal pur de curbe, fără niciun provider criptografic de platformă implicat; articolul despre aritmetica NIST curve în Pascal pur explică cum a fost construit și verificat stratul acela. Pentru curbele Montgomery, întreaga pereche de chei efemere poate fi generată local:

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

procedure AddLegalRecipient(Pdf: THotPDF);
var
  Scalar, OriginatorPublic: TBytes;
begin
  // Scalar efemer proaspăt per envelope; clamping-ul are loc în interiorul ladder-ului
  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: are sens doar pentru curbele NIST
  finally
    HPDFSecureClearBytes(Scalar);
  end;
end;

Curbele NIST cer mai multe de la apelant. HotPDF livrează helper-i de cheie publică doar pentru X25519 și X448 (HPDFX25519PublicFromScalar, HPDFX448PublicFromScalar), deci pentru P-256, P-384 și P-521 generați perechea de chei efemere cu uneltele voastre și pasați un scalar big-endian de exact dimensiunea câmpului (32, 48 sau 66 de octeți) plus punctul necomprimat potrivit 0x04||X||Y ca OriginatorPublicKey. HotPDF validează punctul destinatarului împotriva ecuației curbei, dar nu poate verifica că cheia voastră publică de originator aparține într-adevăr scalarului vostru. Jumătăți nepotrivite produc în continuare un envelope perfect bine format pe care niciun destinatar nu îl poate deschide, motiv pentru care un dus-întors de încărcare aparține suitei voastre de teste, nu doar unei verificări de mărime a fișierului

Diagrama acordului ECDH în HotPDF: AddPubKeyAgreementRecipientWithSecret derivă secretul partajat cu cod Pascal pur de curbe, amestecă un UKM proaspăt de 32 de octeți prin KDF-ul stdDH cu SHA-256 pentru P-256 și X25519, SHA-384 pentru P-384, SHA-512 pentru P-521 și X448, apoi înfășoară cheia de conținut cu AES-256 key wrap din RFC 3394 pentru a construi envelope-ul KeyAgreeRecipientInfo
Valoarea schemei, de la pkasECDHP256 până la pkasX448, trebuie să se potrivească cu cheia certificatului, iar jumătăți nepotrivite de scalar și punct public produc în continuare un envelope bine format pe care niciun destinatar nu îl poate deschide

De ce contează ordinea din /Recipients?

Ordinea din /Recipients contează pentru că file key-ul e un digest peste seed și fiecare envelope în ordinea tabloului, deci scriitorul și cititorul trebuie să hash-uiească aceiași octeți în aceeași secvență. HotPDF păstrează envelope-urile în ordinea în care le adăugați și le scrie neschimbate, ceea ce înseamnă că puteți adăuga destinatari în orice ordine doriți, dar nimic din aval nu are voie să reordoneze, să re-encodeze sau să „curățe” tabloul acela. Majoritatea bug-urilor reale din zona aceasta au fost variații pe tema aceasta, cazuri în care două părți hash-uiau octeți ușor diferiți:

  • Stocarea de tablouri dinamice într-un TList prin Add păstrează doar un pointer brut, în timp ce contorul de referințe rămâne la variabila locală. Următorul SetLength eliberează buffer-ul și îl poate refolosi, deci fiecare slot ajungea să aliaseze ultimul envelope, iar fișierele multi-destinatar derivau cheia greșită. Reparația e să stocați o copie deținută cu List.Add(Pointer(System.Copy(Bytes)))
  • Desfășurarea envelope-urilor parsează DER-ul în loc, iar trecerea de recuperare a cheii hash-uia inițial aceleași tablouri vii. Cititorul face acum instantanee cu copii imaculate ale fiecărui envelope înainte ca orice unwrap să le atingă, iar digest-ul rulează peste instantanee
  • DER-ul binar pasat printr-un TStringList Unicode vede octeți de la $80 în sus re-encodați de pagina de cod, deci HotPDF stochează envelope-urile intern ca text hexazecimal
  • Șirurile criptate și binare trebuie scrise ca hex strings. Un string literal e supus normalizării de sfârșit de linie, în care CR, LF și CRLF devin toate un singur LF (ISO 32000-1 §7.3.4.2), iar asta rescrie în tăcere ciphertext-ul. HotPDF emite fiecare intrare /Recipients ca hex string și o exemptuează de la criptarea de șiruri, pentru că orice cititor are nevoie de envelope-uri înainte să dețină vreo cheie
  • Primul octet al unui BIT STRING DER numără biții nefolosiți și trebuie să fie zero pentru chei aliniate la octet. Lăsat neinițializat după SetLength, scria orice era pe stivă, iar un wrapper strict respingea cheia originatorului, deci un fișier putea ocazional să nu se deschidă cu exact cheia pentru care fusese scris
  • Când aceeași cheie tot nu poate decripta, comparați strat cu strat: file key-ul, apoi prefixul ciphertext-ului (IV-ul), apoi cheia de obiect, apoi plaintext-ul. Bug-ul stă chiar după primul strat care nu se potrivește

Cum deschideți un PDF criptat cu certificate folosind o cheie privată?

Pentru a deschide un PDF criptat cu certificate, înregistrați materialul de cheie privată înainte să apelați LoadFromFile, pentru că HotPDF recuperează file key-ul în timpul trecerii structurale. Atribuiți o cheie RSA sau EC parsată cu HPDFParsePFX către PubSecKeyMaterial, adăugați chei RSA suplimentare cu AddPubSecKeyMaterial și înregistrați scalari ECDH bruti cu AddPubSecAgreementKeyMaterial(CurveOID, PrivateScalar, OwnPublicPoint), folosind constantele HPDFOIDX25519, HPDFOIDX448, HPDFOIDECP256, HPDFOIDECP384 sau HPDFOIDECP521. Curbele NIST cer punctul public necomprimat propriu al destinatarului; curbele Montgomery îl ignoră

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);
    // Opțional: alegeți envelope-ul direct în loc să le încercați pe toate
    Reader.PubSecRecipientQuery :=
      function(Context: Pointer; RecipientCount: Integer): Integer
      begin
        Result := -1;   // -1 = încearcă fiecare envelope în ordine
      end;
    Reader.LoadFromFile('audit-pack.pdf', '');
    Writeln('Pages: ', Reader.GetLoadedPageCount);
  finally
    Reader.Free;
  end;
end;

Fără callback, HotPDF încearcă fiecare envelope împotriva fiecărei chei înregistrate: cheia primară întâi, apoi fiecare cheie RSA suplimentară, apoi materialul EC. PubSecRecipientQuery primește numărul de envelope-uri și întoarce un index bazat pe zero sau -1, iar un index în afara tabloului ridică excepție în loc să fie decolnat. De reținut că AddPubSecKeyMaterial acceptă doar material RSA (se ține morțis de un modul și un exponent privat), deci cheile EC își au locul în PubSecKeyMaterial sau AddPubSecAgreementKeyMaterial. Când nicio cheie nu desface niciun envelope, pasul de recuperare se întoarce fără file key în loc să ridice excepție, deci verificați că conținutul așteptat s-a decriptat cu adevărat, nu doar că apelul de încărcare s-a întors

Diagrama încărcării cheii private în HotPDF: PubSecKeyMaterial cară cheia primară RSA sau EC de la HPDFParsePFX, AddPubSecKeyMaterial adaugă doar chei RSA, AddPubSecAgreementKeyMaterial înregistrează scalari ECDH bruti sub OID-urile de curbe HPDFOIDX25519 până la HPDFOIDP521, iar la LoadFromFile providerul încearcă cheia primară, apoi fiecare cheie RSA suplimentară, apoi materialul EC, împotriva fiecărui envelope
Când nicio cheie nu desface niciun envelope, pasul de recuperare se întoarce fără file key în loc să ridice excepție, deci verificați că conținutul s-a decriptat cu adevărat sau fixați envelope-ul prin PubSecRecipientQuery

Ce nu garantează HotPDF

HotPDF garantează că propriile lui scriitor și cititor sunt de acord octet cu octet și construiește envelope-uri care urmează structurile CMS citate mai sus. Nu garantează că orice vizualizator PDF deschide orice combinație. Suportul pentru transport de cheie RSA-OAEP și pentru destinatari X25519 sau X448 variază între cititoare și versiuni, iar noi nu am publicat rezultate de compatibilitate pentru combinațiile acelea. Dacă un document trebuie să se deschidă într-un anumit vizualizator, criptați un fișier de test pentru un certificat de test al aceluiași tip de cheie și deschideți-l acolo înainte să vă angajați pe o schemă. Permisiunile cărate în envelope rămân o politică pe care software-ul conformant o respectă, exact ca la criptarea cu parolă. Calitatea seed-ului e și ea responsabilitatea voastră: AESGenerateRandomBytes e acolo pentru treaba asta, iar HotPDF își șterge copia seed-ului odată ce file key-ul a fost derivat. Dacă aveți nevoie ca un string, un stream sau un atașament să folosească un alt crypt filter, ghidul de politici crypt filter pentru StmF, StrF și EFF arată ce nume de filtre acceptă handler-ul cu cheie publică

Criptarea cu certificate, envelope-urile de destinatari RSA-OAEP și ECDH și încărcarea cheilor private se livrează în HotPDF Delphi PDF component, alături de criptarea cu parolă, semnăturile digitale și restul setului de unelte ISO 32000 pentru Delphi și C++Builder