Artículo técnico

Filtros Crypt de PDF en Delphi: políticas StmF, StrF y EFF

El componente PDF Delphi HotPDF implementa el modelo de crypt filters de ISO 32000-1 §7.6.5 como tres políticas independientes en lugar de un único switch: ConfigureCryptFilterDefaults asigna por separado el filtro de strings /StrF, el filtro de streams /StmF y el filtro de ficheros incrustados /EFF, SetStreamCryptFilter sustituye uno concreto y GetLoadedCryptFilterInfo informa de lo que declara un fichero entrante. La mayoría de los bugs de interoperabilidad con PDF cifrados viven en los huecos entre esos tres

Este es el fallo que lleva a la gente hasta esta capa. Un equipo entrega un documento cuyo contenido de página debe seguir siendo legible para una herramienta posterior, pero cuyo payload adjunto no, así que fija /EFF /StdCF y deja /StmF /Identity. Acrobat lo abre sin problemas. Un lector de terceros conforme devuelve el adjunto como basura cifrada, porque /EFF es una política del productor sobre qué filtro se aplica a los ficheros incrustados y un lector general sigue resolviendo un stream sin marca mediante /StmF. La solución no es otro valor de /EFF. La solución es un filtro /Crypt explícito en el propio stream del fichero incrustado

Qué controla realmente la capa de crypt filters

Los crypt filters se sitúan entre el algoritmo de cifrado y el grafo de objetos, y deciden qué objetos toca el algoritmo, no cómo funciona. El diccionario /CF dentro del diccionario de cifrado asigna nombres a definiciones de filtros, cada una con un método /CFM, un /Length opcional y un /AuthEvent. Las tres entradas de nivel superior /StrF, /StmF y /EFF seleccionan cuál de esos filtros con nombre se aplica a strings, a streams sin filtro explícito y a ficheros incrustados. HotPDF restringe deliberadamente lo que sus handlers integrados pueden escribir. ConfigureCryptFilterDefaults solo acepta los nombres reservados del handler activo: el security handler estándar emite /StdCF o /Identity, el handler de clave pública emite /DefaultCryptFilter o /Identity, y cualquier otra cosa genera EArgumentException en el punto de llamada. Los filtros que productores externos escribieron con otros nombres se conservan en las rutas de carga, inspección y reescritura compatible, así que HotPDF es conservador al escribir y permisivo al leer. Se aplican además dos protecciones: la llamada genera EInvalidOpException cuando ya ha comenzado la serialización del documento y de nuevo si el documento está en una actualización incremental, porque la política de cifrado no puede cambiar entre revisiones del mismo fichero

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'wrapper.pdf';
    Pdf.OwnerPassword := 'owner-secret';
    Pdf.UserPassword := 'open-secret';
    Pdf.CryptKeyLength := aes128;
    // strings cifradas, streams de página en claro, adjuntos cifrados
    Pdf.ConfigureCryptFilterDefaults('StdCF', 'Identity', 'StdCF');
    Pdf.ActivateProtection := True;
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(50, 50, 0, 'Visible stream operators');
    Pdf.AddDocumentAttachment('payload.bin', 'Encrypted payload');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Conviene exponer una restricción desde el principio, porque se comprueba tarde y sorprende. Los crypt filters con nombre de HotPDF requieren cifrado de documento aes128, aes256 o aesgcm. Configure una política de filtros sobre RC4 k40 o k128 y la pasada de validación que se ejecuta al activar el cifrado generará una excepción en lugar de promocionar silenciosamente el tipo de clave. Es la misma postura de diseño que el resto de la ruta de cifrado PDF AES-256 en Delphi: rechazar la configuración ambigua en lugar de adivinar qué quería decir el caller

¿Por qué la entrada /Length significa dos cosas distintas?

Porque la especificación la define en dos unidades distintas según el security handler, y HotPDF tiene que respetar ambas. En un diccionario de crypt filter cuyo /CFM es /V2, la entrada /Length se expresa en bytes bajo el security handler estándar y en bits bajo el handler de clave pública. El /Length del diccionario de cifrado situado junto a /V (ISO 32000-1 §7.6.2) siempre está en bits. Lea un diccionario de filtros con /Length 16 y tendrá una clave de 128 bits en un fichero con handler estándar y un fichero rechazado en uno de clave pública. HotPDF normaliza esto al capturar la configuración cargada. Multiplica por ocho el /Length de un filtro /V2 solo cuando el fichero no está cifrado con clave pública, vuelve al /Length del documento cuando el filtro omite el suyo y guarda el resultado en THPDFCryptFilterInfo.KeyLengthBits. AESV2 queda fijado en 128 bits y AESV3 y AESV4 en 256, ya que esos métodos no tienen un tamaño de clave negociable. Después viene la parte estricta: solo se aceptan /V2 de 40 y 128 bits. Un filtro que resuelva a cualquier otra longitud se informa como no disponible y la operación falla, en lugar de redondearlo a 128 con la teoría de que la mayoría de los productores querían 128. Normalizar silenciosamente la longitud de una clave es la forma de entregar un fichero que se descifra en su máquina y en ninguna otra

