macOSFastlaneNotarizacióncodesignnotarytool

CI/CD para macOS con Fastlane (parte 3): notarización a fondo

En la parte 2 dejamos las credenciales listas. En esta parte veo por dentro qué pasa realmente cuando Fastlane invoca codesign y notarytool: por qué el Hardened Runtime es obligatorio, cuáles entitlements activar y cuáles apagar, y qué es exactamente lo que se adjunta al final.

Nada de esto es específico de Fastlane. Es lo que Apple exige. Fastlane solo orquesta.

Qué firma codesign

Cuando codesign firma un .app, no firma “el binario”. Firma cada archivo dentro del bundle y guarda todas las firmas en un Contents/_CodeSignature/CodeResources dentro de la propia app. Después firma el ejecutable principal e incluye un hash de ese CodeResources como parte de la firma.

Resultado: si alguien cambia una imagen, un archivo de localización o cualquier recurso dentro del .app, el hash del recurso deja de coincidir con el que quedó dentro de la firma, y Gatekeeper lo detecta al arrancar.

Puedes ver la firma con:

codesign -dvv --deep /path/to/Pokédex.app

Sale algo así (los valores exactos dependen de tu Team ID y de la build):

Identifier=com.slekens.PokedexMac
Format=app bundle with Mach-O universal (arm64 x86_64)
CodeDirectory v=20500 size=... flags=0x10000(runtime) hashes=...
Authority=Developer ID Application: Your Name (XXXXXXXXXX)
Authority=Developer ID Certification Authority
Authority=Apple Root CA
TeamIdentifier=XXXXXXXXXX
Runtime Version=14.0.0
Sealed Resources version=2

Dos cosas para fijarse:

  • flags=0x10000(runtime) — significa que el Hardened Runtime está activo. Sin esto Apple rechaza la notarización.
  • La cadena Authority — tres eslabones: tu certificado Developer ID, la CA intermedia de Apple, el root de Apple. Es lo que Gatekeeper valida contra el trust store de macOS.

El Hardened Runtime

El Hardened Runtime es un conjunto de restricciones que Apple exige a las apps notarizadas. Cierran vectores de ataque comunes: no puedes cargar librerías que no estén firmadas por ti o por Apple, no puedes ejecutar memoria sin firmar como código, no puedes usar JIT sin declararlo explícitamente.

En el project.yml del repo se activa con:

settings:
  base:
    ENABLE_HARDENED_RUNTIME: YES

Xcode toma ese flag y le pasa --options=runtime a codesign. Eso es lo que aparece como flags=0x10000(runtime) en el output de arriba.

El Hardened Runtime no es opcional para notarizar. Si intentas subir una .app sin él, Apple rechaza el paquete con un mensaje explícito (“The executable does not have the hardened runtime enabled”) y notarize en Fastlane te lo devuelve con print_log: true.

Entitlements — Developer ID no es sandbox

Aquí hay otro nudo común. La gente ve que las apps del Mac App Store tienen com.apple.security.app-sandbox: true y asume que todas las apps macOS lo necesitan. No es así.

Developer ID + notarización NO exige sandbox. Puedes activarlo si quieres, pero no lo pide Apple. En el repo lo dejo desactivado explícitamente:

<!-- PokedexMac/PokedexMac.entitlements (equivalente XML del YAML del project.yml) -->
<key>com.apple.security.app-sandbox</key>
<false/>

Lo que sí exige el Hardened Runtime es que declares cualquier “escape” que necesites. Por defecto todo está apagado. Solo activas lo que uses:

# Del project.yml del repo
com.apple.security.cs.allow-jit: false
com.apple.security.cs.allow-unsigned-executable-memory: false
com.apple.security.cs.disable-library-validation: false
com.apple.security.network.client: true
  • allow-jit — permite compilar código en runtime. Solo si tienes un motor de JavaScript embebido o algo así.
  • allow-unsigned-executable-memory — memoria ejecutable no firmada. Solo si usas frameworks que lo requieran.
  • disable-library-validation — cargar librerías no firmadas por ti. Solo si integras plugins de terceros.
  • network.client — hacer requests salientes. Este sí lo activo porque la app llama a la PokeAPI. Aunque suene raro, sin sandbox técnicamente no haría falta —el Hardened Runtime no restringe la red— pero lo dejo declarado por documentación y porque no cuesta nada. Si más adelante activas sandbox, esta línea ya está.

La regla: menos entitlements activos, mejor. Apple mira la lista durante la notarización. Cada true es una superficie más de ataque potencial que ellos evalúan.

Cómo funciona notarytool con API Key

notarytool es la herramienta oficial actual (reemplazó a altool en 2022). Sube el binario a Apple, espera el veredicto, y devuelve el resultado. La sintaxis directa es:

xcrun notarytool submit /path/to/Pokédex.app \
  --key ~/Documents/asc-keys/AuthKey_XXXXXXXXXX.p8 \
  --key-id XXXXXXXXXX \
  --issuer 12345678-abcd-... \
  --wait

