Start Debugging

Solución: CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate en una imagen Docker de Dart 3.13

Dart 3.13 eliminó los certificados raíz de respaldo integrados en la VM. Incluye un paquete de CA en la imagen de runtime: COPY /runtime/ desde dart:stable o instala ca-certificates.

Tu Dockerfile no cambió. Dart sí. A partir de Dart 3.13.0 (el SDK de Flutter 3.47.0 y el que está detrás de la etiqueta dart:stable desde agosto de 2026, actualmente 3.13.5), la VM independiente ya no incluye un conjunto de certificados raíz de respaldo compilado en el binario. Si la imagen en la que se ejecuta tu aplicación no tiene un paquete de CA en una de las rutas estándar de Linux, todas las llamadas HTTPS fallan en el handshake. Se soluciona dando a la etapa de runtime un almacén de confianza: conserva COPY --from=build /runtime/ / en una etapa FROM scratch, o ejecuta apt-get install ca-certificates en una imagen base slim. Si no puedes cambiar la imagen, apunta la VM a un archivo PEM con DART_VM_OPTIONS=--root-certs-file=/path/to/cacert.pem.

Todo lo que sigue se basa en el código fuente del SDK de Dart en las etiquetas 3.12.2, 3.13.0 y 3.13.5, el Dockerfile stable/trixie de dart-lang/dart-docker y el análisis de dart-lang/sdk#64060, donde el equipo de Dart confirmó que el comportamiento es intencional.

El error en contexto

La aplicación se compila bien y arranca bien. La primera conexión TLS saliente, ya sea mediante HttpClient, package:http, dio, un canal gRPC o un controlador de base de datos, lanza:

HandshakeException: Handshake error in client (OS Error:
	CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate(handshake.cc:320))

#0      _SecureFilterImpl._handshake (dart:io-patch/secure_socket_patch.dart:101)
#1      _SecureFilterImpl.handshake (dart:io-patch/secure_socket_patch.dart:146)
#2      _RawSecureSocket._secureHandshake (dart:io/secure_socket.dart:995)
#3      _RawSecureSocket._tryFilter (dart:io/secure_socket.dart:1127)
<asynchronous suspension>

La pista es el momento en que aparece. El mismo código, el mismo Dockerfile y el mismo endpoint funcionaban en dart:3.12.2. Fijar la etapa de compilación de nuevo a dart:3.12.2 hace desaparecer el error, que es exactamente lo que hizo quien reportó #64060 antes de descubrir la causa. Los endpoints con certificados públicos perfectamente válidos (pub.dev, googleapis.com, tu propia API con Let’s Encrypt) fallan igual que los autofirmados, porque el problema no es la cadena del servidor. El cliente no confía en absolutamente nada.

Por qué Dart 3.13 dejó de confiar en algo en una imagen vacía

En Linux, SSLCertContext::TrustBuiltinRoots() en runtime/bin/security_context_linux.cc busca raíces de confianza en un orden fijo:

  1. La opción --root-certs-file o --root-certs-cache, si pasaste alguna.
  2. El primer archivo de paquete que exista entre /etc/ssl/certs/ca-certificates.crt, /etc/pki/tls/certs/ca-bundle.crt, /etc/ssl/ca-bundle.pem, /etc/pki/tls/cacert.pem y /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem.
  3. El primer directorio que exista entre /etc/ssl/certs, /system/etc/security/cacerts, /usr/local/share/certs, /etc/pki/tls/certs y /etc/openssl/certs.
  4. Como último recurso, AddCompiledInCerts(), que carga un paquete de raíces de Mozilla incluido en los binarios dart y dartaotruntime (y por lo tanto en cada salida de dart compile exe).