var
  Reader: THotPDF;
  Info: THPDFCryptFilterInfo;
  I: Integer;
begin
  Reader := THotPDF.Create(nil);
  try
    Reader.AutoLaunch := False;
    if Reader.LoadFromFile('incoming.pdf', 'open-secret') <> 1 then
      Exit;
    // /StrF y /StmF tienen Identity por defecto; /EFF toma /StmF por defecto
    WriteLn(Reader.LoadedStringCryptFilterName);        // StdCF
    WriteLn(Reader.LoadedStreamCryptFilterName);        // Identity
    WriteLn(Reader.LoadedEmbeddedFileCryptFilterName);  // StdCF
    for I := 0 to Reader.GetLoadedCryptFilterCount - 1 do
      if Reader.GetLoadedCryptFilterInfo(I, Info) then
        if (Info.Method = hcfmV2) and
           not (Info.KeyLengthBits in [40, 128]) then
          raise Exception.CreateFmt(
            'crypt filter /%s: unsupported V2 key length %d',
            [String(Info.Name), Info.KeyLengthBits]);
  finally
    Reader.Free;
  end;
end;

¿Qué garantiza /CFM /None y en qué se diferencia /Identity?

Llegan al mismo resultado por rutas distintas, y confundirlas rompe las búsquedas. Un filtro con nombre cuyo /CFM es /None, y un filtro con nombre que omite por completo /CFM, significan que ese filtro no cifra ni descifra nada — HotPDF mapea la entrada ausente a None antes de resolverla, de modo que ambos terminan en hcfmNone con una longitud de clave registrada de cero. /Identity es distinto por naturaleza: es el nombre reservado que evita por completo la búsqueda en /CF, por lo que un documento puede referenciar /Identity sin definirlo en ninguna parte de /CF. Los nombres PDF distinguen mayúsculas y minúsculas, lo que hace innegociable otro detalle de implementación: ninguna búsqueda de crypt filter puede ignorar el caso. HotPDF resuelve con búsquedas case-sensitive los nombres del subdiccionario /CF, la entrada /Length del filtro y la comprobación /Type del stream. Un fichero que define /stdcf mientras /StmF apunta a /StdCF está mal formado, y tratar ambas claves como iguales convertiría un bug de autoría detectable en una clave incorrecta aplicada silenciosamente a todos los streams del documento

Hacer que /EFF se aplique en los streams de ficheros incrustados

Cuando /EFF difiere de /StmF, el stream del fichero incrustado necesita una entrada /Crypt explícita al principio de su /Filter y un diccionario /DecodeParms coincidente que lleve /Name en la misma posición del array. HotPDF lo resuelve por stream durante el guardado: detecta /Type /EmbeddedFile, hereda el filtro de ficheros incrustados configurado y emite la marca explícita /Crypt solo cuando ese nombre heredado difiere del valor efectivo por defecto del stream. Cuando /EFF y /StmF coinciden no se escribe la marca, porque un lector resolvería el mismo filtro de todos modos. La posición dentro del array importa tanto como el nombre. Al volver a leer un stream, HotPDF busca la entrada /Crypt en /Filter, registra su índice y busca después ese mismo índice en el array /DecodeParms para encontrar /Name. Un /Crypt en el índice 0 emparejado con parámetros en el índice 1 se resuelve como /Identity, no como su filtro. Por eso el writer rellena el array de parámetros con un null cuando el stream tenía antes un /Filter pero no /DecodeParms: las posiciones tienen que seguir alineadas

Debajo de esto hay una trampa más seria. Si el /Filter o /DecodeParms existente es un objeto indirecto — algo habitual en ficheros de generadores que comparten un mismo array de filtros entre muchos streams — insertar /Crypt in situ mutaría un grafo de filtros compartido y corrompería todos los demás streams que apuntaran a él. HotPDF resuelve el objeto indirecto y lo clona primero como objeto directo privado del stream, limpiando los números de objeto y generación para que la raíz indirecta original nunca quede incrustada dentro del array nuevo. Para un stream que ya utilizaba ASCIIHexDecode, el resultado serializado es /Filter [ /Crypt /ASCIIHexDecode ] con /DecodeParms [ << /Type /CryptFilterDecodeParms /Name /StdCF >> ... ]. La misma disciplina posicional gobierna cualquier otra cadena de filtros, incluidas las que recorre al extraer imágenes de un PDF cargado mediante sus filtros de decodificación

