BlackMesa Labs

Tu documento y su firma no viajan juntos

Un artefacto firmado publicado en la web son al menos dos recursos HTTP. El verificador los descarga por separado, y nada garantiza que procedan de la misma generación. Cuando no es así, la verificación falla exactamente igual que ante una falsificación. Este es el mecanismo, lo que rompe y las tres formas de cerrarlo.

Publicas un documento firmado. El lector ejecuta el comando que imprimiste, y responde:

message imprint mismatch
Verification: FAILED

Esa respuesta tiene dos causas posibles. Una es que alguien ha modificado tu documento. La otra es que ha descargado tu documento de ayer y tu sello de hoy.

Nada, en el mensaje, las distingue.

Dos ficheros, dos recursos, dos vidas útiles

Un artefacto firmado en la web nunca es un fichero. Son al menos dos recursos HTTP independientes: el documento y la firma separada que lo acompaña. A menudo tres, cuando se añade un sello de tiempo. En nuestro caso, cinco: un informe, su firma y tres atestaciones de fecha.

El verificador los obtiene con peticiones separadas. Cada una pasa por su propia caché del navegador, su propio proxy, su propia CDN, y cada una sale con su propia frescura. Nada en el protocolo los trata como un conjunto. No existe un «tráeme estos cinco ficheros tal como estaban en el mismo instante».

Es evidente en cuanto se dice. No lo es en absoluto cuando se publica.

Lo que hace HTTP cuando no se le dice nada

Si no pones Cache-Control, la caché no renuncia. Adivina. La RFC 9111, sección 4.2.2, dice exactamente esto:

A cache MAY assign a heuristic expiration time when an explicit time is not specified, employing algorithms that use other field values (such as the Last-Modified time) to estimate a plausible expiration time.

Y más adelante:

If the response has a Last-Modified header field, caches are encouraged to use a heuristic expiration value that is no more than some fraction of the interval since that time. A typical setting of this fraction might be 10%.

Un diez por ciento de la edad del documento. Un informe publicado hace diez días se considera, por tanto, fresco durante un día entero, sin que nadie pregunte nada al servidor. Un informe publicado hace tres meses, durante más de una semana.

Y no es un ajuste que hayas elegido: es lo que ocurre a falta de ajuste. El silencio no es neutro.

Por qué este fallo es más grave que los demás

Una entrada de blog servida desde la caché es una molestia. Aquí se combinan tres cosas.

El documento lleva la huella de su propio contenido. Es el principio: el informe imprime la huella de sus hallazgos, y es esa la que se compara con el sello. Un documento caducado lleva una huella caducada.

Y el lector no tiene los dos ficheros en el mismo estado. Aquí es fácil equivocarse, y nos equivocamos al escribir este párrafo: uno imagina que los recursos tienen duraciones de frescura distintas. En nuestro sitio tienen exactamente la misma: el despliegue les da el mismo Last-Modified, y por tanto la misma ventana heurística, al segundo.

La asimetría está en otro sitio, y es de comportamiento: el lector ya ha visitado la página, así que está en su caché. Nunca ha descargado el sello, así que no lo tiene. La primera petición sale de la caché, la segunda va al servidor. Dos generaciones, por el simple hecho de que una página se lee varias veces y una firma se verifica una sola.

Y el mensaje de error es el mismo que para una falsificación. Esa es la cuestión. En la entrada de la semana pasada, explicábamos por qué openssl ts -verify confunde dos situaciones que no tienen nada que ver: una cadena de certificados que no sabe construir y un sello que atestigua otro documento. He aquí una tercera, que va al mismo cajón: dos ficheros perfectamente auténticos, pero no de la misma generación.

El lector ve FAILED. Concluye lo que cualquiera concluiría.

Medido en nuestra casa

Lo encontramos en nuestro propio sitio, y vale la pena contar cómo: un lector señaló que el inventario publicado mostraba todavía una versión que habíamos sustituido. El servidor, en cambio, devolvía la nueva: curl lo mostraba en una línea.

La tentación es responder «vacía tu caché». Es cierto, y es una mala respuesta: la causa estaba en nuestra casa. Nuestros artefactos firmados no llevaban ningún Cache-Control, solo un Last-Modified. La heurística hacía exactamente lo que describe la RFC.

Nadie salió perjudicado: la diferencia estaba en un número de versión y no en la huella, así que la verificación pasaba de todos modos. Pero fue suerte, no diseño: si los hallazgos hubieran cambiado, un lector concienzudo habría concluido que nuestro sello estaba roto.

No es un problema de blog

El mecanismo no depende de nada específico nuestro. Allí donde se publica un artefacto firmado con su firma al lado, la misma desincronización es posible:

  • un SBOM y su firma, en una release de GitHub o en un registro de artefactos;
  • una atestación de procedencia (SLSA, in-toto) servida junto a la imagen que describe;
  • un bundle de sigstore, cuyo .sigstore es una segunda petición;
  • un manifiesto de release y su SHA256SUMS.asc;
  • cualquier sello RFC 3161 depositado junto al documento que fecha.

