· Marc Morel · Engineering · 13 min de lecture

HTTP chiffré n'est pas HTTP signé

Quand un achat délégué tourne mal, les deux parties ont des logs, et ces logs se contredisent. Pourquoi TLS ne peut pas trancher, et ce que les signatures de messages RFC 9421 peuvent (et ne peuvent pas) y faire.

HTTP chiffré n'est pas HTTP signé

Demandez à une équipe comment son API B2B est sécurisée, et la réponse sera presque toujours « HTTPS, plus OAuth ». C’est légitime. TLS est excellent dans ce qu’il fait. Mais ce qu’il fait, c’est protéger une connexion, et dans la plupart des architectures cette connexion s’arrête à un load balancer, bien loin du service qui enregistre réellement la commande. Une fois la connexion fermée, il ne reste rien qu’un tiers puisse vérifier.

La plupart du temps, personne ne s’en soucie. On commence à s’en soucier le jour où le système d’un client passe une commande via votre API et que ce client affirme ensuite ne jamais l’avoir demandée. Je vais dérouler une de ces journées, puis regarder ce qu’apportent les signatures de messages, ce qu’elles coûtent, et quand je pense qu’elles en valent la peine.

Le litige

Imaginons que vous vendiez du mobilier de bureau aux entreprises. Il y a quelques années, vous avez ouvert une API de commande pour que les plus gros clients puissent connecter leur outil d’achat directement à votre catalogue, au lieu d’envoyer des PDF par e-mail. L’un d’eux, une entreprise de taille moyenne qui ouvre un nouveau site à Lyon, l’utilise régulièrement.

Un mardi matin de septembre, ceci arrive depuis leur outil d’achat :

Commande reçue mardi
{
"customer_po": "PO-2026-04417",
"sku": "CHR-ERGO-240-BLK",
"quantity": 120,
"unit_price_eur": "395.00",
"delivery_site": "Lyon, 3rd floor"
}

120 chaises ergonomiques au prix contractuel de 395 euros, soit 47 400 euros. Rien d’inhabituel pour un nouvel étage. Les chaises sont configurées à la commande (tissu, accoudoirs, logo brodé au dos), donc votre système confirme la commande et la production démarre chez le fabricant l’après-midi même. Selon vos conditions, une commande configurée ne peut plus être annulée une fois la production lancée.

Trois semaines plus tard, le camion arrive à Lyon. Le responsable des services généraux refuse la livraison et appelle son service achats, qui vous appelle. Selon eux, ils ont commandé 12 chaises, pas 120. Soit votre système a déformé la commande, soit quelqu’un de chez vous l’a modifiée. Ils paieront 12 chaises et vous pouvez garder le reste.

Vous rassemblez ce que vous avez. L’API gateway a loggé POST /v1/orders 200. La table des commandes contient une ligne avec quantity: 120, leur numéro de bon de commande et le client ID de leur intégration. Vous leur envoyez tout ça.

Leur réponse est polie et, honnêtement, juste. Une ligne dans votre base de données, c’est votre système qui parle de lui-même. Le jeton OAuth prouve que quelqu’un détenait le jeton, pas ce qu’il a demandé. Et leur propre outil d’achat montre une demande d’achat de 12 chaises, approuvée par leur manager, capture d’écran à l’appui.

Vous avez donc deux enregistrements, chacun tenu par une partie qui a intérêt à l’issue, qui disent des choses différentes. 108 chaises portant le logo de quelqu’un d’autre ne retourneront pas en stock. Si cela finit devant un tribunal de commerce, un juge devra décider quelle informatique lui paraît la plus digne de confiance, et ce n’est pas une position dans laquelle j’aimerais me trouver.

Ce que TLS vous apporte, et où il s’arrête

Je passe sur le HTTP en clair. N’importe qui sur le chemin peut lire la requête, transformer un 12 en 120 et la rejouer, et j’espère que personne n’envoie de commandes de cette façon en 2026.

TLS 1.3 règle l’essentiel de ces problèmes tant que la connexion est ouverte. Les clés de session proviennent d’un échange éphémère et ne transitent jamais sur le réseau, ce qui vous donne confidentialité et confidentialité persistante (forward secrecy). Le serveur prouve qu’il détient le certificat de son nom d’hôte (le client ne prouve quoi que ce soit que si vous utilisez du TLS mutuel). Les enregistrements sont chiffrés en AEAD, donc un octet modifié fait échouer le déchiffrement et la connexion est coupée.

