wandres.dev
CI/CD PARA IOS · automatizar el ciclo

Firmar en CI: certificados en una máquina efímera, match y API keys

La firma de código funciona en tu Mac porque llevas años acumulando estado en el llavero sin darte cuenta. Un agente efímero no tiene ese estado y por eso la firma es la fuente número uno de fallos en cualquier pipeline de iOS. Esta lección reconstruye el problema desde la criptografía que hay debajo, monta la solución con fastlane match y claves de API de App Store Connect, y establece la higiene del llavero temporal que separa un pipeline fiable de uno que falla los martes.

⏱ 24 min

Firmar una aplicación es demostrar, con una clave privada que solo tú posees y un certificado que Apple emitió sobre su clave pública, que ese binario viene de quien dice venir. En tu Mac funciona sin que nunca hayas pensado en ello porque la clave privada lleva años en tu llavero, el certificado llegó con ella y los perfiles se descargaron solos. Un agente de integración continua se crea, compila y se destruye: no tiene llavero, no tiene clave privada y no tiene ninguna forma de obtenerla, porque una clave privada que se pudiera obtener automáticamente no sería una clave privada. Todo el problema de firmar en un pipeline consiste en trasladar de forma segura ese estado a una máquina que por diseño no lo tiene, y en no dejarlo ahí cuando termine.

🎯 Al terminar esta lección sabrás
  • Reconstruir qué necesita exactamente una firma y por qué el estado del llavero es intransferible sin ceremonia.
  • Montar fastlane match como repositorio cifrado de identidades y entender su modelo de reencriptación.
  • Sustituir contraseñas y sesiones por claves de API de App Store Connect con el ámbito mínimo.
  • Crear y destruir un llavero temporal por ejecución, y planificar rotación, revocación y caducidad.

Qué necesita una firma y por qué el agente no lo tiene

Firmar exige cuatro piezas y las cuatro deben estar presentes a la vez. Una clave privada, que se genera en una máquina y nunca debería salir de ella en claro. Un certificado emitido por Apple que ata esa clave a una identidad de equipo. Un perfil de aprovisionamiento que combina el identificador de la aplicación, las capacidades declaradas y los certificados autorizados. Y unos entitlements en el binario que sean un subconjunto de lo que el perfil permite. Si falta cualquiera de las cuatro, o si no encajan entre sí, la firma falla, y el mensaje de error rara vez señala cuál de las cuatro es la culpable.

flowchart TB
k[Clave privada generada una vez] --> csr[Peticion de firma de certificado]
csr --> ap[Apple emite el certificado]
ap --> id[Identidad: clave mas certificado en formato p12]
id --> repo[Repositorio cifrado con contrasena compartida]
pp[Perfil de aprovisionamiento] --> repo
repo --> ci[Agente efimero descarga y descifra]
ci --> kc[Llavero temporal creado para esta ejecucion]
kc --> firma[xcodebuild firma el archivo]
firma --> borrar[Llavero destruido al terminar]

La razón de que esto sea difícil está en el primer nodo del diagrama. La clave privada es, por definición, lo único que no se puede regenerar ni pedir a nadie: si se pierde, hay que revocar el certificado y emitir uno nuevo, y todos los perfiles que lo referenciaban dejan de servir. Por eso la solución no puede consistir en que el agente genere sus propias identidades cada vez, aunque técnicamente sea posible: se agotaría el límite de certificados de distribución del equipo y se dejaría un rastro de identidades vivas que nadie controla.

⚠️
El error de diagnóstico más frecuente

Cuando la firma falla en el agente y funciona en local, el reflejo es sospechar del perfil. Casi siempre el problema es anterior: la clave privada no está en el llavero activo, o está pero el llavero no forma parte de la lista de búsqueda, o forma parte pero está bloqueado, o no está bloqueado pero la lista de control de acceso de la clave exige confirmación interactiva que nadie puede dar. Cuatro causas distintas, un solo mensaje de error.

match: el llavero como repositorio cifrado

