Artículo técnico

Validar cadena de revisiones PDF MAC en Delphi (ISO 32004)

HotPDF valida un PDF MAC de ISO/TS 32004 por revisión y no por archivo. THotPDF.ValidatePDFMACChain recorre cada actualización incremental desde el ancla de la cadena hacia adelante y verifica cada MAC contra un flujo prefijo de solo lectura que termina en el startxref y el %%EOF propios de esa revisión. Un MAC válido en la revisión más nueva no prueba nada sobre las revisiones de debajo

Este es el escenario que motiva todo ello. Envías un PDF cifrado con AES-256 y un PDF MAC encima. Alguien abre el archivo en un editor hexadecimal, cambia un byte dentro de la primera revisión protegida por MAC y luego añade una revisión totalmente nueva que lleva un MAC propio perfectamente válido. Cada visor abre el archivo sin quejarse, y un comprobador ingenuo que calcula el hash del rango de bytes actual contra el MAC del tráiler activo informa de éxito, porque ese MAC realmente es correcto para los bytes que cubre. El daño está dos revisiones más abajo, en una región que nadie volvió a comprobar

¿Por qué un MAC de nivel superior válido no prueba que el archivo esté intacto?

Porque un PDF MAC cubre un prefijo, no un documento. La actualización incremental es una parte de primera clase del formato: cada guardado añade un nuevo cuerpo, una nueva sección de referencias cruzadas y un nuevo tráiler, mientras los bytes antiguos se quedan exactamente donde estaban. ISO/TS 32004 se apoya en ese modelo, así que cada revisión lleva su propio diccionario /AuthCode que autentica el archivo tal como estaba en ese momento, y verificar solo el más nuevo deja cada revisión anterior sin examinar. Por eso HotPDF expone las dos preguntas como dos llamadas, y la diferencia entre ellas es el punto central de este artículo. ValidatePDFMAC responde a «¿es auténtica la revisión actual?», rellenando un registro THPDFPDFMACValidationInfo; ValidatePDFMACChain responde a «¿es auténtica cada revisión protegida por MAC de este archivo?», rellenando THPDFPDFMACChainValidationInfo con un array por revisión más un motivo de fallo legible por máquina. En el archivo manipulado y re-MACado de arriba, la primera llamada devuelve True y la segunda devuelve False contra el índice de revisión 1

var
  Pdf: THotPDF;
  Chain: THPDFPDFMACChainValidationInfo;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if not Pdf.ValidatePDFMACChain('incoming.pdf', 'user', Chain) then
    begin
      // El fallo es uno de pmcfRevisionBoundary, pmcfNoPDFMAC,
      // pmcfRequiredRevisionMissing, pmcfRevisionInvalid,
      // pmcfKDFSaltChanged, pmcfDigestDowngrade, pmcfPermissionDowngrade
      Writeln('chain rejected: ', Chain.Message);
      Writeln('revision ', Chain.FailureRevisionIndex,
              ' at xref offset ', Chain.FailureXRefOffset);
      Exit;
    end;
    for I := 0 to High(Chain.Revisions) do
      Writeln(I, ' len=', Chain.Revisions[I].RevisionLength,
              ' mac=', Chain.Revisions[I].HasPDFMAC,
              ' perms=', Chain.Revisions[I].PermissionsAuthenticated);
  finally
    Pdf.Free;
  end;
end;

Cada MAC se verifica en su propio flujo prefijo, nunca en la longitud final del archivo