Ce qu’il ne vous donne pas, c’est précisément ce dont ce litige a besoin.

Premièrement, il protège un saut réseau, pas le message. Dans les déploiements réels, TLS se termine au CDN, au load balancer, au WAF, à l’API gateway ou à un sidecar. Chacun de ces composants déchiffre la requête, peut réécrire des en-têtes voire le corps, et ouvre une nouvelle connexion en aval. Que vous l’ayez prévu ou non, chacun d’eux se trouve dans votre périmètre de confiance, et le client n’a aucune raison de faire confiance à l’un d’entre eux.

Deuxièmement, il ne laisse derrière lui rien qu’un tiers puisse vérifier. L’intégrité des enregistrements repose sur des clés symétriques que les deux extrémités détiennent, donc l’une comme l’autre aurait pu produire n’importe quel enregistrement. Même avec une capture réseau complète et les clés de session, vous ne pouvez pas montrer à un juge qui a écrit quoi.

Troisièmement, un bearer token est lié à une chaîne de caractères, pas à un émetteur. Quiconque le présente est « le client ». Un jeton qui a fuité, un environnement de test mal configuré qui pointe vers la production, ou un collègue ayant accès au coffre de secrets se ressembleront tous exactement.

Ce que résout chaque couche

Signer la requête elle-même

La RFC 9421, HTTP Message Signatures, déplace la garantie de la connexion vers la requête. Le client signe un ensemble choisi de composants avec sa clé privée, et quiconque dispose de la clé publique peut vérifier le résultat, la semaine prochaine ou dans cinq ans, quel que soit le nombre de proxies intermédiaires.

Le corps n’est pas signé en tant que tel. Son empreinte est placée dans un en-tête Content-Digest (RFC 9530), et cet en-tête fait partie des composants signés. Si l’outil d’achat du client avait signé, la commande du mardi aurait ressemblé à peu près à ceci :

Requête signée
POST /v1/orders HTTP/1.1
Host: api.furniture-supplier.example
Content-Type: application/json
Content-Digest: sha-256=:<base64 of SHA-256 over the exact body bytes>:
Signature-Input: sig1=("@method" "@authority" "@path" "content-type" "content-digest");created=1789458120;expires=1789458420;nonce="d9a1f7e3b2c4";keyid="customer-purchasing-2026-01";alg="ed25519";tag="order"
Signature: sig1=:<base64 of the 64-byte Ed25519 signature>:
{"customer_po":"PO-2026-04417","sku":"CHR-ERGO-240-BLK","quantity":120,"unit_price_eur":"395.00","delivery_site":"Lyon, 3rd floor"}

Ce qui est réellement signé n’est pas le texte HTTP brut mais une « signature base » que les deux parties reconstruisent de la même façon, une ligne par composant :

Signature base
"@method": POST
"@authority": api.furniture-supplier.example
"@path": /v1/orders
"content-type": application/json
"content-digest": sha-256=:<same value as the header>:
"@signature-params": ("@method" "@authority" "@path" "content-type" "content-digest");created=1789458120;expires=1789458420;nonce="d9a1f7e3b2c4";keyid="customer-purchasing-2026-01";alg="ed25519";tag="order"

Quelques éléments méritent un commentaire. Oubliez content-digest dans les composants couverts et le corps n’est plus protégé du tout. C’est une erreur facile à commettre et difficile à remarquer, parce que tout continue de se vérifier. @authority et @path fixent la destination. Je ne couvrirais que ce qu’aucun intermédiaire ne réécrit légitimement, sinon vous passerez une semaine à traquer des signatures qui cassent quelque part entre le CDN et la gateway.

created et expires définissent une fenêtre de cinq minutes, et avec un cache de nonces côté serveur, c’est ce qui empêche les rejeux. keyid désigne la clé que le client a enregistrée chez vous lors de l’onboarding, et sa version (j’y reviens plus bas). tag restreint la signature à un seul usage, de sorte qu’une signature produite pour une autre API ne peut pas être présentée à celle-ci. J’ai choisi Ed25519 parce que les clés sont petites, les signatures déterministes, et que les HSM et les services KMS cloud le prennent largement en charge.

