¿Qué es certificate pinning?

HTTPS garantiza que la conexión está cifrada y que el certificado es válido para alguna CA confiable. Pero hay un problema: hay cientos de CAs en el mundo, y si cualquiera de ellas está comprometida o es maliciosa, puede emitir un certificado válido para tu dominio.

Certificate pinning resuelve esto: en lugar de confiar en cualquier CA válida, tu app solo acepta un certificado específico (o su clave pública específica). Un atacante con un certificado de otra CA — aunque sea válido — es rechazado.

Casos de uso:

  • Apps bancarias y financieras
  • Apps enterprise con APIs internas
  • Apps que manejan datos médicos o legales
  • Cualquier app donde un MITM sería catastrófico

Obtener el hash del pin

El pin es el hash SHA-256 de la clave pública del certificado (SPKI — Subject Public Key Info). Podés obtenerlo de varias formas:

# Opción 1: openssl desde el servidor directamente
openssl s_client -connect api.miapp.com:443 -servername api.miapp.com 2>/dev/null \
  | openssl x509 -pubkey -noout \
  | openssl pkey -pubin -outform DER \
  | openssl dgst -sha256 -binary \
  | base64

# Opción 2: desde un archivo .cer o .pem
openssl x509 -in certificado.pem -pubkey -noout \
  | openssl pkey -pubin -outform DER \
  | openssl dgst -sha256 -binary \
  | base64

# Opción 3: dejar que OkHttp lo imprima en logcat (solo para obtener el pin)
# Configurar un CertificatePinner vacío con pins incorrectos y ver el error:
# "Certificate pinning failure!
#   Peer certificate chain:
#     sha256/XXXXX= (CN=api.miapp.com, ...)   ← este es el pin correcto"

Obtener los pins con OkHttpLa forma más fácil en desarrollo: configurar un CertificatePinner con un pin inválido a propósito. OkHttp imprime en la excepción los hashes correctos. Copiá esos hashes y configurá el pinner real.

Pinning programático con OkHttp

// Construir el CertificatePinner
val certificatePinner = CertificatePinner.Builder()
    // Pin del certificado actual del servidor
    .add("api.miapp.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
    // Pin de backup (certificado intermedio o CA raíz)
    .add("api.miapp.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=")
    // También funciona con wildcards para subdominios
    .add("*.miapp.com", "sha256/CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=")
    .build()

val okHttpClient = OkHttpClient.Builder()
    .certificatePinner(certificatePinner)
    .build()

// Con Hilt — agregar al módulo de network:
@Module @InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides @Singleton
    fun provideCertificatePinner(): CertificatePinner {
        return CertificatePinner.Builder()
            .add("api.miapp.com", "sha256/${BuildConfig.CERT_PIN_CURRENT}")
            .add("api.miapp.com", "sha256/${BuildConfig.CERT_PIN_BACKUP}")
            .build()
    }

    @Provides @Singleton
    fun provideOkHttpClient(
        certificatePinner: CertificatePinner,
        authInterceptor: AuthInterceptor
    ): OkHttpClient {
        return OkHttpClient.Builder()
            .certificatePinner(certificatePinner)
            .addInterceptor(authInterceptor)
            .build()
    }
}

Los pins en BuildConfigGuardá los hashes de los pins en BuildConfig via build.gradle, no hardcodeados en el código fuente. Así podés cambiarlos con una actualización del build sin modificar el código. Combinado con R8, los strings en BuildConfig quedan ofuscados en el APK.

Rotación de certificados — el problema real

Los certificados expiran (generalmente cada 1-2 años). Cuando el servidor rota el certificado, todas las versiones de la app con el pin viejo dejan de funcionar. La estrategia correcta:

# Estrategia de rotación segura:

# 1. ANTES de rotar el certificado del servidor:
#    Publicar una versión de la app con el pin NUEVO como backup:
.add("api.miapp.com", "sha256/[PIN_ACTUAL]")
.add("api.miapp.com", "sha256/[PIN_NUEVO]")   # pin del cert que vas a instalar

# 2. Esperar a que la mayoría de usuarios actualice (semanas/meses)

# 3. Rotar el certificado en el servidor

# 4. Publicar nueva versión con solo el pin nuevo:
.add("api.miapp.com", "sha256/[PIN_NUEVO]")
.add("api.miapp.com", "sha256/[PIN_SIGUIENTE_BACKUP]")  # ya tenés el siguiente listo

Usuarios con versiones viejasCualquier usuario que no actualizó la app y tiene el pin viejo quedará sin acceso después de rotar el certificado. Por eso es crítico el período de solapamiento y tener un buen sistema de force-update para apps con requisitos de seguridad altos.

Manejar fallos de pinning en la UI

// OkHttp lanza SSLPeerUnverifiedException cuando el pin no coincide
// Detectarlo en el Repository o en el safeApiCall wrapper:

suspend fun <T> safeApiCall(call: suspend () -> T): Result<T> {
    return try {
        Result.success(call())
    } catch (e: SSLPeerUnverifiedException) {
        // Fallo de pinning — puede ser un ataque MITM o certificado rotado
        // NO mostrar detalles técnicos al usuario
        Result.failure(SecurityException("Conexión rechazada por política de seguridad"))
    } catch (e: SSLHandshakeException) {
        Result.failure(SecurityException("Error de seguridad en la conexión"))
    } catch (e: IOException) {
        Result.failure(Exception("Sin conexión"))
    }
}

// En el ViewModel — manejar el error de seguridad diferente al error de red:
fun manejarError(error: Throwable) {
    when (error) {
        is SecurityException -> {
            // Loggear en Crashlytics como non-fatal — puede indicar un ataque
            Firebase.crashlytics.recordException(error)
            _uiState.update { it.copy(error = "Error de seguridad. Actualizá la app.") }
        }
        else -> {
            _uiState.update { it.copy(error = error.message) }
        }
    }
}

¿Cuándo vale la pena implementar pinning?

Certificate pinning agrega complejidad operativa real (rotación, versiones antiguas, debugging). No siempre vale la pena:

  • Sí implementar: apps bancarias, financieras, de salud, corporativas con APIs internas, apps que manejan información muy sensible.
  • Considerar: apps con alta exposición a ataques MITM (redes corporativas, VPNs sospechosas).
  • No necesario: apps de consumo general que ya usan HTTPS con CAs de confianza (Let's Encrypt, DigiCert). El modelo de CA ya es suficiente para la mayoría de los casos.