Generar material de claves de cifrado PDF con la función Random de la RTL es una trampa en Free Pascal: salvo que algo llame a Randomize, cada proceso produce la misma secuencia de bytes, así que las builds de Linux y macOS escribirían claves de cifrado de archivo, salts, IV de CBC y prefijos de nonce AES-GCM idénticos ejecución tras ejecución. PDFiumPas lee /dev/urandom en los destinos no Windows en su lugar, y lanza una excepción cuando no puede
La trampa en sí cabe en un bucle de cuatro líneas. La lección más útil es por qué una suite de tests de cifrado, AESV3 y AESV4, con y sin PDF MAC, puede mantenerse en verde justo por encima de ella. Una aleatoriedad constante por proceso es invisible para todo test que se ejecute dentro de un solo proceso, y esa es exactamente la forma en que suelen organizarse las suites de tests de cifrado
¿Dónde necesita PDFiumPas bytes aleatorios?
Cada byte aleatorio de la pila de cifrado de PDFiumPas sale de una única procedure, AesGenerateRandomBytes de la unidad FPdfAes, así que una fuente mala lo contamina todo. El security handler estándar de ISO 32000-2 §7.6.4 y la extensión AESV4 de ISO/TS 32003 consumen esos bytes en estos sitios:
- La clave de cifrado de archivo de 32 bytes, generada fresca por DeriveEncryptionKeys para cada documento y luego envuelta en /UE y /OE bajo claves derivadas de la contraseña
- Dos salts de 16 bytes, una guardada en los últimos 16 bytes de /U y otra en los últimos 16 bytes de /O, cada una dividida en un salt de validación de 8 bytes y un salt de clave de 8 bytes
- Los bytes 12 a 15 del plaintext detrás de /Perms, que ISO 32000-2 rellena con datos aleatorios antes de cifrar el bloque bajo la clave de archivo
- Un IV de CBC de 16 bytes antepuesto a cada string y stream cifrados en un documento AESV3
- Un prefijo de nonce de 8 bytes para documentos AESV4, seguido de un contador por objeto de 4 bytes que empieza en cero
- El /KDFSalt de 32 bytes y la clave MAC cuando EnableIntegrityProtection está activado
¿Por qué iba cada proceso a producir la misma clave?
El generador pseudoaleatorio de la RTL parte de RandSeed = 0 salvo que el programa llame a Randomize, así que una rama no Windows que llene el búfer de claves desde él obtiene la misma secuencia en cada proceso. Un comentario que diga que el generador se siembra desde GetTickCount64 no ayuda cuando ninguna línea de código lo hace realmente; el comentario se convierte en el único sitio donde existe la semilla:
// La trampa: un generador de la RTL sin sembrar como fuente de claves
// (un comentario que promete una semilla de GetTickCount64 no aplica ninguna)
P := PByte(Buffer);
for I := 0 to Count - 1 do
P[I] := Byte(Random(256));
La secuencia se reinicia con cada proceso y avanza dentro de él, así que el primer documento que cifre cualquier proceso comparte su clave de archivo con el primer documento de todos los demás procesos que corran la misma build, el segundo con el segundo, y así. La clave de archivo en R5, R6 y R7 no depende para nada de la contraseña, ya que la contraseña solo la envuelve, lo que significa que quien sea capaz de reproducir la secuencia tiene la clave sin conocer ninguna contraseña. AESV4 añade un segundo fallo: la misma clave con el mismo prefijo de 8 bytes y un contador que reinicia a cero repite nonces GCM, lo que NIST SP 800-38D §8 prohíbe sin paliativos. Un nonce GCM repetido bajo una misma clave revela la XOR de los dos plaintexts y expone la subclave de autenticación, así que los tags de los que dependen el cifrado AESV4-GCM y el token PDF MAC dejan de significar nada. Confidencialidad e integridad se van a la vez
El alcance es más estrecho de lo que ese párrafo podría sugerir. En Windows la pregunta no surge, porque la rama Windows llama a CryptGenRandom a través de advapi32 con CRYPT_VERIFYCONTEXT y lanza cuando falla. La trampa es específica de los destinos no Windows, lo que en la práctica significa aplicaciones Lazarus y Free Pascal en Linux y macOS, una entrada más para la lista de trampas de Delphi frente a FPC en builds de PDFium
¿Por qué Randomize no es la respuesta?
Llamar a Randomize esconde el síntoma sin arreglar la fuente, porque RandSeed es un valor de 32 bits y Randomize lo deriva del reloj. Eso limita el número de flujos de claves posibles a 2^32, y saber aproximadamente cuándo se escribió un archivo reduce la búsqueda muy por debajo de esa cifra, que no es nada junto a una clave AES de 256 bits. El material de claves tiene que venir del pool de entropía del kernel, así que AesGenerateRandomBytes lee /dev/urandom, itera sobre lecturas cortas y lanza si el pool no puede entregar cada byte pedido:
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; // fallo o fin inesperado del 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');
Negarse es deliberado, y coincide con lo que hace la rama Windows cuando CryptGenRandom no está disponible. Un guardado cifrado fallido es un incidente que notas el mismo día; un guardado con claves predecibles que termina bien es uno que conoces por boca de otro. Se deducen dos consecuencias prácticas. Un contenedor mínimo o un chroot sin un /dev poblado falla al cifrar en lugar de degradarse en silencio, así que móntalo. Y como la excepción se propaga fuera de TPdf.SaveAsEncrypted después de que el archivo destino se abriera con fmCreate, queda un archivo de salida vacío para que tu manejador de errores lo borre
¿Por qué los tests de round-trip no lo cazan?
Un test de round-trip no puede ver una aleatoriedad constante, porque el descifrado recupera la clave de archivo que el cifrado haya elegido. El test cifra un documento, lo vuelve a abrir con la contraseña, desenvuelve la clave de /UE y descifra cada objeto; una clave predecible desenvuelve y descifra igual de bien que una aleatoria, y los tags GCM verifican porque se computaron con esa misma clave. Incluso un test que cifra dos veces y aserta que las dos salidas difieren pasa, porque la segunda llamada en el mismo proceso extrae los siguientes bytes de la secuencia. La propiedad que importa, una clave distinta en cada proceso, solo es observable comparando salidas entre procesos. Siempre que el mismo código produce y consume un valor, los tests quedan ciegos ante clases enteras de defecto, y la aleatoriedad es el ejemplo más puro
¿Cómo puedes probar la aleatoriedad de claves entre procesos?
Ejecuta una sonda pequeña dos veces como procesos separados en la plataforma objetivo y compara la salida. La sonda de abajo llama a DeriveEncryptionKeys e imprime el salt guardado en los bytes 32 a 47 de la entrada /U. Ese valor se escribe a la vista de todos en cada archivo cifrado, así que imprimirlo en los logs de CI no revela nada, y aun así sale del mismo generador que la clave de archivo:
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 de 32 bytes + salt de 16 bytes
Hex := Hex + IntToHex(Keys.UEntry[I], 2);
WriteLn(Hex); // debe diferir en cada ejecución
end.
Conecta la sonda a la build para cada destino no Windows: ejecútala dos veces y falla el job si las dos líneas coinciden. La misma comparación funciona sobre archivos ya en circulación. Toma dos PDF cifrados escritos por ejecuciones distintas de la misma aplicación, lee los strings /U de sus diccionarios Encrypt y compara los últimos 16 bytes; salts idénticos significan que la fuente de claves es predecible, y esos documentos necesitan cifrarse de nuevo desde su plaintext para que cada uno reciba una clave de archivo fresca. El hábito general es ejercitar las rutas de código no Windows en la propia plataforma en lugar de fiarse de la ejecución Windows, el mismo razonamiento detrás del backend de sellado de tiempo libcurl para builds no Windows
PDFiumPas es un componente PDF para Delphi y Lazarus construido sobre el motor PDFium, con AES-256, AES-GCM y el token PDF MAC implementados de forma nativa en Pascal y material de claves extraído del generador del sistema operativo en todas las plataformas. Detalles y descargas en la página del componente PDFium para Delphi