La idea de fastlane match es tan simple que resulta desconcertante la primera vez: en lugar de que cada máquina tenga sus identidades, se genera un único juego, se cifra con una contraseña simétrica y se guarda en un repositorio privado. Cualquier máquina que tenga acceso al repositorio y conozca la contraseña puede reconstruir el estado completo en segundos. Deja de haber un llavero canónico en el portátil de alguien y pasa a haber una fuente de verdad versionada.

# fastlane/Matchfile
git_url("git@github.com:miorg/certificados-ios.git")
storage_mode("git")
type("appstore")
app_identifier(["com.miorg.miapp", "com.miorg.miapp.widget"])
readonly(true)

Ese readonly(true) es la línea más importante del fichero y merece una explicación. En modo de solo lectura, match descarga y descifra lo que ya existe pero nunca crea ni renueva nada. Es exactamente el comportamiento que se quiere en un agente: si algo falta, debe fallar de forma ruidosa en lugar de generar una identidad nueva en silencio. La creación y la renovación son actos deliberados que hace una persona desde su máquina, con match nuke y match appstore cuando toca.

🔑

Una contraseña simétrica

MATCH_PASSWORD cifra todo el contenido del repositorio. Quien la tenga y tenga acceso de lectura al repositorio puede firmar en nombre del equipo. Trátala como el secreto de mayor valor del pipeline.

🧾

Un repositorio privado aparte

Nunca en el repositorio de la aplicación. El acceso a las identidades y el acceso al código son decisiones distintas y deben poder concederse por separado.

🚪

Acceso de lectura para el agente

Una clave de despliegue con permiso de solo lectura, específica de ese repositorio. Un token amplio de organización convierte una fuga de secreto en un incidente mucho mayor.

🔄

Reencriptar al rotar

Cambiar la contraseña obliga a reencriptar todo el contenido y a actualizar el secreto en cada consumidor. Es una operación planificada, no algo que se improvise un viernes.

Existe además el modo que guarda el contenido cifrado en App Store Connect en lugar de en un repositorio de Git. Elimina la necesidad de gestionar un repositorio aparte y sus permisos, a cambio de perder el historial que a veces resulta útil para saber cuándo y por qué cambió una identidad. Para equipos pequeños suele ser la opción más limpia; para equipos con auditoría, el repositorio versionado sigue ganando.

Claves de API en lugar de contraseñas

La segunda mitad del problema es la autenticación contra App Store Connect. Durante años se resolvió con el usuario y la contraseña de una cuenta de Apple, lo que exigía contraseñas de aplicación, sesiones que caducaban y, en la práctica, una cuenta compartida que nadie quería tener a su nombre. Las claves de API resuelven todo eso: son credenciales de servicio, no de persona, con un rol asignable y revocables de forma individual.

Una clave tiene tres partes: el identificador del emisor, que es único por equipo; el identificador de la clave; y el contenido del fichero con extensión p8, que solo se puede descargar una vez. Esa última característica es importante porque significa que si se pierde no se recupera: se revoca y se emite otra.

# fastlane/Fastfile
platform :ios do
  desc "Preparar la firma en un agente efimero"
  lane :preparar_firma do
    setup_ci(force: true)

    app_store_connect_api_key(
      key_id: ENV["ASC_KEY_ID"],
      issuer_id: ENV["ASC_ISSUER_ID"],
      key_content: ENV["ASC_KEY_CONTENT"],
      is_key_content_base64: true,
      in_house: false
    )

    match(type: "appstore", readonly: true)
  end
end

La llamada a setup_ci es la que crea un llavero temporal, lo desbloquea, lo añade a la lista de búsqueda y ajusta su tiempo de bloqueo automático para que no se cierre a mitad de una compilación larga. Sin ella, la clave privada llega al agente pero xcodebuild no la encuentra, que es el escenario descrito en el aviso anterior.

Sobre el ámbito de la clave conviene ser tacaño. El rol de administrador funciona siempre y por eso es el que todo el mundo asigna, pero un pipeline que solo compila y sube compilaciones necesita bastante menos. Empezar por el rol mínimo y ampliarlo cuando una operación concreta falle deja un rastro claro de qué permisos se necesitan de verdad, y limita el daño de una fuga.

      - name: Preparar la firma
        env:
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_AUTH }}
          ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
          ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
          ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }}
        run: bundle exec fastlane preparar_firma

