Un PDF cifrado con AES-256 y una contraseña no ASCII se abre en el programa que lo escribió y en ningún otro lugar. La causa es casi siempre un paso de preparación faltante: ISO 32000-2 §7.6.4.3.3 exige que la contraseña se procese con el perfil SASLprep de stringprep antes de codificarse en UTF-8 y hashearse. 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 incorrecta ni la historia de los bits de permiso. Si tus usuarios están escribiendo una contraseña que nunca emitiste, la maquinaria de reintento en el artículo sobre reintento de contraseñas de PDF cifrados es lo que buscas, y si estás tratando de averiguar qué es lo que realmente impone un archivo existente, la auditoría de cifrado y permisos cubre ese terreno. Este es más específico y más extraño: la contraseña es correcta, el usuario la escribió correctamente, y el archivo se sigue negando a abrirse en otro lugar
¿Por qué una contraseña no ASCII se abre en un lector pero no en otro?
Porque los dos programas hashean secuencias de bytes distintas a partir de las mismas pulsaciones de tecla. La derivación de clave de revisión 6 en ISO 32000-2 §7.6.4.3.3 toma la contraseña como bytes UTF-8, la trunca a 127 bytes, agrega una sal, y ejecuta el hash reforzado; el resultado se verifica contra las entradas /U y /O en el diccionario de cifrado. Nada en esa cadena es difuso. Un byte distinto en cualquier parte de la entrada produce un digest completamente diferente, la validación falla, y el lector tiene exactamente una cosa que puede decir: 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 ordinario, o un GUION SUAVE (U+00AD) que se renderiza como nada en absoluto. SASLprep existe para colapsar todo eso en una forma canónica antes de que nadie hashee nada, para que cada implementación conforme derive la misma clave a partir de la misma intención
¿Qué cambia realmente SASLprep sobre una contraseña?
RFC 4013 define SASLprep como un perfil del marco stringprep en RFC 3454, y son cuatro pasos ordenados en vez de una sola transformación. El mapeo va primero: la tabla C.1.2 de RFC 3454 (espacios no ASCII) se mapea a U+0020, y la tabla B.1 (caracteres comúnmente mapeados a nada) se elimina por completo. La normalización a Unicode NFKC sigue, que es el paso que pliega caracteres de compatibilidad y secuencias de combinación. Luego la verificació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 RFC 3454 a la cadena normalizada
PDFlibPas implementa todo el perfil en la unidad PDFlibSASLprep, que expone un único punto de entrada. PLSASLprepPassword toma la contraseña cruda, escribe la forma preparada en un parámetro var, y devuelve False cuando la contraseña debe ser rechazada. La función es deliberadamente total en el camino feliz: una contraseña solo ASCII vuelve idéntica byte a byte, así que nada cambia sobre las implementaciones 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 cae en dos tablas de RFC 3454 a la vez, y las dos tablas no coinciden. EL ESPACIO DE ANCHO CERO (U+200B) cae dentro del rango C.1.2 de U+2000 a U+200B, donde la regla dice mapearlo a U+0020, y también cae dentro del rango B.1 de U+200B a U+200D, donde la regla dice eliminarlo. Lee el paso de mapeo en cualquier orden y obtienes bytes distintos de la misma contraseña: a+U+200B+b se prepara como a b bajo C.1.2 y como ab bajo B.1. RFC 4013 nombra ambas tablas y no dice cuál gana, así que esta es una ambigüedad genuina en la especificación en vez de un error de lectura. PDFlibPas prueba la membresía en C.1.2 primero y por lo tanto mapea U+200B a un espacio, que es el comportamiento en el que se establecieron otras implementaciones de stringprep ampliamente desplegadas; coincidir con ellas es lo único que importa aquí, porque el objetivo es concordancia de bytes con cualquier lector que el cliente esté usando
Leer archivos antiguos: preparada primero, cruda segunda
La corrección crea su propio problema de compatibilidad. Cada archivo AES-256 escrito antes del cambio hasheó la contraseña UTF-8 cruda, así que hacer que el lector sea estrictamente conforme bloquearía a los clientes de sus propios archivos. PDFlibPas resuelve esto en el lado de lectura probando dos candidatos en orden. TPDFDocument.SetPassword construye una lista de candidatos que comienza con la forma preparada y recurre a la forma cruda, y solo agrega 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 entrada, y el costo 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 con la contraseña preparada primero
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 respaldo lleva una salvaguarda que vale la pena copiar. El segundo intento en DecryptFile se ejecuta solo cuando las formas preparada y cruda difieren y el primer intento no reportó 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 quema un segundo análisis completo sobre entrada hostil; el razonamiento detrás de ese reflejo se explica en la nota sobre analizar PDFs no confiables de forma segura. Nota también que no hay respaldo en el lado de escritura, y esa asimetría es intencional. Leer tolera la historia, escribir no: cada archivo AES-256 nuevo obtiene los bytes conformes
¿Qué contraseñas se rechazan por completo, y qué es el error 604?
SASLprep puede rechazar una contraseña por completo, y cuando lo hace, el cifrado debe fallar ruidosamente en vez de sustituir algo silenciosamente. Encrypt y EncryptFile preparan tanto la contraseña de propietario como la de usuario siempre que Strength sea 3 o 4, devuelven 0 al rechazar, y establecen LastErrorCode a PDFLIB_ERROR_PASSWORD_SASLPREP, que es 604. Dos familias de entrada lo activan. 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 solitarios (C.5), U+FFFD (C.6), caracteres de descripción ideográfica (C.7), y los rangos de control de visualización y etiquetado (C.8 y C.9). Por separado, la regla bidireccional de la sección 6 de RFC 3454 rechaza cualquier cadena que contenga un carácter RandALCat de la tabla D.1 a menos que la cadena tanto comience como termine con uno y no contenga ninguna letra de izquierda a derecha en absoluto
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 va a sorprender a tu mesa de soporte. Una contraseña árabe o hebrea que termina en un dígito occidental, o una con una letra latina suelta en medio, es rechazada por la especificación aunque se vea perfectamente razonable en el campo de entrada. Muestra el 604 como un mensaje sobre los caracteres de la contraseña, no como un fallo genérico de cifrado, o alguien pasará una tarde buscando un error 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 vez de esconderse. La normalización NFKC se realiza mediante 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 todavía se ejecutan 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 no relacionada con Windows en vez de algo vigente, pero una contraseña que dependa del plegado NFKC produciría ahí bytes distintos y eso es una divergencia real, aunque remota. La verificación bidireccional es la segunda aproximación: detectar caracteres LCat usa los rangos de letras comunes en vez de la tabla D.2 completa de RFC 3454, y la dirección de ese error es lo que lo hace aceptable. Un carácter LCat pasado por alto solo puede causar que la regla bidireccional pase donde la especificación la habría rechazado, nunca al revés, y nunca toca los pasos de mapeo o normalización, así que la secuencia de bytes preparada de una contraseña aceptada permanece sin cambios. El riesgo residual es por lo tanto una divergencia de política en vez de una divergencia de bytes: una contraseña de escritura exótica que una implementación más estricta se negaría a aceptar del todo. Toda contraseña que ambos lados aceptan hashea de forma idéntica, que es la propiedad de la que realmente depende la interoperabilidad
Finalmente, una trampa de sintaxis de Delphi que cuesta una hora si no la has encontrado antes. Cuando una función devuelve un tipo procedimental, asignarla sin paréntesis no la invoca. El compilador lee Proc := GetNormalizeProc; como tomar la dirección de GetNormalizeProc en sí, y luego reporta 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 funciones y decide si un documento cifrado sobrevive el contacto con un cliente en otra región. Los puntos de entrada Encrypt, EncryptFile, DecryptFile y SetPassword descritos aquí son parte de losLab PDF Developer Library Pascal Edition para Delphi y C++Builder, cuya página de producto lleva la referencia completa de cifrado y la tabla completa de códigos de error