El error más caro en esta área es usar el tamaño final del archivo como límite superior al recalcular el hash de una revisión antigua, lo que mete los bytes finales en cada digest excepto el más nuevo y reporta manipulación en un archivo sano. HotPDF en cambio reconstruye, para cada revisión, un flujo de solo lectura acotado que termina en el valor startxref propio de esa revisión seguido de su %%EOF, y calcula el hash solo de eso. Localizar el límite es más delicado de lo que parece: el literal %%EOF puede aparecer dentro de un flujo de contenido o de una cadena, así que un candidato se acepta solo cuando el startxref inmediatamente anterior se interpreta como un número igual al desplazamiento de referencias cruzadas de la sección que se está validando, sin nada entre ellos salvo espacios en blanco. La revisión absorbe después exactamente una secuencia de fin de línea tras el marcador — un CR suelto, un LF suelto o un par CRLF — y nada más. Esa última regla muerde en la práctica, porque un escritor que emite una línea en blanco extra entre dos revisiones ha producido bytes que pertenecen a la revisión siguiente, y tragarse todo el espacio en blanco final en la anterior cambia silenciosamente ambos digest. El enumerado de secciones sigue la misma disciplina: HotPDF recorre las secciones de referencias cruzadas de la más antigua a la más nueva exactamente una vez, reproduciendo las entradas libres, directas y de object-stream para que las secciones posteriores sobrescriban el estado anterior, que es lo contrario de la semántica de «gana el primero visto» que aplica un parser de xref activo

HotPDF verifica cada PDF MAC de ISO 32004 contra un flujo prefijo que termina en el startxref propio de esa revisión y su marcador de fin de archivo, así que un byte cambiado dentro de la revisión 1 hace fallar la cadena aunque el MAC más nuevo siga validando limpiamente
El MAC de cada revisión se recalcula sobre su propio prefijo acotado, así que editar la revisión 1 y añadir una revisión recién MACada sigue satisfaciendo ValidatePDFMAC mientras ValidatePDFMACChain aterriza en la revisión 1

¿Dónde se ancla la cadena y qué la rompe?

La primera revisión que lleva un /AuthCode válido es el ancla, y FirstMACRevisionIndex informa de dónde empieza la protección; todo lo anterior queda sin proteger por construcción, lo cual es normal. Todo lo posterior debe estar protegido por MAC, así que añadir una actualización incremental simple a un archivo protegido por MAC falla con pmcfRequiredRevisionMissing y el índice de la revisión infractora — tolerar un hueco permitiría a un atacante quitar la protección simplemente guardando una vez más. Tres invariantes más se sostienen a lo largo de la cadena, cada una con su propio código de fallo

  • pmcfKDFSaltChanged — el /KDFSalt debe permanecer estable desde el ancla en adelante, porque una sal rotatoria permitiría a un falsificador re-derivar claves bajo parámetros de su propia elección
  • pmcfDigestDowngrade — la fortaleza del digest se compara contra el último MAC verificado en lugar de contra la revisión inmediatamente anterior, así que una cadena que empieza bajo el perfil Modern con SHA-384 no puede continuar en silencio con SHA-256
  • pmcfPermissionDowngrade — una revisión no puede eliminar un requisito de PDF MAC que una revisión anterior hubiera autenticado

La consecuencia que merece interiorizarse es que los MAC históricos se verifican de forma independiente incluso cuando ya no son el tráiler activo. Por eso el ataque de editar una revisión antigua y añadir después un MAC fresco del inicio no sobrevive: el MAC más nuevo pasa la comprobación por sí solo, ValidatePDFMAC queda contento, y la cadena sigue aterrizando en la revisión 1 con pmcfRevisionInvalid

Orden de firma: claves del tráiler primero, signatureDigest al final

Cuando el MAC va adjunto a una firma CMS en lugar de ir solo, el orden de escritura deja de ser una cuestión estilística. HotPDF exige que /AuthCode, /KDFSalt, la extensión de desarrollador ISO 32004 y /SigObjRef se escriban en la misma revisión antes de calcular el /ByteRange de la firma; añadir cualquiera de ellos después coloca esos bytes fuera del rango que cubre la firma, produciendo un archivo cuya firma verifica mientras el enlace del MAC queda sin firmar. Los dos digest van entonces en sentido contrario, lo que parece circular a primera vista y no lo es. El signatureDigest del PDF MAC liga los octetos de contenido crudos del OCTET STRING SignerInfo.signature de CMS — no todo el DER de CMS ni los atributos firmados — así que se construye después de que exista el valor de firma crudo y se inyecta como atributo sin firmar id-attr-pdfMacData. Como /Contents queda excluido del ByteRange de la firma y los atributos sin firmar nunca alimentan el cálculo de firma, la secuencia producir-firma, construir-MAC, envolver-CMS cierra limpiamente sin bucle criptográfico. Se siguen dos corolarios: el centinela /ByteRange y el marcador de posición /Contents deben permanecer en texto plano y fuera de object streams incluso en un archivo cifrado, o el parcheador de ancho fijo no puede encontrarlos; y cuando el digest del MAC también es SHA-256, el digest de firma se reutiliza directamente; en caso contrario ambos contextos de digest se actualizan en una sola pasada sobre el flujo de salida

