Artículo técnico

No podéis parchear en silencio un PDF cifrado en Delphi

Tomad una factura en PDF que ya lleva cifrado AES-256 y pedidle al componente PDFium para Delphi y C++Builder (PDFiumPas) que le estampe PDF/A para retención de archivo, o que la firme con PAdES, mediante una actualización incremental en lugar de una reescritura completa. La biblioteca no llegará ahí parcheando directamente los bytes cifrados: sus seis inyectores de marcadores a nivel de byte detectan una entrada /Encrypt existente y dejan pasar el origen hacia el destino byte a byte sin cambios, y su firmante PAdES lanza una excepción en lugar de emitir una firma que ningún validador vaya a aceptar

Esa es una pregunta distinta de auditar un PDF que no creasteis en busca de riesgo oculto, que es su propio ejercicio de solo lectura. Este artículo trata sobre el lado de escritura de esa misma frontera de confianza: qué tiene permitido hacer vuestro propio código a un archivo cuyos bytes ya están bloqueados detrás de la contraseña de otra persona, en el momento en que ese código intenta añadirle algo después del hecho

¿Qué exige ISO 32000-1 al actualizar un PDF cifrado?

ISO 32000-1 §7.5.6 exige que el trailer de una actualización incremental repita cada entrada del trailer anterior salvo /Prev, y la Tabla 15 incluye /Encrypt entre las entradas que puede llevar un trailer. Eliminadla del trailer nuevo y un lector conforme no tiene ninguna razón para dudar de la omisión: el trailer más nuevo es el autoritativo, así que un lector que no encuentra ningún /Encrypt ahí decide que todo el archivo está sin cifrar e intenta analizar el cuerpo más antiguo, todavía cifrado, como bytes planos. Conservad /Encrypt en el trailer nuevo pero escribid los propios objetos de la actualización como texto plano, y el fallo simplemente se traslada un paso más adelante: el lector detecta correctamente el cifrado, hace pasar cada objeto que toca por el cifrador del archivo, incluidos los nuevos que nunca estuvieron cifrados en primer lugar, y recibe ruido de vuelta para un contenido que era perfectamente legible antes de que el descifrado lo tocara. Cualquiera de los dos errores produce un archivo que parece una actualización incremental normal y bien formada a nivel de byte, hasta que un lector conforme lo abre

Seis inyectores de marcadores, una compuerta de cifrado en v2.14.2

PDFiumPas distribuye seis inyectores de marcadores a nivel de byte, uno por cada subconjunto ISO de PDF que puede etiquetar: PDF/A (ISO 19005), PDF/X (ISO 15930), PDF/UA (ISO 14289-1), PDF/E-1 (ISO 24517-1), PDF/R-1 (ISO 23504-1) y PDF/VT-1 (ISO 16612-2). Cada uno toma los bytes que ya escribió el propio FPDF_SaveAsCopy de PDFium y superpone encima una segunda actualización incremental más pequeña: un nuevo flujo de metadatos XMP, una edición del diccionario de catálogo que apunta a él, y para los subconjuntos orientados a impresión un OutputIntent y un perfil ICC. Desde la v2.14.2, cada uno de InjectPdfAMarkers, InjectPdfXMarkers, InjectPdfUaMarkers, InjectPdfEMarkers, InjectPdfRMarkers e InjectPdfVTMarkers lee primero el trailer de origen, y si reporta una entrada /Encrypt existente, copia el origen hacia el flujo de destino sin modificar y regresa de inmediato. Sin XMP, sin OutputIntent, sin edición de catálogo, quien llama recibe de vuelta el archivo original, byte a byte

var
  Src, Dst: TFileStream;
  Opts: TPdfXSaveOptions;
begin
  Src := TFileStream.Create('signed-encrypted-proof.pdf', fmOpenRead);
  Dst := TFileStream.Create('pdfx-attempt.pdf', fmCreate);
  try
    Opts.Conformance := pxc4;
    InjectPdfXMarkers(Src, Dst, Opts);
    // pdfx-attempt.pdf is byte-identical to the source: still encrypted,
    // no /GTS_PDFXVersion, no OutputIntent. Nothing was written, and
    // nothing was corrupted either
  finally
    Dst.Free;
    Src.Free;
  end;
end;

Tener permitido estar cifrado no es lo mismo que ser seguro para inyectar

PDF/E-1 y PDF/R-1 permiten ambos explícitamente que su documento anfitrión esté cifrado a nivel de especificación, lo que se lee como una exención hasta que miráis qué tiene realmente que ocurrir en disco. ISO 24517-1 §6.3 permite el cifrado para PDF/E-1, e ISO 23504-1 §6.2.3 lo permite para PDF/R-1 siempre que la cabecera declare %PDF-2.0. Ninguna de las dos cláusulas dice nada sobre si un postprocesador a nivel de byte puede añadir con seguridad un objeto en texto plano a ese contenedor cifrado, y no puede, por las mismas razones de §7.5.6 que se aplican a cualquier otro subconjunto. Los propios validadores de conformidad de PDFiumPas para estos dos perfiles, ValidatePdfECompliance y ValidatePdfRCompliance, registran deliberadamente la presencia de /Encrypt sin marcarla como defecto, lo cual es correcto para un validador de solo lectura que nunca escribe un byte. También es un patrón fácil de pasar por alto y asumir que el inyector hermano no necesita una protección aparte, cuando el inyector es la única función del par que realmente tiene que negarse

¿SaveAsPdfX descifra silenciosamente vuestro documento?

