Artículo técnico

Por qué los validadores rechazan las firmas PAdES: PDFium en Delphi

Un validador rechaza una firma PAdES por una de tres razones en casi todos los casos que hemos depurado con el Componente PDFium en Delphi: la matriz /ByteRange sigue conteniendo sus marcadores de posición en cero, el tiempo de firma /M no es una cadena de fecha PDF estructurada correctamente, o una actualización incremental eliminó la entrada /Encrypt de un documento cifrado. Las tres causas producen archivos que se abren e interpretan bien, pero fallan en el instante en que un validador conforme lee el diccionario de firmas

El escenario que motiva este artículo es sumamente específico. Usted firma un contrato con el flujo de trabajo descrito en el artículo sobre la firma PAdES B-B, su propio código de inspección informa de que la firma está presente y es estructuralmente correcta, todas las pruebas locales resultan positivas y luego la otra parte sube el archivo a su plataforma de validación y obtiene una cruz roja. El fallo no es en absoluto visible en un visor, debido a que ninguno de estos tres defectos afecta al contenido de la página. Residen enteramente en el diccionario de firmas y en la sección final (trailer) del archivo, que es exactamente donde buscan los validadores y donde los visores habitualmente no lo hacen

¿Por qué no es válido el ByteRange de mi firma PDF?

Una denegación de ByteRange casi siempre significa que los cuatro campos nunca se rellenaron, no que los rangos sean sutilmente incorrectos. La matriz /ByteRange [A B C D] declara dos tramos, los bytes de A a A+B y los bytes de C a C+D, y la norma EN 319 142-1 §6.3 (requisito k) exige que juntos cubran todo el archivo excepto la cadena /Contents codificada en hexadecimal. En números: A es 0, C >= A+B, C+D es igual a la longitud del archivo y el espacio entre B y C contiene exactamente la cadena hexadecimal <...>: dos bytes de corchetes más dos caracteres hexadecimales por cada byte de datos CMS. Un validador que lee [0 0 0 0] concluye que la firma no cubre nada y la rechaza, independientemente de lo correcta que sea la estructura CMS dentro de /Contents

Vale la pena comprender la mecánica de cómo sucede esto porque el mismo patrón existe en todos los entornos de firma. Un firmante no puede conocer los desplazamientos finales hasta que se define la distribución del archivo, por lo que el escritor genera la matriz con marcadores de posición en cero de ancho fijo y los completa después del diseño. En las versiones del Componente PDFium anteriores a la 2.14.1, ese rellenado posterior buscaba el patrón de marcador de posición comenzando desde la posición de /Contents, pero /ByteRange se sitúa antes de /Contents en el diccionario, por lo que la búsqueda no encontraba nada y los tres reemplazos fallaban silenciosamente. El resumen CMS se calculaba sobre los rangos correctos, por lo que la criptografía era sólida; la declaración de esos rangos permanecía en cero, por lo que cada validador conforme rechazaba el archivo. Las marcas de tiempo de documentos PAdES B-LTA, que utilizan la misma estructura de diccionario, fallaban de la misma manera (un detalle relevante si trabaja con firmas de largo plazo B-LT y B-LTA). La versión 2.14.1 realiza el llenado posterior desde el inicio del objeto de firma, y la solución está cubierta por una prueba de regresión que analiza el archivo producido y evalúa la aritmética que se muestra a continuación

function SignedByteRangeCoversFile(const FileName: string): Boolean;
var
  Raw: TBytes;
  Text: AnsiString;
  P, N: Integer;
  F: array[0..3] of Int64;
