Articolo tecnico

Cifratura PDF a certificato in Delphi: RSA-OAEP ed ECDH

HotPDF cifra un PDF per specifici portatori di certificato attraverso il security handler a chiave pubblica di ISO 32000: EnablePubKeyEncryption prende un seed casuale di 20 byte, e ogni destinatario riceve la sua busta CMS, costruita da AddPubKeyRecipientCertificate per chiavi RSA (trasporto di chiave RSA-OAEP) o da AddPubKeyAgreementRecipientWithSecret per chiavi a curva ellittica (ECDH su P-256, P-384, P-521, X25519 o X448). Nessuno condivide una password; chi possiede la chiave privata corrispondente apre il file

Il caso d'uso è sempre qualche versione della stessa storia. Un pacchetto di audit trimestrale va a tre revisori esterni, la direzione legale vuole che ognuno lo legga, solo uno di loro può stamparlo, e nessuno vuole una password che gironzola in un thread di email accanto all'allegato. La cifratura a password non sa esprimere questo. Quella a certificato sì, perché ogni destinatario sblocca il documento con una chiave che già possiede, e ogni destinatario può portare con sé un set di permessi diverso dentro la propria busta

In che cosa la cifratura PDF a certificato differisce da una password?

Un PDF cifrato a chiave pubblica deriva la sua chiave di file da un seed casuale più i byte esatti di ogni busta dei destinatari, non da nulla che una persona digiti. Il handler è descritto in ISO 32000-1 §7.6.4 (§7.6.5 in ISO 32000-2), e le buste sono strutture CMS EnvelopedData come definite in RFC 5652. HotPDF scrive /Filter /Adobe.PubSec con /SubFilter /adbe.pkcs7.s5; per AES-256 ciò significa /V 5 e una voce /DefaultCryptFilter sotto /CF con /CFM /AESV3, e l'array /Recipients vive dentro quel crypt filter. Ogni busta cifra 24 byte: il seed di 20 byte seguito dalla permission word a 32 bit di quel destinatario. Il valore /P nel dizionario di cifratura è solo un segnaposto, perché i permessi veri viaggiano dentro ogni busta. Al caricamento un reader apre una busta, recupera il seed, e fa l'hash del seed insieme a ogni busta in ordine di /Recipients (SHA-256 per AES-256, SHA-1 per i cifrari più vecchi) per ricostruire la chiave di file. Se stai ancora decidendo tra questo modello e le password ordinarie, la guida sulla cifratura a password AES-256 e sui flag di permesso copre l'altro lato di quel compromesso

Diagramma della cifratura a chiave pubblica di HotPDF: EnablePubKeyEncryption fissa un seed di 20 byte, ogni busta CMS EnvelopedData cifra quei 20 byte più una permission word a 32 bit dentro /Filter /Adobe.PubSec con /SubFilter /adbe.pkcs7.s5 e /CFM /AESV3, e il reader apre una busta, recupera il seed e ne calcola l'hash con ogni voce di /Recipients in ordine di array per ricostruire la chiave di file
Il valore /P nel dizionario di cifratura è solo un segnaposto perché i permessi veri viaggiano dentro ogni busta, e nulla a valle può riordinare o ricodificare l'array su cui gira il digest

Scrivere destinatari RSA con EnablePubKeyEncryption

Per i certificati RSA, chiama EnablePubKeyEncryption con aes256, poi chiama AddPubKeyRecipientCertificate una volta per certificato codificato DER prima di BeginDoc. L'helper costruisce una busta RSAES-OAEP in-process con valori THPDFRSAOAEPHash per il digest OAEP e il digest MGF1 (rohSHA256, rohSHA384 o rohSHA512), e cifra il contenuto della busta con 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);                      // esattamente 20 byte, anche per AES-256
  AESGenerateRandomBytes(@Seed[1], Length(Seed));
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := OutFile;
    Pdf.EnablePubKeyEncryption(Seed, aes256, True);   // il tipo di chiave predefinito è aes128
    // Il revisore A può stampare; il revisore B può solo leggere ed estrarre
    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 dettagli di quel listato reggono il resto. Primo, la lunghezza del seed è fissata a 20 byte per ogni tipo di chiave, AES-256 inclusa; EnablePubKeyEncryption alza un'eccezione su qualunque altra lunghezza. Secondo, EnablePubKeyEncryption ha default aes128, ed entrambi gli helper a certificato si rifiutano di girare se il tipo di chiave non è aes256, quindi dimenticare il secondo argomento ti regala l'eccezione "certificate envelopes require aes256". I cifrari legacy (k40, k128, aes128) funzionano ancora, ma solo attraverso AddPubKeyRecipient con una busta costruita altrove. Terzo, la cifratura a chiave pubblica AES-256 è una feature del PDF 2.0, quindi HotPDF alza automaticamente la versione del documento a 2.0. Con StrictVersionLock impostato su una versione più bassa, EnablePubKeyEncryption restituisce senza attivare nulla, e il fallimento appare solo alla riga successiva come "call EnablePubKeyEncryption first". Cambiare cifratura durante un aggiornamento incrementale alza EInvalidOpException all'istante

