Generare materiale di chiavi di cifratura PDF con la funzione Random della runtime library è una trappola su Free Pascal: a meno che qualcosa non chiami Randomize, ogni processo produce la stessa sequenza di byte, così le build Linux e macOS scriverebbero chiavi di cifratura del file, salt, IV CBC e prefissi nonce AES-GCM identici a esecuzione dopo esecuzione. PDFiumPas legge /dev/urandom sui target non Windows invece, e solleva un'eccezione quando non può
La trappola in sé ci sta in un loop di quattro righe. La lezione più utile è perché una suite di test di cifratura, AESV3 e AESV4, con e senza PDF MAC, può restare verde proprio sopra di essa. Una casualità costante per processo è invisibile a ogni test che gira dentro un solo processo, ed è esattamente così che le suite di test di cifratura sono di solito organizzate
Dove PDFiumPas ha bisogno di byte casuali?
Ogni byte casuale nello stack di cifratura di PDFiumPas viene da un'unica procedura, AesGenerateRandomBytes nell'unit FPdfAes, quindi una sola fonte cattiva contamina tutto. L'handler di sicurezza standard in ISO 32000-2 §7.6.4 e l'estensione AESV4 in ISO/TS 32003 consumano quei byte in questi posti:
- La chiave di cifratura del file di 32 byte, generata fresca da DeriveEncryptionKeys per ogni documento e poi incapsulata in /UE e /OE sotto chiavi derivate dalla password
- Due salt da 16 byte, uno memorizzato negli ultimi 16 byte di /U e uno negli ultimi 16 byte di /O, ciascuno diviso in un salt di validazione da 8 byte e un salt di chiave da 8 byte
- I byte da 12 a 15 del plaintext dietro /Perms, che ISO 32000-2 riempie con dati casuali prima che il blocco venga cifrato sotto la chiave del file
- Un IV CBC da 16 byte anteposto a ogni stringa e stream cifrati in un documento AESV3
- Un prefisso nonce da 8 byte per i documenti AESV4, seguito da un contatore per oggetto da 4 byte che parte da zero
- Il /KDFSalt da 32 byte e la chiave MAC quando EnableIntegrityProtection è impostato
Perché ogni processo produrrebbe la stessa chiave?
Il generatore pseudo-casuale della RTL parte da RandSeed = 0 a meno che il programma non chiami Randomize, così un ramo non Windows che riempie il buffer delle chiavi da esso ottiene la stessa sequenza in ogni processo. Un commento che dice che il generatore è inizializzato da GetTickCount64 non aiuta quando nessuna riga di codice lo fa davvero; il commento diventa l'unico posto dove il seed esiste:
// La trappola: un generatore RTL senza seed come fonte delle chiavi
// (un commento che promette un seed da GetTickCount64 non lo applica)
P := PByte(Buffer);
for I := 0 to Count - 1 do
P[I] := Byte(Random(256));
La sequenza riparte a ogni processo e avanza al suo interno, quindi il primo documento che un processo qualsiasi cifra condivide la sua chiave del file col primo documento di ogni altro processo che esegue la stessa build, il secondo col secondo, e così via. La chiave del file in R5, R6 e R7 non dipende affatto dalla password, dato che la password la incapsula soltanto, il che significa che chiunque sappia riprodurre la sequenza detiene la chiave senza conoscere una password. AESV4 aggiunge un secondo fallimento: la stessa chiave con lo stesso prefisso da 8 byte e un contatore che riparte da zero ripete i nonce GCM, cosa che NIST SP 800-38D §8 vieta categoricamente. Un nonce GCM ripetuto sotto una stessa chiave rivela lo XOR dei due plaintext ed espone la subchiave di autenticazione, quindi i tag su cui si basa la cifratura AESV4-GCM e il token PDF MAC smettono di significare qualcosa. Riservatezza e integrità se ne vanno insieme
La portata è più ristretta di quanto quel paragrafo potrebbe suggerire. Su Windows la questione non sorge, perché il ramo Windows chiama CryptGenRandom attraverso advapi32 con CRYPT_VERIFYCONTEXT e solleva quando fallisce. La trappola è specifica dei target non Windows, che in pratica significa applicazioni Lazarus e Free Pascal su Linux e macOS, una voce in più per la lista dei punti critici Delphi contro FPC nelle build PDFium
Perché Randomize non è la risposta?
Chiamare Randomize nasconde il sintomo senza correggere la fonte, perché RandSeed è un valore a 32 bit e Randomize lo deriva dall'orologio. Questo colloca il numero di possibili flussi di chiavi a 2^32, e sapere a grandi linee quando un file è stato scritto taglia la ricerca molto sotto quel valore, il che è nulla accanto a una chiave AES a 256 bit. Il materiale delle chiavi deve venire dal pool di entropia del kernel, così AesGenerateRandomBytes legge /dev/urandom, ciclana sulle letture corte, e solleva se il pool non può consegnare ogni byte richiesto:
Handle := FileOpen('/dev/urandom', fmOpenRead or fmShareDenyNone);
if Handle <> THandle(-1) then
try
Remaining := Count;
while Remaining > 0 do
begin
Got := FileRead(Handle, P^, Remaining);
if Got <= 0 then
Break; // fallimento o fine inattesa dello stream
Inc(P, Got);
Dec(Remaining, Got);
end;
if Remaining = 0 then
Exit;
finally
FileClose(Handle);
end;
raise Exception.Create('FPdfAes: /dev/urandom unavailable; refusing to emit predictable key/IV');
Rifiutare è deliberato, e corrisponde a ciò che il ramo Windows fa quando CryptGenRandom non è disponibile. Un salvataggio cifrato fallito è un incidente che noti lo stesso giorno; un salvataggio riuscito con chiavi prevedibili è uno che scopri da qualcun altro. Seguono due conseguenze pratiche. Un container o chroot minimale senza un /dev popolato fallisce la cifratura invece di degradare silenziosamente, quindi montatelo. E poiché l'eccezione propaga fuori da TPdf.SaveAsEncrypted dopo che il file di destinazione è stato aperto con fmCreate, resta dietro un file di output vuoto che il vostro gestore degli errori dovrà eliminare
Perché i test di round trip non lo beccano?
Un test di round trip non può vedere la casualità costante, perché la decifratura recupera qualunque chiave del file la cifratura abbia scelto. Il test cifra un documento, lo riapre con la password, spacchetta la chiave da /UE, e decifra ogni oggetto; una chiave prevedibile si spacchetta e decifra esattamente quanto una casuale, e i tag GCM verificano perché sono stati calcolati con quella stessa chiave. Perfino un test che cifra due volte e asserisce che i due output differiscono passa, visto che la seconda chiamata nello stesso processo attinge i byte successivi della sequenza. La proprietà che conta, una chiave diversa in ogni processo, è osservabile solo confrontando l'output tra processi. Ogni volta che lo stesso codice sia produce che consuma un valore, i test sono ciechi a intere classi di difetto, e la casualità è l'esempio più puro
Come si può testare la casualità delle chiavi tra processi?
Eseguite una piccola sonda due volte come processi separati sulla piattaforma target e confrontate l'output. La sonda qui sotto chiama DeriveEncryptionKeys e stampa il salt memorizzato nei byte da 32 a 47 della voce /U. Quel valore è scritto in bella vista in ogni file cifrato, quindi stamparlo nei log di CI non divulga nulla, eppure viene dallo stesso generatore della chiave del file:
program SaltProbe;
{$mode delphi}
uses
SysUtils, FPdfEncrypt;
var
Opts: TPdfEncryptOptions;
Keys: TPdfEncryptionKeys;
I: Integer;
Hex: string;
begin
Opts := TPdfEncryptOptions.Default;
Opts.UserPassword := 'probe';
Opts.Revision := erR6;
DeriveEncryptionKeys(Opts, Keys);
Hex := '';
for I := 32 to 47 do // /U = hash da 32 byte + salt da 16 byte
Hex := Hex + IntToHex(Keys.UEntry[I], 2);
WriteLn(Hex); // deve differire a ogni esecuzione
end.
Cablate la sonda nella build per ogni target non Windows: eseguitela due volte, fate fallire il job se le due righe coincidono. Lo stesso confronto funziona sui file già in circolazione. Prendete due PDF cifrati scritti da esecuzioni diverse della stessa applicazione, leggete le stringhe /U dai loro dizionari Encrypt, e confrontate gli ultimi 16 byte; salt identici significano che la fonte delle chiavi è prevedibile, e quei documenti vanno ricifrati dal loro plaintext così che ciascuno riceva una chiave del file fresca. L'abitudine generale è esercitare i percorsi di codice non Windows sulla piattaforma stessa anziché fidarsi dell'esecuzione Windows, lo stesso ragionamento dietro il backend timestamp libcurl per le build non Windows
PDFiumPas è un componente PDF per Delphi e Lazarus costruito sull'engine PDFium, con AES-256, AES-GCM e il token PDF MAC implementati nativamente in Pascal e il materiale delle chiavi atinto dal generatore del sistema operativo su ogni piattaforma. Dettagli e download sono sulla pagina del componente PDFium per Delphi