Articolo tecnico

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

Un PDF cifrato a certificato porta un unico valore /P nel suo dizionario di cifratura, non importa per chi sia stato scritto il file; nei file costruiti più avanti in questo articolo vale -64. Tre destinatari con tre set di permessi diversi condividono comunque quel numero. Il codice che risponde "questo utente può stampare?" leggendo /P sta leggendo un segnaposto, perché sotto il security handler a chiave pubblica la vera permission word viaggia dentro la busta cifrata di ogni destinatario e compare solo quando la chiave privata di quel destinatario l'ha aperta

Quel design è il motivo per cui la cifratura a certificato vale la fatica. Un pacchetto di audit trimestrale va a tre revisori esterni; il legale vuole che lo leggano tutti, solo uno può stamparlo, e nessuno vuole una password che se ne sta nel thread email accanto all'allegato. Una password non può esprimerlo. I certificati sì: ogni destinatario sblocca il file con una chiave che già possiede, e ogni busta porta la propria permission word

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 destinatario, e nulla di ciò che una persona digita entra nel calcolo. L'handler è specificato in ISO 32000-1 §7.6.4 (§7.6.5 in ISO 32000-2), e le buste sono strutture CMS EnvelopedData da RFC 5652. HotPDF scrive /Filter /Adobe.PubSec con /SubFilter /adbe.pkcs7.s5. Per AES-256 questo significa /V 5, una voce /DefaultCryptFilter sotto /CF con /CFM /AESV3, e l'array /Recipients collocato dentro quel crypt filter invece che nel dizionario di primo livello

Ogni busta cifra 24 byte: il seed da 20 byte seguito dalla permission word a 32 bit di quel destinatario. Al caricamento un reader apre una busta, recupera il seed, e calcola 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. Il revisore B non viene mai a sapere cosa può fare il revisore A, eppure il reader di B calcola comunque l'hash dei byte della busta di A, quindi l'intero array deve sopravvivere byte per byte. Se state ancora scegliendo tra questo modello e le password ordinarie, la guida alla cifratura con password AES-256 e ai 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 la sequenza è breve: EnablePubKeyEncryption con aes256, poi una chiamata AddPubKeyRecipientCertificate per ogni certificato codificato DER, tutto prima di BeginDoc. L'helper costruisce una busta RSAES-OAEP dentro il processo. I suoi ultimi due parametri scelgono il digest OAEP e il digest MGF1 da THPDFRSAOAEPHash (rohSHA256, rohSHA384 o rohSHA512), entrambi con default rohSHA256, e il contenuto della busta è cifrato 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 in quel listato pesano. Il seed è di 20 byte per ogni tipo di chiave, AES-256 compreso, e EnablePubKeyEncryption solleva per qualsiasi altra lunghezza. Il tipo di chiave vale aes128 per default mentre entrambi gli helper a certificato pretendono aes256, quindi omettere il secondo argomento fa sollevare alla prima chiamata AddPubKeyRecipientCertificate "certificate envelopes require aes256". I cifrari legacy (k40, k128, aes128) restano disponibili tramite AddPubKeyRecipient, che prende una busta DER completa costruita da altri strumenti

Il terzo dettaglio è la versione. AES-256 sotto l'handler a chiave pubblica è una funzionalità PDF 2.0, e HotPDF alza da solo la versione del documento a 2.0. Con StrictVersionLock impostato su una versione inferiore quel salto non è permesso: EnablePubKeyEncryption ritorna senza accendere nulla, e l'errore affiora una riga dopo quando l'helper del destinatario riporta "call EnablePubKeyEncryption first". Quel messaggio manda a caccia di un errore nell'ordine delle chiamate. Controllate invece il version lock. Cambiare la cifratura durante un aggiornamento incrementale viene rifiutato in blocco con EInvalidOpException

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

I certificati a curva ellittica passano per AddPubKeyAgreementRecipientWithSecret, che scrive un destinatario CMS a key agreement (KeyAgreeRecipientInfo da RFC 5753, con il profilo X25519 e X448 da RFC 8418) e calcola il segreto condiviso ECDH dentro il processo. Un valore THPDFPubKeyAgreementScheme nomina la curva: pkasECDHP256, pkasECDHP384, pkasECDHP521, pkasX25519 o pkasX448. Lo schema deve combaciare con l'algoritmo di chiave, la curva e la lunghezza di chiave del certificato, altrimenti la chiamata solleva "Certificate key does not match the requested agreement scheme"

Ogni busta riceve un UKM casuale fresco da 32 byte, una key-encryption key dal 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. L'aritmetica delle curve è Pascal puro senza alcun crypto provider di piattaforma sotto; l'articolo sull'aritmetica delle curve NIST in Pascal puro spiega passo passo come è messo insieme quel livello. Sulle curve di Montgomery l'intera coppia di chiavi effimera può essere generata in locale:

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: ha senso solo per le curve NIST
  finally
    HPDFSecureClearBytes(Scalar);
  end;