begin
  Raw := TFile.ReadAllBytes(FileName);
  SetString(Text, PAnsiChar(@Raw[0]), Length(Raw));
  P := Pos('/ByteRange', Text);          // solo la primera firma
  Result := P > 0;
  if not Result then Exit;
  Inc(P, Length('/ByteRange'));
  for N := 0 to 3 do
  begin
    while (P <= Length(Text)) and not (Text[P] in ['0'..'9']) do Inc(P);
    F[N] := 0;
    while (P <= Length(Text)) and (Text[P] in ['0'..'9']) do
    begin
      F[N] := F[N] * 10 + Ord(Text[P]) - Ord('0');
      Inc(P);
    end;
  end;
  // EN 319 142-1 §6.3 req k: los tramos cubren todo excepto /Contents
  Result := (F[0] = 0) and (F[2] >= F[0] + F[1]) and
            (F[2] + F[3] = Int64(Length(Raw)));
end;

Veinte líneas de código RTL básico, sin llamadas a bibliotecas, y detecta toda la categoría de fallo en el punto de producción. Si decide conservar una sola comprobación de este artículo, elija esta: analice su propia salida firmada y verifique las tres igualdades antes de que el archivo salga de su proceso

¿Por qué los validadores marcan la hora de firma /M como malformada?

Los validadores estrictos rechazan un tiempo de firma que no sea una cadena de fecha PDF completa, y las dos partes que faltan con mayor frecuencia son el prefijo D: y el marcador de desplazamiento UTC. La norma ISO 32000-1 §7.9.4 define el formato de fecha como D:YYYYMMDDHHmmSS seguido de un desplazamiento (Z para UTC, o una relación firmada +HH'mm' con respecto a él). Un valor simple como 20260709143000 se analiza como una fecha en un lector flexible, pero un validador que aplique la gramática literalmente ve una cadena malformada en un campo de formato obligatorio y marca la firma con un error. Las versiones del Componente PDFium anteriores a la 2.14.4 escribían la entrada /M de TPdf.SignPades y SignPadesBytes exactamente en ese formato básico; desde la versión 2.14.4, la entrada incluye el prefijo D: y el marcador Z, por lo que la hora de firma declarada se lee como D:20260709143000Z

Dos notas prácticas se asocian a este campo. En primer lugar, escriba en UTC y expréselo así: una marca de tiempo sin marcador de desplazamiento obliga al validador a adivinar la zona horaria, y la sección §7.9.4 trata el desplazamiento como parte del formato y no como un detalle opcional. En segundo lugar, recuerde qué es /M: la hora declarada por el firmante, una afirmación y no una prueba. Un validador comprueba aquí su formato, no su veracidad; la hora demostrable proviene de una marca de tiempo RFC 3161 en PAdES B-T y superiores. Formatear un campo correctamente y confiar en él son decisiones distintas, y los validadores solo imponen la primera

¿Por qué SignPades genera EPadesCrypto en un PDF cifrado?

El Componente PDFium se niega a firmar un documento cifrado porque la alternativa es producir un archivo que los lectores conformes dañarán al abrirlo. Una firma PAdES se añade como una actualización incremental, y la norma ISO 32000-1 §7.5.6 exige que el nuevo trailer de una sección de actualización contenga cada entrada del trailer anterior excepto /Prev (en un documento cifrado, esto incluye /Encrypt). Si se omite, el trailer más reciente declara que el archivo no está cifrado, por lo que un lector conforme analiza el cuerpo cifrado como texto plano y obtiene datos basura. Peor aún, los flujos y las cadenas que añade el firmante tendrían que estar cifrados con la clave del documento para ser válidos, algo que un inyector de firmas de texto plano no puede hacer. No existe forma de añadir una firma de texto plano válida a un archivo cifrado, por lo que desde la versión 2.14.2, TPdf.SignPades, SignPadesBytes y InjectPadesDssMarkers generan EPadesCrypto en lugar de emitir un resultado dañado o inverificable

try
  if not Pdf.SignPades('contract-signed.pdf', AThumbprint) then
    Writeln('Signing reported failure');
