Un contrato cifrado con AES y firmado en otra herramienta carga con un valor de firma que decodifica a bytes aleatorios, o 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 solo el /Contents de la firma sin cifrar, y PDFlibPas lo mantiene en claro al leer y al escribir mientras /Reason y /M siguen cifradas
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 stream del fichero, 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 existen desde siempre: ISO 32000-1 §7.6.1 lista solo esas tres, así que un descifrador escrito contra el texto de 2008 no tiene ninguna instrucción de saltarse nada en un diccionario de firma. Las herramientas de firma escribieron el valor en claro de todos modos. La razón es mecánica: el signer reserva un placeholder hexadecimal, escribe el fichero entero, calcula el hash de todo salvo el placeholder, construye el CMS y parchea su hexadecimal en el hueco. Ese digest cubre los bytes tal y como están en disco, cifrado incluido. Cifrar el valor de la firma no protegería nada, y obligaría al paso de parcheo a conocer la clave del fichero y a meter un IV más padding en 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 | array 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 por DER en claro. Lo que sale depende del algoritmo. RC4 es un cifrador de flujo, así que la salida conserva la 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 texto cifrado, lo que acaba en 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 duro: cada cadena cifrada lleva su propio nonce y 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 distingues el /Contents de una firma de cualquier otro /Contents?
Igualar por el nombre de la clave es el primer intento obvio, y falla en ambas direcciones. Los diccionarios de anotación tienen también una entrada /Contents — una cadena de texto con el comentario que escribió el revisor — y esa cadena debe ir cifrada: sáltatela y cada nota adhesiva de un fichero confidencial se lee sin contraseña. Igualar por /Type /Sig falla al revés. La Tabla 255 de ISO 32000-2 hace /Type opcional en un diccionario de firma, con Sig como valor por defecto, y solo lo exige para /DocTimeStamp, así que un productor conforme puede omitirlo. PDFlibPas trata por tanto una cadena /Contents como valor de firma solo cuando el mismo diccionario lleva un array /ByteRange más o bien un /Type de Sig o DocTimeStamp, o bien ningún /Type junto a un nombre /Filter. /Filter es obligatorio en todo diccionario de firma y /ByteRange en toda firma que pertenezca a un campo de firma; una anotación no tiene ninguno de los dos. La biblioteca aplica la misma prueba al cifrar en el guardado y al descifrar en la carga, así que el /Contents sale de una carga con los bytes que escribió el productor
Primero cifrar, luego firmar con PDFlibPas
Firma lo último. El digest de la firma cubre los bytes almacenados, así que cualquier operación que reescriba el fichero 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 profundidad. La secuencia que funciona es cifrar, guardar y después firmar el fichero cifrado. El proceso de firma añade una actualización incremental cuyas cadenas nuevas se cifran con la clave de fichero existente y cuyo placeholder /Contents se queda en claro. Abre el fichero en el proceso de firma con la contraseña de owner; la de user basta 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, rellenar campos (el bit 9 cubre los 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: clave de fichero usada directa, el /ID no forma parte
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 Algoritmo 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. El Algoritmo 2 (ISO 32000-1 §7.6.3.3, paso e) además alimenta la clave de fichero con el primer elemento /ID. Una actualización de firma reescribe el diccionario AcroForm y el array /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. Falla la generación y ese objeto deja de descifrarse; falla el /ID y no se descifra nada. La revisión 6 usa la clave de fichero directamente (ISO 32000-2 §7.6.3.3, Algoritmo 1.A) y la recupera sin consultar /ID (§7.6.4.3.3, Algoritmo 2.A). PDFlibPas conserva los números de generación en los objetos reescritos a cualquier strength y deriva la clave del /ID tal y como está almacenado, incluso cuando un fichero malformado trae /ID 7 0 R en lugar del array directo que exige la Tabla 15 de ISO 32000-2; un /ID [] vacío falla en la comprobación de contraseña. El camino de contraseña a clave se traza con más detalle junto a abrir PDFs cifrados con una clave de fichero en bruto
Un editor de texto basta para ver el resultado. El diccionario de firma va sin comprimir en la actualización que lo añadió, porque el signer parchea /ByteRange y /Contents por offset de fichero: busca /ByteRange y caes en él. El hexadecimal de /Contents suele abrir con 3082, la cabecera DER del SEQUENCE SignedData del CMS, y termina en una larga ristra de ceros donde el placeholder era más grande que la firma. /Reason es texto cifrado que cambia de una pasada 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 intacto. Usa para esa comprobación un fichero de strength 4. Aspecto esperado en el fichero en bruto, 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 parchea un sello de tiempo?
Todas las firmas añadidas después de la sellada. Un sello de tiempo de firma PAdES es un token RFC 3161 guardado como atributo sin firmar del firmante (RFC 5652 §5.3, unsignedAttrs), así que vive dentro del CMS en /Contents. Añadir uno implica reescribir ese hexadecimal in situ. En un fichero cifrado esa reescritura funciona precisamente porque /Contents está en claro: sin recifrado y sin objeto nuevo, el token solo necesita caber en el hueco reservado al firmar. La firma propietaria sobrevive porque su /ByteRange se salta esos bytes. Las firmas posteriores no reciben exención alguna: ISO 32000-2 §12.8.1 hace que el byte range de cada firma vaya desde la cabecera %PDF- hasta el final de la actualización incremental que la añadió. Una segunda firma, o un sello de tiempo de documento, incluye por tanto en el hash el /Contents de la primera junto con todo lo demás. El hueco pertenece a una sola firma: parchéalo después de que un segundo firmante haya firmado y el digest de la segunda deja de cuadrar. La petición del token en sí forma parte de la firma y validación PAdES desde Delphi; esta sección trata solo de qué bytes se mueven
PDFlibPas comprueba el solape antes de incluir nada en el hash. 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 /Contents del objetivo. Cualquier solape detiene la preparación con resultado de proceso 17, y no se escribe ningún fichero de salida. El proceso pertenece a la edición Enterprise, que instancias como TPDFlibEnterprise desde la unidad PDFlibEnterprise; las demás ediciones conservan los métodos, que devuelven 0 y ponen LastErrorCode a 900. Vigila el número 17 en tus logs: desde GetPAdESSignatureTimeStampProcessResult significa que una firma posterior cubre esta; desde GetSignProcessResult significa que un nivel de certificación o un bloqueo de campo prohíbe firmar. Mapea 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; // valor con hash hecho, 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;
Sella cada firma antes de que el siguiente firmante toque el fichero. Cuesta un viaje de ida y vuelta a la TSA por firmante y da a cada firma su propia hora de confianza. Un fichero que llega con varias firmas ya puestas es otro caso: las anteriores ya no pueden recibir un sello de tiempo de firma. Añade 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 demuestra que existían en ese momento. ApplyPAdESLongTermArchiveValidation añade uno tras adjuntar el material de validación. Reserva sitio para un sello de tiempo de firma cuando firmas, mediante SetSignProcessReserveContentsBytes como en el primer listado: un token que incrusta la cadena de certificados de la TSA se va fácilmente a varios kilobytes, y un placeholder sin ese margen hace fallar la ampliación posterior con resultado 13
Descifrar un fichero firmado y qué incluye en el hash un verificador
Los tramos firmados de un fichero cifrado son texto cifrado. Un verificador compara un digest del texto cifrado almacenado con el digest dentro del CMS: /ByteRange son enteros, /Contents está en claro, y la comprobación de integridad no necesita clave — solo la necesitan el nombre del campo y /Reason. Ese mismo hecho explica por qué descifrar un fichero firmado y guardarlo en claro invalida todas sus firmas sin que cambie un solo píxel visible. Conserva 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 xref stream conserva sus offsets legibles antes de derivar cualquier clave. La disposición se describe en object streams y cross-reference streams en Delphi. Cuando un tramo firmado es demasiado grande para leerlo en memoria y compararlo, PDFlibPas reporta la comprobación de integridad como desconocida en lugar de inválida, porque bytes que nunca se compararon no han demostrado diferir
El valor de firma de un fichero cifrado sigue siendo escribible después de firmar hasta que otra firma o un sello de tiempo de documento lo cubre. Mete el paso del sello de tiempo dentro de esa ventana, y la secuencia cifrar y luego firmar del primer listado se traslada sin cambios a los ficheros multi-firmante en PDF Library para Delphi