14.4.5. Verificación de un servidor público (la cámara como cliente)

Todo lo que se dijo en la página anterior sobre que un cliente «ya tiene la raíz» es cierto para los navegadores, los teléfonos y los PC, pero no lo es para la cámara. El módulo ssl de MicroPython no incluye ningún almacén de confianza integrado: una cámara recién flasheada no confía en ninguna CA, y el valor por defecto (ssl.CERT_NONE) no verifica nada y queda totalmente expuesto a un ataque de intermediario (man-in-the-middle). Por eso, cuando la cámara es el cliente que se conecta a un servidor TLS público (una API HTTPS, un broker MQTT, …) y quieres que verifique realmente a ese servidor, tienes que proporcionar tú mismo el ancla de confianza.

La mecánica es la misma que en el ejemplo del cliente autofirmado de Certificados autofirmados; la única diferencia es que el archivo que cargas es un certificado de CA real en lugar del propio certificado del par:

  1. Obtén el certificado de CA que ancla la cadena del servidor. «Ancla» se refiere al certificado situado en (o cerca de) la cima de la cadena del servidor que eliges como punto de partida de tu confianza. Un servidor TLS envía su certificado de hoja y normalmente su(s) intermedio(s); nunca envía su raíz. Debes obtener ese ancla de confianza por tu cuenta y de forma independiente al servidor: confiar sin más en lo que un servidor te entregue echaría por tierra todo el sentido de la verificación.

    Primero averigua qué CA emitió realmente el certificado del servidor. Por ejemplo, contra openmv.io:

    openssl s_client -connect openmv.io:443 -showcerts < /dev/null
    

    El bloque Certificate chain enumera cada certificado con su sujeto (s:) y su emisor (i:); las versiones más recientes de OpenSSL también imprimen líneas a: (tipo de clave) y v: (validez) que aquí puedes ignorar:

    Certificate chain
     0 s:CN=openmv.io
       i:C=US, O=Let's Encrypt, CN=E8
     1 s:C=US, O=Let's Encrypt, CN=E8
       i:C=US, O=Internet Security Research Group, CN=ISRG Root X1
    

    La entrada 0 es la hoja (openmv.io), emitida por el intermedio E8. La entrada 1 es ese intermedio, emitido por la raíz ISRG Root X1. El emisor (i:) de la entrada superior nombra la raíz: aquí ISRG Root X1. (El intermedio es E8 en lugar del R10 / R11 que quizá hayas visto en otros sitios porque openmv.io usa un certificado ECDSA; Let’s Encrypt firma las hojas ECDSA con sus intermedios de la serie E y las hojas RSA con los de la serie R. Ambos encadenan hasta ISRG Root X1.)

    OpenSSL también imprime líneas depth= y puede informar de la raíz con Verification: OK. Eso ocurre únicamente porque tu PC ya confía en ISRG Root X1: el servidor no la envió (un servidor nunca envía su raíz), y la cámara, al no tener almacén de confianza, tampoco la tendrá. Precisamente por eso debes proporcionarla.

    Descarga esa raíz desde las raíces que la propia CA publica. Let’s Encrypt cataloga todas las suyas en la página de certificados de Let’s Encrypt; el archivo directo de ISRG Root X1 es isrgrootx1.pem (también lo ofrecen precodificado como isrgrootx1.der). Otras CA publican las suyas en una página similar de «certificados raíz» / «repositorio»; el conjunto público canónico es el programa de CA de Mozilla (CCADB). Comprueba que has obtenido el archivo correcto comparando su huella con el valor que publica la CA (añade -inform DER si descargaste el .der):

    openssl x509 -in isrgrootx1.pem -noout -subject -fingerprint -sha256
    

    Si prefieres no tener que hacer seguimiento de una raíz, puedes copiar en su lugar el intermedio directamente de la salida de -showcerts (el segundo bloque -----BEGIN CERTIFICATE-----), confiar en él y aceptar que tendrás que actualizarlo cada vez que la CA rote el intermedio, mucho más a menudo que la raíz (consulta el compromiso más abajo).

  2. Conviértelo a DER, exactamente como antes:

    openssl x509 -in isrgrootx1.pem -outform DER -out ca.der
    
  3. Copia ca.der a la cámara (sistema de archivos o ROMFS) y cárgalo como ancla de confianza:

    import socket
    import ssl
    import ntptime
    
    ntptime.settime()                  # validity check needs the clock
    
    addr = socket.getaddrinfo("api.example.com", 443)[0][-1]
    sock = socket.socket()
    sock.connect(addr)
    
    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
    ctx.verify_mode = ssl.CERT_REQUIRED
    ctx.load_verify_locations(cafile="ca.der")
    ssock = ctx.wrap_socket(sock, server_hostname="api.example.com")
    

    server_hostname es obligatorio aquí: controla el SNI y es el nombre que se comprueba contra el subjectAltName del certificado del servidor.

Truco

Atajo para el caso común. Let’s Encrypt es la CA pública más utilizada, y tanto sus certificados RSA como los ECDSA encadenan actualmente hasta ISRG Root X1 (como muestra el ejemplo de openmv.io anterior). Si los servidores con los que habla tu cámara usan Let’s Encrypt, puedes saltarte por completo la inspección: basta con poner isrgrootx1.der en la cámara y aplicarle load_verify_locations.

Esto no hace que TLS funcione con todos los sitios. Un servidor cuyo certificado provenga de una CA distinta (DigiCert, Google Trust Services, Amazon, Sectigo, …) seguirá fallando la verificación y, dado que la cámara confía en un único certificado DER por ssl.SSLContext, no puedes agrupar todas las raíces como hace un navegador. En caso de duda, identifica la CA real del servidor como se mostró arriba y confía en esa raíz.

El certificado en el que confías es un compromiso:

  • La raíz (recomendado). De larga duración (a menudo décadas), por lo que ca.der rara vez cambia. Requiere que el servidor envíe su intermedio para que mbedTLS pueda construir la ruta hoja → intermedio → tu raíz de confianza; prácticamente todos los servidores públicos bien configurados lo hacen.

  • El intermedio. También funciona, y sigue funcionando aunque un servidor omita el intermedio, pero los intermedios se rotan con mucha más frecuencia que las raíces, por lo que tendrás que actualizar ca.der más a menudo.

  • La propia hoja (fijación de certificados o certificate pinning). Es lo más estricto, pero la hoja cambia en cada renovación (aproximadamente cada 90 días con Let’s Encrypt), así que esto solo tiene sentido cuando también controlas el servidor y puedes enviar el nuevo pin a todas las cámaras de forma sincronizada. Esto es exactamente lo que hace el ejemplo del cliente autofirmado.

Nota

ssl.SSLContext.load_verify_locations() toma un único certificado de CA codificado en DER, por lo que la cámara confía exactamente en un ancla a la vez. Para llegar a servidores bajo diferentes CA, usa un ssl.SSLContext distinto por ancla. Y dado que ese certificado acabará por caducar o ser rotado por la CA, trátalo como cualquier otro certificado del dispositivo.