except
  on E: EPadesCrypto do
  begin
    // por ejemplo, 'SignPadesBytes: el documento de origen está cifrado;
    //       elimine el cifrado antes de firmar'
    Writeln('Cannot sign: ', E.Message);
  end;
end;

La excepción es el resultado correcto, por lo que conviene diseñar el flujo de trabajo en torno a ella en lugar de capturarla y volver a intentarlo. Descifre primero con autoridad de propietario, firme la copia en texto plano y, si el canal de distribución requiere cifrado, acepte que cifrar y luego firmar y firmar y luego cifrar producen artefactos diferentes con historias de validación distintas. Una firma calculada sobre bytes de texto plano no puede sobrevivir al cifrado posterior de esos bytes, por lo que las opciones reales son un archivo de texto plano firmado o una decisión de política de seguridad documentada junto al código

Cómo dos errores pueden certificarse mutuamente

El defecto del ByteRange de la firma permaneció oculto durante tanto tiempo porque nuestro propio validador fallaba de manera complementaria, y esa es la lección más aplicable de este artículo. La comprobación de cobertura en ValidatePadesCompliance exigía que el segundo tramo comenzara exactamente en A+B (un espacio de separación de cero), lo que clasifica erróneamente la estructura estándar donde el espacio contiene la cadena hexadecimal de /Contents. Así, el validador interno rechazaba precisamente la estructura conforme y el generador interno nunca producía una, por lo que los flujos de firma y validación de extremo a extremo lograban mantenerse activos. Cada error privaba al conjunto de pruebas del contraejemplo que habría expuesto al otro. La versión 2.14.1 solucionó ambos aspectos en el mismo lanzamiento: el generador completa posteriormente los cuatro campos y el validador acepta C >= A+B con C+D igual a la longitud del archivo

La solución metodológica es la validación cruzada frente a una implementación que no haya escrito usted mismo. Un generador y un validador que comparten una base de código, un autor o incluso solo un modelo mental del formato pueden ponerse de acuerdo en un malentendido compartido de forma indefinida; un validador independiente rompe esa simetría. Pase su salida firmada por al menos un comprobador de conformidad externo antes de una distribución y conserve una autocomprobación estructural en la compilación como primera línea de defensa rápida: TPdf.ValidatePades, descrito en el artículo sobre inspección de firmas, informa del fallo de cobertura como un problema identificado

var
  R: TPadesValidationResult;
begin
  Pdf.FileName := 'contract-signed.pdf';
  Pdf.Active := True;
  R := Pdf.ValidatePades;
  if ppeiByteRangeNotCoveringFile in R.Issues then
    Writeln('ByteRange no cubre el archivo');
  if not R.IsCompliant then
    Writeln('Hay problemas estructurales: no distribuya este archivo');
end;

Una lista de comprobación de rechazo para la salida firmada

Cuando una plataforma rechaza su firma PAdES, compruebe las causas estructurales sencillas antes de sospechar de los certificados o de las cadenas de confianza. Lea la matriz /ByteRange y verifique las tres igualdades: primer campo en cero, el segundo tramo comenzando en o después del final del primero, y el segundo tramo finalizando exactamente en el extremo del archivo. Lea la entrada /M y verifique que sea una cadena de fecha completa según la sección §7.9.4 con el prefijo D: y un marcador de desplazamiento. Confirme que el documento de origen no estaba cifrado cuando se añadió la firma; y si su entorno lo firmó de todos modos sin quejarse, trate ese silencio como un error del propio entorno de firma. Las tres comprobaciones se ejecutan en bytes brutos en milisegundos y, en nuestra experiencia, explican el rechazo con mucha mayor frecuencia que cualquier causa criptográfica

Las llamadas de firma, inspección y validación utilizadas aquí (SignPades, ValidatePades y las comprobaciones de conformidad de PAdES con su aritmética corregida de ByteRange, formateo de fecha y control de cifrado) se distribuyen con el Componente PDFium para Delphi, C++Builder y Lazarus