Artículo técnico

Cuando un PDF AES-256 R6 rechaza la contraseña correcta

Cuando un PDF de revisión 6 AES-256 rechaza una contraseña que usted sabe correcta, el culpable usual es el contador de rondas del hash reforzado R6 (ISO 32000-2 Algorithm 2.B). El PDFium Component for Delphi tuvo exactamente este bug antes de v2.57.2: TPdf.SaveAsEncrypted contaba las rondas desde la base equivocada, y aproximadamente el 30% de los archivos que producía eran rechazados por FPDF_LoadDocument con FPDF_ERR_PASSWORD

Lo feo no es la aritmética. Lo feo es que el defecto era invisible para toda prueba que cifrara un archivo y luego lo verificara con el mismo código, y que un solo round trip exitoso no probaba nada. Este post recorre lo que el Algorithm 2.B realmente pide, dónde se esconde un resbalón de una ronda, por qué solo falla a veces, y cómo construir una prueba de regresión que atrape un bug así antes que un cliente

¿Qué dice realmente la regla de terminación del hash R6?

El Algorithm 2.B se detiene solo después de al menos 64 rondas, y entonces solo cuando el último byte de la salida AES más reciente E es menor o igual al número de ronda menos 32, donde el número de ronda es el conteo con base 1 de rondas ya ejecutadas. Después de la ronda N se compara contra N. No contra N − 1, no contra el índice de la siguiente ronda. Esa única definición es toda la superficie del bug

El resto del algoritmo es mecánico. Arranque con K como el SHA-256 de contraseña, salt y (para la llave del propietario) el valor /U de 48 bytes. Cada ronda arma K1 como password ‖ K ‖ UK repetido 64 veces, lo cifra con AES-128-CBC sin padding usando los primeros 16 bytes de K como llave y los siguientes 16 como IV, toma los primeros 16 bytes de E como un entero de 128 bits big-endian mod 3, y elige SHA-256, SHA-384 o SHA-512 en consecuencia para producir la 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 datos es deliberada, porque hace impredecible el trabajo por intento de contraseña para un cracker de GPU

Aquí está la forma correcta del bucle, escrita como ilustración y no como implementación lista para pegar. BuildK1, AesCbcNoPad128, FirstBlockMod3 y los helpers de hash hacen de substitutos de las primitivas que su base de código ya tenga

// Ilustración: esqueleto del bucle del 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);

Lea la línea de terminación con el contador en mente. El incremento se sienta al inicio del cuerpo, así que para cuando corre la prueba del until, RoundNumber carga exactamente el número de rondas completadas, que es lo que el estándar 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 al inicio del bucle, así que el valor probado en la cláusula until era siempre el conteo de rondas ejecutadas menos uno. Dos cosas salen mal a la vez: el mínimo de 64 rondas se vuelve 65, y el umbral de byte queda una muesca más apretado de lo que dice el estándar. Ambos desplazan el punto de salida exactamente una ronda en los casos donde importa

El mismo resbalón tiene varios disfraces igual de plausibles. Arrancar en 0 e incrementar al final del cuerpo, después de que la prueba ya fue escrita contra el valor pre-incremento. Usar un índice de bucle for que es de base 0 y reusarlo en la comparación. Traducir una referencia en C cuya variable de bucle se revisa antes del ++. Cada una de estas se lee natural en una revisión de código, y cada una produce un hash que se ve perfectamente aleatorio, porque lo es: es solo una salida SHA distinta, igual de plausible en apariencia, que ningún otro lector calcula. El CalculateR6Hash corregido en FPdfEncrypt.pas ahora carga un comentario que fija la semántica de base 1, precisamente porque la versión equivocada no se ve equivocada

¿Por qué un PDF AES-256 R6 falla solo a veces?

La falla es probabilística porque la ronda extra solo cambia el resultado cuando el byte de terminación aterriza 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 la misma K, así que el archivo abre bien. En la medición del PDFium Component, 18 de 60 cifrados (alrededor del 30%) produjeron valores /U, /O, /UE y /OE que discrepaban de una implementación estándar; el otro 70% era correcto byte a byte

Los salts aleatorios frescos se sortean en cada guardado, así que el mismo documento y la misma contraseña tienen éxito en una corrida y fallan en la siguiente. Desde una mesa de soporte eso parece almacenamiento inestable, una subida corrupta o un usuario que escribe mal la contraseña. Nadie piensa en culpar a un contador de bucle cuando siete de cada diez archivos abren. Si su producto ve reportes intermitentes de "contraseña incorrecta" sobre PDFs que su propio código cifró, y solo en revisión 6, este es el primer lugar donde mirar

