Articolo tecnico

PDF firmati AES-256 lenti in Delphi: cosa ha corretto HotPDF

HotPDF carica i PDF firmati e cifrati AES-256 in Delphi molto più velocemente di un tempo, perché tre costi separati sono stati tolti dal percorso di caricamento: parsing dei token senza buffer dello stream sorgente, una seconda esecuzione del controllo password dell'Algoritmo 2.A di ISO 32000-2 durante la decifratura, e un loop interno AES-128-CBC lento dentro l'hash rafforzato R6. Sui target Delphi la sola correzione R6 ha portato il caricamento di un documento da circa 66 ms a 16 ms

Nessuno di questi è emerso sui piccoli file di test che la maggior parte delle suite usa. Un campione non firmato e non cifrato di uno o due kilobyte si apre in pochi centesimi di secondo qualunque cosa faccia il parser. I guai cominciano con i documenti che i clienti mandano davvero: un contratto con una firma CMS incorporata, circa 35 KB, protetto con una password di revisione 6. Quella combinazione accende tutti i percorsi di codice costosi in una volta, e ognuno aveva una propria ragione per essere più lento del necessario. Questo articolo li ripercorre nell'ordine in cui sono stati trovati, insieme agli errori di cache-key emersi quando i risultati di verifica delle firme hanno cominciato a essere memoizzati

Perché un PDF firmato da 34 KB impiega 170 ms a caricare?

Un PDF firmato da 34 KB impiegava 170 ms a caricarsi perché il parser leggeva direttamente un THandleStream nudo, e ogni byte di token diventava una chiamata di sistema ReadFile tutta sua. Un tokenizer PDF avanza un byte alla volta mentre cerca i delimitatori, quindi uno stream handle senza buffer converte una scansione economica in memoria in migliaia di transizioni kernel

I dettagli spiegano perché il bug è sopravvissuto. THotPDF.LoadFromFile avvolge da tempo la propria sorgente file in un buffer di lettura interno, e la sonda AutoDecrypt era già stata corretta per la stessa ragione dopo aver speso 1,4 s a richiedere di nuovo la dimensione di un file da 56 MB. I documenti firmati e cifrati prendono però una strada diversa. Per assicurarsi che le letture pigre degli oggetti e l'ispezione delle firme vedano sempre la stessa revisione immutabile, persino dopo che il chiamante libera il proprio stream, THotPDF.LoadFromStream fa uno snapshot della sorgente e lo esegue in parsing una seconda volta. Quella seconda analisi, insieme al load di riutilizzo usato per le revisioni autenticate, leggeva l'handle dello snapshot senza buffer davanti. Entrambi ora passano per lo stesso reader bufferizzato interno che usa il percorso file

Pipeline di caricamento PDF firmato e cifrato AES-256 di HotPDF in Delphi: stream snapshot, parsing strutturale bufferizzato, pre-scan dell'Algoritmo 2.A, decifratura singola che riusa la chiave recuperata, e un hash rafforzato R6 che chiama AES-128-CBC nativo, tagliando un caricamento da 66 ms a 16 ms
Le tre correzioni si impilano su un solo percorso: il buffering elimina migliaia di chiamate di sistema, la chiave pre-scansionata viene riusata sotto un flag di scope, e il loop interno R6 raggiunge il backend CBC nativo

Quel buffer è interno, quindi non lo create mai voi. Ciò che controllate è lo stream che consegnate a LoadFromStream, e HotPDF legge quello stream così com'è durante il primo passaggio strutturale. Se i dati sono già in memoria, passate un TMemoryStream. Se sono su disco, chiamate LoadFromFile, o da Delphi 10.1 Berlin in poi date al loader un TBufferedFileStream invece di un TFileStream grezzo

uses
  System.SysUtils, System.Classes, HPDFDoc;

function LoadEncryptedFromBytes(const FileName: string;
  const Password: AnsiString): THotPDF;
var
  Mem: TMemoryStream;
