Un contrato cifrado con AES y firmado en otra herramienta se carga con un valor de firma que decodifica a bytes aleatorios, o bien, bajo AES-GCM, con un diccionario de firma que no se deja descifrar. Fuera del /ID del trailer y del diccionario de cifrado, ISO 32000-2 §7.6.2 deja sin cifrar únicamente el /Contents de la firma, y PDFlibPas lo mantiene en claro tanto al leer como al escribir, mientras que /Reason y /M quedan cifrados
La exención que ISO 32000-2 añadió a las reglas de cifrado
ISO 32000-2 §7.6.2 dice que el cifrado cubre cada cadena y cada stream del archivo, con cuatro excepciones:
- los valores de la entrada
/IDdel trailer - las cadenas dentro del diccionario
/Encrypt - las cadenas dentro de los streams, que se cifran como un todo
- toda cadena hexadecimal que sea el valor de
/Contentsen un diccionario de firma
Las tres primeras son de siempre. ISO 32000-1 §7.6.1 lista solamente esas tres, así que un descifrador escrito contra el texto de 2008 no tiene instrucción alguna de saltarse nada de un diccionario de firma. Las herramientas de firma escribieron el valor en claro de todos modos. El motivo es mecánico — un firmante reserva un placeholder hexadecimal, escribe el archivo completo, hashea todo excepto el placeholder, construye el CMS y parcha su hex dentro del hueco. Ese resumen cubre los bytes tal como quedan en el disco, ciphertext incluido. Cifrar el valor de la firma no protegería nada, y además obligaría al paso de parchado a conocer la file key y a meter un IV más su padding dentro de un placeholder dimensionado antes de que existiera el hash. El resto del diccionario sigue las reglas normales:
| Entrada del diccionario de firma | Tipo PDF | ¿Se guarda cifrada? |
|---|---|---|
/Contents | cadena hexadecimal | No, exenta según ISO 32000-2 §7.6.2 |
/Reason, /Location, /ContactInfo, /Name | cadena de texto | Sí |
/M | cadena de fecha | Sí |
/ByteRange | arreglo de enteros | No, los enteros nunca se cifran |
/Type, /Filter, /SubFilter | nombre | No, los nombres nunca se cifran |
Un lector que se pierda la exención pasa el cifrador sobre DER en claro. Lo que salga depende del algoritmo. RC4 es un stream cipher, así que la salida conserva su longitud y se convierte en ruido; el parser del CMS ve un primer byte que ya no es 0x30 y reporta una firma malformada. AES-CBC trata los primeros 16 bytes como vector de inicialización y el resto como ciphertext, lo que termina en un error de padding o en un blob de basura más corto. AES-GCM, definido para PDF por ISO/TS 32003, es el caso más severo: cada cadena cifrada lleva su propio nonce y su tag de autenticación, y un valor en claro no puede pasar la verificación del tag. Un descifrador que trate un tag fallido como fatal para todo el diccionario pierde entonces /Reason y /M junto con el valor de la firma
¿Cómo distinguir el /Contents de una firma de cualquier otro /Contents?
Comparar por el nombre de la clave es el primer intento obvio, y falla en ambas direcciones. Los diccionarios de anotación también tienen una entrada /Contents — una cadena de texto con el comentario que escribió el revisor — y esa cadena sí debe quedar cifrada. Saltársela deja cada nota adhesiva de un archivo confidencial legible sin la contraseña. Comparar por /Type /Sig falla al revés. La Tabla 255 de ISO 32000-2 vuelve opcional el /Type en un diccionario de firma, con Sig como valor por defecto, y solo lo exige para /DocTimeStamp, de modo que un productor conforme puede omitirlo. Por eso PDFlibPas trata una cadena /Contents como valor de firma únicamente cuando el mismo diccionario trae un arreglo /ByteRange más un /Type de Sig o DocTimeStamp, o ningún /Type junto a un nombre /Filter. El /Filter es obligatorio en todo diccionario de firma y el /ByteRange en toda firma que pertenece a un campo de firma; una anotación no tiene ninguno de los dos. La biblioteca aplica la misma prueba cuando cifra al guardar y cuando descifra al cargar, así que el /Contents sale de la carga con los bytes que escribió el productor
Primero cifrar, después firmar con PDFlibPas
Firme al final. El resumen de la firma cubre los bytes almacenados, así que cualquier operación que reescriba el archivo después de firmar rompe todas sus firmas. Aplicar cifrado a un documento firmado vuelve a cifrar cada cadena y stream; quitar el cifrado las reescribe con la misma intensidad. La secuencia que funciona es cifrar, guardar y después firmar el archivo cifrado. El proceso de firma añade una actualización incremental cuyas cadenas nuevas se cifran con la file key existente y cuyo placeholder de /Contents queda en claro. Abra el archivo en el proceso de firma con la contraseña del propietario; una contraseña de usuario alcanza solo cuando los permisos incluyen el bit 9 de la Tabla 22 de ISO 32000-2, que menciona explícitamente los campos de firma. Encrypt y el proceso de firma están disponibles en todas las ediciones de PDFlibPas
uses
SysUtils, PDFlibrary;
const
OwnerPwd = 'owner-secret';
UserPwd = 'reader-secret';
procedure EncryptCopy(const Source, Target: string);
var
Lib: TPDFlib;
Perms: Integer;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile(Source, '') <> 1 then
raise Exception.CreateFmt('Load failed (%d)', [Lib.LastErrorCode]);
// imprimir, llenar campos (bit 9 cubre campos de firma), copia accesible, impresión de alta resolución
Perms := Lib.EncodePermissions(1, 0, 0, 0, 1, 1, 0, 1);
// Strength 4 = AES-256 revisión 6: la file key se usa directamente, /ID no participa
if Lib.Encrypt(OwnerPwd, UserPwd, 4, Perms) <> 1 then
raise Exception.CreateFmt('Encrypt failed (%d)', [Lib.LastErrorCode]);
if Lib.SaveToFile(Target) <> 1 then
raise Exception.CreateFmt('Save failed (%d)', [Lib.LastErrorCode]);
finally
Lib.Free;
end;
end;
procedure SignField(Lib: TPDFlib; const Source, Target, FieldName,
PFXFile, PFXPassword: string);
var
SP, R: Integer;
begin
SP := Lib.NewSignProcessFromFile(Source, OwnerPwd);
if SP = 0 then
raise Exception.CreateFmt('Input not found: %s', [Source]);
try
Lib.SetSignProcessField(SP, FieldName); // se crea invisible si falta
Lib.SetSignProcessPFXFromFile(SP, PFXFile, PFXPassword);
Lib.SetSignProcessInfo(SP, 'Approved for release', 'Head office', '');
// margen en el placeholder para un sello de tiempo de firma añadido después
Lib.SetSignProcessReserveContentsBytes(SP, 16384);
Lib.EndSignProcessToFile(SP, Target); // siempre devuelve 0
R := Lib.GetSignProcessResult(SP);
if R <> 1 then
raise Exception.CreateFmt('Signing %s failed (result %d)', [FieldName, R]);
finally
Lib.ReleaseSignProcess(SP);
end;
end;
Strength 4 selecciona AES-256 revisión 6, y esa elección elimina dos modos de fallo. Con RC4 y AES-128, el Algorithm 1 (ISO 32000-1 §7.6.2) deriva una clave separada para cada objeto a partir de su número de objeto y su número de generación, y el Algorithm 2 (ISO 32000-1 §7.6.3.3, paso e) además alimenta la file key con el primer elemento /ID. Una actualización de firma reescribe el diccionario AcroForm y el arreglo /Annots de la página, así que cada objeto reescrito debe conservar su número de generación original y el trailer de la actualización debe llevar el mismo primer elemento /ID. Equivocarse con la generación deja ese objeto sin descifrar; equivocarse con el /ID deja todo sin descifrar. La revisión 6 usa la file key directamente (ISO 32000-2 §7.6.3.3, Algorithm 1.A) y la recupera sin consultar el /ID (§7.6.4.3.3, Algorithm 2.A). PDFlibPas conserva los números de generación en los objetos reescritos con cualquier strength y deriva la clave desde el /ID tal como está almacenado, incluso cuando un archivo malformado trae /ID 7 0 R en lugar del arreglo directo que exige la Tabla 15 de ISO 32000-2; un /ID [] vacío falla la verificación de contraseña. El camino de contraseña a clave se analiza con más detalle junto a abrir PDFs cifrados con una file key cruda
Un editor de texto alcanza para ver el resultado. Un diccionario de firma queda sin comprimir en la actualización que lo añadió, porque el firmante parcha /ByteRange y /Contents por offset de archivo; busque /ByteRange y caerá en él. El hex de /Contents suele abrir con 3082, el header DER del CMS SignedData SEQUENCE, y termina en una tira larga de ceros donde el placeholder era más grande que la firma. El /Reason es ciphertext que cambia de una corrida de firma a la siguiente aunque el texto no cambie, porque AES sortea un IV fresco por cadena. Lo esperado con un qpdf reciente: qpdf --password=owner-secret --show-object=14 signed.pdf imprime el /Reason descifrado junto a un /Contents sin cambios. Use un archivo strength 4 para esa prueba. La forma esperada en el archivo crudo, con offsets y bytes variables:
14 0 obj
<</Type /Sig /Filter /Adobe.PPKLite
/ByteRange [0 48211 81093 2304]
/Contents <308240A206092A864886F70D010702A0...000000000000>
/Reason <7F21C9E04B...>
/M <D35A0F98...>
...>>
endobj
¿Qué firmas se rompen cuando se parcha un sello de tiempo?
Todas las firmas añadidas después de la que recibe el sello. Un sello de tiempo de firma PAdES es un token RFC 3161 almacenado como atributo sin firmar del firmante (RFC 5652 §5.3, unsignedAttrs), así que vive dentro del CMS, en el /Contents. Añadirlo significa reescribir ese hex en su lugar. En un archivo cifrado esa reescritura funciona únicamente porque /Contents está en claro: sin recifrado, sin objeto nuevo — el token solo necesita caber en el espacio reservado al momento de firmar. La firma dueña sobrevive porque su /ByteRange se salta esos bytes. Las firmas posteriores no gozan de esa exención. ISO 32000-2 §12.8.1 hace que el byte range de cada firma corra desde el header %PDF- hasta el final de la actualización incremental que la añadió. Una segunda firma, o un sello de tiempo de documento, hashea por tanto el /Contents de la primera firma junto con todo lo demás. El hueco le pertenece a una sola firma: párchelo después de que un segundo firmante haya firmado y el resumen de la segunda firma ya no coincidirá. La petición del token en sí es parte de la firma y validación PAdES desde Delphi; esta sección trata solo de qué bytes se mueven
PDFlibPas verifica el solapamiento antes de hashear nada. El proceso de sello de tiempo de firma lee el /ByteRange de todas las demás firmas del documento y compara cada par de tramos con el hueco de /Contents del objetivo. Cualquier solapamiento detiene la preparación con resultado de proceso 17, y no se escribe ningún archivo de salida. El proceso pertenece a la edición Enterprise, que usted instancia como TPDFlibEnterprise desde la unidad PDFlibEnterprise; las demás ediciones conservan los métodos, que devuelven 0 y fijan LastErrorCode en 900. Vigile el número 17 en sus logs. Desde GetPAdESSignatureTimeStampProcessResult significa que una firma posterior cubre esta; desde GetSignProcessResult significa que un nivel de certificación o un bloqueo de campo impide firmar. Mapee los dos por separado. El helper de abajo usa SignField y OwnerPwd del listado anterior
uses
SysUtils, PDFlibrary, PDFlibEnterprise;
const
STS_COVERED_BY_LATER_SIGNATURE = 17;
function StampSignature(Lib: TPDFlibEnterprise; const Source, Target,
FieldName: string): Integer;
var
STS: Integer;
begin
Result := 0;
STS := Lib.NewPAdESSignatureTimeStampProcessFromFile(Source, OwnerPwd);
if STS = 0 then
Exit;
try
Lib.SetPAdESSignatureTimeStampField(STS, FieldName);
if Lib.ApplyPAdESSignatureTimeStamp(STS, 0) <> 1 then
begin
Result := Lib.GetPAdESSignatureTimeStampProcessResult(STS);
if Result = 1 then
Result := -1; // el valor ya quedó hasheado, falló el intercambio con la TSA: ver GetTimeStampError
Exit;
end;
Result := Lib.EndPAdESSignatureTimeStampProcessToFile(STS, Target);
finally
Lib.ReleasePAdESSignatureTimeStampProcess(STS);
end;
end;
procedure SignAndStampInOrder(const EncryptedFile: string);
var
Lib: TPDFlibEnterprise;
R: Integer;
begin
Lib := TPDFlibEnterprise.Create;
try
Lib.SetTimeStampServer('https://tsa.example.com/tsa', '', '');
SignField(Lib, EncryptedFile, 'step1.pdf', 'Author', 'author.pfx', 'pfx-1');
R := StampSignature(Lib, 'step1.pdf', 'step1-ts.pdf', 'Author');
if R <> 1 then
raise Exception.CreateFmt('Author time stamp failed (%d)', [R]);
SignField(Lib, 'step1-ts.pdf', 'step2.pdf', 'Approver', 'approver.pfx', 'pfx-2');
R := StampSignature(Lib, 'step2.pdf', 'final.pdf', 'Approver');
if R <> 1 then
raise Exception.CreateFmt('Approver time stamp failed (%d)', [R]);
// Esperado: 17. El byte range de Approver cubre el /Contents de Author en
// step2.pdf, así que el proceso se niega y restamped.pdf nunca se escribe
R := StampSignature(Lib, 'step2.pdf', 'restamped.pdf', 'Author');
if R <> STS_COVERED_BY_LATER_SIGNATURE then
raise Exception.CreateFmt('Unexpected result %d', [R]);
finally
Lib.Free;
end;
end;
Selle cada firma antes de que el siguiente firmante toque el archivo. Cuesta un viaje de ida y vuelta a la TSA por firmante y le da a cada firma su propia hora de confianza. Un archivo que llega con varias firmas ya puestas es otro caso: las anteriores ya no pueden recibir un sello de tiempo de firma. Añada en su lugar un sello de tiempo de documento — una firma /Type /DocTimeStamp con /SubFilter /ETSI.RFC3161 en una revisión nueva, cuyo byte range cubre todas las firmas anteriores y prueba que existían en ese momento. ApplyPAdESLongTermArchiveValidation añade uno después de adjuntar el material de validación. Reserve espacio para un sello de tiempo de firma al firmar, vía SetSignProcessReserveContentsBytes como en el primer listado. Un token que incrusta la cadena de certificados de la TSA alcanza varios kilobytes sin esfuerzo, y un placeholder sin ese margen hace que la aumentación posterior falle con resultado 13
Descifrar un archivo firmado y qué hashea un verificador
Los tramos firmados de un archivo cifrado son ciphertext. Un verificador compara un resumen del ciphertext almacenado con el resumen dentro del CMS: el /ByteRange son enteros, el /Contents está en claro, y la verificación de integridad no necesita clave — solo el nombre del campo y el /Reason la necesitan. Ese mismo hecho explica por qué descifrar un archivo firmado y guardarlo en claro invalida todas sus firmas aunque no haya cambiado ni un píxel visible. Conserve el original cifrado y firmado como copia de registro. Los cross-reference streams tampoco se cifran nunca (ISO 32000-2 §7.5.8.2), así que una actualización escrita con un xref stream mantiene sus offsets legibles antes de derivar cualquier clave. La estructura se describe en object streams y cross-reference streams en Delphi. Cuando un tramo firmado es demasiado grande para cargarlo en memoria y compararlo, PDFlibPas reporta la verificación de integridad como desconocida en lugar de inválida, porque los bytes que nunca se compararon no se ha demostrado que difieran
El valor de firma de un archivo cifrado sigue siendo escribible después de firmar hasta que otra firma o un sello de tiempo de documento lo cubre. Meta el paso de sellado dentro de esa ventana y la secuencia de cifrar primero y firmar después del primer listado se traslada sin cambios a archivos con varios firmantes en PDF Library for Delphi