Rotación de las claves de Semaphore — ops.una.ac.cr

Aplicado el 7 de octubre de 2026. Este documento sirve tanto de registro de lo que se hizo como de procedimiento para la próxima vez.


El problema

El fichero config.json.bak estaba versionado en git desde el primer commit del repositorio INFRA/ansible, y contenía las cuatro claves que Semaphore usaba en ese momento, todas vivas:

ClaveQué protege
postgres.passla base de datos de Semaphore
cookie_encryptioncifra las cookies de sesión
cookie_hashfirma las cookies de sesión
access_key_encryptioncifra el Key Store entero

La última es la grave. Con ella, más un volcado de la base —cuya contraseña estaba en el mismo fichero—, se descifran las 51 credenciales del Key Store: las llaves SSH de los 96 servidores gestionados, las contraseñas de ansible-vault y las de los dos vCenter.

El fichero estaba en Bitbucket y en todos los clones del repositorio.


Por qué NO se truncó el historial de git

Fue la primera idea y se descartó, por una razón concreta:

Un secreto publicado no se despublica.

  • Quien ya había clonado el repositorio tiene las cuatro claves en su .git. Un force push no le toca nada.
  • Bitbucket conserva un tiempo los commits sin referencia, y siguen siendo accesibles por su SHA. No desaparecen porque se reescriba master.
  • Truncar costaba todo el historial del repositorio.

Es decir: se pagaba un precio alto y las claves seguían sirviendo. La pregunta no era cómo borrarlas del repositorio, sino cómo hacer que dejaran de servir. Lo único que lo consigue es rotarlas. Una vez rotadas, lo que quede en el historial ya no abre nada.


Lo que se temía, y por qué no pasó

La preocupación era access_key_encryption: si se cambia, Semaphore ya no puede descifrar el Key Store, y parecía que habría que volver a meter las 51 credenciales a mano.

No hizo falta. Semaphore sabe recifrarlo:

semaphore vaults rekey --old-key <clave-vieja>

Re-encrypt Key Store in database with using current encryption key

Se cambia la clave en config.json, se corre eso, y recifra las 51 sin tocar ninguna.


El procedimiento

El script completo está en /home/cgi/rotar_claves_semaphore.sh. Se corre como root:

su -c 'bash /home/cgi/rotar_claves_semaphore.sh'

Lo que hace, en orden:

#PasoDetalle
1Respaldosconfig.json y un volcado de la base, fuera del repositorio, en /home/cgi/respaldos/. Verifica que el volcado trae la tabla access_key antes de seguir
2Genera 4 claves nuevasopenssl rand -base64 32 para las tres de 44 caracteres
3Para SemaphoreSi sigue corriendo mientras se recifra, él tiene la clave vieja en memoria y la base pasa a la nueva: cualquier tarea en esa ventana falla
4Contraseña de PostgreSQLALTER USER + actualiza /home/cgi/.pgpass_semaphore
5Reescribe config.jsonLas cuatro claves. Comprueba después que la base acepta la nueva
6Recifra el Key Storesemaphore vaults rekey --old-key. Si falla, aborta y dice qué restaurar
7Levanta SemaphoreY verifica que quedó activo
8CompruebaQue siguen siendo 51 credenciales

Impacto

  • Semaphore parado unos minutos.
  • 🔴 Todos tienen que volver a entrar a la interfaz: las cookies cambian.
  • Las credenciales del Key Store siguen funcionando.

🔴 La comprobación que de verdad valida el cambio

Que el script termine bien no basta. Un rekey a medias no se ve al arrancar.

Hay que correr una tarea real que use una llave SSH del Key Store. Si alguna quedó mal recifrada, se ve ahí: la tarea no puede conectarse al servidor.

El 2026-10-07 se validó con la plantilla PB | PROD | ADMISIÓN | DEBIAN | USOK | N: PROD-ADMISION-N01, que usa la llave 43: ok=32 changed=0 failed=0 unreachable=0.


🔑 Cómo consultar las claves actuales

# Contraseña de PostgreSQL
cut -d: -f5 /home/cgi/.pgpass_semaphore

# Las tres de config.json
python3 -c "import json; c=json.load(open('/home/cgi/semaphore/config.json')); \
[print('%-24s %s' % (k, c[k])) for k in ('cookie_encryption','cookie_hash','access_key_encryption')]"

# Datos de conexión a la base, sin la contraseña
python3 -c "import json; pg=json.load(open('/home/cgi/semaphore/config.json'))['postgres']; \
print('host:', pg['host'], ' base:', pg['name'], ' usuario:', pg['user'])"

⚠️ access_key_encryption es la crítica. Si se pierde, el Key Store queda ilegible y hay que volver a meter las 51 credenciales a mano. Guárdela donde no se pierda —TeamPass o equivalente— y nunca en el repositorio.

Comprobar que una clave antigua ya no sirve

python3 -c "
import json, hashlib
h = lambda s: hashlib.sha256(s.encode()).hexdigest()[:16]
c = json.load(open('/home/cgi/semaphore/config.json'))
vieja = input('clave antigua a comprobar: ')
print('SIGUE VIVA' if any(h(vieja)==h(v) for v in
      [c['postgres']['pass'], c['cookie_encryption'], c['cookie_hash'], c['access_key_encryption']])
      else 'ya no sirve')"

Vuelta atrás

Los respaldos quedan en /home/cgi/respaldos/ con la fecha en el nombre y permisos 600.

systemctl stop semaphore
cp /home/cgi/respaldos/config.json.pre-rotacion-<fecha> /home/cgi/semaphore/config.json
zcat /home/cgi/respaldos/semaphore_db_pre-rotacion-<fecha>.sql.gz \
  | PGPASSFILE=/home/cgi/.pgpass_semaphore psql -h 127.0.0.1 -U semaphore_user -d semaphore_db
systemctl start semaphore

Después de rotar

  • Los dos config.json.bak* salieron del repositorio. El .gitignore ya bloquea config.json.bak*, para que no vuelvan a entrar.
  • Quedan copias de consulta en /home/cgi/respaldos/config-historicos/, con permisos 600.

Un fallo del script, por si reaparece

El primer intento abortó con «el volcado no trae access_key» aunque el volcado sí lo traía. La causa:

set -o pipefail
zcat fichero_grande.gz | grep -q PATRON     # devuelve 141 CUANDO EL PATRÓN SÍ ESTÁ

grep -q sale en cuanto encuentra la primera coincidencia y cierra la tubería. zcat, que iba por el 1% del fichero, recibe SIGPIPE y muere con 141. Con pipefail, ese 141 es el código del pipeline.

Con ficheros pequeños no se reproduce —grep los consume enteros antes de salir—, así que un ensayo con pocas líneas lo da por bueno. La forma correcta:

n=$(zcat fichero_grande.gz | grep -c PATRON || true)
[ "${n:-0}" -ge 1 ] || { echo "no está"; exit 2; }

El mismo problema afecta a cualquier | head, | grep -q o | first-match sobre algo grande con pipefail activo.