begin
  Result := THotPDF.Create(nil);
  Mem := TMemoryStream.Create;
  try
    Mem.LoadFromFile(FileName);   // una lettura a blocco invece di letture per token
    Mem.Position := 0;
    // LoadFromStream restituisce il numero di pagine, 0 quando il documento non ha caricato
    if Result.LoadFromStream(Mem, Password) < 1 then
      raise Exception.Create('Could not load ' + FileName);
  except
    Result.Free;
    Mem.Free;
    raise;
  end;
  Mem.Free;
end;

Liberare il memory stream subito dopo il caricamento è sicuro per input firmati e cifrati, perché il componente tiene già il proprio snapshot dei byte. Conservate comunque il file o il buffer originale se pensate di verificare le firme dopo, visto che la verifica fa l'hash dei segmenti raw /ByteRange del file invece che del modello a oggetti decifrato

Misurare caricamento e verifica come fasi separate

Tempo di caricamento e tempo di verifica delle firme vanno misurati separatamente, perché falliscono in modi diversi e si correggono in posti diversi. La fase di caricamento copre parsing, controllo password e decifratura. La fase di verifica ricalcola l'hash degli intervalli di byte firmati e controlla la firma CMS. Se li ripiegate in un numero solo, una regressione dell'uno si nasconde nell'altro

Il TStopwatch della RTL da System.Diagnostics basta per questo. Il harness sotto carica con l'overload con password di THotPDF.LoadFromFile e poi verifica la prima firma attraverso l'overload TStream di VerifyLoadedSignature. L'overload stream è la scelta giusta per un file cifrato, perché il percorso di caricamento con password non conserva un nome di file sorgente che gli overload senza parametri possano riaprire

uses
  System.SysUtils, System.Classes, System.Diagnostics,
  HPDFDoc, HPDFSignVerify;

procedure TimeSignedEncryptedLoad(const FileName: string;
  const Password: AnsiString);
var
  Pdf: THotPDF;
  Src: TFileStream;
  Info: THPDFSignatureInfo;
  Status: THPDFSignatureVerifyStatus;
  Watch: TStopwatch;
  Pages: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Watch := TStopwatch.StartNew;
    Pages := Pdf.LoadFromFile(FileName, Password);
    Writeln(Format('Load + decrypt: %d ms, %d pages, encrypted=%s',
      [Watch.ElapsedMilliseconds, Pages, BoolToStr(Pdf.IsEncrypted, True)]));
    if (Pages < 1) or (Pdf.GetLoadedSignatureFieldCount = 0) then
      Exit;

    Src := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
    try
      Watch := TStopwatch.StartNew;
      Status := Pdf.VerifyLoadedSignature(0, Src, Info);
      Writeln(Format('Verify signature 0: %d ms, status %d, signer %s',
        [Watch.ElapsedMilliseconds, Ord(Status), string(Info.SignerName)]));
    finally
      Src.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

Eseguetelo parecchie volte e ignorate la prima esecuzione. La prima chiamata paga costi di setup una tantum, compresa la costruzione delle tabelle AES e il loro self-test, e il caching del disco falsa anche la prima lettura. Quello che cercate è il numero a regime per un file cliente reale, misurato contro una copia non firmata e non cifrata dello stesso documento

Perché la password AES-256 veniva derivata due volte?

HotPDF derivava due volte la file key AES-256 perché due stadi dello stesso caricamento eseguivano ciascuno per conto proprio il controllo completo della password. Prima del parsing strutturale, RecoverReloadKeysFromStreamRanges pre-scansiona i byte grezzi così che i contenitori object stream cifrati possano essere decifrati mentre i filtri vengono decodificati. Quel pre-scan esegue già i controlli dell'Algoritmo 2.A di ISO 32000-2 §7.6.4.3.3, validando la password contro i salt utente e owner e recuperando la chiave di cifratura del file. DecryptLoadedDocument poi ignorava quel risultato e rifaceva l'intera derivazione

Per la revisione 4 e precedenti, il duplicato costava poco più di un loop MD5. Per la revisione 6, l'Algoritmo 2.A chiama l'hash rafforzato dell'Algoritmo 2.B, quindi il duplicato raddoppiava la parte più costosa dell'apertura del file. Riusare la chiave recuperata è semplice. Riusarla in sicurezza è la parte più difficile, perché una chiave in cache che sopravvive al proprio controllo password si trasforma in un bypass di autenticazione

