wandres.dev
CI/CD · automatizar el ciclo

Firmar en CI sin filtrar la llave: el almacén como secreto, el entorno y los registros

Firmar un artefacto en una máquina que no controlas obliga a poner una clave privada donde antes no estaba y a confiar en que nadie la lea. Esta lección desarrolla el modelo de amenaza real de la firma automatizada, explica por qué el almacén se transporta codificado y qué significa exactamente esa codificación, detalla cómo las variables de entorno atraviesan el proceso de compilación sin quedar escritas en disco ni en los registros, examina las vías de fuga menos evidentes como los artefactos, los volcados de error y las ramas ajenas, y contrasta el modelo de clave propia con la firma delegada a la tienda.

⏱ 22 min

Automatizar la firma es el momento en que la integración continua deja de ser una comodidad y pasa a ser un problema de seguridad con consecuencias irreversibles. Hasta ese punto, lo peor que puede ocurrir en el peor de los flujos es que alguien compile algo que no debía. A partir de ahí, la máquina que ejecuta tus flujos tiene acceso a la identidad criptográfica de tu aplicación, la misma que determina si una actualización puede reemplazar a la instalada en el teléfono de un usuario, y esa identidad no se puede revocar, ni rotar sin coordinación, ni recuperar si se pierde. La firma no autentica el binario ante un servidor que pueda cambiar de opinión: autentica la continuidad de la aplicación ante el sistema operativo, y esa relación se estableció el día de la primera publicación.

🎯 Al terminar esta lección sabrás
  • Formular el modelo de amenaza de la firma automatizada y decidir qué se protege de quién.
  • Transportar el almacén de claves como secreto codificado y reconstruirlo sin dejar rastro en disco.
  • Inyectar las contraseñas por entorno de forma que el proceso de compilación las consuma sin escribirlas.
  • Enumerar las vías de fuga menos evidentes y cerrarlas antes de que alguien las encuentre.

El modelo de amenaza: qué se protege y de quién

Antes de escribir una línea conviene enunciar de qué se está protegiendo la clave, porque las medidas razonables cambian por completo según la respuesta. Hay cuatro adversarios plausibles y solo uno de ellos suele estar en la cabeza de quien configura el flujo. El primero es el atacante externo que compromete el repositorio o una cuenta con permisos; es el que todo el mundo imagina. El segundo es el colaborador legítimo que, sin mala intención, ejecuta un flujo con un paso que imprime el entorno para depurar y deja la contraseña en un registro que queda archivado durante noventa días. El tercero es la dependencia de terceros: cualquier acción del mercado que se incorpore al flujo se ejecuta con acceso al mismo entorno, y una versión comprometida de una acción popular es un vector demostrado. El cuarto, el más incómodo, es el propio historial: una clave que estuvo alguna vez comprometida en el repositorio sigue ahí aunque el commit se haya revertido.

De ese análisis salen tres principios que ordenan todo lo demás. El primero es que la clave nunca debe existir en el sistema de archivos del repositorio, ni siquiera cifrada, porque el historial es indeleble y la contraseña de cifrado acaba viviendo en el mismo sitio. El segundo es que la clave solo debe estar disponible en los trabajos que realmente firman, y no en los que compilan variantes de depuración o ejecutan pruebas, porque cada trabajo que la ve amplía la superficie sin aportar nada. El tercero es que la fuga esperable no es un robo sofisticado sino una impresión accidental, y por tanto las defensas más rentables son las que hacen imposible imprimirla.

Existe además una asimetría de daño que conviene tener presente. Filtrar la clave de subida a la tienda es grave pero recuperable: se revoca, se registra una nueva y las publicaciones futuras usan la nueva. Filtrar la clave de firma de aplicación en el modelo antiguo, donde el binario que llega al dispositivo va firmado con ella, es catastrófico e irreversible, porque cualquiera puede producir actualizaciones que el sistema operativo aceptará como legítimas. Esa asimetría es, por sí sola, el argumento decisivo a favor de delegar la firma final en la tienda.

🔐

Clave de subida

Identifica al publicador ante la tienda. Es revocable y sustituible, y es la única que debe tocar tu flujo.

🏛️

Clave de aplicación

La que el dispositivo verifica en cada actualización. Custodiada por la tienda en el modelo moderno.

📼

Secreto codificado

El almacén viaja como texto en base64 porque el almacén de secretos guarda cadenas, no ficheros binarios.

🙈

Enmascarado

Todo valor derivado de un secreto debe registrarse como enmascarado para que no aparezca en los registros.

Transportar el almacén: por qué codificado y qué implica