HTTPS avec signatures de messages : ce qui est garanti

Signatures de messages HTTPS

La partie ingrate : ce que vous conservez

Vérifier une signature puis la jeter ne vous apporte rien trois semaines plus tard. L’essentiel du vrai travail se fait côté réception, et c’est surtout une question de discipline de stockage.

Le service qui enregistre la commande doit être celui qui refuse une requête non signée ou invalide. Vérifier à la gateway convient comme premier filtre, mais si la gateway est le seul contrôle, elle devient un composant de plus qui aurait pu modifier la commande après vérification, et vous revenez au point de départ.

Conservez les octets exactement tels qu’ils sont arrivés, avec les en-têtes de signature, avant tout parsing JSON. C’est l’erreur que je m’attends à voir commise par la plupart des équipes : elles stockent l’objet parsé, le resérialisent plus tard, et l’empreinte ne correspond plus parce que l’ordre des clés, les espaces ou le format des nombres ont changé en route.

Conservez aussi l’historique des clés. Les clés tournent, donc la preuve doit désigner la clé valide au moment de created. Enregistrez les clés des clients avec leurs dates de validité, et ne supprimez jamais une clé publique retirée tant qu’un élément signé avec elle est encore sous rétention.

Enfin, scellez le tout. Le client ne peut pas falsifier sa propre signature, mais il peut encore contester le moment où vous avez reçu la commande, ce qui compte si vos conditions disent que la production démarre à réception. Écrire le paquet dans une piste d’audit inviolable avec des horodatages qualifiés règle ce point, et j’ai expliqué comment dans Audit Trails: Logs are not enough.

Pour notre commande, le paquet de preuves est petit. Le corps brut, les en-têtes Content-Digest, Signature-Input et Signature, la clé publique derrière keyid telle qu’elle était ce jour-là, et la preuve que le paquet a été scellé dans la piste d’audit.

Retour à mardi

Avec ce paquet, « qu’a envoyé leur système ? » cesse d’être une question d’opinion. N’importe qui peut le vérifier avec des outils standard et sans accès à vos systèmes. OpenSSL 3 suffit pour Ed25519 :

Vérifier le paquet de preuves
# 1. le corps stocké correspond toujours à son empreinte
openssl dgst -sha256 -binary body.bin | base64
# comparer avec la valeur entre les deux-points dans Content-Digest
# 2. reconstruire la signature base à partir des en-têtes stockés dans base.txt
# (lignes jointes par \n et sans retour à la ligne final, ça piège tout le monde une fois)
# 3. décoder la signature depuis l'en-tête Signature
printf '%s' "$SIG_B64" | base64 --decode > sig.bin
# 4. vérifier avec la clé publique enregistrée du client
openssl pkeyutl -verify -pubin -inkey customer-purchasing-2026-01.pem \
-rawin -in base.txt -sigfile sig.bin
# Signature Verified Successfully

Leur équipe informatique peut exécuter exactement les mêmes commandes. Si la vérification passe, trois choses sont établies. Le corps que vous avez conservé est, octet pour octet, celui qui a été signé, donc personne chez vous n’a transformé un 12 en 120. Il a été signé avec la clé que leur entreprise a enregistrée sous customer-purchasing-2026-01. Et l’horodatage et le nonce montrent qu’il s’agissait d’une requête unique et fraîche, pas d’un rejeu par un proxy.

Face à cela, leur équipe examine son propre côté et trouve la cause en une heure. Lors du dernier import du catalogue fournisseur dans leur outil d’achat, cette chaise est arrivée avec une unité de mesure « lot de 10 ». Le demandeur a commandé 12, l’outil a converti 12 lots en 120 unités, et c’est ce que l’intégration a envoyé. Leur capture d’écran ne mentait pas, elle montrait simplement une autre étape du processus que celle qui vous a parlé.

Cela clôt le débat technique. Pas le débat commercial, et vous pouvez tout de même décider de partager le coût pour garder un bon client. Mais vous discutez désormais de leur import de catalogue et de vos conditions de vente, plutôt que de savoir quels logs croire, ce qui est une conversation bien plus saine.