El orden de escritura de HotPDF para un PDF MAC adjunto a una firma CMS: las claves del MAC entran en la revisión antes de medir el ByteRange, y el digest de firma se construye después a partir de los octetos crudos de la firma SignerInfo
Escribir AuthCode, KDFSalt, SigObjRef y la extensión de desarrollador antes de medir el ByteRange es lo que mantiene el enlace del MAC dentro del rango que cubre la firma
var
  Pdf: THotPDF;
  Options: THPDFPDFMACOptions;
  Info: THPDFPDFMACValidationInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'unsigned.pdf';
    Pdf.ActivateProtection := True;
    Pdf.CryptKeyLength := aesgcm;
    Pdf.OwnerPassword := 'owner';
    Pdf.UserPassword := 'user';
    Pdf.ProtectOptions := [prPrint, prExtractContent];
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.AddSignedSignatureField('Approval',
      Rect(72, 120, 280, 160), 16384);
    Pdf.EndDoc;

    Options := THPDFPDFMACOptions.Modern;      // digest de documento SHA-384
    if THotPDF.SignPDFWithPFXAndAttachedPDFMAC('unsigned.pdf',
         'signed.pdf', 'signer.pfx', 'pfx-secret', 'user', Options) then
      if Pdf.ValidatePDFMAC('signed.pdf', 'user', Options, Info) then
      begin
        // Location = pmlAttachedToSignature, y los dos digest
        // se reportan por separado
        Writeln('signature object  : ', Info.SignatureObjectNumber);
        Writeln('signature digest  : ', Info.SignatureDigestMatched);
        Writeln('full file coverage: ', Info.FullFileCoverage);
        Writeln('perms authentic   : ', Info.PermissionsAuthenticated);
      end;
  finally
    Pdf.Free;
  end;
end;

La validación recorre el mismo camino desde el otro extremo: lee el /AuthCode directo del tráiler clásico de referencias cruzadas actualmente activo, sigue el /SigObjRef indirecto consciente de la generación, confirma que liga el /V del único campo de firma, y reporta un fallo del digest de documento por separado de un fallo del digest de firma. Son diagnósticos distintos, y colapsarlos en un único booleano descarta la única información que dice si se tocó el contenido de la página o el valor de la firma. Si ya trabajas con CMS, esto va junto a el artículo de firma PAdES y la guía para verificar firmas en documentos cargados

No confíes nunca en /P: descifra primero el /Perms de 16 bytes

ISO/TS 32004 señala «este documento requiere un PDF MAC» mediante el bit de permiso 13, y la forma obvia de leerlo es la equivocada, porque el entero /P del diccionario de cifrado está en texto plano y sin autenticar — cualquiera puede cambiar ese bit en un editor de texto y degradar el requisito. ISO 32000-2 §7.6 da la respuesta en la entrada /Perms, y HotPDF la usa: descifra la cadena /Perms de 16 bytes con la clave de cifrado del archivo bajo AES-256 CBC, IV cero, sin relleno, y luego comprueba cada campo del texto plano antes de creerse nada. Los bytes 1 a 4 contienen el valor de permiso en orden little-endian y deben ser exactamente iguales al entero /P; los bytes 5 a 8 son 0xFF; el byte 9 es la bandera de cifrado de metadatos T o F; los bytes 10 a 12 son el marcador literal adb. Solo cuando todo eso se cumple PermissionsAuthenticated pasa a True y se lee el bit 13 — y ojo a su polaridad, porque el requisito de MAC se afirma cuando el bit 0x1000 está apagado. Una discrepancia entre /P y los permisos descifrados no es un aviso que registrar y dejar pasar; es un conjunto de permisos falsificado, y la respuesta correcta es fallar de forma cerrada

