Servidores

Cómo usar age para cifrar secretos en scripts bash

Un secreto metido en un script bash es un secreto esperando que alguien lea el repositorio. DB_PASSWORD=root pegado en un deploy.sh sobrevive al pull request, al backup del servidor y a la copia que le mandas a un compañero. age existe para cerrar ese hueco sin convertir tu pipeline en un Transcript: el archivo cifrado viaja por donde quieras, y solo el proceso que tiene la clave privada lo puede abrir.

La herramienta es de Filippo Valsorda, está escrita en Go y usa licencia BSD de 3 cláusulas. La versión estable actual es la 1.3.2, publicada el 29 de agosto de 2026. Su formato de archivo está especificado por C2SP, el proyecto de especificaciones de criptografía que mantiene el formato como una especificación pública y versionada, así que no dependes de un único binario para leer lo que produzcas.

Pero hay una trampa que convierte esta guía en algo más útil que un tutorial de comandos: la opción -p, la que todo el mundo busca primero, no sirve para scripts desatendidos. La frase de seguridad se pide de forma interactiva en la terminal y el CLI principal no tiene ninguna variable de entorno para pasarla. Si tu plan era age -p dentro de un cron, ya te adelanto que no va a funcionar. La ruta correcta es age -d -i archivo-de-identidad, y entender la diferencia entre las dos es el 80% de lo que necesitas saber.

TL;DR — Lo que vas a tener al final

  • Tiempo de setup: unos 10 minutos. Generar un par de claves, cifrar un archivo de secretos y descifrarlo desde un script bash que se ejecuta sin interacción.
  • Requisitos: age 1.0 o superior (la 1.3.2 es la actual), un archivo de secretos en formato KEY=valor y una clave privada protegida con permisos 600.
  • Resultado: un archivo .age que se versiona, se copia y se despliega sin filtrar nada, más un script que lo abre en tiempo de ejecución con age -d -i.
  • Troubleshooting: si tu script se queda esperando una frase de seguridad, estás usando -p. Para automatización, la respuesta es la identidad nativa, no la frase.

Instala age y comprueba la versión que tienes

age está en los repositorios oficiales de las distribuciones grandes, así que en la mayoría de servidores no hace falta compilar nada. Lo único que tienes que hacer después es mirar qué versión te dieron, porque las funciones importantes que vamos a usar (criptografía poscuántica, el plugin para frases y los endurecimientos de la 1.3.2) tienen requisitos de versión distintos.

sudo apt-get update
sudo apt-get install -y age
age --version
age-keygen --version

Si age --version te devuelve algo como v1.3.2, ya tienes todo lo que este post usa. Si te devuelve una versión anterior, la tabla de más abajo te dice exactamente qué te falta y qué no vas a poder hacer todavía.

Si no usas paquetes de distribución, los binarios oficiales se publican para Linux, macOS y Windows en la página de releases del proyecto. Para Linux el archivo es un tar.gz con los binarios age, age-keygen y age-inspect dentro.

Genera la identidad y cifra tu primer secreto

age trabaja con dos piezas: un recipient, que es la clave pública y puede compartirse sin riesgo, y una identidad, que es la clave privada y nunca sale del servidor. Tu script de despliegue solo necesita el recipient; el proceso que lee el secreto necesita la identidad.

El primer paso es generar el par. Fíjate en el umask 077: sin él, la clave privada puede quedar legible para otros usuarios del sistema en servidores multiusuario.

umask 077
age-keygen -o clave.age.txt
chmod 600 clave.age.txt
age-keygen -y clave.age.txt

age-keygen -o escribe la identidad en el archivo indicado y no lo sobrescribe si ya existe, lo que evita que un segundo age-keygen en algún script destruya la clave que ya tenías. El comando age-keygen -y convierte esa identidad en el recipient que vas a distributes: es la línea que empieza con age1.

Un detalle que casi nadie conoce: si generas la clave con la redirección de shell en vez de con -o, el archivo queda con permisos 644, es decir, legible por cualquier usuario de la máquina. Con -o el archivo nace en 600. Usa siempre -o.

Ahora el secreto. Un archivo .env con líneas CLAVE=valor es el formato más cómodo, porque bash lo puede leer directamente con source una vez descifrado.

printf 'DB_PASSWORD=clave-real-de-produccion\nAPI_TOKEN=token-real-de-la-api\nDB_HOST=db.interno.local\n' > secretos.env
RECIPIENT="$(age-keygen -y clave.age.txt)"
age -r "$RECIPIENT" -o secretos.env.age secretos.env
rm -f secretos.env

El resultado es secretos.env.age: un archivo binario que no se puede leer sin la clave, que se puede versionar en Git sin filtrar nada y que ocupa muy poco. age añade alrededor de 200 bytes por destinatario más 16 bytes por cada 64 KiB de texto plano, así que el overhead es irrelevante frente al valor de no filtrar las credenciales.

