Artículo técnico

Claves de cifrado repetidas en FPC: fix del RNG

Antes de la versión 3.114.8, PDFiumPas generaba el material de claves de cifrado PDF en targets 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, IV de CBC y prefijos de nonce AES-GCM idénticos ejecución tras ejecución. 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 bucle de cuatro líneas. La lección más útil es por qué una suite de tests que cifra y descifra cientos de documentos, con AESV3 y AESV4, con y sin PDF MAC, se mantuvo verde todo el tiempo. Una aleatoriedad constante por proceso es invisible para cualquier test que corre dentro de un solo proceso, y así es exactamente como suelen escribirse los 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
Cada byte aleatorio de la pila de cifrado de PDFiumPas fluye desde AesGenerateRandomBytes en FPdfAes hacia seis consumidores: la clave de cifrado de archivo de 32 bytes envuelta en /UE y /OE, los salts de /U y /O, los bytes de relleno de /Perms, el IV de CBC de AESV3, el prefijo de nonce GCM de AESV4, y el salt KDF y la clave MAC
Un generador compartido significa que una fuente mala contamina el material de claves en todas partes de golpe, y por eso el fix aterrizó en una única procedure en lugar de en cada punto de llamada

¿Por qué cada proceso producía la misma clave?

AesGenerateRandomBytes solo usaba el generador del sistema operativo en Windows; en todos los demás sitios llenaba el buffer desde el generador pseudoaleatorio de la RTL, y ese generador arranca de RandSeed = 0 salvo que el programa llame a Randomize. El comentario sobre el bucle decía que el generador se sembraba desde GetTickCount64. Ninguna línea de código hizo jamás eso, con lo que el comentario era el único sitio donde existía la semilla:

// Rama no Windows de AesGenerateRandomBytes antes de 3.114.8
// (el comentario sobre ella 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 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

Generador de la RTL sin sembrar en PDFiumPas sobre builds FPC no Windows: con RandSeed 0 cada proceso emite la misma secuencia, así que el documento uno del proceso A lleva la misma clave de archivo que el documento uno del proceso B, y AESV4 repite nonces GCM porque la misma clave se encuentra con el mismo prefijo con el contador reiniciando a cero
Como la clave de archivo nunca depende de la contraseña, quien pueda reproducir la secuencia tiene la clave directamente, y los nonces GCM repetidos destruyen confidencialidad e integridad juntas

El alcance es más estrecho de lo que ese párrafo podría sugerir. Los builds Windows nunca estuvieron afectados, porque la rama Windows siempre ha llamado a CryptGenRandom a través de advapi32 con CRYPT_VERIFYCONTEXT y lanzaba cuando fallaba. Lo que quedó expuesto fue la salida de builds no Windows anteriores a 3.114.8, que en la práctica son 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 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 limita el número de flujos de claves posibles a 2^32, y saber más o menos cuándo se escribió un archivo recorta la búsqueda muy por debajo de eso, que no es nada al lado de una clave AES de 256 bits. El material de claves tiene que salir 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;               // 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 la rama Windows siempre ha hecho cuando CryptGenRandom no está disponible. Un guardado cifrado fallido es un incidente que notas el mismo día; un guardado exitoso con claves predecibles es uno del que te enteras por otra persona. Se deducen dos consecuencias prácticas. Un contenedor mínimo o un chroot sin un /dev poblado ahora falla al cifrar en vez 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 error handler lo borre

¿Por qué los tests de round-trip nunca lo cazaron?

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:

Test de aleatoriedad entre procesos para PDFiumPas: el programa SaltProbe llama a DeriveEncryptionKeys e imprime el hex de los bytes 32 a 47 de /U, el job lo ejecuta dos veces como procesos separados y falla cuando las líneas coinciden, y los PDF enviados se comparan por los últimos 16 bytes de sus strings /U
La aleatoriedad constante es invisible dentro de un proceso porque el descifrado recupera la clave que el cifrado haya elegido, así que la propiedad que importa solo es observable comparando salidas entre procesos
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.

Cablea la sonda en la build de cada target no Windows: ejecútala dos veces y falla el trabajo si las dos líneas coinciden. La misma comparación funciona sobre archivos ya en circulación. Coge 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 identifican una build afectada, y los documentos deberían 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 propia plataforma en vez de fiarse de la ejecución 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 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