Artículo técnico

Signature wrapping en PDF: huecos ByteRange y segunda firma

HotPDF, el componente Delphi PDF, ahora rechaza el signature wrapping: desde la v2.759.0 tanto VerifyLoadedSignatureEx como el validator por lotes exigen que el hueco entre los dos segmentos de /ByteRange sea exactamente el hex string de /Contents, delimitadores incluidos, y la v2.761.0 agrega AddLoadedSignedSignatureField para que una segunda firma pueda adjuntarse a un PDF ya firmado como una revisión incremental limpia. Los dos cambios van juntos, porque una segunda firma correcta es precisamente el layout que el verifier más estricto espera

La situación que destapó el problema es corriente. Un contrato lo firma el proveedor, después viaja a un aprobador que debe countersignar sin perturbar la primera firma. La segunda revisión se adjunta después de la primera, su propio /ByteRange abarca el archivo entero ya crecido, y ambas firmas deberían verificar. Llegar ahí a mano significaba escribir usted mismo la sección incremental, y el fixture de test que hacía exactamente eso resultó ser una estructura de signature wrapping de manual que el viejo verifier aceptaba con gusto. Si nunca ha mirado la API de verificación, la guía de verificación de firmas digitales PDF con HotPDF cubre las bases sobre las que este artículo construye

¿Qué pertenece exactamente al hueco del ByteRange?

El hueco debe contener el valor completo de /Contents y nada más: ISO 32000-1 §12.8.3.3 dice que el string hexadecimal, con sus delimitadores < y >, calza justo en el espacio entre los dos rangos de bytes, e ISO 32000-2 §12.8.1 arrastra la misma regla hacia adelante. La Tabla 252 y los documentos de PAdES solo dicen que el digest excluye el valor de Contents, lo cual se lee fácil como excluir solo los dígitos hex. Releases anteriores de HotPDF lo leían así: PreparePDFForSigning y la preparación CMS de streaming hasheaban también los angle brackets, con un comentario en el fuente insistiendo en que los brackets tenían que quedar cubiertos. Los validators que comparan el hueco contra el valor de la firma marcan ese layout como un byte range inválido, así que la v2.759.0 saca ambos delimitadores de los rangos firmados. Un chequeo independiente rápido sobre cualquier archivo firmado es mirar dos bytes: el byte en el offset ByteRange[1] debe ser < y el byte en el offset ByteRange[2] - 1 debe 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 sostiene el hex string completo de /Contents incluidos los delimitadores menor-que y mayor-que, el segundo rango cubre el trailer hasta el final, y dos chequeos de un byte en ByteRange[1] y ByteRange[2] - 1 confirman el layout en cualquier archivo firmado
Desde la 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é un chequeo de hueco no vacío se pierde el signature wrapping?

Un chequeo 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 de /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 temprano 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 cambió, los rangos siguen arrancando en 0 y terminando en el tamaño del archivo, y el viejo verifier de HotPDF reportaba svValid con CoversWholeDocument en True. Un lector de PDF, mientras tanto, parsea lo que sea que habite ese hueco sin firmar

HotPDF ahora trata el hueco como data que se valida byte a byte. El verifier lee el hueco, le quita los delimitadores, acepta solo dígitos hex más whitespace de PDF (tab, line feed, form feed, carriage return, espacio), decodifica los dígitos y exige que el resultado iguale al /Contents del diccionario de firma exactamente. Cualquier otra cosa degrada el resultado a svInvalidByteRange. El chequeo corre tanto en el camino de firma única como en ValidateLoadedSignatureBatch, que conservaba su propia lógica de cobertura y necesitaba el mismo fix. Los archivos producidos por HotPDF antes de la v2.759.0, cuyo hueco cargaba solo dígitos con los brackets apenas adentro de los rangos, siguen verificando, así que los documentos archivados no se ponen rojos de repente

Cómo el signature wrapping explota un ByteRange de PDF poco chequeado en Delphi: el atacante cierra el hex string temprano 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 el viejo chequeo de HotPDF reportaba svValid con CoversWholeDocument en true hasta que la 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 hueco rellenado 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 agregar una segunda firma a un PDF ya firmado?

Abra el archivo firmado con BeginIncrementalUpdate, llame a AddLoadedSignedSignatureField, guarde con SaveIncrementalUpdate, y después firme el archivo preparado con la class function THotPDF.SignPDFWithPFX. Antes de la v2.761.0 la receta documentada de llamar a THPDFPage.AddSignedSignatureField después de BeginIncrementalUpdate no podía funcionar, porque CurrentPage es nil en modo incremental y nada podía colgar un placeholder de /V sobre un campo de un documento cargado. El método nuevo crea el widget sobre la página cargada y cuelga bajo /V el mismo diccionario de placeholder que usa el camino 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 otros creadores de campos AddLoaded* fijan /NeedAppearances true sobre el AcroForm, lo que le dice al visor que regenere las apariencias de los campos; sobre un documento firmado esa regeneración puede reescribir contenido firmado, así que el método nuevo vuelve a quitar el flag salvo que la fuente ya lo llevara. /SigFlags conserva su valor original OR 3 (SignaturesExist más AppendOnly, ISO 32000-1 Tabla 219). Tampoco necesita llamar a MarkDirty en la página: agregar a /Annots y a /Fields propaga el flag dirty 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 nueva revisión, que el análisis de revisiones reportaría después como una modificación de página. Por último, el placeholder escribe /ByteRange antes que /Contents, porque el patcher localiza primero el sentinel /ByteRange y busca hacia adelante el hex string que le corresponde

El flujo de trabajo de HotPDF Delphi para countersignar un PDF ya firmado: BeginIncrementalUpdate abre el archivo, AddLoadedSignedSignatureField crea el widget y reserva el placeholder de /Contents, SaveIncrementalUpdate adjunta una segunda revisión, y SignPDFWithPFX la rellena, dejando la primera firma válida con UnsignedTrailingBytes mientras el nuevo ByteRange abarca el archivo entero ya crecido
Un placeholder por revisión, preparado y parchado por la misma serialización en ambas rutas de firma — el layout limpio que el verifier más estricto espera

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

En el flujo de trabajo no cambia nada, pero los offsets ahora significan lo que la especificación dice. PreparePDFForSigning devuelve dos rangos base 0 cuyo hueco es el string completo de /Contents, y ContentsHexStart es el índice base 1 del primer dígito hex en el AnsiString. Un CMS más chico 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 existen 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 completo: '<' cierra el rango 1, '>' antecede 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 los nuevos chequeos?

El chequeo del hueco cierra un agujero específico y no debería vendérsele de más. svValid sigue significando integridad de bytes más una clave que matchea con el certificado embebido; la confianza en ese certificado es una decisión aparte. El hueco se valida solo cuando el verifier tiene los bytes de la fuente, que VerifyLoadedSignatureEx lee del archivo cargado y las sobrecargas de TStream reciben de usted. Para la primera firma de un archivo countersignado, CoversWholeDocument es correctamente False, y si la revisión adjuntada solo agregó una firma o también cambió páginas es pregunta para el análisis de DocMDP, FieldMDP y revisiones en HotPDF. Note además que el chequeo de PDF MAC adjunto compara offsets contra las posiciones de < y >, así que acepta tanto el layout viejo como el nuevo; cualquier herramienta propia que tenga hardcodeados los offsets anteriores a la v2.759.0 fallará primero cuando se cruce con un archivo recién firmado

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