Firmé las tres imágenes de un artículo de este blog con Content Credentials, atadas a mi cuenta de LinkedIn. Subí los archivos, verifiqué, y todo daba bien. Siete minutos después las tres se habían quedado sin manifiesto, y no las había tocado ningún atacante: las tocó mi propio plugin de optimización.

Diagrama del recorrido que destruye una credencial C2PA en un stack de WordPress
El recorrido completo de una credencial firmada, desde tu carpeta local hasta el navegador del lector. Cada estación es una oportunidad de romperla.

Cr — Verificar las credenciales de contenido de esta imagen

Lo que sigue es el registro de ese incidente, con los pesos exactos, el diagnóstico que hice mal durante tres rondas seguidas y el orden de operaciones que terminó funcionando.

Qué es firmar en el origen

C2PA nació para distinguir el contenido generado por IA del que no lo es, cuando ya no queda pista visual que sirva, y lo resuelve invirtiendo la lógica con la que crecimos: en vez de perseguir lo falso, certifica lo verdadero. La imagen viaja con un manifiesto firmado que dice quién la creó, con qué herramienta y qué se le hizo después.

Ya escribí sobre el estándar en su momento, así que acá voy directo al cómo: el estándar funciona, lo que no funciona es el camino entre tu carpeta local y el navegador de tu lector.

Conviene saber antes que los modos de falla son tres, y cuál te toca depende de qué capa rompió la imagen. Puede llegar sin manifiesto, y el verificador no muestra nada; con el manifiesto intacto pero el contenido cambiado, y muestra tus credenciales con un aviso de cambios no registrados; o con credenciales inválidas, que se leen como manipulación.

Lo primero: las copias que genera WordPress

Al subir una imagen, WordPress genera copias derivadas en thumbnail, medium y large. Conservan el manifiesto C2PA pero con el hash roto, porque el archivo ya no es el mismo que se firmó, y después el navegador sirve vía srcset la copia que corresponda al viewport en lugar de tu original. El lector nunca ve el archivo que firmaste. No es un problema solo mío: hay un issue abierto en el repositorio de WordPress pidiendo que detecte los manifiestos C2PA al subir, y el módulo de firma que publicó IPTC tampoco resuelve lo que pasa después.

El disparador es la clase wp-image-{id} en la etiqueta <img>, así que la solución es insertar el bloque Gutenberg sin ella:

<!-- wp:image {"sizeSlug":"full","linkDestination":"none"} -->
<figure class="wp-block-image size-full"><img src="URL_ORIGINAL" alt="..."/>
<figcaption class="wp-element-caption">...</figcaption></figure>
<!-- /wp:image -->

Para verificarlo alcanza con contar los atributos srcset en el HTML renderizado de la página publicada, que tiene que dar cero, contando el atributo dentro de una etiqueta y no la palabra suelta.

Lo segundo: el optimizador que pisa el archivo

Este es el que me costó las tres rondas. El plugin de LiteSpeed manda las imágenes a la nube de QUIC.cloud por cron, recibe las versiones comprimidas y pisa los archivos originales en disco, sin confirmación y sin log visible en el flujo normal de trabajo. Lo traicionero está en el peso: el archivo seguía marcando 135 KB, casi lo mismo que antes, pero la validación C2PA había pasado de Valid a sin manifiesto.

Tres ajustes tienen que estar apagados antes de subir cualquier imagen firmada:

AjusteValor obligatorioPor qué
Optimize Original ImagesOFFPisa el archivo firmado en disco
Next-Gen Image FormatOFFReescribe img.src a un .webp reencodeado
Auto Request CronOFFDispara la optimización sin intervención

Conviene dejar apagado también Remove Original Backups, que es lo único que permite revertir si el daño ya ocurrió. Y Conservar los datos EXIF/XMP viene apagado por defecto, con el manifiesto C2PA viajando justamente en XMP: el día que enciendas la optimización con ese ajuste en off destruís la firma aunque todo lo demás esté bien.