La correzione limita il riutilizzo con un flag di scope vero solo tra il pre-scan e la chiamata di decrypt dentro gli overload con password di LoadFromFile e LoadFromStream, e azzerato in un blocco finally. Il flag viene impostato solo quando il pre-scan ha davvero validato la password. Una chiamata diretta a DecryptLoadedDocument dopo un caricamento semplice esegue sempre la derivazione completa, perché quella password non è mai stata pre-scansionata. Senza quella restrizione, una chiave di reload stantia di un'apertura riuscita precedente potrebbe decifrare silenziosamente il documento per un chiamante successivo che ha fornito la password sbagliata, e quello è un bug di gran lunga peggiore di un caricamento lento

Dove spende tempo l'hash rafforzato R6?

L'hash rafforzato R6 spende quasi tutto il suo tempo nella cifratura AES-128-CBC. L'Algoritmo 2.B di ISO 32000-2 §7.6.4.3.4 ripete un round almeno 64 volte. Ogni round costruisce un buffer di 64 copie della password, dell'hash corrente e, sul lato owner, della stringa /U, cifra quel buffer con AES-128-CBC usando i primi 16 byte dell'hash come chiave e i successivi 16 come IV, e sceglie SHA-256, SHA-384 o SHA-512 per l'hash successivo dai primi 16 byte del ciphertext letti come numero modulo 3. Gli hash sono corti. I buffer da cifrare sono parecchi kilobyte per round

Diagramma del loop dell'hash rafforzato Algorithm 2.B R6 di HotPDF per Delphi: ognuno degli almeno 64 round costruisce un buffer di 64 copie di password, hash e stringa /U, lo cifra con AES-128-CBC usando hash come chiave e IV, e sceglie SHA-256, SHA-384 o SHA-512 dai primi byte di ciphertext
Il costo R6 sta nella cifratura del buffer AES-128-CBC, non nei digest, ed è per questo che una routine CBC nativa dentro PDF20HashDanceR6 fa scendere il caricamento completo di un documento da circa 66 ms a 16 ms

L'implementazione di quel loop in HotPDF, PDF20HashDanceR6, cifrava il buffer un blocco alla volta attraverso un AES di riferimento orientato ai byte. Ora prova prima una routine CBC nativa senza padding, che sui target Delphi raggiunge il backend AES C collegato. Con quel cambiamento un caricamento completo di un documento è sceso da circa 66 ms a 16 ms. Se la routine nativa non è disponibile o restituisce una lunghezza che non combacia, il codice ripiega sul loop storico a blocchi, e quel loop può a sua volta usare un blocco AES-128 a tabelle le cui T-tables sono generate dall'S-box esistente. Il cifrario è lo stesso in ogni caso, quindi la chiave derivata è byte-identica indipendentemente da quale percorso l'abbia calcolata

Questo articolo parla di velocità. Azzeccare la regola di terminazione dell'Algoritmo 2.B è un problema di correttezza a parte, e un off-by-one lì rifiuta le password corrette solo alcune volte. Il lato configurazione di R5 contro R6 è trattato nella guida alla configurazione della cifratura PDF AES-256 con HotPDF

Quattro modi in cui un blocco AES veloce può mentirvi

