Artículo técnico

Signature wrapping en PDF: ByteRange y segundas firmas

HotPDF, el componente PDF para Delphi, rechaza ya el signature wrapping: desde v2.759.0 tanto VerifyLoadedSignatureEx como el validador por lotes exigen que el hueco entre los dos segmentos del /ByteRange sea exactamente el hex string del /Contents, delimitadores incluidos, y v2.761.0 añade AddLoadedSignedSignatureField para poder adjuntar una segunda firma a un PDF ya firmado como una revisión incremental limpia. Los dos cambios van de la mano, porque una segunda firma correcta es precisamente la disposición que el verificador más estricto espera

La situación que destapó el problema es de lo más normal. Un contrato lo firma el proveedor, y luego viaja hasta un aprobador que debe dar la contrafirma sin perturbar la primera firma. La segunda revisión se añade detrás de la primera, su propio /ByteRange abarca todo el archivo crecido, y ambas firmas deberían verificar. Llegar ahí a mano significaba escribir usted mismo una sección incremental, y el fixture de test que hacía exactamente eso resultó ser una estructura de signature wrapping de manual que el antiguo verificador aceptaba encantado. Si no ha mirado antes la API de verificación, la guía para verificar firmas digitales PDF con HotPDF cubre lo básico sobre lo que este artículo construye

¿Qué tiene que haber exactamente en el hueco del ByteRange?

El hueco tiene que contener el valor /Contents completo y nada más: ISO 32000-1 §12.8.3.3 dice que el hex string, con sus delimitadores < y >, encaja justo en el espacio entre los dos rangos de bytes, e ISO 32000-2 §12.8.1 arrastra la misma regla hacia delante. La Tabla 252 y los documentos PAdES solo dicen que el digest excluye el valor de Contents, lo que se deja leer como excluyendo solo los dígitos hex. Versiones anteriores de HotPDF lo leían así: PreparePDFForSigning y la preparación CMS en streaming hasheaban también los paréntesis angulares, con un comentario del código fuente insistiendo en que los paréntesis tenían que quedar cubiertos. Los validadores que comparan el hueco contra el valor de la firma marcan esa disposición como un byte range inválido, así que v2.759.0 saca ambos delimitadores de los rangos firmados. Una comprobación independiente rápida sobre cualquier archivo firmado es mirar dos bytes: el byte en el offset ByteRange[1] tiene que ser < y el byte en el offset ByteRange[2] - 1 tiene que ser >

Anatomía de un ByteRange de firma PDF correctamente rellenado en HotPDF: el primer rango cubre el archivo desde el byte cero, el hueco guarda el hex string de /Contents completo con los delimitadores de menor que y mayor que, el segundo rango cubre el trailer hasta el final, y dos comprobaciones de un byte en ByteRange[1] y ByteRange[2] - 1 confirman la disposición en cualquier archivo firmado
Desde v2.759.0 los delimitadores quedan fuera de los rangos firmados, así que el digest cubre solo los dígitos y el hueco se puede validar byte a byte

¿Por qué una comprobación de hueco no vacío se pierde el signature wrapping?

Una comprobación de hueco no vacío solo prueba que algo quedó fuera del digest, no qué quedó fuera, y esa es toda la superficie de ataque. El placeholder del /Contents se reserva con miles de dígitos cero, mientras que un contenedor CMS real rara vez lo llena. Un atacante puede cerrar el hex string antes de tiempo dentro de ese padding de ceros con un >, escribir objetos nuevos o una revisión falsificada en el resto del espacio reservado, y dejar los rangos de bytes intactos. La firma CMS sigue verificando porque cada byte firmado no cambia, los rangos siguen empezando en 0 y acabando en el tamaño del archivo, y el antiguo verificador de HotPDF reportaba svValid con CoversWholeDocument puesto en True. Un lector PDF, mientras tanto, parsea lo que sea que esté en ese agujero sin firmar