Aggiungere destinatari ECDH: P-256, P-384, P-521, X25519 e X448

Per i certificati a curva ellittica, AddPubKeyAgreementRecipientWithSecret scrive un destinatario CMS key-agreement (KeyAgreeRecipientInfo, la struttura KARI di RFC 5753, con il profilo X25519 e X448 di RFC 8418) e calcola il segreto condiviso ECDH in-process. Scegli la curva con un valore THPDFPubKeyAgreementScheme: pkasECDHP256, pkasECDHP384, pkasECDHP521, pkasX25519 o pkasX448. Lo schema deve combaciare con la chiave nel certificato, o la chiamata alza "Certificate key does not match the requested agreement scheme". Sotto il cofano, ogni busta riceve un UKM casuale fresco di 32 byte, una key-encryption key derivata con il KDF stdDH (SHA-256 per P-256 e X25519, SHA-384 per P-384, SHA-512 per P-521 e X448), e un AES-256 key wrap come definito in RFC 3394. Il segreto condiviso in sé viene da codice di curve in Pascal puro, senza alcun provider crittografico di piattaforma coinvolto; l'articolo sull'aritmetica NIST in Pascal puro spiega come quel layer è stato costruito e verificato. Per le curve di Montgomery l'intera coppia di chiavi effimera può essere generata localmente:

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

procedure AddLegalRecipient(Pdf: THotPDF);
var
  Scalar, OriginatorPublic: TBytes;
begin
  // Scalare effimero fresco per busta; il clamping avviene dentro la 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: significativo solo per le curve NIST
  finally
    HPDFSecureClearBytes(Scalar);
  end;
end;

Le curve NIST chiedono di più a chi chiama. HotPDF spedisce helper a chiave pubblica solo per X25519 e X448 (HPDFX25519PublicFromScalar, HPDFX448PublicFromScalar), quindi per P-256, P-384 e P-521 generi la coppia di chiavi effimera con i tuoi strumenti e passi uno scalare big-endian di esattamente la dimensione del campo (32, 48 o 66 byte) più il punto non compresso corrispondente 0x04||X||Y come OriginatorPublicKey. HotPDF valida il punto del destinatario contro l'equazione della curva, ma non può controllare che la tua chiave pubblica di origine appartenga davvero al tuo scalare. Metà disallineate producono comunque una busta perfettamente ben formata che nessun destinatario può aprire, ecco perché un load di andata e ritorno appartiene alla tua suite di test, non solo un controllo sulla dimensione del file

Diagramma del key agreement ECDH in HotPDF: AddPubKeyAgreementRecipientWithSecret deriva il segreto condiviso con codice di curve in Pascal puro, mescola un UKM fresco di 32 byte attraverso il KDF stdDH con SHA-256 per P-256 e X25519, SHA-384 per P-384, SHA-512 per P-521 e X448, poi avvolge la chiave di contenuto con il AES-256 key wrap di RFC 3394 per costruire la busta KeyAgreeRecipientInfo
Il valore dello schema da pkasECDHP256 a pkasX448 deve combaciare con la chiave del certificato, e metà di scalare e punto pubblico disallineate producono comunque una busta ben formata che nessun destinatario può aprire

Perché l'ordine di /Recipients conta?