Descifra el secreto dentro de un script bash

Este es el corazón de la guía y el patrón que vas a usar en todos tus despliegues. La clave privada se queda en el servidor, el script la abre en tiempo de ejecución y el texto plano existe solo en memoria o en un archivo temporal que se borra al terminar.

#!/usr/bin/env bash
set -euo pipefail
CLAVE="/etc/miapp/clave.age.txt"
CIFRADO="/etc/miapp/secretos.env.age"
temporal="$(mktemp)"
trap 'rm -f "$temporal"' EXIT
age -d -i "$CLAVE" "$CIFRADO" > "$temporal"
set -a
source "$temporal"
set +a
echo "Conectando a ${DB_HOST} con el usuario configurado"

Tres detalles hacen que esto funcione y no se rompa en producción:

  • set -euo pipefail. Si age falla al descifrar, el script se detiene ahí. Sin -e, un fallo de descifrado te deja ejecutando el resto con variables vacías, que es la forma más común de que un deploy rompa producción a las tres de la mañana.
  • trap 'rm -f "$temporal"' EXIT. El archivo temporal se borra aunque el script falle. Y si necesitas que el contenido no toque el disco, puedes cambiar la redirección por source <(age -d -i "$CLAVE" "$CIFRADO"), que usa un descriptor de archivo en memoria.
  • set -a antes del source. Exporta automáticamente cada variable que se define, que es justo lo que quieres para que el proceso que sigue vea las credenciales en el entorno.

Y algo que no es negociable: la clave privada no se versiona. Si la identity está en el repositorio, todo el esquema se cae, porque cualquiera que clone el repo puede descifrar todo lo que cifraste con ese recipient.

Por qué la frase de seguridad no sirve en un script

Vamos directo al punto, porque aquí es donde se pierden la mayoría de las horas cuando alguien pone age por primera vez en un servidor.

La opción -p cifra con una frase de seguridad. El manual de age lo dice literalmente: la frase se solicita de forma interactiva desde la terminal, y si la entrada está protegida con frase, age la detecta y también la pide de forma interactiva. El CLI principal no expone ninguna variable de entorno para pasarla.

Qué significa eso en la práctica. Un age -p dentro de un cron job, un systemd timer o un pipeline de CI se queda esperando una entrada que nunca va a llegar, o falla con un error del estilo standard input is not a terminal. No es un bug de tu configuración: es el diseño.

El propio proyecto explica el motivo en el manual del plugin que discute este caso, y es una razón de fondo: las personas somos malos recordando y generando frases fuertes, y como age usa scrypt para frenar los ataques de fuerza bruta, la frase termina siendo un problema de seguridad y de rendimiento al mismo tiempo. Por eso la recomendación oficial es usar claves nativas siempre que puedas.

Hay una segunda trampa relacionada, y esta sí es un error de configuración tuyo. Si el archivo de entrada está protegido con frase y le pasas -i, age se niega a trabajar:

age -d -i clave.age.txt secretos.env.age
age: error: file is passphrase-encrypted but identities were specified with -i/--identity or -j
age: hint: remove all -i/--identity/-j flags to decrypt passphrase-encrypted files

La regla es simple y no tiene excepciones: frase de seguridad para trabajo manual, identidad nativa para trabajo automatizado. Nunca las mezcles en el mismo comando.

Automatiza la frase con el plugin batchpass

Desde la versión 1.3.0 existe una salida oficial para el caso en el que de verdad necesitas una frase en un proceso desatendido: el plugin age-plugin-batchpass. Viene incluido en el tarball oficial de la 1.3.2 que descargas del proyecto, aunque los paquetes de las distribuciones no siempre lo incluyen, así que verifica antes de confiar en él.

El plugin habilita dos variables de entorno: AGE_PASSPHRASE, que lleva la frase directamente, y AGE_PASSPHRASE_FD, que apunta a un descriptor de archivo desde el que leerla. La segunda opción es la interesante, porque evita que la frase quede en el entorno del proceso y se la herede a todos los procesos hijos que lances.

export AGE_PASSPHRASE="frase-larga-y-unica-para-ci"
age -e -j batchpass -o secretos.env.age secretos.env
AGE_PASSPHRASE_FD=3 age -d -j batchpass secretos.env.age 3<<<"frase-larga-y-unica-para-ci"

El plugin también controla el costo de scrypt con AGE_PASSPHRASE_WORK_FACTOR (entre 1 y 30, por defecto 18) y AGE_PASSPHRASE_MAX_WORK_FACTOR (por defecto 22), que es el techo que age acepta al descifrar. Si subes el factor de cifrado por encima de 22, el propio plugin se niega a descifrar salvo que subas también ese máximo.