// El editor ya contiene un documento cargado, y ContentStream es un
// THPDFStreamObject cuyo /Filter es un nombre /ASCIIHexDecode indirecto
Editor.OwnerPassword := 'owner-secret';
Editor.UserPassword := 'open-secret';
Editor.CryptKeyLength := aes128;
Editor.ConfigureCryptFilterDefaults('StdCF', 'Identity');
Editor.SetStreamCryptFilter(ContentStream, 'StdCF');
Editor.ActivateProtection := True;
Editor.SaveLoadedDocument('out.pdf');

// Un nombre vacío borra el override y elimina el /Crypt obsoleto
// junto con sus parámetros de decodificación en el siguiente guardado
Editor.SetStreamCryptFilter(ContentStream, '');
Editor.SaveLoadedDocument('cleared.pdf');

¿Heredan los object streams la política /Encrypt del documento?

No, y darlo por hecho es una forma fiable de producir basura. Un object stream debe seguir la política real de /StmF o su propia marca /Crypt explícita: la mera presencia de un diccionario /Encrypt no convierte cada contenedor /ObjStm en ciphertext. Un documento con /StmF /Identity tiene object streams en claro aunque sus strings estén completamente cifradas, y un decoder que los descifre de todos modos entrega a la fase inflate una entrada que nunca fue salida deflate

La consecuencia para los objetos miembro es la parte que merece una segunda lectura. Según ISO 32000-1 §7.5.7, las strings que están dentro de un object stream cifrado ya están en claro una vez descifrado el contenedor, así que descifrarlas otra vez sería un doble descifrado. HotPDF lo protege preguntando si el contenedor de cada objeto de tipo 2 estaba cifrado y omitiendo el objeto cuando lo estaba, y cuenta las omisiones en XRefProbeDecryptObjStmSkips como evidencia directa de que se activó la protección. Cuando el contenedor estaba en claro, las strings miembro nunca estuvieron cubiertas por nada, así que HotPDF materializa esos miembros y aplica /StrF a cada uno por separado, con la clave que utiliza realmente la implementación: el número de objeto miembro y su generación, no el número de objeto /ObjStm contenedor. Invierta esta lógica en un fichero con políticas mixtas y cada string de cada objeto comprimido se decodificará como ruido. Las reglas a nivel de contenedor se explican con más detalle en las notas sobre object streams PDF y actualizaciones incrementales

Dónde se niega HotPDF a adivinar

La semántica de crypt filters no existe por debajo de /V 4, así que HotPDF rechaza cualquier override por stream en un fichero así con un error explícito, en lugar de escribir una marca /Crypt que ningún lector conforme respetaría. Lo mismo ocurre en la lectura: un diccionario de cifrado con /V inferior a 4 borra los tres nombres de filtros cargados, porque no hay nada que informar. Además se aplican deliberadamente tres límites:

  • Se rechaza un filtro por stream distinto de Identity en un documento cifrado con clave pública, porque una política específica del stream bajo el handler de clave pública necesita un envelope de destinatario específico del stream que HotPDF todavía no emite
  • Se rechazan los ficheros incrustados cifrados con clave pública cuyo /EFF difiere del /StmF efectivo por el mismo motivo, en lugar de escribir una forma que no descifraría para nadie
  • La ruta rápida de fichero directo AES-256 solo se aplica cuando strings, streams y ficheros incrustados resuelven al mismo método de crypt filter y ningún objeto del fichero lleva un /Crypt explícito; una política mixta o unos metadatos en claro obligan a volver a la ruta completa del grafo de objetos

Ninguno de estos límites es una decisión de rendimiento. Marcan los puntos donde una suposición incorrecta produce un PDF que se abre en un visor, falla en otro y no da ninguna señal al desarrollador hasta que un cliente informa del problema. Un rechazo en ConfigureCryptFilterDefaults o durante el guardado cuesta una excepción; un fichero incrustado con una clave incorrecta de forma silenciosa cuesta un ciclo de soporte. Si crea software Delphi o C++Builder que produce o consume PDF cifrados — contenido de página selectivamente en claro con adjuntos cifrados, wrappers de payload cifrado de PDF 2.0 o interoperabilidad con ficheros cuyas políticas de crypt filters no eligió — la API de crypt filters descrita aquí se incluye en el componente PDF Delphi HotPDF actual, junto a las rutas de cifrado, object streams y actualizaciones incrementales sobre las que se apoya