Ce que cela ne prouve pas

Il est tentant d’en faire trop, alors soyons prudents.

Une signature prouve que quelque chose détenant la clé a signé la requête. Elle ne dit rien sur le fait qu’un humain voulait réellement 120 chaises. Leur outil d’achat a signé une erreur parfaitement valide, et il l’aurait signée à nouveau.

Elle ne vaut aussi que ce que vaut la garde de la clé. Si la clé privée vivait dans un fichier de configuration sur un serveur partagé, « quelqu’un d’autre a utilisé notre clé » reste un argument crédible. Si elle a été générée dans un HSM ou un KMS cloud et ne peut pas être exportée, cet argument s’affaiblit considérablement.

Elle n’a pas non plus de valeur juridique à elle seule. Ce qu’elle vaut dans un litige dépend du contrat : les conditions de l’API ou l’accord-cadre devraient stipuler que les commandes vérifiées avec une clé enregistrée sont contraignantes, comment les clés sont enregistrées et révoquées, et combien de temps les preuves sont conservées. Impliquez votre service juridique tôt, pas après la première livraison refusée.

Et elle ne couvre qu’un seul sens. Le client peut vouloir une preuve de ce que vous lui avez confirmé en retour, prix et date de livraison compris. La RFC 9421 peut aussi signer les réponses, et si vous êtes du côté acheteur, c’est cette partie que vous devriez exiger de vos fournisseurs.

La facture

Rien de tout cela n’est gratuit, et le code est la partie la moins chère.

La gestion des clés est là où ça devient sérieux. Des clés générées dans un HSM ou un KMS, des clés publiques échangées via un endpoint JWKS, une rotation avec période de chevauchement, et un processus de révocation que quelqu’un a réellement testé. Perdez une clé privée et les signatures deviennent un sujet de débat. Supprimez une ancienne clé publique et vous avez supprimé des preuves.

La canonicalisation est le deuxième piège. Utilisez une bibliothèque mature des deux côtés et passez les vecteurs de test de l’annexe de la RFC avant la mise en production. Sinon, le premier faux rejet en production sera « corrigé » en désactivant la vérification, et personne ne la réactivera.

La protection contre le rejeu nécessite un cache de nonces partagé par toutes les instances et dimensionné selon la fenêtre de validité, plus des horloges synchronisées. Les composants couverts doivent être choisis avec ceux qui gèrent le réseau, parce qu’un proxy finira par réécrire un en-tête auquel vous ne vous attendiez pas. Et chaque client doit implémenter la signature dans sa propre stack, ce qui ralentit l’onboarding, alors attendez-vous à ce que les commerciaux poussent pour des exceptions.

Il existe d’autres outils, et ils sont bons sur des problèmes voisins. Le TLS mutuel authentifie le client et peut lier les jetons à son certificat (RFC 8705), mais il s’arrête toujours au premier point de terminaison et ne laisse aucune preuve au niveau du message. DPoP lie un jeton à une clé et signe la méthode et l’URI, pas le corps. JWS peut signer le payload, attaché ou détaché, ce qui donne la même preuve pour le corps mais rien pour les en-têtes ou la cible, et chaque API finit par inventer sa propre convention autour.

En pratique, je les combinerais. mTLS ou DPoP pour contraindre les jetons à chaque appel, et les signatures de messages uniquement sur la poignée d’opérations où un litige est plausible.

Ce qui change

Pour la majorité du trafic, rien. Parcourir un catalogue ou consulter le statut d’une livraison est parfaitement bien servi par TLS.

Ma règle empirique : la signature justifie son coût quand un système engage une entreprise sur quelque chose en son nom (commandes, paiements, avenants), quand une erreur coûterait assez cher pour que quelqu’un se batte à son sujet, et quand les deux parties ne partagent pas de périmètre de confiance, si bien qu’aucune n’a de raison de prendre les logs de l’autre pour argent comptant.

Si une opération coche ces cases, je poserais une seule question avant sa mise en production. Si le client conteste cette requête dans trois semaines, que lui montrons-nous exactement, et peut-il le vérifier sans avoir à nous faire confiance ? Si la réponse honnête est « une ligne de log », signez-la.

Partager :
Retour