end;

Le curve NIST chiedono di più al chiamante. HotPDF deriva le chiavi pubbliche dell'originator solo per X25519 e X448 (HPDFX25519PublicFromScalar, HPDFX448PublicFromScalar), quindi per P-256, P-384 e P-521 la coppia effimera viene dai vostri strumenti. Passate uno scalare big-endian esattamente della dimensione del campo (32, 48 o 66 byte) e il corrispondente punto non compresso 0x04||X||Y (65, 97 o 133 byte) come OriginatorPublicKey, e consegnate lo stesso punto a OwnPublicPoint, il parametro riservato agli schemi NIST. Lunghezze sbagliate sollevano subito. Il punto del destinatario preso dal certificato viene controllato contro l'equazione della curva prima che giri qualsiasi moltiplicazione

Ciò che nessuna libreria può controllare è se la vostra chiave pubblica dell'originator appartiene al vostro scalare. Metà disallineate producono comunque una busta ben formata. Il writer deriva la sua chiave da scalare per punto del destinatario, il destinatario deriva la propria dalla sua chiave privata per il punto originator sbagliato, e il controllo di integrità RFC 3394 lato destinatario è il primo ad accorgersene, quando ormai il file è partito. Un caricamento round-trip contro una chiave di test sulla stessa curva va nella vostra build; un EndDoc riuscito e una dimensione di file plausibile qui non provano nulla

Quel passo di accoppiamento è il motivo per cui la scelta della curva conta in pratica. Se controllate l'emissione dei certificati, X25519 è la curva più facile da fare bene da Delphi: uno scalare casuale da 32 byte, una chiamata helper, nessun punto da tenere in sincrono a mano. Le curve NIST si guadagnano il posto quando i certificati dei destinatari vengono da una PKI che emette solo P-256 o P-384, e in quel caso il caricamento round-trip smette di essere facoltativo

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 seguito da ogni busta in ordine di array, quindi writer e reader devono hashare byte identici in sequenza identica. HotPDF tiene le buste nell'ordine in cui le aggiungete e le scrive invariate, il che vi lascia liberi di scegliere qualsiasi ordine. Ciò che succede all'array dopo è la parte rischiosa. Strumenti di post-processing, pipeline di firma e builder di buste fatti in casa sono dove si rompe, e i fallimenti condividono una forma — due parti che hashano byte leggermente diversi:

  • La TList non tipizzata di Delphi memorizza un puntatore grezzo. Raccogliere le buste con List.Add(Pointer(Bytes)) lascia il reference count alla variabile locale, quindi la SetLength successiva su quella variabile libera o riusa il buffer e ogni slot può finire per aliasare l'ultima busta. TList<TBytes> o un semplice TArray<TBytes> tiene i riferimenti contati
  • I parser DER che lavorano sul posto modificano il buffer che parsano. Hashate una copia intonsa di ogni busta, presa prima che qualsiasi tentativo di apertura la tocchi
  • Una TStringList Unicode non è posto per DER binario: i byte da $80 in su vengono ricodificati dalla code page in ingresso. Tenete le buste in TBytes, o codificatele prima in esadecimale
  • Il ciphertext va nelle stringhe esadecimali. Dentro una literal string, CR, LF e CRLF si rileggono tutti come un unico LF (ISO 32000-1 §7.3.4.2), quindi una busta che per caso contiene $0D $0A perde un byte. HotPDF scrive ogni voce di /Recipients come stringa esadecimale e la lascia fuori dalla cifratura delle stringhe, visto che un reader ha bisogno delle buste prima di possedere qualsiasi chiave
  • Il primo byte di contenuto di una BIT STRING DER conta i bit inutilizzati e deve essere $00 per le chiavi allineate al byte. Un DER scritto a mano che omette questo byte e mette il punto 0x04||X||Y direttamente nel contenuto sposta l'intera chiave di un byte, e un parser rigoroso rifiuta in blocco la chiave dell'originator
  • Quando una chiave che dovrebbe funzionare continua a non decifrare, confrontate livello per livello, in quest'ordine: chiave di file, l'IV in testa al ciphertext, chiave per oggetto, plaintext. Il guasto sta subito dopo il primo livello in disaccordo

Caricare un PDF cifrato a certificato con le chiavi private

