Artículo técnico

El valor de firma que un PDF cifrado deja en claro

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 /ID del 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 /Contents en 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 firmaTipo PDF¿Se guarda cifrada?
/Contentscadena hexadecimalNo, exenta según ISO 32000-2 §7.6.2
/Reason, /Location, /ContactInfo, /Namecadena de textoSí
/Mcadena de fechaSí
/ByteRangearray de enterosNo, los enteros nunca se cifran
/Type, /Filter, /SubFilternombreNo, 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

Diccionario de firma de un PDF cifrado en PDFlibPas para Delphi con el /Contents hexadecimal dejado en claro según ISO 32000-2 §7.6.2 mientras /Reason y /M siguen cifradas, más los modos de fallo de RC4, AES-CBC y AES-GCM
El /Contents de la firma sigue siendo DER legible en un PDF cifrado mientras /Reason y /M son texto cifrado, y descifrar ese DER a la fuerza falla de forma distinta con cada cifrador

¿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

Prueba de cifrado de PDFlibPas en Delphi que distingue el /Contents de una firma del /Contents de una anotación por /ByteRange, /Type Sig o DocTimeStamp y /Filter antes de cifrar o descifrar la cadena
Los comentarios de las anotaciones siguen cifrados porque PDFlibPas trata /Contents como valor de firma solo junto a /ByteRange y un /Type coincidente, o un nombre /Filter cuando /Type está ausente

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

Orden de sellos de tiempo de firma PAdES en PDFlibPas para Delphi: el /ByteRange de una firma posterior que cubre el hueco /Contents anterior hace que GetPAdESSignatureTimeStampProcessResult devuelva 17 sin escribir fichero
Cuando Approver ha firmado, su byte range cubre el hueco /Contents de Author, y PDFlibPas rechaza el sello de tiempo tardío de Author con resultado de proceso 17 en lugar de romper la firma posterior
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