Un blocco AES a tabelle può produrre ciphertext sbagliato e passare comunque il proprio self-test. Ognuna delle quattro trappole sotto è emersa costruendo il percorso veloce, e due di loro hanno superato il self-test così com'era progettato in origine

  • Re-entrancy del self-test. La routine di readiness imposta il proprio flag di inizializzazione prima di eseguire il self-test, quindi una chiamata rientrante fatta durante il test prende il percorso di riferimento. La prima versione del test confrontava perciò l'implementazione di riferimento con sé stessa. La correzione è un blocco veloce raw senza guardie che il self-test chiama direttamente, così il percorso a tabelle è il codice che viene davvero testato
  • Layout di tabelle scambiati. Scambiare l'ordine dei byte di due delle quattro tabelle di round (Te1 e Te3) produce ciphertext sbagliato che il self-test rientrante non aveva modo di notare. A beccarlo è stato un programma di sonda autonomo che stampava il ciphertext FIPS-197 Appendice C.1 69c4e0d86a7b0430d8cdb78070b4c55a per la chiave 000102…0f e il plaintext 00112233…ff. Il self-test ora richiede sia quel vector sia l'uguaglianza byte a byte con il blocco di riferimento prima di fidarsi del percorso veloce
  • Controlli di range e overflow in FPC. Sotto Free Pascal con -Cr e -Co, le conversioni esplicite Byte() in stile Delphi e l'aritmetica di shift a larghezza byte sollevano eccezioni di range o overflow. Gli indici di tabella e il raddoppio GF(2^8) ora si calcolano alla larghezza intera nativa e si mascherano con and $FF
  • Miscompilation dell'inliner in FPC. Piccoli helper di load e store big-endian che prendono parametri PByte venivano compilati in access violation quando marcati inline sotto FPC. Togliere inline li ha corretti, e il costo della chiamata è trascurabile accanto alle ricerche in tabella

La stessa esperienza sta dietro il cablaggio finale. Instradare ogni blocco AES-128 della libreria attraverso le tabelle veloci causò una access violation FPC deterministica durante un caricamento candidato alla pubblicazione, così quel cambiamento fu annullato. Il design consegnato è conservativo: prima il CBC nativo, un ramo esplicito a tabelle veloci dentro il loop R6, e il blocco di riferimento ovunque altrove. La lezione generale è che il nuovo codice crittografico che deve compilare sotto più compilatori ha bisogno di una sonda known-answer indipendente eseguita su ogni compilatore target. Un fallimento di test a livello DLL non sa dirvi se avete una tabella sbagliata o un crash. Per di più sul lato Free Pascal, vedete le note sui backend Deflate e AES di HotPDF sotto Free Pascal

Come si mette in cache la verifica delle firme senza metterci la risposta sbagliata?

Una cache di verifica delle firme è sicura solo se la sua chiave copre ogni input che può cambiare il verdetto, inclusi gli input che vivono fuori dagli intervalli di byte firmati. Il layer documento headless di HotPDF memoizza i risultati di verifica CMS facendo l'hash dei byte CMS, dei byte del dizionario di firma nel gap /Contents, dei contenuti degli intervalli coperti, di una fingerprint di policy, dei budget di verifica, della dimensione totale della sorgente e di un bucket temporale di un minuto. Tre di quegli input sono stati aggiunti solo dopo che un bug ha mostrato che mancavano

Input della chiave di cache di verifica delle firme di HotPDF per Delphi: byte CMS, dizionario di firma nel gap /Contents, contenuti degli intervalli coperti, fingerprint di policy, budget, dimensione totale della sorgente e un bucket temporale di un minuto vengono sottoposti a hash insieme prima che un verdetto CMS venga memoizzato
Un verdetto può essere riusato solo quando ogni input che potrebbe cambiarlo è nella chiave - la dimensione della sorgente conta perché una revisione base e il suo aggiornamento incrementale possono condividere intervalli coperti identici

I file di certificato fidati sono chiavati per contenuto, non per percorso. Il layer delle operazioni scrive lo stesso materiale PEM su un nome di file temporaneo fresco per ogni comando, così una chiave basata sul percorso non combaciava mai e la cache non colpiva mai. La dimensione totale della sorgente doveva entrare nella chiave perché il UnsignedTrailingBytes riportato dipende da essa: una revisione base e un aggiornamento incrementale successivo possono condividere esattamente lo stesso contenuto coperto avendo lunghezze finali diverse, e una cache chiavata solo sugli intervalli consegnerebbe il verdetto della revisione base al file aggiornato. Il contesto su cosa significano quei byte finali è nella guida HotPDF alla verifica delle firme digitali PDF in Delphi

Il terzo bug era una trappola del linguaggio Pascal, non un errore di design. Le routine di hash prendono un buffer const non tipizzato, e passare una variabile di array dinamico a un parametro non tipizzato passa l'indirizzo del riferimento all'array, non i dati dell'array