Le chiavi private vanno registrate prima che LoadFromFile giri, perché HotPDF recupera la chiave di file durante il passaggio strutturale. Le identità RSA parsate con HPDFParsePFX vanno in PubSecKeyMaterial, e le ulteriori chiavi RSA in AddPubSecKeyMaterial, che rifiuta materiale senza un modulo e un esponente privato. Entrambi quegli slot di chiave vengono abbinati su modulo RSA ed esponente privato, quindi le chiavi EC, comprese quelle da PFX, prendono una terza strada: AddPubSecAgreementKeyMaterial(CurveOID, PrivateScalar, OwnPublicPoint), con una delle costanti HPDFOIDX25519, HPDFOIDX448, HPDFOIDECP256, HPDFOIDECP384 o HPDFOIDECP521. Le curve NIST richiedono lì il punto pubblico non compresso del destinatario stesso; le curve di Montgomery lo ignorano

Un'identità EC che arriva come file PFX sta nella stessa chiamata. HPDFParsePFX la riporta con KeyAlgorithm = pfxkaECDSA (identità X25519 e X448 comprese) e compila CurveOID, lo scalare privato in PrivateKey e il punto del certificato in PublicKey, che mappano dritti sui tre parametri

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;
    // L'identità RSA diventa la chiave primaria
    Reader.PubSecKeyMaterial :=
      HPDFParsePFX(TFile.ReadAllBytes('reviewer-a.pfx'), 'pfx-password');
    // Identità EC da un PFX: curva, scalare e punto vanno nella lista di agreement
    EcKey := HPDFParsePFX(TFile.ReadAllBytes('reviewer-c-p256.pfx'), 'pfx-password');
    if EcKey.KeyAlgorithm = pfxkaECDSA then
      Reader.AddPubSecAgreementKeyMaterial(EcKey.CurveOID,
        EcKey.PrivateKey, EcKey.PublicKey);
    // Scalare X25519 grezzo tenuto dal team legale; nessun punto necessario
    Reader.AddPubSecAgreementKeyMaterial(HPDFOIDX25519, LegalScalar, nil);
    Reader.LoadFromFile('audit-pack.pdf', '');
    Writeln('Pages: ', Reader.GetLoadedPageCount);
  finally
    Reader.Free;
  end;
end;

Lasciato a sé, HotPDF prova ogni busta contro ogni chiave registrata: la chiave RSA primaria, poi ogni chiave RSA addizionale, poi il materiale EC. PubSecRecipientQuery restringe il tutto a una singola busta. La callback riceve il conteggio delle buste e deve restituire un indice a base zero dentro l'array; qualsiasi altro valore, -1 compreso, solleva durante il caricamento. Per provare ogni busta, lasciate la proprietà non assegnata. Sotto Delphi il tipo è un reference to function, quindi un metodo anonimo funziona, mentre sotto FPC è un semplice puntatore a funzione il cui argomento Context viene da PubSecRecipientQueryContext

Un comportamento merita un test tutto suo. Quando nessuna chiave registrata apre alcuna busta, il recupero della chiave termina senza chiave di file e non solleva per quel motivo. Fate assert su contenuto che sapete essere nel documento, come una stringa di testo o un valore di metadati; una chiamata LoadFromFile che ritorna normalmente mostra solo che la struttura del file è stata parsata

Diagramma del caricamento della chiave privata in HotPDF: PubSecKeyMaterial porta la chiave primaria RSA da HPDFParsePFX, AddPubSecKeyMaterial aggiunge solo chiavi RSA, AddPubSecAgreementKeyMaterial registra scalari ECDH grezzi sotto gli OID di curva da HPDFOIDX25519 a HPDFOIDECP521, 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 verificate che il contenuto sia davvero decifrato o fissate la busta attraverso PubSecRecipientQuery

Cosa controllare nel viewer di destinazione?

Writer e reader di HotPDF concordano byte per byte, e le buste seguono le strutture CMS citate sopra. I viewer di terze parti sono un'altra questione: il supporto per il key transport RSA-OAEP, e ancor più per i destinatari X25519 o X448, varia tra i reader e tra le versioni dello stesso reader. Se il documento deve aprirsi in un viewer particolare, cifrate un file di test per un certificato di test dello stesso tipo di chiave e apritelo lì prima di impegnarvi su uno schema. Quando il viewer è fuori dal vostro controllo, la sua lista di supporto sceglie lo schema, e questo prevale sull'argomento di comodità di X25519 fatto prima

I permessi della busta restano policy che il software conforme onora, esattamente come sotto la cifratura a password. La qualità del seed tocca a voi: AESGenerateRandomBytes esiste per quel lavoro, e HotPDF cancella la propria copia del seed una volta derivata la chiave di file. Se una stringa, uno stream o un allegato ha bisogno di un crypt filter diverso, la guida alle policy dei crypt filter per StmF, StrF ed EFF mostra come assegnarne uno; l'handler a chiave pubblica accetta DefaultCryptFilter o Identity

Le buste per destinatario per certificati RSA e a curva ellittica, insieme al caricamento delle chiavi sulla via del ritorno, fanno parte del componente PDF HotPDF per Delphi e C++Builder, accanto alla cifratura a password e alle firme digitali