Mi primer método de diagnóstico fue buscar archivos .webp en el servidor y, si no había ninguno, concluir que el plugin no tocaba nada. No prueba nada: un servidor limpio de .webp a los dos minutos de subir es un servidor que va a destruir las imágenes a los siete. Subí, verifiqué, di el visto bueno y volví más tarde a encontrar el manifiesto destruido, tres veces seguidas, con la causa a un clic en el panel del plugin. Estaba midiendo un síntoma con latencia en lugar de leer una configuración estática, y de ahí me quedó la regla: verificá la configuración, no el efecto.

Lo tercero: la recompresión en el CDN

Supongamos que apagaste el plugin. Todavía te queda el CDN, que con optimización de imágenes activada recomprime en el POP: el origen queda intacto, tu archivo local valida, el del servidor valida, y lo que llega al navegador está roto. Es el más confuso de los tres, porque todo lo que podés inspeccionar cómodamente está bien. Lo delató un header, x-qc-cache: hit sirviendo un archivo ya destruido.

El daño del CDN y el del cron tampoco son el mismo daño, y confundirlos lleva a apagar cosas que no hacía falta apagar. En el destructivo, que es el del cron, el archivo en disco se pisa por completo y el verificador no tiene nada que mostrar. En el intermedio, que es el del CDN, la recompresión conserva el segmento del manifiesto pero cambia el contenido, así que el verificador sí muestra tus credenciales, quién firmó y el opt-out de entrenamiento de IA, con un aviso de cambios no registrados encima. Lo comprobé sobre un artículo publicado un mes antes: seguía comunicando lo esencial, con ese asterisco.

La diferencia importa al decidir: el asterisco puede ser un precio aceptable a cambio de imágenes más livianas, y en el nivel destructivo no hay nada que negociar. Averiguar en cuál estás toma treinta segundos, abriendo el verificador sobre una imagen tuya ya publicada.

Lo primero que intenté fue no apagar nada, porque apagar la optimización entera para proteger tres imágenes castiga a las otras trescientas del blog. Cache-Control: no-transform le dice a cualquier intermediario que no toque el contenido, y va en el .htaccess de la carpeta de subidas y no en el de la raíz, porque los plugins reescriben el de la raíz cada vez que guardás sus ajustes y no tocan los de las subcarpetas.

# .htaccess de la carpeta de subidas
<FilesMatch "^c2pa-.*\.(jpe?g|png)$">
    Header set Cache-Control "public, max-age=31536000, no-transform"
    Header set Access-Control-Allow-Origin "*"
</FilesMatch>

El FilesMatch limita la protección al prefijo que uso para las imágenes firmadas, y el resto de la biblioteca se sigue optimizando igual.

Veinte minutos después de subir con la directiva puesta, los cuatro archivos seguían pesando exactamente lo firmado. Di el resultado por bueno y estaba equivocado: al día siguiente habían bajado entre 15,2% y 20,6%, con cinco .webp generados por imagen, uno por cada tamaño que recorta WordPress. Contra las marcas de tiempo del servidor, la reescritura cae entre 17 y 20 minutos después de cada carga, así que mi ventana cerró justo en el borde. Es el mismo falso positivo que este artículo denuncia, cometido dentro del artículo.

no-transform nunca iba a servir para esto. Ese header le habla a los intermediarios que están entre tu servidor y el lector, y lo que reescribía mis archivos era un plugin corriendo en el propio servidor, que no lo lee ni tiene por qué. Contra la recompresión del CDN sigue valiendo la pena ponerlo, y por eso el bloque se queda.

Lo que sí funciona es apagar la optimización automática al subir y dejar encendida la copia de originales que hace el mismo plugin. Esa copia fue lo que me salvó: los cuatro archivos firmados estaban ahí intactos, con el mismo sha256 que los de mi disco, así que la recuperación se resolvió restaurando. Todo se mide comparando el hash del archivo servido contra el firmado local, al menos media hora después de subir.

Lo cuarto, que encontré escribiendo esto

Antes de publicar fui a comprobar que la configuración del servidor siguiera como la había dejado, y encontré un bloque que no recordaba haber puesto, con la firma de SpeedyCache, un plugin de caché:

RewriteCond %{HTTP_ACCEPT} image/webp
RewriteCond %{REQUEST_FILENAME} \.(jpe?g|png|gif)$
RewriteRule ^(.+)\.(jpe?g|png|gif)$ $1.webp [T=image/webp,L]