Y empeora con cada capa que se apila. Una CDN guarda su propia copia, con su propia duración, y no purga necesariamente los cinco ficheros en el mismo instante. Un espejo de paquetes se resincroniza por lotes. Cuantos más intermediarios, más larga es la ventana durante la cual tus recursos son de generaciones distintas.

Tres formas de cerrarlo, y lo que cuestan

1. Prohibir usar sin preguntar. Cache-Control: no-cache en la familia de URL que lleva los artefactos firmados. El nombre engaña: no prohíbe almacenar, solo servir sin revalidar. El coste real es un 304 Not Modified, unos cientos de bytes, y el beneficio es que un lector ya no puede comparar dos generaciones.

# lo que dice ahora el servidor, en /audit/*
cache-control: no-cache

# y lo que cuesta la revalidación
curl -s -o /dev/null -w '%{http_code}\n' -H "If-None-Match: $ETAG" …
304

Es el remedio más barato y más inmediato. Es el que aplicamos.

2. Llevar la huella en la URL. Un fichero llamado report-8ecb829c.html no puede estar caducado: si el contenido cambia, el nombre cambia, y por tanto la dirección cambia. Una caché que te sirve la URL antigua te sirve el documento antiguo, lo cual es correcto, y verificable, porque su huella está en su nombre.

Es más sólido que el punto 1, y más caro: los enlaces cambian con cada publicación, y hay que mantener accesibles las versiones antiguas o aceptar enlaces rotos. Resérvalo para los artefactos que se archivan, no para los que se vuelven a publicar.

3. Imprimir la huella en el documento. Este no sustituye a los otros dos, los hace diagnosticables. Si el informe muestra la huella que cubre, el verificador puede comparar tres valores en lugar de dos: el que anuncia el documento, el que atestigua el sello y el que se recalcula.

Y la combinación por fin distingue los casos:

lo que se observa lo que es
el documento anuncia A, el sello atestigua A, el recálculo da A todo va bien
el documento anuncia A, el sello atestigua B los dos ficheros no coinciden. Casi siempre una copia caducada: vuelve a cargarlo todo, sin caché, antes de pensar en otra cosa
el documento anuncia A, el sello atestigua A, el recálculo da B ahí alguien ha modificado el documento

Tres líneas que convierten un FAILED mudo en tres diagnósticos separados.

Lo que dice de una herramienta de verificación

Es la misma lección que con los cuatro estados de un sello de tiempo, y se generaliza: una herramienta que verifica debe distinguir las razones del fallo, no solo constatar el fallo.

«Estos dos ficheros no son de la misma generación» y «este documento ha sido modificado» piden gestos opuestos. El primero se arregla volviendo a cargar. El segundo es un incidente. Confundirlos es o bien lanzar una alerta por un problema de caché, o bien, mucho peor, archivar una falsificación real entre las rarezas de red y pasar a otra cosa.

Por eso también hay que probar el comando de verificación desde una máquina que no sea la tuya, y a ser posible desde un contenedor vacío. El equipo que produjo el artefacto tiene todas las versiones correctas en local; es precisamente el único lugar donde el fallo es invisible.

Buenas prácticas

  • Trata el documento, su firma y su sello como un conjunto, ya que HTTP nunca lo hará por ti. Si solo vas a hacer una cosa, que sea esta.
  • Pon un Cache-Control explícito en los artefactos firmados. No poner nada no es neutro: la RFC permite a la caché adivinar, y adivina.
  • Imprime la huella en el propio documento. Cuesta una línea y permite distinguir una copia caducada de una falsificación.
  • Verifica desde otro sitio. Un contenedor vacío, otra máquina, otra red. El origen no reproduce el fallo que provoca.
  • Si archivas en lugar de volver a publicar, pon la huella en el nombre del fichero. Una URL direccionada por su contenido no puede servir una versión caducada.

Puntos de atención

  • no-cache y no-store no dicen lo mismo. El primero permite guardar y obliga a revalidar; el segundo prohíbe guardar. Para un artefacto público basta el primero, y el segundo desperdicia ancho de banda sin aportar nada.
  • Tu CDN tiene su propia política, que puede sobrevivir a la del origen. Compruébalo en el cable, desde fuera, y no en tu fichero de configuración.
  • Purgar una caché fichero por fichero vuelve a abrir la ventana: durante la purga, los recursos son de generaciones distintas. Purga toda la familia de una vez, o deja que la revalidación haga el trabajo.
  • Un verificador que descarga sus ficheros con unos minutos de diferencia puede caer en pleno despliegue. Es raro, es real, y es otro fallo más que parece una falsificación.

← Todos los artículos