L'ordine di /Recipients conta perché la chiave di file è un digest sul seed e su ogni busta in ordine di array, quindi writer e reader devono fare l'hash degli stessi byte nella stessa sequenza. HotPDF mantiene le buste nell'ordine in cui le aggiungi e le scrive invariate, il che significa che puoi aggiungere i destinatari nell'ordine che preferisci, ma nulla a valle può riordinare, ricodificare o «ripulire» quell'array. La maggior parte dei bug veri in quest'area era una variazione sul tema, con due lati che facevano l'hash di byte leggermente diversi:

  • Memorizzare array dinamici in una TList via Add conserva solo un puntatore grezzo mentre il reference count resta con la variabile locale. Il prossimo SetLength libera il buffer e può riutilizzarlo, così ogni slot finiva per aliasare l'ultima busta e i file multi-destinatario derivavano la chiave sbagliata. La correzione è memorizzare una copia di proprietà con List.Add(Pointer(System.Copy(Bytes)))
  • L'apertura delle buste parsifica il DER sul posto, e la passata di recupero chiave originariamente faceva l'hash di quegli stessi array vivi. Il reader ora scatta istantanee pristine di ogni busta prima che qualunque apertura la tocchi, e il digest gira sulle istantanee
  • DER binario passato attraverso una TStringList Unicode fa ricodificare i byte a $80 o sopra dal code page, quindi HotPDF memorizza le buste internamente come testo esadecimale
  • Le stringhe cifrate e binarie vanno scritte come hex string. Una stringa letterale è soggetta alla normalizzazione di fine riga, dove CR, LF e CRLF diventano tutti un singolo LF (ISO 32000-1 §7.3.4.2), e quello riscrive il ciphertext in silenzio. HotPDF emette ogni voce /Recipients come hex string e la esenta dalla cifratura delle stringhe, dato che ogni reader ha bisogno delle buste prima di possedere qualunque chiave
  • Il primo byte di una BIT STRING DER conta i bit non usati e deve essere zero per chiavi allineate al byte. Lasciarlo non inizializzato dopo SetLength scriveva ciò che era sullo stack, e un unwrapper severo rifiutava la chiave di origine, così un file poteva ogni tanto fallire l'apertura proprio con la chiave per cui era stato scritto
  • Quando la stessa chiave continua a non decifrare, confronta layer per layer: la chiave di file, poi il prefisso del ciphertext (l'IV), poi la chiave di oggetto, poi il plaintext. Il bug sta subito dopo il primo layer che discorda

Come si apre un PDF cifrato a certificato con una chiave privata?

Per aprire un PDF cifrato a certificato, registra il materiale della chiave privata prima di chiamare LoadFromFile, perché HotPDF recupera la chiave di file durante la passata strutturale. Assegna una chiave RSA o EC parsificata con HPDFParsePFX a PubSecKeyMaterial, aggiungi altre chiavi RSA con AddPubSecKeyMaterial, e registra scalari ECDH grezzi con AddPubSecAgreementKeyMaterial(CurveOID, PrivateScalar, OwnPublicPoint), usando le costanti HPDFOIDX25519, HPDFOIDX448, HPDFOIDECP256, HPDFOIDECP384 o HPDFOIDECP521. Le curve NIST richiedono il punto pubblico non compresso del destinatario stesso; le curve di Montgomery lo ignorano

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);
    // Opzionale: scegli la busta direttamente invece di provarle tutte
    Reader.PubSecRecipientQuery :=
      function(Context: Pointer; RecipientCount: Integer): Integer
      begin
        Result := -1;   // -1 = prova ogni busta in ordine
      end;
    Reader.LoadFromFile('audit-pack.pdf', '');
    Writeln('Pages: ', Reader.GetLoadedPageCount);
  finally
    Reader.Free;
  end;
end;

Senza callback, HotPDF prova ogni busta contro ogni chiave registrata: prima la chiave primaria, poi ogni chiave RSA addizionale, poi il materiale EC. PubSecRecipientQuery riceve il numero di buste e restituisce un indice 0-based o -1, e un indice fuori dall'array alza un'eccezione anziché essere troncato. Nota che AddPubSecKeyMaterial accetta solo materiale RSA (pretende un modulo e un esponente privato), quindi le chiavi EC stanno in PubSecKeyMaterial o in AddPubSecAgreementKeyMaterial. Quando nessuna chiave apre alcuna busta, il passo di recupero restituisce senza chiave di file anziché alzare un'eccezione, quindi verifica che il contenuto che ti aspetti sia davvero stato decifrato anziché fidarti del fatto che la chiamata di load sia tornata

Diagramma del caricamento della chiave privata in HotPDF: PubSecKeyMaterial porta la chiave primaria RSA o EC da HPDFParsePFX, AddPubSecKeyMaterial aggiunge solo chiavi RSA, AddPubSecAgreementKeyMaterial registra scalari ECDH grezzi sotto gli OID di curva da HPDFOIDX25519 a HPDFOIDP521, e a LoadFromFile il provider prova la chiave primaria, poi ogni chiave RSA addizionale, poi il materiale EC contro ogni busta
Quando nessuna chiave apre alcuna busta il passo di recupero restituisce senza chiave di file anziché alzare un'eccezione, quindi verifica che il contenuto sia davvero decifrato o fissa la busta attraverso PubSecRecipientQuery

Che cosa HotPDF non garantisce

HotPDF garantisce che il suo writer e il suo reader concordano byte per byte, e costruisce buste che seguono le strutture CMS citate qui sopra. Non garantisce che ogni viewer PDF apra ogni combinazione. Il supporto al trasporto di chiave RSA-OAEP e ai destinatari X25519 o X448 varia tra reader e versioni, e non abbiamo pubblicato risultati di compatibilità per quelle combinazioni. Se un documento deve aprirsi in uno specifico viewer, cifra un file di test per un certificato di test dello stesso tipo di chiave e aprilo lì prima di impegnarti su uno schema. I permessi portati nella busta restano policy che il software conforme onora, esattamente come sotto cifratura a password. La qualità del seed è responsabilità tua: AESGenerateRandomBytes esiste proprio per quel lavoro, e HotPDF cancella la sua copia del seed una volta derivata la chiave di file. Se ti serve anche che una stringa, uno stream o un allegato usino un crypt filter diverso, la guida alle policy dei crypt filter per StmF, StrF e EFF mostra quali nomi di filtro il handler a chiave pubblica accetta

La cifratura a certificato, le buste dei destinatari RSA-OAEP ed ECDH, e il caricamento delle chiavi private sono tutti nella PDF component HotPDF Delphi, accanto alla cifratura a password, alle firme digitali e al resto del toolset ISO 32000 per Delphi e C++Builder