HotPDF trata ahora el hueco como datos que validar byte a byte. El verificador lee el hueco, quita los delimitadores, acepta solo dígitos hex más espacio en blanco PDF (tabulador, salto de línea, form feed, retorno de carro, espacio), decodifica los dígitos y exige que el resultado iguale exactamente el /Contents del diccionario de firma. Cualquier otra cosa degrada el resultado a svInvalidByteRange. La comprobación corre tanto en la vía de firma única como en ValidateLoadedSignatureBatch, que conservaba su propia lógica de cobertura y necesitaba el mismo fix. Los archivos que HotPDF produjo antes de v2.759.0, cuyo hueco guardaba solo dígitos con los paréntesis dentro de los rangos, siguen verificando, así que los documentos archivados no se vuelven rojos de repente

Cómo explota el signature wrapping un ByteRange PDF poco comprobado en Delphi: el atacante cierra el hex string antes de tiempo dentro de miles de dígitos cero reservados, escribe una revisión falsificada en el hueco sin firmar sin tocar ningún byte cubierto, y la antigua comprobación de HotPDF reportaba svValid con CoversWholeDocument en true hasta que v2.759.0 empezó a validar el hueco byte a byte
Un hueco no vacío solo prueba que algo quedó fuera del digest, no qué — el agujero relleno de ceros es toda la superficie de ataque
var
  Pdf: THotPDF;
  Info: THPDFSignatureInfo;
  Status: THPDFSignatureVerifyStatus;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('SignedTwice.pdf');
    for I := 0 to Pdf.GetLoadedSignatureFieldCount - 1 do
    begin
      Status := Pdf.VerifyLoadedSignatureEx(I, Info);
      case Status of
        svValid:
          if Info.CoversWholeDocument then
            Writeln(Info.FieldName, ': valid, covers the whole file')
          else
            Writeln(Info.FieldName, ': valid, ',
              Info.UnsignedTrailingBytes, ' bytes appended later');
        svInvalidByteRange:
          Writeln(Info.FieldName, ': ByteRange gap rejected (wrapping?)');
      else
        Writeln(Info.FieldName, ': failed, status ', Ord(Status));
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

¿Cómo añadir una segunda firma a un PDF ya firmado?

Abra el archivo firmado con BeginIncrementalUpdate, llame a AddLoadedSignedSignatureField, guarde con SaveIncrementalUpdate, y firme después el archivo preparado con la class function THotPDF.SignPDFWithPFX. Antes de v2.761.0 la receta documentada de llamar a THPDFPage.AddSignedSignatureField tras BeginIncrementalUpdate no podía funcionar, porque CurrentPage es nil en modo incremental y nada podía colgar un placeholder de /V a un campo de un documento cargado. El método nuevo crea el widget en la página cargada y cuelga bajo /V el mismo diccionario de placeholder que usa la vía de documento nuevo, así que ambas rutas de firma comparten una sola serialización. Para la primera firma en sí, el artículo sobre crear firmas digitales PAdES en Delphi recorre el pipeline de PFX

var
  Pdf: THotPDF;
  FieldIndex: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.BeginIncrementalUpdate('Signed.pdf');
    // Página 0, rectángulo del widget en puntos, 8192 bytes reservados para el CMS
    FieldIndex := Pdf.AddLoadedSignedSignatureField(0, 320, 60, 520, 120,
      'ApproverSignature', 8192);
    if FieldIndex < 0 then
      raise Exception.Create('Page index out of range');
    Pdf.SaveIncrementalUpdate('Prepared.pdf');
  finally
    Pdf.Free;
  end;

  if not THotPDF.SignPDFWithPFX('Prepared.pdf', 'SignedTwice.pdf',
    'approver.pfx', 'pfx-password') then
    raise Exception.Create('Second signature failed');
end;

AddLoadedSignedSignatureField es deliberadamente más silencioso que sus hermanos. Los demás creadores de campos AddLoaded* ponen /NeedAppearances true en el AcroForm, lo que le dice al visor que regenere las appearances de los campos; en un documento firmado esa regeneración puede reescribir contenido firmado, así que el método nuevo quita el flag otra vez salvo que el origen ya lo llevara. /SigFlags conserva su valor original OR 3 (SignaturesExist más AppendOnly, ISO 32000-1 Tabla 219). Tampoco hace falta llamar a MarkDirty en la página: añadir a /Annots y /Fields propaga el flag de sucio al objeto indirecto dueño, y una marca de página explícita solo arrastraría un diccionario de página sin cambios a la revisión nueva, que el análisis de revisiones reportaría después como una modificación de página. Por último, el placeholder escribe /ByteRange antes de /Contents, porque el patcher localiza primero el sentinel /ByteRange y busca hacia adelante el hex string que le corresponde