Sí, siempre que paséis por los métodos de conveniencia públicos en lugar de llamar directamente a un inyector. Cada uno de TPdf.SaveAsPdfA, SaveAsPdfX, SaveAsPdfUa, SaveAsPdfE, SaveAsPdfR y SaveAsPdfVT renderiza el documento actual a un flujo temporal con SaveAs(Tmp, saRemoveSecurity) antes de entregar esos bytes a su inyector correspondiente. saRemoveSecurity se asocia al propio indicador FPDF_REMOVE_SECURITY de PDFium, así que la copia temporal que recibe el inyector nunca estuvo cifrada en primer lugar, y la protección /Encrypt del inyector nunca tiene motivo para dispararse. La salida lleva vuestros marcadores PDF/A, PDF/X, PDF/UA, PDF/E-1, PDF/R-1 o PDF/VT-1, pero ya no está protegida por la contraseña que fuera que abrió el origen

Esa compensación es invisible hasta que alguien aguas abajo abre la copia de archivo «protegida» sin contraseña y nota que simplemente funciona. La solución no es una llamada de método distinta; PDFiumPas no tiene ninguna contrapartida saAddSecurity que emparejar con saRemoveSecurity, porque el motor PDFium subyacente nunca se construyó para escribir cifrado nuevo, solo para eliminarlo. Si ambas propiedades importan para un mismo archivo, el cifrado tiene que ser un paso aparte que vosotros gestionéis, aplicado después de los marcadores de conformidad, no plegado dentro de la misma llamada a SaveAsPdfA

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.Password := 'open-secret';           // needed to open the source at all
    Pdf.FileName := 'signed-encrypted-invoice.pdf';
    Pdf.LoadDocument;
    Pdf.SaveAsPdfA('invoice-pdfa.pdf', pac2b);
    // invoice-pdfa.pdf now declares PDF/A-2b, but SaveAs(saRemoveSecurity)
    // ran first inside SaveAsPdfA: the output opens without a password
  finally
    Pdf.Free;
  end;
end;

¿Qué ocurre al firmar con PAdES un PDF cifrado?

PDFiumPas se niega de plano, en lugar de descartar la solicitud en silencio como hace un inyector de marcadores. Tanto TPdf.SignPades como SignPadesToStream se canalizan a través de un SignPadesBytes interno, y lo primero que hace después de analizar el trailer de origen es comprobar la presencia de /Encrypt. Si la entrada está presente, lanza EPadesCrypto con el mensaje «SignPadesBytes: the source document is encrypted; remove encryption before signing» en lugar de continuar. InjectPadesDssMarkers, la función que incrusta certificados, respuestas OCSP y CRL para la validación a largo plazo, aplica la comprobación idéntica por la razón idéntica, con su propio mensaje: «InjectPadesDssMarkers: the source document is encrypted; remove encryption before embedding DSS validation material»

El razonamiento aquí es más estricto que el simple paso silencioso de los inyectores de marcadores, y deliberadamente así. Un paso silencioso es seguro para un sello PDF/A porque saltárselo os deja con el mismo PDF válido con el que empezasteis, solo que sin etiquetar. Firmar no puede fallar con tanto silencio: una firma que en silencio nunca se añadió parece, para cualquier código que llame y que solo compruebe un resultado booleano, exactamente igual que una firma que se añadió con éxito. EPadesCrypto desciende de la clase Exception ordinaria, así que capturarla es manejo de excepciones normal, no una convención especial de flujo de control que tengáis que aprender

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.Password := 'open-secret';
    Pdf.FileName := 'encrypted-contract.pdf';
    Pdf.LoadDocument;
    try
      Pdf.SignPades('encrypted-contract-signed.pdf', 'A1B2C3D4E5F6...');
    except
      on E: EPadesCrypto do
        // E.Message: 'SignPadesBytes: the source document is encrypted;
        // remove encryption before signing'
        raise Exception.Create('Decrypt the contract before signing: '+ E.Message);
    end;
  finally
    Pdf.Free;
  end;
end;

Ordenar sellos de conformidad, firmas y cifrado

La solución práctica es el orden, no una biblioteca distinta. Aplicad primero los marcadores PDF/A, PDF/X, PDF/UA, PDF/E-1, PDF/R-1 o PDF/VT-1, añadid después cualquier firma PAdES, y solo entonces ejecutad cualquiera que sea el paso de vuestro pipeline que realmente gestione el cifrado, ya sea un escritor de PDF dedicado, un dispositivo de firma o vuestra propia implementación de AES. La capa de actualización incremental de PDFiumPas encaja de forma natural en medio de esa secuencia, añadiendo objetos pequeños y concretos a un archivo por lo demás terminado, y el cifrado pertenece al final precisamente porque es la única operación de la cadena que el propio PDFiumPas no puede realizar ni revertir

Nada de esto cambia cómo lee PDFiumPas los datos de trailer y de referencia cruzada de los que depende cada actualización incremental, que es su propia fuente de sutileza en cuanto entran en juego los flujos xref; la validación de los flujos de objetos y xref de un PDF cubre cómo esa misma vía de lectura de trailer gestiona las estructuras comprimidas de PDF 1.5+. Y en cuanto un documento está listo para algo más fuerte que un sello de conformidad, firmar un PDF con una firma PAdES B-B en Delphi es donde SignPades toma el relevo justo en el punto donde lo deja este artículo

Los inyectores de marcadores y los métodos SignPades descritos aquí forman parte del componente PDFium para Delphi y C++Builder, junto con el renderizado y la inspección de solo lectura que PDFium proporciona de forma nativa