Un PDF cifrado con AES-256 con una contraseña no ASCII se abre en el programa que lo escribió y en ningún otro sitio. La causa es casi siempre un paso de preparación ausente: la norma ISO 32000-2 §7.6.4.3.3 exige que la contraseña se procese con el perfil SASLprep de stringprep antes de codificarla en UTF-8 y aplicarle el hash. PDFlibPas, la librería PDF para Delphi y C++Builder, realiza esa preparación dentro de Encrypt, EncryptFile y DecryptFile
Esta no es la historia de la contraseña equivocada ni la historia de los bits de permiso. Si tus usuarios están escribiendo una contraseña que tú nunca emitiste, la maquinaria de reintento en el artículo sobre reintento de contraseñas de PDF cifrados es lo que necesitas, y si estás intentando averiguar qué aplica realmente un archivo existente, la auditoría de cifrado y permisos cubre ese terreno. Este artículo es más estrecho y más extraño: la contraseña es correcta, el usuario la escribió correctamente, y el archivo sigue negándose a abrir en otro sitio
¿Por qué una contraseña no ASCII se abre en un lector pero no en otro?
Porque los dos programas aplican hash a secuencias de bytes distintas a partir de las mismas pulsaciones de tecla. La derivación de clave de revisión 6 en la norma ISO 32000-2 §7.6.4.3.3 toma la contraseña como bytes UTF-8, la trunca a 127 bytes, añade una sal y ejecuta el hash reforzado; el resultado se comprueba contra las entradas /U y /O del diccionario de cifrado. Nada en esa cadena es impreciso. Un solo byte distinto en cualquier punto de la entrada produce un resumen completamente diferente, la validación falla, y el lector solo puede decir una cosa: contraseña incorrecta
Los bytes divergen porque Unicode ofrece varias formas de escribir lo que parece la misma contraseña. Una contraseña china puede llegar como caracteres precompuestos desde un método de entrada y como formas de compatibilidad desde otro. Una contraseña alemana o francesa copiada de un procesador de texto puede llevar un ESPACIO DE NO SEPARACIÓN (U+00A0) donde el usuario cree que hay un espacio normal, o un GUION SUAVE (U+00AD) que no se renderiza como nada en absoluto. SASLprep existe para colapsar todo eso en una única forma canónica antes de que nadie le aplique hash a nada, de modo que toda implementación conforme derive la misma clave a partir de la misma intención
¿Qué cambia realmente SASLprep en una contraseña?
La RFC 4013 define SASLprep como un perfil del marco stringprep de la RFC 3454, y son cuatro pasos ordenados en lugar de una única transformación. El mapeo va primero: la tabla C.1.2 de la RFC 3454 (espacios no ASCII) se mapea a U+0020, y la tabla B.1 (caracteres comúnmente mapeados a nada) se elimina directamente. Le sigue la normalización a Unicode NFKC, que es el paso que pliega los caracteres de compatibilidad y las secuencias combinantes. Luego la comprobación de salida prohibida rechaza cualquier cosa en las tablas C.2.1 a C.9. Finalmente se aplica la regla bidireccional de la sección 6 de la RFC 3454 a la cadena normalizada
PDFlibPas implementa el perfil completo en la unidad PDFlibSASLprep, que expone un único punto de entrada. PLSASLprepPassword toma la contraseña en bruto, escribe la forma preparada en un parámetro var, y devuelve False cuando la contraseña debe rechazarse. La función es deliberadamente total en el camino feliz: una contraseña únicamente ASCII vuelve idéntica byte a byte, así que nada cambia en los despliegues existentes
uses
PDFlibSASLprep;
var
Prepared: WideString;
begin
// RFC 4013: mapping, then NFKC, then prohibited output, then the bidi rule
PLSASLprepPassword('I' + WideChar($00AD) + 'X', Prepared); // -> 'IX' B.1 deletes SOFT HYPHEN
PLSASLprepPassword('a' + WideChar($00A0) + 'b', Prepared); // -> 'a b' C.1.2 maps NBSP to U+0020
PLSASLprepPassword(WideString(WideChar($00AA)), Prepared); // -> 'a' NFKC folds ORDINAL INDICATOR
PLSASLprepPassword(WideString(WideChar($2168)), Prepared); // -> 'IX' NFKC folds ROMAN NUMERAL NINE
PLSASLprepPassword('user', Prepared); // -> 'user' ASCII is never touched
end;
La ambigüedad de U+200B que las tablas no resuelven
Un punto de código aterriza en dos tablas de la RFC 3454 a la vez, y las dos tablas discrepan. EL ESPACIO DE ANCHURA CERO (U+200B) cae dentro del rango C.1.2 de U+2000 a U+200B, donde la regla dice que se mapee a U+0020, y también cae dentro del rango B.1 de U+200B a U+200D, donde la regla dice que se elimine. Lee el paso de mapeo en un orden u otro y obtendrás bytes distintos a partir de la misma contraseña: a+U+200B+b se prepara como a b bajo C.1.2 y como ab bajo B.1. La RFC 4013 nombra ambas tablas y no dice cuál prevalece, así que se trata de una ambigüedad genuina en la especificación y no de un error de lectura. PDFlibPas comprueba primero la pertenencia a C.1.2 y, por tanto, mapea U+200B a un espacio, que es el comportamiento en el que se han asentado otras implementaciones de stringprep ampliamente desplegadas; hacer coincidir con ellas es lo único que importa aquí, porque el objetivo es la concordancia de bytes con el lector que el cliente use, sea cual sea
Leer archivos antiguos: primero la forma preparada, después la forma en bruto
La corrección crea su propio problema de compatibilidad. Todo archivo AES-256 escrito antes del cambio aplicó hash a la contraseña UTF-8 en bruto, así que hacer que el lector sea estrictamente conforme dejaría a los clientes fuera de sus propios archivos. PDFlibPas resuelve esto en el lado de la lectura probando dos candidatos en orden. TPDFDocument.SetPassword construye una lista de candidatos que empieza con la forma preparada y recurre a la forma en bruto, y solo añade la entrada preparada cuando el documento es realmente AES-256 y las dos formas difieren. Para una contraseña ASCII las formas son idénticas, la lista contiene una sola entrada, y el coste de todo el mecanismo es una única comparación de cadenas. DecryptFile hace lo mismo a lo largo de su ruta directa de reescritura AES-256, llamando a PLDirectDecryptFileAES256 primero con la contraseña preparada
var
Lib: TPDFlib;
Bytes: AnsiString;
begin
Lib := TPDFlib.Create;
try
Lib.SetOrigin(1);
Lib.DrawText(100, 100, 'saslprep roundtrip');
// Strength 3 and 4 are the two AES-256 values; both are prepared before hashing
Lib.Encrypt('ow' + WideChar($00AD) + 'ner', 'pa' + WideChar($00AD) + 'ss', 4,
Lib.EncodePermissions(1, 0, 0, 0, 0, 0, 0, 1));
Bytes := Lib.SaveToString;
finally
Lib.Free;
end;
Lib := TPDFlib.Create;
try
// 'pass' is what SASLprep produced and what any conforming reader computes,
// so the plain ASCII form opens a file created with the soft-hyphen form
if Lib.LoadFromString(Bytes, 'pass') = 1 then
Caption := IntToStr(Lib.PageCount);
finally
Lib.Free;
end;
end;
El mecanismo de reserva lleva una salvaguarda que merece la pena copiar. El segundo intento en DecryptFile se ejecuta solo cuando las formas preparada y en bruto difieren y el primer intento no informó de un código de error grave. Un fallo estructural significa que la entrada está dañada o no es la revisión de cifrado que asumiste, y reintentar un archivo roto con otra contraseña simplemente consume un segundo análisis completo sobre una entrada hostil; el razonamiento detrás de ese reflejo se expone en la nota sobre el análisis seguro de PDF no confiables. Nótese también que no hay reserva en el lado de la escritura, y esa asimetría es intencionada. La lectura tolera el historial, la escritura no: todo archivo AES-256 nuevo recibe los bytes conformes
¿Qué contraseñas se rechazan directamente, y qué es el error 604?
SASLprep puede rechazar una contraseña por completo, y cuando lo hace, el cifrado debe fallar de forma ruidosa en lugar de sustituir algo en silencio. Encrypt y EncryptFile preparan tanto la contraseña de propietario como la de usuario siempre que Strength sea 3 o 4, devuelven 0 en caso de rechazo, y fijan LastErrorCode en PDFLIB_ERROR_PASSWORD_SASLPREP, que es 604. Dos familias de entrada lo desencadenan. Las tablas de salida prohibida rechazan caracteres de control (C.2.1 y C.2.2), puntos de código de uso privado (C.3), no caracteres (C.4), sustitutos sueltos (C.5), U+FFFD (C.6), caracteres de descripción ideográfica (C.7), y los rangos de control de presentación y etiquetado (C.8 y C.9). Por separado, la regla bidireccional de la sección 6 de la RFC 3454 rechaza cualquier cadena que contenga un carácter RandALCat de la tabla D.1 a menos que la cadena empiece y termine con uno de ellos y no contenga ninguna letra de izquierda a derecha
var
Lib: TPDFlib;
begin
Lib := TPDFlib.Create;
try
// U+0007 is a C.2.1 control character, so preparation refuses the password
if Lib.Encrypt('owner', 'bad' + WideChar($0007), 4,
Lib.EncodePermissions(1, 0, 0, 0, 0, 0, 0, 1)) = 0 then
begin
if Lib.LastErrorCode = PDFLIB_ERROR_PASSWORD_SASLPREP then // 604
ShowMessage('The password contains characters that PDF encryption does not permit.');
end;
finally
Lib.Free;
end;
end;
Esa regla bidireccional es la que sorprenderá a tu servicio de soporte. Una contraseña en árabe o hebreo que termine en un dígito occidental, o una con una letra latina suelta en medio, es rechazada por la especificación aunque parezca perfectamente razonable en el campo de entrada. Muestra el error 604 como un mensaje sobre los caracteres de la contraseña, no como un fallo de cifrado genérico, o alguien se pasará una tarde buscando un fallo en tu derivación de clave
Límites honestos: NFKC, un LCat aproximado y una trampa de Delphi
Dos partes de la implementación son aproximaciones, y ambas merecen decirse con claridad en lugar de esconderse. La normalización NFKC la realiza la API NormalizeString de Windows, cargada dinámicamente desde Normaliz.dll. Cuando esa librería no está disponible se usa la cadena mapeada sin normalizar, lo que significa que los pasos de mapeo y prohibición siguen ejecutándose pero el plegado de compatibilidad no. En la práctica, la DLL se ha distribuido con cada versión de Windows desde Vista, así que la ruta degradada es una preocupación previa a Vista y ajena a Windows más que una situación real, pero una contraseña que dependa del plegado NFKC produciría allí bytes distintos y eso es una divergencia real, aunque remota. La comprobación bidireccional es la segunda aproximación: la detección de caracteres LCat usa los rangos de letras comunes en lugar de la tabla D.2 completa de la RFC 3454, y la dirección de ese error es lo que lo hace aceptable. Un carácter LCat pasado por alto solo puede hacer que la regla bidireccional pase donde la especificación la habría rechazado, nunca al revés, y nunca afecta a los pasos de mapeo o normalización, así que la secuencia de bytes preparada de una contraseña aceptada no cambia. El riesgo residual es, por tanto, una divergencia de política y no una divergencia de bytes: una contraseña de escritura exótica que una implementación más estricta se negaría directamente a aceptar. Toda contraseña que ambos lados aceptan produce el mismo hash, que es la propiedad de la que realmente depende la interoperabilidad
Por último, una trampa de sintaxis de Delphi que cuesta una hora si no te has topado con ella antes. Cuando una función devuelve un tipo procedimental, asignarlo sin paréntesis no lo llama. El compilador lee Proc := GetNormalizeProc; como si tomara la dirección del propio GetNormalizeProc, y luego informa del error E2009 con la queja poco útil de que las convenciones de llamada difieren, porque el accesor usa la convención por defecto mientras que el tipo de API importado es stdcall. Los paréntesis vacíos son obligatorios
type
TNormalizeString = function(NormForm: Integer; SrcString: PWideChar; SrcLength: Integer;
DstString: PWideChar; DstLength: Integer): Integer; stdcall;
function GetNormalizeProc: TNormalizeString; // loads Normaliz.dll on first use
...
var
Proc: TNormalizeString;
begin
// Proc := GetNormalizeProc; // E2009: reads as @GetNormalizeProc, conventions differ
Proc := GetNormalizeProc(); // correct: calls the accessor and assigns its result
if not Assigned(Proc) then
Exit; // no NFKC available, mapped string is used as-is
end;
La preparación de contraseñas es uno de esos detalles que nunca aparece en una lista de características y que decide si un documento cifrado sobrevive al contacto con un cliente en otra configuración regional. Los puntos de entrada Encrypt, EncryptFile, DecryptFile y SetPassword descritos aquí forman parte de losLab PDF Developer Library Pascal Edition para Delphi y C++Builder, cuya página de producto incluye la referencia completa de cifrado y la tabla completa de códigos de error