El flujo de HotPDF Delphi para contradar un PDF ya firmado: BeginIncrementalUpdate abre el archivo, AddLoadedSignedSignatureField crea el widget y reserva el placeholder de /Contents, SaveIncrementalUpdate añade una segunda revisión, y SignPDFWithPFX la rellena, dejando la primera firma válida con UnsignedTrailingBytes mientras el nuevo ByteRange abarca todo el archivo crecido
Un placeholder por revisión, preparado y parcheado por la misma serialización en ambas rutas de firma — la disposición limpia que el verificador más estricto espera

¿Qué cambia cuando un firmante externo o un HSM produce el CMS?

En el flujo no cambia nada, pero los offsets ahora significan lo que la especificación dice. PreparePDFForSigning devuelve dos rangos 0-based cuyo hueco es el string de /Contents entero, y ContentsHexStart es el índice 1-based del primer dígito hex en el AnsiString. Un CMS más corto se rellena con 0 al final, antes del > de cierre. Como PreparePDFForSigning parchea el primer sentinel sin parchear que encuentra, prepare exactamente un placeholder por revisión, y prefiera InsertSignatureHexAt con los offsets devueltos antes que el InsertSignatureHex basado en búsqueda cuando ya existan firmas anteriores en el archivo

var
  Bytes, ToSign, CmsHex: AnsiString;
  R1Start, R1Len, R2Start, R2Len, HexStart, HexLen: Integer;
begin
  Bytes := LoadFileAsAnsiString('Prepared.pdf');   // su helper
  if not THotPDF.PreparePDFForSigning(Bytes, R1Start, R1Len,
    R2Start, R2Len, HexStart, HexLen) then
    raise Exception.Create('No signature placeholder found');

  // El hueco es el hex string entero: '<' cierra el rango 1, '>' precede al rango 2
  Assert(Bytes[R1Start + R1Len + 1] = '<');
  Assert(Bytes[R2Start] = '>');

  ToSign := Copy(Bytes, R1Start + 1, R1Len) + Copy(Bytes, R2Start + 1, R2Len);
  CmsHex := SignDetachedWithHsm(ToSign);           // su firmante CMS, DER en hex
  if not THotPDF.InsertSignatureHexAt(Bytes, HexStart, HexLen, CmsHex) then
    raise Exception.Create('CMS does not fit the reserved space');
  SaveAnsiStringToFile(Bytes, 'SignedTwice.pdf');  // su helper
end;

¿Dónde están los límites de las comprobaciones nuevas?

La comprobación del hueco cierra un agujero concreto y no conviene venderla de más. svValid sigue significando integridad de bytes más una clave que casa con el certificado incrustado; la confianza en ese certificado es una decisión aparte. El hueco solo se valida cuando el verificador tiene los bytes de origen, que VerifyLoadedSignatureEx lee del archivo cargado y las sobrecargas con TStream reciben de usted. Para la primera firma de un archivo contradado, CoversWholeDocument es correctamente False, y si la revisión añadida solo metió una firma o también cambió páginas es pregunta para DocMDP, FieldMDP y análisis de revisiones en HotPDF. Note además que la comprobación del PDF MAC adjunto compara offsets contra las posiciones de < y >, así que acepta tanto la disposición antigua como la nueva; cualquier herramienta suya que tenga incrustados a fuego los offsets anteriores a v2.759.0 fallará primero cuando se cruce con un archivo recién firmado

Si su aplicación Delphi o C++Builder firma, contradar o audita PDFs, el camino más seguro es dejar que una sola library produzca y verifique la misma disposición. HotPDF, el componente PDF nativo para Delphi trae la validación de hueco más estricta, las segundas firmas incrementales y los hooks de firmante externo mostrados arriba en un único componente