Blog ·
Una actualización firmada que se rechaza sola
Una suma de comprobación demuestra que la descarga llegó intacta, no quién la envió. Lo que cuesta de verdad firmar un agente que se actualiza solo.
Por Provibr
Cualquier software que se actualiza a sí mismo es ejecución remota de código a la que se ha dado permiso de entrada. Eso no es un argumento contra las actualizaciones automáticas, que son la única manera realista de evitar que una flota de agentes sobre hardware ajeno se pudra. Es un argumento a favor de ser preciso sobre qué promete el mecanismo de actualización, porque la mayoría prometen menos de lo que da a entender su documentación.
Nosotros ejecutamos un agente en servidores de otras personas. Habla con nuestra plataforma, aplica comandos a su hipervisor y sustituye su propio binario cuando publicamos una versión nueva. Esto es lo que hubo que construir antes de estar dispuestos a dejarle hacer eso.
Una suma de comprobación responde a la pregunta equivocada
Casi toda herramienta que se actualiza sola publica un SHA-256 junto a su descarga, y muchas se quedan ahí. Una suma de comprobación es útil, y no hace el trabajo que se cree que hace.
| Control | Qué demuestra realmente | Qué no puede ver |
|---|---|---|
| TLS en la descarga | Que los bytes vinieron de ese host, por esa conexión | Nada sobre quién los produjo |
| SHA-256 publicado junto al fichero | Que la descarga no está truncada ni corrupta | Quien pueda cambiar el fichero puede cambiar el número |
| Firma sobre los bytes | Que estos bytes los produjo el poseedor de una clave privada concreta | Si esa clave sigue siendo nuestra |
La fila del medio es el problema entero. La suma de comprobación y el artefacto suelen vivir en el mismo sitio y suelen ser escribibles por el mismo proceso. Si un atacante puede sustituir uno, puede sustituir el otro, y la verificación pasará con entusiasmo.
Así que la suma de comprobación es una prueba previa barata y explícitamente no es la frontera de confianza. Detecta una descarga truncada en una sola pasada sobre unos bytes que ya se estaban transmitiendo de todos modos. El ancla es la firma.
Dónde tiene que vivir la clave pública
Aquí está la primera decisión que determina si algo de esto es real: la clave verificadora no puede ser un fichero que se entrega junto al binario, porque entonces comprometer la ruta de entrega compromete también la clave.
Nuestro agente recibe la clave pública Ed25519 de la plataforma compilada dentro de él, a partir de una variable de entorno de tiempo de compilación. En una compilación de publicación no hay otra fuente. En una compilación de depuración, quien desarrolla puede apuntarla a una clave local con una variable de entorno, y ese camino se elimina por completo en compilación mediante un atributo cfg, porque una clave que se puede sobrescribir en tiempo de ejecución no es un ancla sino una sugerencia.
Dos detalles alrededor de eso importan más de lo que parecen.
El script de compilación declara que debe volver a ejecutarse cuando esa variable de entorno cambia. Sin esa línea, una compilación de publicación que comparte caché de destino con una compilación anterior reutiliza tan tranquila la clave del compilado previo, y nadie se entera hasta que llega a un cliente.
Y el analizador de la clave es estricto con la codificación, no con la longitud. Una clave pública Ed25519 en forma SPKI es un prefijo fijo de 12 bytes seguido de 32 bytes; una clave X25519 también mide 32 bytes, con un identificador de algoritmo distinto. Aceptar cualquier cosa de 32 bytes tomaría una clave de otro algoritmo y fallaría después, en algún sitio donde no tiene ningún sentido.
Rechazar antes de descargar
Cuando la plataforma le dice al agente que se actualice, el agente recorre la lista entera de precondiciones antes de descargar un solo byte:
- ¿Hay un SHA-256 utilizable en la directiva?
- ¿Hay una URL de descarga?
- ¿Hay una firma, y mide exactamente 64 bytes?
- ¿Ya se está ejecutando esta versión?
- ¿Existe en este host el script privilegiado que aplica la actualización?
- ¿Existe el fichero local del ancla de confianza?
- ¿Tiene este binario alguna clave de plataforma compilada dentro?
El paso tres merece la atención. Una plataforma antigua que no rellena el campo de la firma debería fallar aquí, ruidosamente, en lugar de recaer en silencio sobre la comprobación más débil que se hacía antes. La verificación opcional no es verificación. Convertir el campo ausente en un error duro es la diferencia entre un mecanismo y una costumbre.
El orden de los tres últimos también es deliberado. Los mensajes del tipo «a este host le falta el paso privilegiado, hay que ejecutar de nuevo el instalador» son más accionables que «este binario no tiene clave de plataforma», así que van primero.
Firmar los bytes, no una descripción de ellos
La firma cubre el artefacto exactamente tal como aterriza en el disco. Ed25519 puro, sin prehash, sin sobre canónico, sin metadatos incluidos. La razón no es purismo. Es que dos implementaciones completamente distintas tienen que llegar al mismo veredicto.
En el agente es ed25519-dalek con verify_strict, que rechaza las firmas con componentes de torsión. Con una clave y un fichero debería haber exactamente una firma válida, y la variante no estricta no lo garantiza.
En el host, el script privilegiado que instala de verdad la actualización verifica esos mismos bytes con openssl pkeyutl -verify -pubin -rawin. Misma clave, mismos bytes, misma respuesta. Si hubiéramos hecho un prehash, o firmado un manifiesto en vez del artefacto, la comprobación a nivel de shell tendría que reimplementar nuestro encuadre, y las dos comprobaciones se separarían con el tiempo.
Esa es además la comprobación que ejecutamos a mano en el servidor después de cada publicación, contra la clave pública de producción, antes de creernos que una versión ha salido.
El manifiesto que acompaña al artefacto lleva la versión, el identificador de compilación, la suma de comprobación, el tamaño, la marca de tiempo de publicación, el tipo de artefacto y la firma. Son metadatos para el panel, y nada de eso goza de confianza: la firma que contiene es sobre el binario, no sobre el manifiesto.
El agente no puede instalar su propia actualización
El agente se ejecuta como usuario sin privilegios. No puede escribir en el directorio donde vive su propio binario, y no debería poder.
Así que prepara. Escribe primero la firma y la suma de comprobación en un directorio de preparación, y solo entonces renombra el binario verificado a su sitio junto a ellas, porque el script privilegiado usa la presencia de ese binario como señal de arranque y los otros dos ficheros tienen que estar ya ahí. Después sale limpiamente, y el gestor de servicios lo reinicia, ejecutando primero un paso privilegiado previo al arranque.
Ese script merece una descripción, porque sus supuestos son lo contrario de lo que cabría adivinar.
Siempre sale con cero. Un paso previo al arranque que fallara impediría que el servicio arrancara siquiera, y una actualización rota que deja a un cliente sin servicio es mucho peor que una actualización rota que sencillamente no se aplica.
El directorio de preparación no goza de confianza. Todo lo que hay dentro lo escribió el usuario sin privilegios del agente: el binario, la suma de comprobación, la firma. Quien tome el control de ese usuario puede sustituir las tres cosas. Por eso la firma, verificada con la clave del lado privilegiado, es lo único que decide.
Copia antes de comprobar. Los bytes preparados se copian exactamente una vez a un fichero temporal recién creado y propiedad de root, junto al destino, y toda comprobación posterior lee la copia. El fichero que se verifica es exactamente el mismo inodo que se renombra en su sitio, así que no hay ninguna ventana en la que se cuele otro fichero entre la comprobación y la instalación.
El resto es deliberadamente aburrido: rechazar enlaces simbólicos, exigir que la suma de comprobación tenga exactamente 64 caracteres hexadecimales, exigir que el fichero empiece por el número mágico ELF, ampliar los permisos a ejecutable solo después de que la firma pase, arrancarlo una vez como el usuario del servicio y no como root, y solo entonces renombrarlo sobre el binario vivo. Una firma dice de dónde vienen unos bytes. No dice que merezcan root.
La versión que firmamos con la clave equivocada
El mecanismo de arriba funciona. Aun así publicamos una versión rota, y la manera en que falló es la parte útil.
Nuestro script de compilación aceptaba un indicador para «esto es una publicación de producción». En su momento ese indicador gobernaba solo la publicación, no qué clave se usaba para firmar. Una versión construida sin nombrar además la clave de producción en el servidor quedó firmada con la clave de desarrollo, llevaba compilada dentro la clave de licencias de desarrollo, y después se verificó localmente contra esa misma clave de desarrollo. La comprobación dijo que el artefacto estaba bien, porque era internamente consistente. Simplemente no era nuestro.
Todos los agentes reales rechazaron la actualización, correctamente, porque la firma no coincidía con la clave compilada dentro de ellos. El mecanismo hizo exactamente aquello para lo que se construyó. El error estaba antes que él.
De ahí salieron dos guardas, ambas en código y no en un documento. Compilar para producción sin nombrar la clave de producción es ahora un fallo duro que detiene el script. Y la clave que firma y la clave que verifica tienen que estar en la misma máquina: comprobar una firma con una clave traída de otro sitio no demuestra absolutamente nada.
La lección general merece decirse con claridad. Un paso de verificación al que se le entregan las dos mitades de un par emparejado del entorno equivocado va a pasar, y va a pasar con confianza. Los esquemas de firma protegen frente a atacantes. No protegen frente a uno mismo.
Una licencia no es un binario, y ambos van firmados
Firmamos dos cosas distintas con la misma clave: binarios de agente y ficheros de licencia. Eso es un peligro, porque una firma sobre uno nunca debería aceptarse como firma sobre el otro.
La separación es estructural y no una convención de nombres. Un fichero de licencia firma un sobre de texto construido a partir del identificador de formato, la sal, el nonce y el texto cifrado, unidos con saltos de línea. Un binario firma bytes ELF en crudo. El script de instalación comprueba el número mágico ELF antes de verificar nada, de modo que una firma de licencia válida no puede presentarse jamás como una firma de binario válida.
El flujo de licencias tiene una propiedad más que vale la pena robar: verifica la firma antes de derivar la clave de descifrado. Argon2id con un coste de memoria de 64 MiB es caro por diseño, y hacer ese trabajo para un fichero que nunca fue nuestro es una denegación de servicio gratuita. Las comprobaciones baratas de autenticidad van antes que la criptografía cara.
Lo que esto no compra
Firmar ata un artefacto a una clave. No dice nada sobre si esa clave sigue siendo nuestra, ni sobre si la versión firmada vale algo. Si nuestra clave de firma se filtra, todos los agentes aceptan lo que construya el atacante, y rotarla significa recompilar cada binario y reemplazar el ancla de confianza en cada host, porque la clave va compilada dentro. Ese es el precio de que la clave sea un ancla y no una sugerencia, y es el intercambio correcto, pero es un coste real y su sitio está en el plan y no en una nota al pie.
Así funciona la actualización del agente en Provibr, donde el agente se ejecuta en el hardware del propio cliente, solo conecta hacia fuera y guarda las credenciales del hipervisor en ese host.