Subidas seguras de archivos con cURL y certificados de cliente
Para subir un archivo a un endpoint HTTPS que requiere un certificado de cliente, combina --upload-file,
--cert y --key en el mismo comando de cURL. Esta guía te proporciona un servidor local
con TLS mutuo (mTLS), certificados temporales y una subida que puedes verificar byte por byte.
Separa la confianza en el servidor de la autenticación del cliente
HTTPS cifra la conexión y permite que cURL verifique la identidad del servidor. Con mTLS, el servidor también exige una prueba de que el cliente posee la clave privada de un certificado de confianza. Estas verificaciones se realizan en sentidos opuestos:
| Opción de cURL | Propósito en este ejemplo |
|---|---|
--cacert ca.crt | Confiar en la CA que emitió el certificado del servidor; seguir verificando el nombre de host de la URL. |
--cert client.crt | Presentar el certificado público del cliente al servidor. |
--key client.key | Demostrar la posesión de la clave privada correspondiente. |
Un certificado de cliente no convierte un servidor no confiable en uno de confianza. Mantén
habilitada la verificación del servidor de cURL; --insecure la omitiría.
Solo estos comandos y este receptor confían en la CA temporal que se muestra a continuación, sin
modificar el almacén de confianza de tu sistema.
Prepara las herramientas locales
Requisitos previos
Usa un shell de Linux con Bash, cURL compilado con OpenSSL, OpenSSL 3, Python 3 y cmp.
No se necesitan paquetes de Python. Comprueba las herramientas instaladas:
curl --version
openssl version
python3 --version
El comando de cURL usa --fail-with-body, disponible desde cURL 7.76.0. Consulta las
combinaciones probadas más abajo; esta guía no establece el comportamiento
con los almacenes de certificados de Windows ni con otros backends de TLS.
Crea certificados temporales
Ejecuta lo siguiente desde un directorio donde puedas crear mtls-demo. El subshell se detiene
si hay errores y se niega a reutilizar un directorio existente. Tu terminal permanece en el directorio
padre. Todas las claves y los certificados permanecen dentro de mtls-demo, y los certificados
vencen después de dos días.
(
set -eu
umask 077
mkdir mtls-demo
cd mtls-demo
openssl req -x509 -newkey rsa:2048 -noenc -sha256 -days 2 \
-keyout ca.key -out ca.crt -subj '/CN=Local upload demo CA' \
-addext 'basicConstraints=critical,CA:TRUE' \
-addext 'keyUsage=critical,keyCertSign,cRLSign'
openssl req -new -newkey rsa:2048 -noenc \
-keyout server.key -out server.csr -subj '/CN=localhost'
printf '%s\n' 'basicConstraints=critical,CA:FALSE' \
'keyUsage=critical,digitalSignature,keyEncipherment' \
'extendedKeyUsage=serverAuth' 'subjectAltName=DNS:localhost' > server.ext
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key \
-set_serial 1 -days 2 -sha256 -extfile server.ext -out server.crt
openssl req -new -newkey rsa:2048 -noenc \
-keyout client.key -out client.csr -subj '/CN=Local upload client'
printf '%s\n' 'basicConstraints=critical,CA:FALSE' \
'keyUsage=critical,digitalSignature' 'extendedKeyUsage=clientAuth' > client.ext
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key \
-set_serial 2 -days 2 -sha256 -extfile client.ext -out client.crt
)
Las extensiones de los certificados asignan funciones distintas al servidor y al cliente. El nombre
alternativo del sujeto del servidor es localhost, que debe coincidir con el nombre de host
de la URL de subida. OpenSSL documenta estas
extensiones de los certificados y la
opción -noenc, que deja estas claves desechables
sin cifrar. umask 077 restringe el acceso al nuevo directorio y sus archivos a tu cuenta.
Inicia un receptor que requiera un certificado de cliente
Guarda lo siguiente como mtls-demo/receiver.py. Acepta una solicitud PUT /upload sin procesar con un
Content-Length conocido, incluido un cuerpo vacío, hasta un máximo de 1 MiB. Cada subida completada reemplaza
received.bin; las solicitudes rechazadas dejan el archivo anterior intacto.
import argparse
import ssl
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
class UploadHandler(BaseHTTPRequestHandler):
def do_PUT(self):
if self.path != "/upload":
self.send_error(404, "Use /upload")
return
length = self.headers.get("Content-Length", "")
if self.headers.get("Transfer-Encoding") or not length.isascii() or not length.isdecimal():
self.send_error(411, "A Content-Length is required")
return
size = int(length)
if size > 1024 * 1024:
self.send_error(413, "Limit is 1 MiB")
return
self.connection.settimeout(10)
data = self.rfile.read(size)
if len(data) != size:
self.send_error(400, "Incomplete upload")
return
Path("received.bin").write_bytes(data)
reply = f"Stored {len(data)} bytes\n".encode()
self.send_response(201)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(reply)))
self.end_headers()
self.wfile.write(reply)
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=0)
args = parser.parse_args()
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.minimum_version = ssl.TLSVersion.TLSv1_2
context.load_cert_chain("server.crt", "server.key")
context.load_verify_locations("ca.crt")
context.verify_mode = ssl.CERT_REQUIRED
with HTTPServer(("127.0.0.1", args.port), UploadHandler) as server:
server.socket = context.wrap_socket(server.socket, server_side=True)
print(f"https://localhost:{server.server_port}/upload", flush=True)
server.serve_forever()
CERT_REQUIRED de Python rechaza
los clientes que no tengan un certificado válido emitido por una CA de confianza. En este ejemplo,
cualquier certificado de cliente válido de nuestra CA permite realizar subidas. Un servicio real
necesita también su propia política de autorización.
Inicia el receptor desde el directorio padre y déjalo en ejecución:
(cd mtls-demo && python3 receiver.py)
Solo escucha en la interfaz de loopback IPv4 y muestra una URL de subida con un puerto disponible.
Copia esa URL para el siguiente paso. Si falta un certificado o se produce un error al vincularse a
la dirección, el inicio se detiene antes de mostrar una URL. Este es un servidor local de aprendizaje;
http.server de Python no está diseñado para producción.
Sube y compara el archivo
En una segunda terminal, abre el mismo directorio padre y crea un archivo pequeño. Este comando
reemplaza cualquier payload.txt existente en el directorio del ejemplo:
(cd mtls-demo && printf 'mTLS upload\n' > payload.txt)
En el bloque siguiente, reemplaza PORT por el puerto que muestra el receptor.
Ejecútalo desde el directorio padre:
(
set -eu
cd mtls-demo
upload_url='https://localhost:PORT/upload'
status=$(curl --disable --silent --show-error --fail-with-body \
--noproxy '*' --connect-timeout 5 --max-time 20 \
--cacert ca.crt --cert client.crt --key client.key \
--upload-file payload.txt --output response.txt --write-out '%{http_code}' \
"$upload_url")
cat response.txt
printf 'HTTP %s\n' "$status"
test "$status" = 201
cmp payload.txt received.bin
)
Salida esperada:
Stored 12 bytes
HTTP 201
cmp no muestra ninguna salida cuando los bytes originales y los recibidos coinciden.
Para este endpoint, HTTP 201 significa que el receptor escribió el archivo; no garantiza
un análisis de seguridad, copias de seguridad ni almacenamiento duradero. El archivo permanece en el
disco cuando detienes el receptor.
--upload-file selecciona HTTP PUT con el archivo como cuerpo
de la solicitud. Una API que espera un POST multiparte necesita un formato de solicitud diferente;
confirma el método y el contrato del cuerpo del endpoint antes de adaptar este comando.
--fail-with-body hace que los errores HTTP devuelvan el
código de salida 22 de cURL y guarda el cuerpo de la respuesta en response.txt.
El subshell se detiene ante ese fallo. La comprobación explícita de 201 también rechaza
estados de éxito o de redirección inesperados. Una respuesta recibida sobrescribe response.txt;
un fallo temprano de TLS puede dejar un archivo de respuesta anterior, así que no uses ese archivo
como única prueba de éxito. --disable omite tu configuración predeterminada de cURL, y
--noproxy '*' evita que esta solicitud de loopback pase por proxies configurados en el entorno.
Solución de problemas comunes
Lee el error de cURL antes de inspeccionar cualquier respuesta guardada. Para ver el estado de salida
del subshell, ejecuta echo "$?" inmediatamente después del bloque de subida. Realiza estas
comprobaciones de una en una y restaura el comando que funciona entre intentos:
Fallo en la verificación del certificado
Cambiar el nombre de host de la URL de localhost a 127.0.0.1 debería producir el error
60 de cURL: el certificado identifica localhost, no la dirección IP. Una CA ajena
proporcionada mediante --cacert también provoca un fallo de verificación. Usa la CA de confianza
del operador del servidor y el nombre de host cubierto por el certificado; no resuelvas ninguno de
estos fallos deshabilitando la verificación.
Errores del certificado de cliente
Elimina --cert client.crt --key client.key y la negociación TLS debería fallar antes de que se ejecute el manejador
de subidas. Un certificado de cliente no confiable también provoca un fallo. El código exacto de cURL
para una negociación rechazada puede variar según la versión de TLS y el backend; es distinto de un
rechazo HTTP. Algunas compilaciones informan de un error de envío o recepción en lugar de un mensaje
específico del certificado.
En cambio, el error 58 indica problemas al cargar o usar las credenciales locales del
cliente. Comprueba que el certificado esté en formato PEM, que la clave privada corresponda a él y
que tu cuenta pueda leer ambos archivos. Usa las
opciones de certificado adecuadas para tu backend de TLS. En
cURL 8.22.0, la ausencia del archivo de clave se rechaza antes con el código 43 y un
diagnóstico de carga de archivos.
Rechazo HTTP
Cambia /upload por /missing y conserva los certificados válidos. La conexión TLS se
establece correctamente, pero el servidor devuelve HTTP 404 y cURL termina con 22.
Un archivo de más de 1 MiB se rechaza con HTTP 413. Ninguno de los casos reemplaza
received.bin. Revisa el cuerpo de la respuesta actual en mtls-demo/response.txt para estos fallos HTTP;
cambiar los certificados no resolverá una ruta incorrecta ni un archivo demasiado grande.
Buenas prácticas de seguridad
Mantén ca.key, server.key y client.key privados, al igual que cualquier
archivo PEM combinado que contenga una clave. No los incluyas en el control de versiones ni subas el
directorio del ejemplo. Detén el receptor con Ctrl+C cuando termines y luego elimina el directorio
desechable tras comprobar que no contiene nada que quieras conservar.
Gestión de frases de contraseña de los certificados
Las claves del ejemplo no están cifradas para que el ejercicio local se ejecute sin pedir datos.
Con una clave PEM cifrada y el backend de OpenSSL, cURL puede solicitar su frase de contraseña.
Nunca añadas la frase de contraseña a --cert ni la pases como argumento de la línea de
comandos: eso puede exponerla en el historial del shell o en los argumentos del proceso. Las tareas
desatendidas necesitan un mecanismo independiente de entrega de secretos, como un gestor de secretos
y un archivo de credenciales restringido, con acceso limitado a la cuenta de servicio.
Compatibilidad de versiones
La guía local completa se probó en Linux con estas combinaciones:
| Entorno | cURL y su backend de TLS | CLI de OpenSSL | Python |
|---|---|---|---|
| Contenedor de Ubuntu 24.04 | cURL 8.5.0, OpenSSL 3.0.13 | 3.0.13 | 3.12.3 |
| Host basado en Arch | cURL 8.22.0, OpenSSL 3.6.4 | 3.6.4 | 3.14.7 |
Estas comprobaciones cubren los bytes del archivo y el comportamiento ante fallos con el receptor local. Windows, macOS, otros backends de TLS y los servicios de subida en producción requieren sus propias verificaciones.