Traducido: si el navegador acepta WebP y existe el .webp en disco, sirve ese en lugar del original y la URL no cambia. El lector pide portada.jpg y recibe un WebP reencodeado, sin manifiesto. Es el más difícil de detectar porque el archivo en el origen está intacto, la URL que devuelve la API es la correcta y el peso en disco también: podés auditar las tres cosas, darlas por buenas, y estar sirviendo otro archivo. Es integridad rota sin una sola alarma.

El hallazgo de fondo me gustó menos que el bloque en sí. Fui a buscar en qué panel se configuraba ese plugin y resulta que no estaba instalado: lo había sacado meses atrás y sus reglas seguían operando, porque desinstalar un plugin de WordPress no siempre limpia lo que escribió en el .htaccess. Ya que estaba, listé todo lo que tenía instalado, que es lo que debí hacer el primer día, y apareció Imagify, un optimizador de imágenes que no había tocado en ningún momento del diagnóstico. Ese venía pisando los archivos mientras yo apagaba toggles en otro panel.

La pregunta útil no es qué plugin creés que tenés, es cuántas cosas pueden reescribir una imagen sin cambiarle la URL.

El orden que funciona para firmar sin romper

Todo lo anterior se resume en una línea:

generar → optimizar → FIRMAR → subir → no tocar nunca más
Orden correcto de operaciones para firmar imágenes con C2PA sin romper la firma
La optimización va antes de la firma. Invertir estos dos pasos es lo que rompe la credencial.

Cr — Verificar las credenciales de contenido de esta imagen

La optimización va antes de la firma, y cualquier cosa que recomprima después la mata, sin excepción y sin aviso. Estos son los números de mi caso, sobre las tres imágenes de un mismo artículo:

EstadoPortadaDiagrama 1Diagrama 2Total
Firmadas sin optimizar411 KB453 KB427 KB1.291 KB
Optimizadas y luego firmadas135 KB161 KB158 KB454 KB
Optimizadas sin firmar55 KB68 KB65 KB188 KB

El orden correcto ahorra un 65% de peso: firmar el archivo crudo cuesta 837 KB de página por artículo sin ninguna ganancia visual. Adobe además infla alrededor de 2,4 veces al firmar, porque recomprime al aplicar el manifiesto. El piso real de una imagen firmada queda entre 135 y 160 KB, así que el objetivo genérico de menos de 200 KB se cumple con bastante menos margen del que uno supone.

Tabla con el peso de tres imágenes firmadas con C2PA según el orden del pipeline
Mediciones propias sobre los archivos reales. El orden correcto ahorra 837 KB por artículo.

Cr — Verificar las credenciales de contenido de esta imagen

Cómo verificar que la credencial quedó bien

Antes que nada hace falta el header CORS sobre los archivos subidos, sin el cual nada de esto se puede comprobar desde el navegador, porque el verificador de Adobe lee el archivo desde el navegador del lector. Va sin envolverlo en <IfModule mod_headers.c>, porque LiteSpeed no siempre evalúa ese wrapper como verdadero y el bloque queda silenciosamente inerte:

<FilesMatch "\.(jpe?g|png|webp|avif|gif|pdf)$">
    Header set Access-Control-Allow-Origin "*"
</FilesMatch>

Lo puse en julio y cuando fui a verificarlo para publicar no estaba: cada plugin reescribe su propio tramo del .htaccess al guardar sus ajustes, y mi bloque había quedado dentro de uno de esos tramos. Desapareció sin error y sin log. Por eso conviene ponerlo en un .htaccess de subcarpeta y comprobarlo con una petición real antes de cada publicación. De los cuatro problemas es el único que deja la firma intacta y rompe solo la capacidad de comprobarla, que en la práctica se lee parecido: la credencial está impecable y el botón de verificar falla igual.

Sobre qué verificador enlazar, la respuesta tiene una ironía incorporada. El visor público del estándar, verify.contentauthenticity.org, solo lee el manifiesto embebido, o sea que falla precisamente en el escenario que acabo de documentar, y además no renderiza los datos de Connected Accounts, así que el dato que me interesa mostrar, mi nombre junto a mis cuentas, está en el archivo pero no se pinta. La credencial está completa; lo que falla es el visor.

