Téléversement sécurisé de fichiers avec cURL et certificat client
Pour téléverser un fichier vers un point de terminaison HTTPS qui exige un certificat client, combinez --upload-file,
--cert et --key dans la même commande cURL. Ce tutoriel vous fournit un serveur TLS mutuel (mTLS)
local, des certificats temporaires et un téléversement que vous pouvez vérifier octet par octet.
Séparer la confiance envers le serveur de l’authentification du client
HTTPS chiffre la connexion et permet à cURL de vérifier l’identité du serveur. Avec mTLS, le serveur exige en outre la preuve que le client détient la clé privée d’un certificat de confiance. Ces vérifications s’exercent dans des sens opposés :
| Option cURL | Rôle dans cet exemple |
|---|---|
--cacert ca.crt | Faire confiance à l’autorité de certification (AC) qui a émis le certificat du serveur ; le nom d’hôte de l’URL reste vérifié. |
--cert client.crt | Présenter le certificat public du client au serveur. |
--key client.key | Prouver la possession de la clé privée correspondante. |
Un certificat client ne rend pas digne de confiance un serveur qui ne l’est pas. Laissez
la vérification du serveur par cURL activée ; --insecure la contournerait.
L’AC temporaire ci-dessous n’est approuvée que par ces commandes et ce récepteur, sans modifier le
magasin de certificats de confiance de votre système.
Préparer les outils locaux
Prérequis
Utilisez un shell Linux avec Bash, cURL compilé avec OpenSSL, OpenSSL 3, Python 3 et cmp. Aucun
paquet Python n’est nécessaire. Vérifiez les outils installés :
curl --version
openssl version
python3 --version
La commande cURL utilise --fail-with-body, disponible depuis cURL 7.76.0. Consultez les
combinaisons testées ci-dessous ; ce tutoriel ne détermine pas le comportement
avec les magasins de certificats Windows ou d’autres backends TLS.
Créer des certificats temporaires
Exécutez ceci depuis un répertoire où vous pouvez créer mtls-demo. Le sous-shell s’arrête en cas
d’erreur et refuse de réutiliser un répertoire existant. Il laisse votre terminal dans le répertoire
parent. Toutes les clés et tous les certificats restent dans mtls-demo, et les certificats expirent
au bout de deux jours.
(
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
)
Les extensions de certificat attribuent des rôles distincts au serveur et au client. Le nom
alternatif du sujet du serveur est localhost, qui doit correspondre au nom d’hôte de l’URL de
téléversement. OpenSSL documente ces extensions de certificat et
l’option -noenc, qui laisse ces clés jetables non
chiffrées. umask 077 restreint l’accès au nouveau répertoire et aux fichiers à votre compte.
Démarrer un récepteur qui exige un certificat client
Enregistrez le code suivant sous mtls-demo/receiver.py. Il accepte une requête PUT /upload brute avec un
Content-Length connu, y compris un corps vide, jusqu’à 1 MiB. Chaque téléversement terminé remplace
received.bin ; les requêtes rejetées laissent le fichier précédent en place.
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 rejette les clients
dépourvus d’un certificat valide émis par une AC de confiance. Pour cette démo, tout certificat
client valide émis par notre AC peut téléverser. Un vrai service a aussi besoin de sa propre
politique d’autorisation.
Démarrez le récepteur depuis le répertoire parent et laissez-le tourner :
(cd mtls-demo && python3 receiver.py)
Il n’écoute que sur la boucle locale IPv4 et affiche une URL de téléversement avec un port
disponible. Copiez cette URL pour l’étape suivante. Un certificat manquant ou une erreur de liaison
interrompt le démarrage avant l’affichage de toute URL. Il s’agit d’un serveur local
d’apprentissage ; http.server de Python n’est pas destiné à la
production.
Téléverser et comparer le fichier
Dans un second terminal, ouvrez le même répertoire parent et créez un petit fichier. Cette commande
remplace tout payload.txt existant dans le répertoire de démo :
(cd mtls-demo && printf 'mTLS upload\n' > payload.txt)
Dans le bloc ci-dessous, remplacez PORT par le port affiché par le récepteur. Exécutez-le
depuis le répertoire parent :
(
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
)
Sortie attendue :
Stored 12 bytes
HTTP 201
cmp ne produit aucune sortie lorsque les octets source et les octets reçus correspondent. Pour ce
point de terminaison, le code HTTP 201 signifie que le récepteur a écrit le fichier ; il ne
promet ni analyse de sécurité, ni sauvegarde, ni stockage durable. Le fichier reste sur le disque
lorsque vous arrêtez le récepteur.
--upload-file sélectionne HTTP PUT avec le fichier comme corps de la
requête. Une API qui attend un POST multipart nécessite un autre format de requête ; vérifiez la
méthode et le contrat de corps du point de terminaison avant d’adapter cette commande.
--fail-with-body fait en sorte que les erreurs HTTP renvoient le
code de sortie cURL 22, le corps de la réponse étant enregistré dans response.txt. Le sous-shell
s’arrête sur cet échec. La vérification explicite de 201 rejette également les statuts de
succès ou de redirection inattendus. Une réponse reçue écrase response.txt ; un échec TLS précoce
peut laisser un ancien fichier de réponse, donc n’utilisez pas ce fichier seul comme preuve de
réussite. --disable ignore votre configuration cURL par défaut, et --noproxy '*' tient cette requête
de boucle locale à l’écart des proxys configurés par l’environnement.
Dépannage des problèmes courants
Lisez l’erreur cURL avant d’examiner toute réponse enregistrée. Pour voir le statut de sortie du
sous-shell, exécutez echo "$?" immédiatement après le bloc de téléversement. Effectuez ces
vérifications une par une, en rétablissant la commande qui fonctionne entre deux essais :
Échec de la vérification du certificat
Remplacer le nom d’hôte de l’URL localhost par 127.0.0.1 devrait produire l’erreur cURL
60 : le certificat nomme localhost, pas l’adresse IP. Une AC sans rapport fournie avec
--cacert fait également échouer la vérification. Utilisez l’AC de confiance de l’opérateur du
serveur et le nom d’hôte couvert par le certificat ; ne résolvez aucun de ces échecs en désactivant
la vérification.
Erreurs de certificat client
Supprimez --cert client.crt --key client.key et la négociation TLS devrait échouer avant que le gestionnaire de
téléversement ne s’exécute. Un certificat client non approuvé échoue également. Le code cURL exact
d’une négociation rejetée peut varier selon la version de TLS et le backend ; il diffère d’un rejet
HTTP. Certaines compilations signalent une erreur d’envoi ou de réception plutôt qu’un message
propre au certificat.
L’erreur 58, en revanche, indique un problème de chargement ou d’utilisation des identifiants
client locaux. Vérifiez que le certificat est au format PEM, que la clé privée lui correspond et que
votre compte peut lire les deux fichiers. Utilisez les
options de certificat adaptées à votre backend TLS. Dans
cURL 8.22.0, un fichier de clé manquant est rejeté plus tôt avec le code 43 et un
diagnostic de chargement de fichier.
Rejet HTTP
Remplacez /upload par /missing en conservant les certificats valides. La négociation TLS
réussit, mais le serveur renvoie le code HTTP 404 et cURL se termine avec 22. Un
fichier de plus de 1 MiB est rejeté avec le code HTTP 413. Aucun de ces cas ne remplace
received.bin. Consultez le corps de la réponse actuelle dans mtls-demo/response.txt pour ces échecs HTTP ;
changer de certificats ne corrigera ni un chemin erroné ni un fichier trop volumineux.
Bonnes pratiques de sécurité
Gardez ca.key, server.key et client.key privés, y compris tout fichier PEM combiné contenant
une clé. Ne les commitez pas et ne téléversez pas le répertoire de démo. Arrêtez le récepteur avec
Ctrl+C une fois terminé, puis supprimez le répertoire jetable après avoir vérifié qu’il ne contient
rien que vous souhaitez conserver.
Gestion des phrases secrètes de certificat
Les clés de la démo ne sont pas chiffrées afin que l’exercice local s’exécute sans invite. Avec une
clé PEM chiffrée et le backend OpenSSL, cURL peut demander sa phrase secrète. N’ajoutez jamais la
phrase secrète à --cert et ne la passez jamais en argument de ligne de commande : elle pourrait
alors être exposée dans l’historique du shell ou dans les arguments de processus. Les tâches sans
surveillance nécessitent un dispositif distinct de transmission des secrets, comme un gestionnaire
de secrets et un fichier d’identifiants à accès restreint, avec un accès limité au compte de
service.
Compatibilité des versions
Le tutoriel local complet a été exécuté sous Linux avec ces combinaisons :
| Environnement | cURL et son backend TLS | CLI OpenSSL | Python |
|---|---|---|---|
| Conteneur Ubuntu 24.04 | cURL 8.5.0, OpenSSL 3.0.13 | 3.0.13 | 3.12.3 |
| Hôte basé sur Arch | cURL 8.22.0, OpenSSL 3.6.4 | 3.6.4 | 3.14.7 |
Ces vérifications couvrent les octets des fichiers et le comportement en cas d’échec avec le récepteur local. Windows, macOS, les autres backends TLS et les services de téléversement en production nécessitent leurs propres vérifications.