HotPDF autentica los permisos PDF descifrando la cadena Perms de dieciséis bytes con la clave de cifrado del archivo y comprobando el valor de permiso little-endian, los bytes de relleno FF, la bandera de metadatos y el marcador adb antes de leer el bit 13
El entero /P en texto plano no está autenticado, así que el requisito de PDF MAC se lee solo después de comprobar cada campo del /Perms descifrado

La agilidad de algoritmos se detiene en el digest

ISO/TS 32004 te permite elegir el digest del documento, y solo el digest del documento. HotPDF mantiene fijos HMAC-SHA-256 para autenticación, HKDF-SHA-256 según RFC 5869 para derivación de claves y AES-256 key wrap según RFC 3394 por debajo de un THPDFPDFMACDigestAlgorithm variable que va de pmdaSHA256 a pmdaSHA3_512, porque el error natural es tratar un «perfil SHA3-512» como licencia para cambiar también el HMAC, lo que produce un archivo que ya no es un PDF MAC en ningún sentido interoperable. Un detalle de implementación merece copiarse si escribes tu propio verificador: lee el OID del digest del AuthenticatedData de CMS antes de calcular el hash del rango de bytes, porque fijar SHA-256 en el código y conciliar después convierte la agilidad en una etiqueta y permite a un archivo hostil hacerte transmitir el documento entero antes de descubrir que el algoritmo nunca tuvo soporte. CMSAlgorithmProtection, el algoritmo de digest del AuthenticatedData, el messageDigest de la información de integridad y el digest del rango de bytes deben nombrar todos un mismo algoritmo, y cualquier discrepancia falla de forma cerrada

var
  Options: THPDFPDFMACOptions;
begin
  Options := THPDFPDFMACOptions.Compatibility;  // SHA-256, acepta los seis
  Options := THPDFPDFMACOptions.Modern;         // SHA-384, rechaza el de 256 bits
  Options := THPDFPDFMACOptions.HighAssurance;  // solo SHA3-512, AES-GCM

  // Un perfil personalizado es legal, pero el algoritmo con el que genera
  // debe aparecer también en la lista de permitidos de la validación, o la
  // configuración se rechaza antes de escribir un solo byte
  Options.Profile := pmppCustom;
  Options.DigestAlgorithm := pmdaSHA512;
  Options.AllowedDigestAlgorithms := [pmdaSHA512, pmdaSHA3_512];
  Options.RequireAESGCM := True;
end;

Lo que un PDF MAC prueba y lo que no

Una cadena de PDF MAC verificada prueba que cada revisión protegida es idéntica byte a byte a lo que escribió alguien que poseía la clave de cifrado del archivo, que ninguna revisión protegida se eliminó o reordenó, y que no se añadió ninguna revisión sin proteger después del ancla — exactamente la clase de ataque que deja abierta el cifrado AES-256 a secas, ya que la confidencialidad no dice nada sobre la integridad y un PDF cifrado con una revisión empalmada se descifra igual de contento que uno intacto. Lo que no prueba es la autoría. La clave del MAC se deriva de la clave de cifrado del archivo, así que cualquiera que pueda abrir el documento también puede producir un MAC válido sobre una versión modificada, incluido cada destinatario legítimo; es una primitiva simétrica, y las primitivas simétricas no atribuyen. Si necesitas saber quién cambió algo necesitas una firma digital con un certificado detrás, y el PDF MAC la complementa entonces protegiendo la estructura incremental que la firma sola no cubre. Trátalas como capas y deja que los dos veredictos se reporten de forma independiente en lugar de colapsarlos en un icono de estado

Los puntos de entrada de PDF MAC descritos aquí — AddStandalonePDFMAC, SignPDFWithPFXAndAttachedPDFMAC, ValidatePDFMAC y ValidatePDFMACChain — se entregan con el HotPDF Delphi Component estándar para Delphi y C++Builder, donde la página del producto lleva la referencia completa del registro de opciones, las enumeraciones de estado y el array de validación por revisión