Prima della versione 3.114.8, PDFiumPas generava il materiale delle chiavi di cifratura PDF sui target non Windows con la funzione Random della runtime library, e siccome nessuno chiamava Randomize, ogni processo produceva la stessa sequenza di byte. Le build Free Pascal su Linux e macOS quindi scrivevano chiavi di cifratura dei file, salt, IV CBC e prefissi nonce AES-GCM identici a esecuzione dopo esecuzione. La versione 3.114.8 legge invece /dev/urandom e solleva un'eccezione quando non ci riesce
Il difetto in sé è un loop di quattro righe. La lezione più utile è perché una suite di test che cifra e decifra centinaia di documenti, con AESV3 e AESV4, con e senza PDF MAC, è rimasta verde per tutto il tempo. La casualità costante per processo è invisibile a ogni test che gira dentro un solo processo, ed è esattamente così che vengono scritti di solito i test di cifratura
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 produceva la stessa chiave?
AesGenerateRandomBytes usava il generatore del sistema operativo solo su Windows; ovunque altrove riempiva il buffer dal generatore pseudo-casuale della RTL, e quel generatore parte da RandSeed = 0 a meno che il programma chiami Randomize. Il commento sopra il loop diceva che il generatore veniva inizializzato da GetTickCount64. Nessuna riga di codice l'ha mai fatto, il che rendeva il commento l'unico posto dove il seed esisteva:
// Ramo non Windows di AesGenerateRandomBytes prima della 3.114.8
// (il commento sopra prometteva un seed GetTickCount64 mai applicato)
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
Il perimetro è più stretto di quanto quel paragrafo possa suggerire. Le build Windows non sono mai state toccate, perché il ramo Windows ha sempre chiamato CryptGenRandom attraverso advapi32 con CRYPT_VERIFYCONTEXT e sollevava un'eccezione quando falliva. Ciò che era esposto era l'output delle build non Windows precedenti alla 3.114.8, il che in pratica significa applicazioni Lazarus e Free Pascal su Linux e macOS, una voce in più per la lista dei pitfall Delphi contro FPC nelle build PDFium
Perché Randomize non sarebbe mai stata la correzione giusta?
Chiamare Randomize avrebbe nascosto il sintomo senza correggere la fonte, perché RandSeed è un valore a 32 bit e Randomize lo deriva dall'orologio. Questo pone un tetto di 2^32 ai possibili stream di chiavi, e sapere all'incirca quando un file è stato scritto taglia la ricerca ben sotto quel numero, che è nulla accanto a una chiave AES a 256 bit. Il materiale delle chiavi deve venire dal pool di entropia del kernel, così AesGenerateRandomBytes nella 3.114.8 legge /dev/urandom, itera su letture corte, e solleva un'eccezione se il pool non riesce a 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');
Il rifiuto è deliberato, e replica ciò che il ramo Windows ha sempre fatto quando CryptGenRandom non è disponibile. Un salvataggio cifrato fallito è un incidente che notate lo stesso giorno; un salvataggio riuscito con chiavi prevedibili è uno che scoprite da qualcun altro. Seguono due conseguenze pratiche. Un container minimale o un chroot senza un /dev popolato ora fallisce la cifratura invece di degradare in silenzio, quindi montatelo. E siccome l'eccezione si 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 error handler dovrà cancellare
Perché i test di round trip non l'hanno mai beccato?
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.
Incollate 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 identificano una build colpita, e i documenti andrebbero ricifrati dal loro plaintext con la 3.114.8 o successiva così che ciascuno riceva una chiave del file fresca. L'abitudine generale è esercitare i percorsi di codice non Windows sulla piattaforma stessa invece di fidarsi dell'esecuzione Windows, lo stesso ragionamento dietro il backend di timestamping 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