El almacén de claves es un fichero binario y el almacén de secretos de la plataforma guarda cadenas de texto. La solución universal es codificarlo en base64 y guardar la cadena resultante como secreto, reconstruyendo el fichero durante la ejecución. Conviene entender que esa codificación no es cifrado: no aporta ninguna confidencialidad por sí misma, solo transporte. Lo que protege el almacén es la contraseña con la que se creó y el control de acceso del almacén de secretos, no el base64.

De ahí se deriva una regla práctica que mucha gente incumple: la cadena codificada y la contraseña del almacén deben ser secretos distintos, y quien tenga acceso a uno no debería tener acceso automático al otro cuando la organización permite esa separación. Guardar ambos en el mismo lugar es aceptable en equipos pequeños, pero conviene saber que se está aceptando.

La reconstrucción debe ocurrir en un directorio efímero fuera del árbol de trabajo, para que ninguna tarea que empaquete el directorio del repositorio lo arrastre por accidente, y debe eliminarse al terminar aunque el trabajo falle. La codificación sin saltos de línea evita un fallo clásico en el que la decodificación produce un fichero corrupto y el error que aparece varias fases después no menciona en ningún momento el almacén.

# En tu maquina, una sola vez: codificar sin saltos de linea
base64 -i upload-keystore.jks -o keystore.b64
# El contenido de keystore.b64 se pega como secreto del repositorio
- name: Reconstruir el almacen fuera del arbol de trabajo
  run: |
    echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > "$RUNNER_TEMP/upload.jks"
    echo "KEYSTORE_PATH=$RUNNER_TEMP/upload.jks" >> "$GITHUB_ENV"

- name: Construir y firmar
  env:
    KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
    KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
    KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
  run: ./gradlew bundleRelease

- name: Borrar el almacen pase lo que pase
  if: always()
  run: rm -f "$RUNNER_TEMP/upload.jks"
⚠️
Lo que nunca debe aparecer en un paso de shell

Tres construcciones bastan para arruinar todo el esquema. La primera es activar la traza de comandos del intérprete, que imprime cada línea expandida incluyendo los valores de las variables. La segunda es cualquier volcado del entorno completo con fines de depuración, que la plataforma no puede enmascarar de forma fiable cuando el valor aparece transformado. La tercera es pasar la contraseña como argumento de línea de comandos en lugar de por entorno, porque queda visible para cualquier proceso del sistema que enumere la tabla de procesos y aparece en los mensajes de error de la propia herramienta cuando falla el análisis de argumentos.

El entorno y la configuración de firma en Gradle

La configuración de firma debe leer del entorno y degradar limpiamente cuando ese entorno no está, para que un desarrollador pueda compilar la variante de publicación en local sin poseer la clave. El patrón correcto es condicionar la existencia misma de la configuración a la presencia de las variables, en lugar de definirla siempre y fallar con un error críptico. Así el flujo de integración que no firma sigue funcionando y el que firma obtiene lo que necesita.

Hay un detalle de higiene que conviene añadir: si el proyecto todavía usa un fichero de propiedades local para las credenciales de firma, ese fichero debe estar excluido del control de versiones y la lógica debe preferir siempre el entorno cuando ambos existan. La combinación inversa, en la que el fichero gana, produce el escenario en el que la máquina firma con una clave equivocada porque alguien dejó un fichero olvidado en la imagen.

// build.gradle.kts del modulo de aplicacion
android {
    signingConfigs {
        create("release") {
            val ruta = System.getenv("KEYSTORE_PATH")
            if (ruta != null) {
                storeFile = file(ruta)
                storePassword = System.getenv("KEYSTORE_PASSWORD")
                keyAlias = System.getenv("KEY_ALIAS")
                keyPassword = System.getenv("KEY_PASSWORD")
            }
        }
    }
    buildTypes {
        getByName("release") {
            isMinifyEnabled = true
            if (System.getenv("KEYSTORE_PATH") != null) {
                signingConfig = signingConfigs.getByName("release")
            }
        }
    }
}

Después de firmar merece la pena verificar, y no dar por hecho. La herramienta de firma del SDK informa de con qué esquemas quedó firmado el artefacto y de qué certificado lo respalda, y comparar la huella del certificado con la esperada convierte un fallo silencioso de configuración en un error explícito. Es el equivalente de leer el recibo.

# Verificar el esquema y el certificado del artefacto firmado
apksigner verify --print-certs --verbose app-release.apk

# Para un paquete de aplicacion, comprobar la huella del almacen usado
keytool -list -v -keystore "$KEYSTORE_PATH" -alias "$KEY_ALIAS"
flowchart TD
A[Secreto en base64 en el almacen de la plataforma] --> B[Paso de reconstruccion]
B --> C[Fichero temporal fuera del arbol de trabajo]
D[Contrasenas como variables de entorno] --> E[Proceso de gradle]
C --> E
E --> F[Artefacto firmado]
F --> G[Verificacion del certificado]
G --> H[Subida al canal de prueba]
E --> I[Paso de limpieza siempre]
I --> J[Fichero temporal eliminado]
style J fill:#a6e3a1,color:#11111b
style A fill:#f9e2af,color:#11111b