Mi recomendación sigue siendo la misma que la del proyecto: si puedes usar una clave nativa, usa una clave nativa. El plugin existe para los casos residuales, y su propio manual advierte que no está integrado en el CLI principal a propósito.

Protege la identidad con una clave SSH cuando no tengas clave nativa

age también acepta claves SSH existentes como destinatario, lo cual es útil cuando el otro lado de la comunicación ya tiene una clave SSH y no quieres pedirle que instale nada nuevo. Solo acepta ssh-rsa y ssh-ed25519, y las claves RSA deben tener al menos 2048 bits: una RSA de 1024 bits es rechazada con el aviso ignoring unsupported SSH key.

age -R <(cat ~/.ssh/id_ed25519.pub) -o secretos.env.age secretos.env

Y para descifrar, se usa la clave privada correspondiente:

age -d -i ~/.ssh/id_ed25519 secretos.env.age

Tres límites que conviene tener presentes antes de elegir esta ruta:

  • El agente SSH no funciona. age no usa ssh-agent, así que si tu clave está cargada en el agente, age no la ve. Y las claves guardadas en YubiKeys tampoco se pueden usar para descifrar.
  • La etiqueta del destinatario queda dentro del archivo cifrado. Si cifras con una clave SSH, el archivo contiene una línea con el identificador de esa clave pública. Eso permite a alguien con el archivo saber para quién se cifró, aunque no pueda leerlo. Con claves nativas de age esa correlación no es posible.
  • El manual recomienda usarlas solo como último recurso. La criptografía del soporte SSH es más compleja que la nativa y, como el proyecto advierte, las claves SSH no siempre se conservan a largo plazo: están revocables cuando solo se usan para autenticación.

Cifra para varios destinatarios sin mezclar estilos

Puedes cifrar un mismo archivo para varias personas o máquinas. age genera una clave de archivo aleatoria y la envuelve por separado para cada destinatario, así que cualquiera de ellos puede abrirlo y nadie más.

printf '# recipients del equipo\n\n%s\n%s\n' "$RECIPIENT_ANA" "$RECIPIENT_BOB" > recipients.txt
age -R recipients.txt -o secretos.env.age secretos.env

En un archivo de destinatarios cada línea es un recipient. Las líneas vacías y las que empiezan con # se ignoran, así que puedes documentarlo con comentarios. Lo mismo funciona en un archivo de identidades, con una diferencia útil: las claves que no corresponden a ningún destinatario del archivo se ignoran sin error, en vez de fallar. Eso te deja mantener un único archivo de identidades con varias claves y que cada servidor use la suya.

Con -R - los destinatarios se leen de la entrada estándar, útil cuando los generas al vuelo.

Activa la criptografía poscuántica cuando tu versión lo permita

La 1.3.0 trajo algo grande: recipients poscuánticos híbridos con ML-KEM-768 combinados con X25519. La resistencia futura contra computadoras cuánticas es real y no es un apunte de marketing, pero hay condiciones que cumplir.

Se generan con age-keygen -pq. El recipient empieza con age1pq1 y la identidad con AGE-SECRET-KEY-PQ-1. El manual de age dice sin rodeos que las claves híbridas poscuánticas son el tipo de destinatario recomendado para la mayoría de las aplicaciones, porque combinan X25519, que ya entendimos, con ML-KEM-768, que nos protege del futuro.

age-keygen -pq -o clave-pq.age.txt
age -r "$(age-keygen -y clave-pq.age.txt)" -o secretos.env.age secretos.env

El precio es el tamaño: un recipient poscuántico mide alrededor de 2000 caracteres, contra los 62 de uno clásico. Para archivos de texto con secretos eso es irrelevante.

Y hay una restricción que no es opcional: no puedes cifrar el mismo archivo a recipients poscuánticos y clásicos a la vez. age lo bloquea, porque mezclar los dos anularía la protección poscuántica.

age -R recipients-mixtos.txt -o secretos.env.age secretos.env
age: error: incompatible recipients: can't mix post-quantum and classic recipients, or the file would be vulnerable to quantum computers

Si tienes usuarios en versiones distintas, la solución es generar dos archivos: uno para los que soportan poscuántico y otro para los que no.

Ahora la parte menos relevante de la historia: la versión que te instala tu distribución. La criptografía poscuántica necesita age 1.3.0 o superior, así que la pregunta es si tu distro llegó a esa versión. Fedora 42 en adelante y Debian sid ya la tienen; las versiones estables de Debian y Ubuntu todavía no.

