Cuando un PDF AES-256 revisión 6 rechaza una contraseña que sabes correcta, el culpable habitual es el contador de rondas del hash endurecido R6 (ISO 32000-2, Algorithm 2.B). El PDFium Component para Delphi tuvo exactamente este bug antes de la v2.57.2: TPdf.SaveAsEncrypted contaba las rondas desde la base equivocada, y en torno al 30% de los archivos que producía los rechazaba FPDF_LoadDocument con FPDF_ERR_PASSWORD
La parte desagradable no es la aritmética. La parte desagradable es que el defecto era invisible para todo test que cifraba un archivo y luego lo comprobaba con el mismo código, y que un único round trip con éxito no probaba nada. Este artículo recorre qué pide en realidad Algorithm 2.B, dónde se esconde un resbalón de una ronda, por qué solo falla a ratos y cómo construir un test de regresión que cace un bug así antes que un cliente
¿Qué dice en realidad la regla de terminación del hash R6?
Algorithm 2.B se detiene solo tras al menos 64 rondas, y además solo cuando el último byte de la salida AES más reciente E es menor o igual que el número de ronda menos 32, donde el número de ronda es el recuento de rondas ya ejecutadas empezando en 1. Tras la ronda N comparas contra N. Ni N − 1, ni el índice de la siguiente ronda. Esa única definición es toda la superficie del bug
El resto del algoritmo es mecánico. Parte de K como el SHA-256 de contraseña, salt y (para la clave de propietario) el valor /U de 48 bytes. Cada ronda construye K1 como password ‖ K ‖ UK repetido 64 veces, lo cifra con AES-128-CBC sin padding usando los primeros 16 bytes de K como clave y los siguientes 16 como IV, toma los primeros 16 bytes de E como un entero big-endian de 128 bits mod 3, y elige SHA-256, SHA-384 o SHA-512 en consecuencia para producir el siguiente K. El hash final son los primeros 32 bytes de K. ISO 32000-2 §7.6.4.3 lo define; la longitud del bucle dependiente de los datos es deliberada, porque hace que el trabajo por intento de contraseña sea impredecible para un cracker de GPU
Aquí está la forma correcta del bucle, escrita como ilustración en lugar de implementación lista para soltar. BuildK1, AesCbcNoPad128, FirstBlockMod3 y los helpers de hash hacen de stand-in por las primitivas que tu código ya tenga
// Ilustración: esqueleto del bucle de Algorithm 2.B (ISO 32000-2, revisión 6)
K := Sha256(Password + Salt + UK);
RoundNumber := 0; // rondas ejecutadas hasta ahora
repeat
Inc(RoundNumber); // esta es la ronda 1, 2, 3 ...
K1 := BuildK1(Password, K, UK); // (Password || K || UK) repetido 64 veces
E := AesCbcNoPad128(Copy(K, 0, 16), Copy(K, 16, 16), K1);
case FirstBlockMod3(E) of // primeros 16 bytes de E, big-endian, mod 3
0: K := Sha256(E);
1: K := Sha384(E);
2: K := Sha512(E);
end;
until (RoundNumber >= 64) and (E[High(E)] <= RoundNumber - 32);
Result := Copy(K, 0, 32);
Lee la línea de terminación con el contador en la cabeza. El incremento está arriba en el cuerpo, así que para cuando corre el test del until, RoundNumber contiene exactamente el número de rondas completadas, que es lo que la norma quiere decir con número de ronda
Dónde se esconde el off-by-one en un port a Delphi
El bug del PDFium Component era un contador que arrancaba en −1 con el incremento arriba en el bucle, así que el valor comprobado en la cláusula until era siempre el recuento de rondas ejecutadas menos uno. Dos cosas fallan a la vez: el mínimo de 64 rondas se vuelve 65, y el umbral del byte queda un punto más estricto de lo que dice la norma. Ambos desplazan el punto de salida exactamente una ronda en los casos donde importa
El mismo resbalón tiene varios disfraces igual de plausibles. Empezar en 0 e incrementar abajo en el cuerpo, después de que el test ya se escribió contra el valor previo al incremento. Usar un índice de bucle for de base 0 y reutilizarlo en la comparación. Traducir una referencia C cuya variable de bucle se comprueba antes del ++. Cada una de estas se lee con naturalidad en una revisión de código, y cada una produce un hash que parece perfectamente aleatorio, porque lo es: es solo una salida SHA distinta, de aspecto igual de válido, que ningún otro lector calcula. El CalculateR6Hash corregido en FPdfEncrypt.pas lleva ahora un comentario que clava la semántica de base 1, precisamente porque la versión equivocada no parece equivocada
¿Por qué un PDF AES-256 R6 falla solo a ratos?
El fallo es probabilístico porque la ronda extra solo cambia el resultado cuando el byte de terminación cae en la ventana de una ronda entre el umbral correcto y el desplazado. En todos los demás casos ambas versiones salen en la misma ronda y producen el mismo K, así que el archivo abre bien. En la medición del PDFium Component, 18 de 60 cifrados (en torno al 30%) produjeron valores /U, /O, /UE y /OE en desacuerdo con una implementación estándar; el otro 70% era correcto byte a byte
Se sortean salts aleatorias nuevas en cada guardado, así que el mismo documento y la misma contraseña triunfan en una ejecución y fallan en la siguiente. Desde un escritorio de soporte eso parece almacenamiento inestable, una subida corrupta o un usuario que se equivoca al teclear la contraseña. A nadie se le ocurre culpar a un contador de bucle cuando siete de cada diez archivos abren. Si tu producto ve informes intermitentes de "contraseña incorrecta" sobre PDF que cifró tu propio código, y solo en revisión 6, este es el primer sitio donde mirar
La revisión 5 es inmune, ya que usa una única pasada SHA-256 sin iteración. La ruta AESV4 de ISO/TS 32003 no lo es: el valor erR7 de TPdfEncryptionRevision reutiliza Algorithm 2.B para sus hashes de contraseña, así que el mismo arreglo cubre los archivos GCM descritos en cifrado AES-256-GCM e integridad PDF MAC con ISO/TS 32003
La trampa de la autoconsistencia: por qué pasan los tests de round trip
Un round trip a través de tu propio cifrado y tu propia verificación siempre triunfa, porque ambos lados corren el mismo bucle equivocado y acuerdan el mismo hash equivocado. El defecto solo existe en relación con la implementación de otro, así que el juez tiene que ser uno independiente: el motor PDFium en pdfium.dll, Acrobat, qpdf, o cualquier lector que no hayas escrito tú
Esa es la lección general incómoda para cualquiera que escriba código documental colindante con la criptografía. Los unit tests que calculan un hash y lo comparan con la salida de la función bajo prueba prueban determinismo, no corrección. Los known-answer vectors de la norma o de una herramienta de referencia son mejores, y un round trip entre implementaciones es lo más parecido a una verdad de referencia que vas a conseguir. El PDFium Component está en una posición afortunada aquí: el cifrado se hace en Pascal mediante EncryptDocumentBytes, mientras que el descifrado al cargar pasa por el propio security handler C++ de PDFium, así que reabrir el archivo guardado con TPdf es una comprobación genuinamente independiente y no un espejo
uses
SysUtils, PDFium, FPdfEncrypt;
procedure SaveR6(const Source, Target: string);
var
Pdf: TPdf;
Opts: TPdfEncryptOptions;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := Source;
Pdf.Active := True;
Opts := TPdfEncryptOptions.Default;
Opts.UserPassword := 'userpw';
Opts.OwnerPassword := 'ownerpw';
Opts.Revision := erR6; // AESV3, hash de contraseña Algorithm 2.B
if not Pdf.SaveAsEncrypted(Target, Opts) then
raise Exception.Create('SaveAsEncrypted failed');
finally
Pdf.Free;
end;
end;
TPdfEncryptOptions.Default arranca en revisión 5 con todos los permisos permitidos y los metadatos cifrados, así que fija Revision explícitamente cuando quieras R6. TPdf.SaveAsEncryptedToStream toma el mismo record de opciones cuando la salida va a un TStream en lugar de a un archivo. Fíjate en que las contraseñas son AnsiString en el record de opciones, lo que importa si pasas credenciales no ASCII; normalízalas y codifícalas antes de que lleguen al record
¿Cómo haces un test de regresión de un bug de cifrado probabilístico?
Repite el round trip entre implementaciones suficientes veces para que una tasa de fallo del 30% no se cuele. Una sola ejecución falla al cazar el bug del PDFium Component en torno al 70% de las veces. Veinte ejecuciones, cada una con salts frescas, llevan la tasa de fallo a en torno al 0,08% (0.7 elevado a 20), que es por lo que el test de regresión enviado repite 20 veces en lugar de una
procedure TEncryptTests.R6_EncryptedDocumentReopensWithUserPassword;
var
Pdf: TPdf;
I: Integer;
Target: string;
begin
Target := TempOutputPath('encrypt_r6_roundtrip.pdf');
for I := 1 to 20 do
begin
SaveR6(SamplePdf, Target); // salts frescas en cada guardado
Pdf := TPdf.Create(nil);
try
Pdf.Password := 'userpw';
Pdf.FileName := Target;
Pdf.Active := True; // PDFium verifica /U de forma independiente
AssertTrue('Run ' + IntToStr(I) + ' must reopen', Pdf.Active);
finally
Pdf.Free;
end;
end;
end;
Empareja el caso positivo con el negativo, una contraseña equivocada que debe fallar, para que un loader roto no haga que el test positivo pase en vacío. Y si compilas con Lazarus, recompila el paquete de la biblioteca antes de correr el proyecto de tests tras tocar Lib\*.pas; la build de tests enlaza las unidades .ppu precompiladas del paquete, y correr contra código objeto rancio te da un verde tranquilizador sobre un bug que no has arreglado de verdad. El seeding es una trampa relacionada que conviene revisar en la misma pasada: FPC Random produciendo claves de cifrado constantes es otro caso donde la salida parece aleatoria y está silenciosamente equivocada
FAQ
¿Afecta el bug del contador de rondas R6 a los archivos cifrados con revisión 5?
No. La revisión 5 hashea la contraseña con una única pasada SHA-256 y no tiene recuento de iteraciones, así que no hay contador de rondas que equivocar. Solo la revisión 6 y la revisión AESV4 de ISO/TS 32003, que ambas usan Algorithm 2.B, están afectadas
¿Se puede abrir aún un archivo escrito por la versión con el bug?
En torno al 70% abren con normalidad porque su hash coincidió por casualidad. El resto lleva valores /U y /O que ningún lector estándar reproduce, así que la comprobación de contraseña falla en todas partes; el arreglo práctico es recifrar desde la fuente en claro con v2.57.2 o posterior
¿Por qué testear contra PDFium en lugar de contra mi propio código de descifrado?
Tu propio descifrador comparte la misma implementación de Algorithm 2.B que tu cifrador, así que un recuento de rondas equivocado se cancela y todo round trip pasa. Una implementación independiente como el security handler de PDFium es la única comprobación capaz de discrepar de ti
El cifrado, la validación PDF MAC y el resto del pipeline de guardado vienen en el PDFium Component para Delphi y C++Builder, con fuente y tests de regresión para Delphi y Lazarus