Antes de la versión 3.114.8, PDFiumPas generaba el material de claves de cifrado PDF en destinos no Windows con la función Random de la runtime library, y como nadie llamaba a Randomize, cada proceso producía la misma secuencia de bytes. Los builds de Free Pascal en Linux y macOS escribían por tanto claves de cifrado de archivo, salts, IVs CBC y prefijos de nonce AES-GCM idénticos, corrida tras corrida. La versión 3.114.8 lee /dev/urandom en su lugar y lanza una excepción cuando no puede
El defecto en sí es un loop de cuatro líneas. La lección más útil es por qué una suite de pruebas que cifra y descifra cientos de documentos, con AESV3 y AESV4, con y sin PDF MAC, se mantuvo verde todo el tiempo. La aleatoriedad constante por proceso es invisible para toda prueba que corra dentro de un solo proceso, y así es exactamente como suelen escribirse las pruebas de cifrado
¿Dónde necesita PDFiumPas bytes aleatorios?
Cada byte aleatorio del stack de cifrado de PDFiumPas sale de un único procedimiento, AesGenerateRandomBytes en la unidad FPdfAes, así que una fuente mala 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 lugares:
- 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 partida en una salt de validación de 8 bytes y una salt de clave de 8 bytes
- Los bytes 12 al 15 del plaintext detrás de /Perms, que ISO 32000-2 llena con datos aleatorios antes de que el bloque se cifre bajo la clave de archivo
- Un IV CBC de 16 bytes antepuesto a cada string y stream cifrado de un documento AESV3
- Un prefijo de nonce de 8 bytes para documentos AESV4, seguido de un contador por objeto de 4 bytes que arranca en cero
- El /KDFSalt de 32 bytes y la clave MAC cuando EnableIntegrityProtection está activado
¿Por qué cada proceso producía la misma clave?
AesGenerateRandomBytes usaba el generador del sistema operativo solo en Windows; en el resto llenaba el buffer desde el generador pseudoaleatorio de la RTL, y ese generador arranca desde RandSeed = 0 salvo que el programa llame a Randomize. El comentario encima del loop decía que el generador se sembraba desde GetTickCount64. Ninguna línea de código hizo eso jamás, lo que convertía al comentario en el único lugar donde existía la semilla:
// Rama no Windows de AesGenerateRandomBytes antes de 3.114.8
// (el comentario encima prometía una semilla GetTickCount64 que nunca se aplicó)
P := PByte(Buffer);
for I := 0 to Count - 1 do
P[I] := Byte(Random(256));
La secuencia reinicia con cada proceso y avanza dentro de él, así que el primer documento que cualquier proceso cifre comparte su clave de archivo con el primer documento de todos los demás procesos que corran el mismo 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 cualquiera capaz de reproducir la secuencia tiene la clave sin conocer contraseña alguna. AESV4 agrega una segunda falla: la misma clave con el mismo prefijo de 8 bytes y un contador reiniciando en cero repite nonces GCM, lo que NIST SP 800-38D §8 prohíbe de plano. 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 depende el cifrado AESV4-GCM y el token PDF MAC dejan de significar nada. Confidencialidad e integridad se van al mismo tiempo
El alcance es más angosto de lo que sugiere ese párrafo. Los builds Windows jamás se vieron afectados, porque la rama Windows siempre llamó a CryptGenRandom vía advapi32 con CRYPT_VERIFYCONTEXT y lanzaba cuando eso fallaba. Lo que quedó expuesto fue la salida de builds no Windows anteriores a 3.114.8, 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 contra FPC en builds de PDFium
¿Por qué Randomize nunca era el fix correcto?
Llamar a Randomize habría escondido el síntoma sin arreglar la fuente, porque RandSeed es un valor de 32 bits y Randomize lo deriva del reloj. Eso tapa el número de streams de claves posibles en 2^32, y saber aproximadamente cuándo se escribió un archivo recorta la búsqueda muy por debajo de eso, que 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 en 3.114.8 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; // falla o fin de stream inesperado
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 la rama Windows siempre hizo cuando CryptGenRandom no estaba disponible. Un guardado cifrado que falla es un incidente que usted nota el mismo día; un guardado exitoso con claves predecibles es uno que usted se entera por otra persona. Dos consecuencias prácticas se siguen. Un contenedor mínimo o un chroot sin un /dev poblado ahora falla al cifrar en lugar de degradarse en silencio, así que móntelo. Y como la excepción se propaga hacia afuera de TPdf.SaveAsEncrypted después de que el archivo destino se abrió con fmCreate, queda un archivo de salida vacío para que su manejador de errores lo borre
¿Por qué las pruebas de round trip nunca lo atraparon?
Una prueba de round trip no puede ver la aleatoriedad constante, porque el descifrado recupera la clave de archivo que el cifrado haya elegido. La prueba cifra un documento, lo abre de nuevo con la contraseña, desenvuelve la clave desde /UE, y descifra cada objeto; una clave predecible se desenvuelve y descifra exactamente tan bien como una aleatoria, y los tags GCM verifican porque se computaron con esa misma clave. Hasta una prueba que cifra dos veces y afirma que las dos salidas difieren pasa, porque la segunda llamada en el mismo proceso toma los siguientes bytes de la secuencia. La propiedad que importa, una clave distinta en cada proceso, solo es observable comparando salida entre procesos. Cuando el mismo código produce y consume un valor, las pruebas quedan ciegas ante clases enteras de defecto, y la aleatoriedad es el ejemplo más puro
¿Cómo se puede probar la aleatoriedad de claves entre procesos?
Corra una pequeña sonda dos veces como procesos separados en la plataforma destino y compare la salida. La sonda de abajo llama a DeriveEncryptionKeys e imprime la salt guardada en los bytes 32 al 47 de la entrada /U. Ese valor queda a la vista en cada archivo cifrado, así que imprimirlo en los logs de CI no divulga nada, y sin embargo viene 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 corrida
end.
Conecte la sonda al build para cada destino no Windows: córrala dos veces, haga fallar el job si las dos líneas coinciden. La misma comparación funciona sobre archivos ya en circulación. Tome dos PDF cifrados escritos por corridas distintas de la misma aplicación, lea los strings /U de sus diccionarios Encrypt, y compare los últimos 16 bytes; salts idénticas identifican un build afectado, y los documentos deben cifrarse de nuevo desde su plaintext con 3.114.8 o posterior para que cada uno reciba una clave de archivo fresca. El hábito general es ejercitar los caminos de código no Windows en la plataforma misma en lugar de confiar en la corrida Windows, el mismo razonamiento detrás del backend de timestamps 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 nativamente en Pascal y material de claves tomado del generador del sistema operativo en todas las plataformas. Detalles y descargas en la página del componente PDFium para Delphi