Distribución Versión de age ¿Soporta poscuántico?
Debian trixie 1.2.1-1 No
Debian forky y sid 1.3.2-1 Sí
Debian bookworm 1.1.1-1 No
Debian bullseye 1.0.0~rc1-2 No
Ubuntu noble (24.04 LTS) 1.1.1-1ubuntu0.24.04.3 No
Fedora 42 a 45 1.3.1 Sí
Fedora 41 1.2.1 No

Si tu distribución va por detrás (Debian trixie, bookworm o Ubuntu noble, por ejemplo), el camino es compilar la 1.3.2 desde los binarios oficiales o instalar desde el repositorio de pruebas de tu distribución. Si no, la clave nativa X25519 de toda la vida sigue siendo una elección sólida y no vas a quedarte sin cifrar nada.

Verifica el contenido cifrado sin descifrarlo

Antes de mandarle un archivo a alguien, o cuando estás investigando por qué un descifrado falla, necesitas ver qué hay dentro sin abrirlo. Para eso está age-inspect, una utilidad que llegó con la 1.3.0.

age-inspect secretos.env.age
age-inspect --json secretos.env.age

La salida te dice la versión del formato, los tipos de destinatario, si el archivo usa criptografía poscuántica y el desglose de tamaños. La opción --json da la misma información en formato legible por máquina, que es lo que vas a necesitar si esto entra en un script de verificación.

Un detalle de la 1.3.2 que te va a ahorrar un susto: age ahora se niega a volcar datos binarios o texto no UTF-8 directamente a la terminal. Si ves el error refusing to output binary to the terminal, no es un fallo del descifrado, es una protección. El mensaje te dice cómo forzarlo si de verdad lo necesitas, con -o -.

Automatiza el despliegue en CI y en cron

En un pipeline de CI la variable secreta llega por el gestor de secretos del proveedor. El patrón es materializar la identidad en un archivo temporal, usarlo y borrarlo en el mismo paso.

umask 077
printf '%s\n' "$AGE_SECRET_KEY" > clave.tmp
age -e -i clave.tmp -o secretos.env.age secretos.env
rm -f clave.tmp

Y para el lado del servidor, un cron job que descifra, usa y limpia. El archivo temporal con mktemp y el trap se encargan de que nada quede tirado en disco.

#!/usr/bin/env bash
set -euo pipefail
temporal="$(mktemp)"
trap 'rm -f "$temporal"' EXIT
age -d -i /etc/miapp/clave.age.txt /etc/miapp/secretos.env.age > "$temporal"
set -a
source "$temporal"
set +a
/usr/local/bin/miapp-sync

Para que el cron encuentre el binario, define el PATH en el archivo de crontab, porque las tareas programadas no heredan el PATH interactivo:

SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
0 * * * * root /usr/local/bin/miapp-sync

Preguntas frecuentes

¿Puedo pasar la frase de seguridad con una variable de entorno?

En el CLI principal de age, no. La opción -p pide la frase de forma interactiva en la terminal y no existe ninguna variable de entorno para inyectarla. Desde la versión 1.3.0 el plugin oficial age-plugin-batchpass sí ofrece AGE_PASSPHRASE y AGE_PASSPHRASE_FD, pero viene aparte y el propio proyecto recomienda usar claves nativas para automatización.

¿Qué diferencia hay entre -r e -i?

-r cifra a un recipient (clave pública) y -i descifra con una identidad (clave privada). También se puede cifrar con age -e -i, que es equivalente a convertir tu identidad en recipient y pasarla por -R. En los scripts, la combos que importa es age -d -i identidad.txt.

¿Puedo usar la misma clave SSH que uso para conectarme al servidor?

Sí, age acepta claves ssh-ed25519 y ssh-rsa como destinatario, con RSA de al menos 2048 bits. Pero el agente SSH no está soportado, las claves en YubiKeys no sirven, y la etiqueta de la clave pública queda incrustada en el archivo cifrado. El manual recomienda usar claves nativas de age siempre que sea posible.

¿Por qué age se niega a mezclar recipients poscuánticos y clásicos?

Porque el archivo se volvería vulnerable a una computadora cuántica: si uno de los destinatarios usa una clave clásica, basta con romper esa para leer todo el contenido. age bloquea esa combinación a propósito y te obliga a generar dos archivos separados si necesitas cubrir sistemas con versiones distintas.

¿Cómo compruebo que el archivo cifrado está bien sin descifrarlo?

Usa age-inspect archivo.age, que viene con age 1.3.0 o superior. Te muestra el tipo de destinatario, si usa criptografía poscuántica y el desglose de tamaños sin abrir el contenido. Con --json la salida es legible por máquina, útil para verificaciones automatizadas.

Lee también

Alfonso Moreno | Sysadmin Senior

Soy Alfonso, sysadmin senior con RHCE y más de 10 años administrando sistemas Linux: infraestructura, redes, virtualización y self-hosting.

Deja una respuesta

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