La revisión 5 es inmune, ya que usa una sola pasada de SHA-256 sin iteración. La ruta AESV4 de ISO/TS 32003 no lo es: el valor erR7 de TPdfEncryptionRevision reusa el 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 las pruebas de round trip

Un round trip a través de su propio cifrador y su propio verificador siempre tiene éxito, porque ambos lados corren el mismo bucle equivocado y concuerdan en el mismo hash equivocado. El defecto solo existe en relación con la implementación de otro, así que el juez debe ser uno independiente: el motor PDFium en pdfium.dll, Acrobat, qpdf, o cualquier lector que usted no haya escrito

Esta es la lección general incómoda para cualquiera que escriba código de documentos adyacente a crypto. 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 vectores de respuesta conocida del estándar o de una herramienta de referencia son mejores, y un round trip cruzando implementaciones es lo más cercano a la verdad de fondo que usted consigue. El PDFium Component está en una posición afortunada aquí: el cifrado se hace en Pascal vía EncryptDocumentBytes, mientras que el descifrado en la carga pasa por el security handler propio de PDFium en C++, así que reabrir el archivo guardado con TPdf es una verificació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 del Algorithm 2.B
    if not Pdf.SaveAsEncrypted(Target, Opts) then
      raise Exception.Create('SaveAsEncrypted failed');
  finally
    Pdf.Free;
  end;
end;

TPdfEncryptOptions.Default arranca en la revisión 5 con todos los permisos permitidos y los metadatos cifrados, así que fije Revision explícitamente cuando quiera R6. TPdf.SaveAsEncryptedToStream toma el mismo record de opciones cuando la salida va a un TStream en lugar de a un archivo. Note que las contraseñas son AnsiString en el record de opciones, lo que importa si está pasando credenciales no ASCII; normalícelas y codifíquelas antes de que lleguen al record

¿Cómo hace regression test de un bug de cifrado probabilístico?

Repita el round trip cruzando implementaciones las veces suficientes para que una tasa de falla del 30% no se cuele. Una sola corrida se pierde el bug del PDFium Component alrededor del 70% de las veces. Veinte corridas, cada una con sales frescas, llevan la tasa de escape a alrededor de 0.08% (0.7 elevado a la 20), que es por lo que la prueba de regresión enviada itera 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);       // sales 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;

Empárejela con el caso negativo, una contraseña equivocada que debe fallar, para que un loader roto no pueda hacer que la prueba positiva pase de forma vacua. Y si compila con Lazarus, recompile el paquete de biblioteca antes de correr el proyecto de pruebas después de tocar Lib\*.pas; el build de pruebas enlaza las unidades .ppu precompiladas del paquete, y correr contra código objeto viejo le da un verde tranquilizador sobre un bug que en realidad no arregló. El seeding es una trampa relacionada que vale revisar en la misma pasada: el Random de FPC produciendo llaves de cifrado constantes es otro caso donde la salida se ve aleatoria y está silenciosamente equivocada

Preguntas frecuentes

¿El bug del contador de rondas R6 afecta archivos cifrados con la revisión 5?

No. La revisión 5 hace hash de la contraseña con una sola pasada de SHA-256 y no tiene conteo 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 el Algorithm 2.B, están afectadas

¿Un archivo escrito por la versión con bug todavía se puede abrir?

Alrededor del 70% de ellos abren normalmente porque su hash casualmente coincidía. El resto carga valores /U y /O que ningún lector estándar reproduce, así que el chequeo de contraseña falla en todas partes; el arreglo práctico es volver a cifrar desde la fuente en texto plano con v2.57.2 o posterior

¿Por qué probar contra PDFium en lugar de mi propio código de descifrado?

Su propio descifrador comparte la misma implementación del Algorithm 2.B que su cifrador, así que un conteo de rondas equivocado se cancela y todo round trip pasa. Una implementación independiente como el security handler de PDFium es el único chequeo que puede discrepar con usted

El cifrado, la validación de PDF MAC y el resto del pipeline de guardado vienen en el PDFium Component para Delphi y C++Builder, con código fuente y pruebas de regresión tanto para Delphi como para Lazarus