Higiene del llavero y el calendario de caducidades

Si se prefiere no depender de fastlane para la parte del llavero, el mecanismo subyacente cabe en unas pocas líneas y conviene conocerlo aunque se use la herramienta, porque es lo que hay que inspeccionar cuando algo falla.

LLAVERO="$RUNNER_TEMP/ci.keychain-db"
CLAVE=$(openssl rand -base64 24)

security create-keychain -p "$CLAVE" "$LLAVERO"
security set-keychain-settings -lut 3600 "$LLAVERO"     # no bloquear durante una hora
security unlock-keychain -p "$CLAVE" "$LLAVERO"
security list-keychains -d user -s "$LLAVERO" $(security list-keychains -d user | tr -d '"')

security import identidad.p12 -k "$LLAVERO" -P "$P12_PASS" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: -s -k "$CLAVE" "$LLAVERO"

# Al terminar, pase lo que pase.
trap 'security delete-keychain "$LLAVERO"' EXIT

La penúltima línea es la que más gente omite y la que produce el fallo más desconcertante: sin ajustar la lista de particiones de la clave, macOS exige una confirmación interactiva la primera vez que codesign intenta usarla, y en un agente sin nadie delante eso se manifiesta como un bloqueo hasta agotar el tiempo. El trap final tampoco es opcional en agentes persistentes, donde un llavero olvidado se acumula ejecución tras ejecución hasta que dos identidades distintas compiten por firmar.

Queda la dimensión temporal, que es la que sorprende a los equipos por lo demás bien organizados. Los certificados de distribución caducan a los tres años, los perfiles al año, las claves de API se pueden revocar en cualquier momento y la contraseña de match se rota cuando alguien deja el equipo. Ninguna de esas fechas avisa: simplemente un día el pipeline falla, normalmente el día de una entrega. Anotarlas en un calendario compartido con aviso a un mes vista convierte una urgencia en una tarea de veinte minutos.

La firma es gestión de secretos disfrazada de configuración

Casi todo el mundo trata la firma como un problema de configuración de Xcode que se resuelve una vez y se olvida, y esa lectura es la que hace que reaparezca cada pocos meses. Lo que hay debajo es un sistema de gestión de identidades con todas sus obligaciones: una clave privada irremplazable, un conjunto de secretos con distintos titulares, una superficie de acceso que hay que poder conceder y retirar, y un calendario de caducidades que corre aunque nadie lo mire. Verlo así reordena las decisiones. La razón de que el agente vaya en modo de solo lectura no es la comodidad sino el principio de que una máquina automática no debe poder crear identidades a nombre del equipo. La razón de que las identidades vivan en un repositorio distinto del código no es el orden sino que el acceso al código y el acceso a la capacidad de publicar en nombre de la empresa son dos decisiones que deben poder tomarse por separado. La razón de crear y destruir el llavero en cada ejecución no es la limpieza sino que el material criptográfico no debe sobrevivir al trabajo que lo necesitaba. Y la prueba de fuego de todo el montaje es una pregunta que conviene poder responder en voz alta: si mañana se marcha la persona que configuró esto, ¿cuánto se tarda en revocar todo lo que tenía y volver a firmar? Si la respuesta es que nadie lo sabe, el problema no es la integración continua, es que la firma nunca se trató como lo que es.

⚔️ Montar la firma efímera de principio a fin
  1. Crea un repositorio privado nuevo, ejecuta match en modo de creación desde tu máquina y comprueba que el contenido queda cifrado y versionado.
  2. Emite una clave de API con el rol mínimo que se te ocurra, guárdala en base64 como secreto y comprueba qué operación falla primero. Amplía solo lo necesario.
  3. Escribe la etapa de firma con setup_ci y match en modo de solo lectura, y verifica que un agente limpio archiva y firma sin intervención.
  4. Sustituye setup_ci por el bloque manual de security y localiza cuál de las líneas hace que codesign deje de pedir confirmación.
  5. Anota en un calendario compartido las fechas de caducidad de tu certificado de distribución y de tus perfiles, con aviso un mes antes, y documenta el procedimiento de revocación en cinco líneas.