Por las dos razones, el botón que va debajo de cada imagen publicada apunta a:

https://contentauthenticity.adobe.com/inspect?source=<URL>
Verificador de Adobe mostrando las credenciales de una imagen y el aviso de cambios no registrados
El verificador muestra la cuenta de LinkedIn y el opt-out de entrenamiento de IA, y encima el aviso de que el archivo cambió después de firmarse.

Cr — Verificar las credenciales de contenido de esta imagen

Adobe inspect recupera la credencial desde la nube usando la credencial duradera cuando el manifiesto embebido no está. Lleva etiqueta Beta y pide un clic extra, y lo acepto. Un aviso que ahorra tiempo: el patrón contentcredentials.org/verify?source= circula en tutoriales de 2025 y hoy devuelve 404.

Para validar sin visor web queda el SDK c2pa sobre la URL de producción con cache-buster, comprobando que validation_state sea Valid y que los bytes sean idénticos al archivo firmado local, media hora después de la subida.

Con qué identidad quedan firmadas

Las imágenes de este artículo están firmadas desde Adobe Content Authenticity con mi cuenta de LinkedIn conectada. Quien abre el verificador ve mi nombre y mi perfil como origen del archivo, la herramienta con la que se creó, y el opt-out de entrenamiento de modelos de IA. Ese es el dato que justifica todo el trabajo de los apartados anteriores, y hay otras dos vías para firmar que no lo entregan.

VíaQué identidad firmaQué pierde
Adobe Content Authenticity (web, gratis)Mi cuenta de LinkedIn conectadaNada
Content Authenticity API (Firefly Services)Un certificado corporativoLa identidad personal: firma la organización, no vos
c2patool / SDK open sourceUn certificado propio o autofirmadoEl verificador muestra emisor no reconocido, sin cuentas conectadas

La segunda cambia mi identidad por la de una empresa y la tercera produce una credencial que dice emisor no reconocido, que comunica menos que no firmar.

Una precisión que un lector con C2PA en las manos va a detectar enseguida: lo que uso no es una identidad verificada por documento, es una cuenta conectada por OAuth, el modelo CAWG de agregador de declaraciones de identidad, que prueba control de la cuenta y no identidad documentada. Para marca personal alcanza y es honesto, porque mi audiencia verifica mi perfil público y no mi pasaporte.

Y ahí aparece el hallazgo que menos esperaba. El nivel superior de identidad, el sello respaldado por documento de gobierno, hoy está cerrado para mí por geografía: el proveedor de verificación exige pasaporte con chip NFC para Bolivia, y Bolivia no figura en la lista de países donde acepta cédula nacional. El escalón más alto del estándar de procedencia más relevante de la industria queda fuera del alcance de un profesional boliviano que hizo todo bien. La salida existe, un pasaporte biométrico que sí trae NFC, pero es un trámite y no un ajuste de configuración.

Casi toda la cobertura de C2PA se escribió desde Estados Unidos y Europa, donde el pasaporte con chip es lo normal y ese paso es invisible. La adopción de un estándar no se mide solo por su diseño técnico; se mide también por dónde queda parado el último que intenta cumplirlo.

Para cerrar

Nadie instala un CDN, ni un optimizador, ni un conversor a WebP para romper credenciales; los instala para ir más rápido. Ahí está el caso de manual de Secure by Design: la procedencia verificable y la compresión persiguen objetivos legítimos y opuestos, y cuando el orden no se diseña gana el rendimiento por default, sin que nadie haya decidido sacrificar nada.

Si estás por firmar el contenido de tu sitio, la pregunta que te ahorra tres rondas de subidas es cuál de tus procesos automáticos va a tocar ese archivo después de que dejes de mirarlo, y conviene contestarla antes de subir nada.

¿Ya intentaste implementar Content Credentials en tu sitio? Me interesa saber qué se te rompió a vos.

#ViveLibreViveSeguro


Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *


Math Captcha
forty six ÷ forty six =