El paso 4 es lo que cambió. El registro de cambios de Dart 3.13.0 lo dice en una línea bajo “Dart Runtime”: los certificados raíz de respaldo integrados “are no longer included”. El commit del SDK es 7e5b075680, “Reland [standalone] Remove the fallback root certificates”, integrado el 2026-06-01 después de un primer intento en mayo que se revirtió. La función sigue existiendo en 3.13, pero runtime/bin/BUILD.gn ya no incluye third_party/fallback_root_certificates y define incondicionalmente DART_IO_ROOT_CERTS_DISABLED, así que root_certificates_pem es nulo y la función retorna sin agregar nada.

Durante años ese respaldo cubrió en silencio a las imágenes de runtime que no tenían un paquete de CA. Una etapa FROM scratch que copiaba solo el binario compilado funcionaba porque el binario llevaba sus propias raíces. Una base debian:trixie-slim sin ca-certificates funcionaba por la misma razón. En 3.13 esas imágenes terminan con un X509_STORE vacío, y BoringSSL informa que el primer certificado de cualquier cadena tiene un emisor desconocido.

Dos detalles lo hacen más confuso de lo que debería:

Reproducción mínima

Un programa Dart que hace una solicitud HTTPS:

// Dart 3.13.5, bin/server.dart
import 'dart:io';

Future<void> main() async {
  final client = HttpClient();
  try {
    final request = await client.getUrl(Uri.parse('https://pub.dev/api/packages/http'));
    final response = await request.close();
    print('status: ${response.statusCode}');
    await response.drain<void>();
  } finally {
    client.close();
  }
}

Y un Dockerfile multietapa que copia solo el ejecutable a la etapa final, una forma común en Dockerfiles escritos a mano y en plantillas de CI anteriores a la oficial:

# Dart 3.13.5 (dart:stable, September 2026)
FROM dart:stable AS build
WORKDIR /app
COPY pubspec.* ./
RUN dart pub get
COPY . .
RUN dart compile exe bin/server.dart -o bin/server

FROM debian:trixie-slim
COPY --from=build /app/bin/server /app/bin/server
CMD ["/app/bin/server"]

Compilado con dart:3.12.2 imprime status: 200, gracias a las raíces integradas. Compilado con dart:3.13.0 o posterior lanza la HandshakeException de arriba, porque debian:trixie-slim no incluye el paquete ca-certificates.

La solución en detalle

Elige la primera opción que encaje con tu imagen. Todas dan a la VM un almacén de confianza real; ninguna desactiva la verificación.

1. Conserva la copia oficial de /runtime/ en una etapa FROM scratch

Este es el diseño que recomienda la documentación de la imagen oficial dart, y ya incluye los certificados:

# Dart 3.13.5 (dart:stable)
FROM dart:stable AS build
WORKDIR /app
COPY pubspec.* ./
RUN dart pub get
COPY . .
RUN dart compile exe bin/server.dart -o bin/server

FROM scratch
COPY --from=build /runtime/ /
COPY --from=build /app/bin/server /app/bin/
CMD ["/app/bin/server"]

Si tu etapa final es FROM scratch y el único COPY es el binario, agrega la línea de /runtime/. Además de los certificados, también trae el cargador de glibc, libnss_dns y /etc/nsswitch.conf, así que corrige problemas de resolución DNS que quizás aún no hayas encontrado.

2. Instala ca-certificates en una imagen de runtime slim

Si ejecutas en debian:*-slim o ubuntu:*, instala el paquete en la etapa final:

# Dart 3.13.5 AOT binary on debian:trixie-slim
FROM debian:trixie-slim
RUN apt-get update \
 && apt-get install -y --no-install-recommends ca-certificates \
 && rm -rf /var/lib/apt/lists/*
COPY --from=build /app/bin/server /app/bin/server
CMD ["/app/bin/server"]

El script de posinstalación del paquete genera /etc/ssl/certs/ca-certificates.crt, que es la primera ruta que revisa la VM. No necesitas una llamada aparte a update-ca-certificates a menos que estés agregando tus propios certificados (ver más abajo). En imágenes RHEL, UBI o Fedora el paquete equivalente también se llama ca-certificates, y produce /etc/pki/tls/certs/ca-bundle.crt, que también está en la lista.

Distroless también funciona: gcr.io/distroless/cc-debian12 incluye /etc/ssl/certs/ca-certificates.crt y la glibc que necesita un binario AOT de Dart.

3. Actualiza una imagen base antigua

Si la imagen de runtime tiene ca-certificates pero tiene años, el paquete es anterior a las raíces a las que se encadenan las CA más nuevas. Esa fue la situación real en #64060. La solución es pasar a una imagen base actual. Actualizar el paquete en el lugar (apt-get update && apt-get install --only-upgrade ca-certificates) solo funciona mientras la distribución siga publicando actualizaciones.

4. Apunta la VM a un paquete con DART_VM_OPTIONS

Cuando no puedes tocar la imagen, pero sí montar un archivo o definir una variable de entorno (la especificación de un pod de Kubernetes, un runtime administrado), usa --root-certs-file. Para JIT (dart run, dart bin/server.dart) es una opción normal de la VM:

dart --root-certs-file=/certs/cacert.pem bin/server.dart

Un binario de dart compile exe ignora su propia línea de comandos para las opciones de la VM, ya que todos los argumentos van a tu main. Sí lee DART_VM_OPTIONS, una lista separada por comas que main_impl.cc analiza solo para ejecutables con un snapshot adjunto, y --root-certs-file es una de las opciones que acepta:

# Dart 3.13.5 AOT binary, CA bundle supplied explicitly
FROM debian:trixie-slim
COPY cacert.pem /certs/cacert.pem
COPY --from=build /app/bin/server /app/bin/server
ENV DART_VM_OPTIONS=--root-certs-file=/certs/cacert.pem
CMD ["/app/bin/server"]

--root-certs-cache=<dir> hace lo mismo para un directorio de archivos de certificados con hash al estilo c_rehash. Cualquiera de las dos opciones reemplaza por completo la búsqueda del sistema: los pasos 2 y 3 se omiten, así que el archivo que pases es todo el almacén de confianza. Usa un paquete mantenido, como el cacert.pem derivado de Mozilla que publica curl, y mantenlo actualizado.

5. Carga el paquete desde código

Si prefieres que el programa sea autosuficiente, agrega raíces al contexto predeterminado al inicio. SecurityContext.defaultContext es lo que usan HttpClient, el IOClient de package:http y la mayoría de los demás clientes cuando no les pasas un contexto:

// Dart 3.13.5
import 'dart:io';

void main() {
  const bundle = '/certs/cacert.pem';
  if (File(bundle).existsSync()) {
    SecurityContext.defaultContext.setTrustedCertificates(bundle);
  }
  // ... start the server, create clients afterwards
}

Para una configuración totalmente acotada, crea un contexto que confíe solo en tu paquete y pásalo al cliente:

// Dart 3.13.5, package:http 1.x
import 'dart:io';
import 'package:http/io_client.dart';

IOClient buildClient(List<int> pemBytes) {
  final context = SecurityContext(withTrustedRoots: false)
    ..setTrustedCertificatesBytes(pemBytes);
  return IOClient(HttpClient(context: context));
}

Es la herramienta adecuada para fijar una CA privada para un servicio. Como solución general para endpoints públicos, solo recrea el paquete de respaldo que perdiste, ahora con la responsabilidad de actualizarlo a tu cargo, así que prefiere las opciones 1 o 2.

Cómo confirmar que la imagen no tiene almacén de confianza

Las imágenes FROM scratch y distroless no tienen shell, así que docker run ... ls no funcionará. Copia la ruta desde un contenedor detenido:

docker create --name probe my-dart-app:latest
docker cp probe:/etc/ssl/certs/ca-certificates.crt - | tar -tv
docker rm probe

Si docker cp dice Could not find the file, revisa las otras cuatro rutas de paquete de la lista anterior. Si ninguna existe y /etc/ssl/certs no existe o está vacío, encontraste la causa. Para imágenes con shell, basta con ls -la /etc/ssl/certs | head.

Trampas y casos parecidos

Relacionado

Fuentes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Volver