Las fugas que nadie anticipa

Las tres vías de fuga clásicas no tienen nada que ver con el paso de firma. La primera son los artefactos que el flujo publica: un directorio de resultados que incluye por accidente el fichero reconstruido, un informe de compilación que copia el entorno o un volcado de fallo que adjunta el directorio temporal completo. Conviene enumerar explícitamente qué rutas se suben en lugar de subir directorios enteros, y auditar una vez el contenido real de un artefacto descargado.

La segunda son los flujos disparados por ramas ajenas. Un pull request desde una bifurcación no debe tener acceso a los secretos, y por defecto no lo tiene, pero existe un disparador alternativo diseñado para ejecutar en el contexto del repositorio principal que sí los expone y que se combina con demasiada frecuencia con un paso que descarga y ejecuta el código propuesto. Esa combinación concreta es una entrega directa de la clave a cualquiera que abra un pull request. La regla es sencilla: si un flujo puede ejecutar código de una bifurcación, no debe ver secretos de firma bajo ninguna circunstancia.

La tercera es el registro producido por herramientas de terceros que no saben que están manejando un secreto. Un plugin que imprime su configuración al arrancar, una acción que reporta las variables que ha recibido, una herramienta de subida que incluye la petición completa en su mensaje de error. La plataforma enmascara los valores exactos de los secretos que conoce, pero no enmascara transformaciones de esos valores, de modo que un secreto que aparece decodificado, troceado o vuelto a codificar de otra forma atraviesa el filtro sin problema. Cuando un paso derive un valor sensible nuevo, hay que registrarlo explícitamente como enmascarado.

💡
Reducir el alcance antes que reforzar la custodia

La medida más eficaz no es proteger mejor la clave sino necesitarla menos veces. Un flujo bien diseñado firma en un trabajo aislado, con permisos mínimos, que recibe el artefacto sin firmar como entrada y devuelve el firmado como salida, sin ejecutar ninguna acción de terceros ni ningún código del repositorio más allá de la propia invocación de firma. Ese trabajo puede además restringirse a un entorno protegido que exija aprobación manual y que limite las ramas desde las que puede invocarse. Con esa separación, comprometer el resto del pipeline deja de implicar comprometer la clave.

La clave no firma el binario: firma la continuidad

Hay una confusión conceptual muy extendida sobre qué hace exactamente una firma en Android, y desmontarla cambia por completo la percepción del riesgo. Instintivamente uno piensa que la firma certifica que el binario es auténtico, como el sello de un notario sobre un documento. Pero el sistema operativo no consulta a ninguna autoridad, no valida ninguna cadena de certificación contra una raíz de confianza y no le importa quién eres: el certificado es autofirmado y podría decir cualquier cosa. Lo único que el sistema comprueba, en el momento de instalar una actualización, es que la clave que firma el paquete nuevo sea la misma que firmó el que ya está instalado. La firma no establece identidad, establece continuidad. Esta distinción tiene consecuencias que se sienten años después. Significa que el valor de la clave no reside en su secreto sino en su permanencia, y que perderla es tan grave como filtrarla, porque una clave perdida rompe la cadena y obliga a publicar una aplicación nueva que ningún usuario actualizará automáticamente. Significa también que el modelo de firma delegada a la tienda no es una comodidad administrativa sino un cambio arquitectónico profundo: introduce una indirección entre la identidad que tú controlas y la que el dispositivo verifica, de modo que la primera se vuelve rotable mientras la segunda permanece estable bajo custodia de un tercero con procedimientos de recuperación. Es el mismo movimiento que la industria hizo hace décadas al separar credenciales de sesión de credenciales de identidad, y por los mismos motivos. Y explica por qué la pregunta correcta al diseñar un flujo de firma no es cómo proteger mejor la clave, sino cómo reducir el conjunto de cosas cuya pérdida sería irreversible.

⚔️ Intenta filtrar tu propia clave
  1. Revisa cada paso de tus flujos buscando trazas de comandos, volcados de entorno y contraseñas pasadas como argumento en lugar de por entorno.
  2. Descarga un artefacto real producido por tu pipeline, descomprímelo y busca dentro cualquier rastro del almacén o de las contraseñas.
  3. Comprueba si algún flujo con acceso a secretos puede dispararse desde una bifurcación, y elimina esa posibilidad si existe.
  4. Verifica la huella del certificado del último artefacto publicado y compárala con la que la tienda declara como esperada.
  5. Aísla la firma en un trabajo propio con permisos mínimos y un entorno protegido, y comprueba que el resto del pipeline sigue funcionando sin ver ningún secreto.