Con --wait bloquea hasta que Apple responde. Sin --wait te devuelve un ID que puedes consultar después con notarytool info.

Fastlane envuelve esto:

notarize(
  package:      app_path,
  bundle_id:    BUNDLE_ID,
  api_key_path: ENV["APP_STORE_CONNECT_API_KEY_PATH"],
  print_log:    true,
  verbose:      true
)

Como comenté en la parte 2, si el .p8 mantiene el nombre canónico AuthKey_<KEY_ID>.p8, Fastlane extrae Key ID e Issuer ID solo. print_log: true es útil: si Apple rechaza, escupe el JSON completo del log con la razón exacta. Sin eso te toca ir a buscar el log a mano con notarytool log <id>.

Un submission exitoso se ve así en el output (los detalles exactos varían según la build):

Successfully uploaded file
  id: 12345678-abcd-1234-abcd-1234567890ab
  path: /Users/.../Pokédex.app
Waiting for processing to complete...
Current status: Accepted
Processing complete
  id: 12345678-abcd-1234-abcd-1234567890ab
  status: Accepted

Suele tardar entre 30 segundos y 5 minutos. La primera vez que subes una app nueva puede tardar más porque Apple hace un análisis inicial más profundo.

Un atajo: guardar el perfil con store-credentials

Escribir los tres flags cada vez que notarizas a mano se vuelve tedioso rápido. notarytool trae un modo para guardar la combinación de .p8 + Key ID + Issuer ID en el Keychain bajo un nombre corto, y luego reusar solo ese nombre.

Se hace una vez:

xcrun notarytool store-credentials "pokedex-notary" \
  --key ~/Documents/asc-keys/AuthKey_XXXXXXXXXX.p8 \
  --key-id XXXXXXXXXX \
  --issuer 12345678-abcd-...

El perfil queda en el Keychain del usuario (buscando notarytool en Keychain Access lo ves). A partir de ahí, cualquier invocación posterior de notarytool se simplifica:

xcrun notarytool submit /path/to/Pokédex.app \
  --keychain-profile "pokedex-notary" \
  --wait

Lo mismo aplica a notarytool info, notarytool log y notarytool history — todos aceptan --keychain-profile en vez de los tres flags sueltos.

Nota sobre Fastlane: la acción notarize del Fastfile sigue usando api_key_path directamente porque le pasas la ruta del .p8 por ENV y ella arma la llamada. El perfil del Keychain es útil sobre todo para invocaciones manuales de notarytool — cuando estás debugueando una notarización fallida y quieres reproducir el submit a mano, o revisar el log de una build antigua con notarytool log <id>.

Adjuntar el ticket al binario — por qué no es opcional

El ticket que Apple genera vive en sus servidores. Gatekeeper puede consultarlo online, pero necesitamos que la app funcione sin red. Para eso stapler adjunta el ticket dentro del bundle:

xcrun stapler staple /path/to/Pokédex.app

El ticket queda en Contents/CodeResources como un blob adicional. Con eso, la próxima vez que Gatekeeper vea la app puede verificar todo localmente en milisegundos, sin llamar a Apple.

La acción notarize de Fastlane adjunta el ticket automáticamente si la notarización sale bien. Por eso en el Fastfile del repo no aparece un paso separado, está incluido. Si por alguna razón separas los pasos y notarizas sin adjuntarlo, el usuario final va a ver un delay perceptible la primera vez que abra la app mientras Gatekeeper consulta el servidor de Apple, o la app rehúsa abrirse si el usuario no tiene red.

Se puede verificar que el ticket quedó bien adjunto:

xcrun stapler validate /path/to/Pokédex.app
# The validate action worked!

Verificación final con spctl

spctl (System Policy Control) es lo que Gatekeeper usa internamente. Sirve para simular localmente lo que le pasará al usuario:

spctl --assess --type execute --verbose /path/to/Pokédex.app

Salida esperada:

/path/to/Pokédex.app: accepted
source=Notarized Developer ID

Esas dos líneas son el objetivo del pipeline entero. Si sale accepted con source=Notarized Developer ID, la app se abrirá en cualquier Mac sin fricción. Cualquier otra cosa (rejected, source=No matching rule found) significa que algo del pipeline se rompió y hay que revisar.

En el Fastfile del repo esta verificación va como paso 5 después de crear el DMG:

sh "spctl --assess --type execute --verbose '../#{app_path}' 2>&1 || true"

El || true está intencionalmente ahí para que la salida se imprima pero no aborte el lane —a veces spctl sale con exit code no cero por warnings menores que no impiden la distribución. El humano lee la salida y decide.

Qué sigue

En la parte 4 me meto con la parte de Fastlane pura: cómo maneja MARKETING_VERSION y CURRENT_PROJECT_VERSION, por qué existen dos lanes (qa y release) y qué las diferencia, y cómo el modo skip_publish sirve para ensayar el pipeline sin publicar de verdad.