function Checksum(const Data; Count: Integer): Cardinal;
var
  P: PByte;
  I: Integer;
begin
  Result := 0;
  P := @Data;
  for I := 0 to Count - 1 do
  begin
    Result := Result + P^;
    Inc(P);
  end;
end;

procedure DigestCMS(const CMS: TBytes);
begin
  // Sbagliato: legge il riferimento all'array e tutto ciò che lo segue sullo stack.
  // Una lettura di 16 KB da lì esce dalla pagina dello stack e solleva una access
  // violation, e Delphi e FPC falliscono in punti diversi.
  Writeln(Checksum(CMS, Length(CMS)));

  // Giusto: passare il primo elemento, con guardia per l'array vuoto
  if Length(CMS) > 0 then
    Writeln(Checksum(CMS[0], Length(CMS)));
end;

Il lock della cache è SyncObjs.TCriticalSection invece che l'API Windows InitializeCriticalSection, perché la stessa unità compila anche sotto FPC su Linux, dove quell'API non esiste

Cosa resta lento di proposito

La derivazione della chiave R6 in sé non viene mai messa in cache tra i caricamenti, ed è deliberato. I byte esterni e una password fornita esplicitamente passano sempre per il vero percorso di autenticazione, quindi ogni caricamento con password di un documento R6 paga almeno un hash dell'Algoritmo 2.B, e sia un hash lato utente sia uno lato owner quando viene provata la password owner. Il riutilizzo con scope descritto sopra toglie il duplicato dentro un singolo caricamento e niente oltre

  • La cifra da 66 ms a 16 ms vale per i target Delphi con il backend AES nativo collegato. Le build FPC prendono il blocco a tabelle, e la generazione di codice FPC su Windows girava da 2 a 4 volte più lenta di Delphi con le stesse asserzioni
  • La cache di verifica si lega a un bucket temporale di un minuto, quindi controlli ripetuti degli stessi byte entro una finestra breve costano poco, mentre un verdetto in cache non sopravvive mai al minuto in cui è stato calcolato
  • Una cache di autorità delle revisioni unite fu implementata, fatta in debug e poi tolta. Non chiudeva il divario FPC e rendeva misurabilmente più lente le callback di sicurezza Delphi, il che è un buon promemoria a misurare prima di tenere una cache
  • La verifica delle firme ha bisogno dei byte originali del file cifrato, quindi tenete la sorgente disponibile. Una decifratura più veloce non fa nulla per rimuovere quel requisito

FAQ

Riusare la chiave pre-scansionata indebolisce il controllo della password?

No. HotPDF riusa la file key recuperata solo dentro la stessa chiamata overload con password che ha validato la password con i controlli completi dell'Algoritmo 2.A, e il flag di scope viene azzerato in un blocco finally. Una chiamata autonoma a DecryptLoadedDocument esegue sempre la derivazione completa, quindi una chiave avanzata da un'apertura precedente non può essere presa in prestito con una password sbagliata

Perché non mettere in cache il risultato dell'hash R6 tra i caricamenti?

Mettere in cache l'hash tra i caricamenti significherebbe accettare una password senza controllarla contro il file che si sta aprendo. HotPDF tratta ogni sorgente di byte esterna e ogni password esplicita come input non fidato che deve autenticarsi da sé, quindi l'accelerazione misurabile è venuta dal rendere più veloce l'Algoritmo 2.B invece che dal saltarlo

Conviene passare un TFileStream a LoadFromStream?

Preferite LoadFromFile per i file su disco, perché bufferizza già internamente. Quando vi serve l'overload stream, passate un TMemoryStream o un TBufferedFileStream, visto che HotPDF legge direttamente lo stream che fornite durante il primo passaggio strutturale e uno stream handle grezzo costa una chiamata di sistema per ogni piccola lettura

Caricamento con protezione a password, cifratura AES-256 R6, verifica delle firme CMS e il resto dell'API dei documenti caricati sono inclusi insieme in un'unica libreria VCL nativa per Delphi e C++Builder. I dettagli delle funzionalità e le versioni IDE supportate sono sulla pagina del prodotto HotPDF Component