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 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 /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 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 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í
/ByteRangearreglo 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 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

PDFlibPas diccionario de firma de un PDF cifrado en Delphi con el /Contents hexadecimal en claro según ISO 32000-2 §7.6.2 mientras /Reason y /M quedan cifrados, 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 ciphertext, y descifrar ese DER a la fuerza falla de forma distinta con cada cifrador

¿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

PDFlibPas prueba de cifrado en Delphi que distingue un /Contents de firma de un /Contents de anotación mediante /ByteRange, /Type Sig o DocTimeStamp y /Filter antes de cifrar o descifrar la cadena
Los comentarios de anotación quedan cifrados porque PDFlibPas trata /Contents como valor de firma solo junto a /ByteRange y un /Type que coincida, o un nombre /Filter cuando /Type no está

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

PDFlibPas orden de sellos de tiempo de firma PAdES en Delphi: un /ByteRange de firma posterior que cubre el hueco de /Contents anterior hace que GetPAdESSignatureTimeStampProcessResult devuelva 17 sin escribir archivo
Cuando Approver ya firmó, su byte range cubre el hueco de /Contents de Author, y PDFlibPas rechaza el sello 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;  // 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