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 >
¿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
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
¿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