Blog ·
Un secret qui ne voyage qu’une fois
Une vérification de supervision a besoin d’un mot de passe à l’autre bout. Voici comment il atteint une seule sonde, pour un seul essai, sans trace nulle part.
Par Provibr
Certaines vérifications de supervision réclament des identifiants. On ne peut pas demander à un serveur PostgreSQL s’il se porte bien sans s’y connecter, et une instance Redis avec un mot de passe ne répond rien d’utile tant qu’on ne le lui a pas envoyé. Une règle de supervision doit donc porter un secret depuis chez nous jusqu’à la sonde ou l’agent qui prendra par hasard l’essai suivant.
La version bon marché de cela est une colonne. Mettre le mot de passe dans la règle, le lire au moment de l’envoi, le joindre. Cette version est fausse d’une manière qui ne devient visible que treize mois plus tard, et voici la forme que nous avons bâtie à la place.
Les deux chemins qui existaient déjà, et pourquoi aucun ne convient
Une commande de supervision atteint une source par deux endroits, et tous deux sont inadaptés.
La table des révisions de la règle est immuable par conception, ce qui est exactement juste pour une révision et exactement faux pour un identifiant : personne ne peut faire tourner un mot de passe logé dans une ligne qui ne peut pas changer sans produire une nouvelle révision de chaque règle qui l’utilisait.
La ligne de commande est pire, car c’est une ligne d’audit et elle traverse le bus de messages en sortant. Nos propres notes de projet disent depuis un an qu’une ligne d’audit de commande contient des noms de champs et jamais des valeurs. Un mot de passe qui y atterrit une fois y reste toute la durée de conservation, lisible par quiconque a accès à la base et copié dans la sauvegarde qui a tourné cette nuit-là.
Il existe aussi un canal latéral éphémère pour exactement ce genre de chose, employé quand quelqu’un commande une machine et qu’un mot de passe d’administration doit atteindre l’agent sans être stocké. Il ne convient pas ici non plus. Ce canal existe pour des secrets que le créateur d’une commande connaît déjà, et ici le créateur est un planificateur qui tourne des minutes plus tôt et ignore encore quelle source prendra la tâche.
Les quatre messages de protocole ajoutés lors de cette ronde n’ont donc ni champ d’utilisateur ni champ de mot de passe. Ce n’est pas que ces champs sont inutilisés : ils n’existent pas.
Scellé à l’instant de l’envoi
L’enveloppe est créée dans le processus de session, juste avant la construction du message, une fois par essai. Ce n’est pas de la propreté. C’est le seul point du système où l’on connaît en même temps les quatre choses qu’elle doit lier : quelle source se trouve à l’autre bout de cette session, quel essai c’est, à quelle révision de la règle elle appartient et quand elle expire.
La conséquence est qu’elle ne peut être nulle part ailleurs, et cela se vérifie au lieu de se proclamer. Un test plante un mot de passe connu, parcourt le vrai chemin et le cherche dans la charge de la commande, dans les lignes d’événements, dans la configuration de vérification enregistrée, dans le bus de messages, dans les octets bruts du protocole en UTF-8 comme en base64, et dans le journal de la passerelle. Il exige zéro occurrence dans les six. Une liste d’affirmations sur les endroits où un secret ne va pas ne vaut rien ; une recherche vaut quelque chose.
Le texte en clair existe le temps d’une expression. Une fonction le produit, la suivante le consomme, et ensuite il n’y a plus de référence. JavaScript ne sait pas effacer un tampon : la seule option est de n’en garder aucun. De l’autre côté, en Rust, il atterrit dans un type qui se met à zéro à sa libération.
La construction
Une paire de clés X25519 éphémère par essai, un Diffie-Hellman contre la clé publique d’exactement une source, HKDF-SHA256 pour dériver la clé de chiffrement et AES-256-GCM sur le texte en clair. Sur le fil passent la clé publique éphémère ainsi qu’un nombre à usage unique, le chiffré et l’étiquette.
Les deux clés publiques entrent dans le sel de la HKDF, pour que la clé dérivée soit liée à cette paire et non à un secret partagé qui pourrait en principe naître d’une autre paire. La même idée que le plan de clés d’un schéma hybride courant, avec la simplification honnête ici : il y a une clé éphémère fraîche par essai, donc aucune clé n’est employée deux fois.
Quatre champs entrent dans les données authentifiées supplémentaires :
| Champ | Ce qu’il empêche |
|---|---|
| Identifiant d’essai | Que la même source réutilise une enveloppe pour une mesure ultérieure |
| Identifiant de révision | Qu’une enveloppe soit collée sous une autre vérification |
| Échéance | Qu’une enveloppe issue d’une capture ou d’un vidage serve plus tard |
| Finalité | Qu’un identifiant PostgreSQL soit donné à une vérification Redis |
L’identité de la source ne figure pas dans cette liste parce qu’elle n’a pas besoin d’y être : le scellement vise une seule clé publique, une autre source ne l’ouvre donc pas du tout.
Des préfixes de longueur, parce qu’un séparateur est ambigu
Les données supplémentaires sont construites avec la longueur de chaque champ devant lui plutôt qu’avec un séparateur entre les champs. Cela se lit comme du pinaillage jusqu’à ce qu’on écrive l’autre solution.
Avec a:b comme format, le couple ("x", "y:z") et le couple ("x:y", "z") produisent des octets identiques. Le lien que ces données doivent établir ne distingue alors plus deux situations différentes, et qui contrôle une partie d’un champ peut déplacer la frontière. Quatre champs avec quatre octets de longueur devant eux ignorent ce problème. Les deux côtés les construisent octet par octet de la même façon, avec un vecteur de test partagé, si bien qu’un écart est un test rouge et non une enveloppe que plus personne n’ouvre.
Pourquoi pas le chiffre que nous employons déjà
Le coffre à identifiants de l’agent emploie XChaCha20-Poly1305, et la même construction ici donnerait une seule réponse à la question « comment chiffrons-nous un secret » au lieu de deux. Nous avons mesuré, et c’est impossible :
node v24.20.0 crypto.getCiphers() contient "chacha20-poly1305"
bun 1.4.0 crypto.getCiphers() renvoie 28 noms, aucun chacha
createCipheriv("chacha20-poly1305", ...) -> "Unknown cipher"La production tourne sur Node, cela fonctionnerait donc là-bas. Mais le banc de tests de ce dépôt est bun, et un chiffrement authentifié que le banc ne sait pas exécuter est un chiffrement dont l’aller-retour n’est jamais prouvé. Le seul contrôle sur l’enveloppe vivrait alors dans un test ignoré, et c’est exactement la saveur de mesure verte à vide que nous mettons beaucoup d’efforts à éviter.
L’autre solution était d’écrire à la main la pièce manquante. Cela a réellement été fait, cela fonctionnait contre le vecteur de test publié, puis cela a été retiré. Du code cryptographique qui n’existe que pour contourner un banc de tests n’a pas sa place dans un dépôt de produit.
Donc AES-256-GCM, le même chiffrement authentifié qui protège déjà ce secret au repos, présent dans les deux environnements d’exécution et déjà dans l’arbre de dépendances de l’agent. Le nombre à usage unique de 96 bits pour lequel on se tourne d’ordinaire vers XChaCha n’est pas gênant ici, car la raison d’être de XChaCha est de pouvoir tirer ces nombres au hasard sous une clé de longue vie. Il n’y a aucune clé de longue vie. Chaque enveloppe en a une éphémère fraîche, donc chaque clé sert exactement une fois et une répétition ne serait pas un second message sous la même clé.
Où la source garde sa moitié
La clé privée se trouve à côté de l’identité de l’agent ou de la sonde, dans un fichier de trente-deux octets bruts en mode 0600, à côté de la clé privée du certificat client. Pas dans le coffre à identifiants, et les trois raisons méritent d’être écrites parce qu’elles ont été la vraie décision.
La révocation l’efface gratuitement : elle supprime déjà ce répertoire, et une clé rangée ailleurs demanderait sa propre règle de nettoyage, que la première ronde qui l’oublie laisserait sur un hôte révoqué. Elle appartient à une inscription, et la plateforme conserve la moitié publique à côté du certificat ; une nouvelle inscription doit donc produire une nouvelle paire, ce qu’elle fait puisque le répertoire est alors vide. Et le coffre n’est pas toujours disponible : il s’ouvre avec du matériel issu du fichier de licence, et une sonde de supervision n’a pas de licence, donc pas de coffre. Y placer la clé imposerait un second mécanisme pour les sondes, et il y aurait alors deux réponses à une question.
Elle n’est pas non plus gardée en mémoire, et c’est délibéré, même si c’est tentant pour une sonde qui ne stocke rien. La plateforme conserve la moitié publique : après un redémarrage elle ne correspondrait plus et toute enveloppe resterait close jusqu’à une nouvelle inscription. Un point de supervision qui cesse en silence de faire des vérifications authentifiées après un redémarrage est exactement le genre de panne que personne ne voit.
Jamais de repli
Si une source n’a pas de clé, la vérification ne tourne pas. Elle signale une erreur de configuration qui nomme le courtier comme indisponible, et c’est tout le comportement. Il n’y a pas de repli en clair et, plus important, pas d’état de panne trompeur pour la cible, car une vérification qu’on ne peut pas mesurer ne doit jamais ressembler à un service cassé. C’est une alerte qui envoie quelqu’un à la mauvaise machine à trois heures du matin.
Ce que nous noterions
- Une ligne d’audit n’est pas un canal de livraison. Ce qui y est écrit y reste toute la durée de conservation, dans chaque sauvegarde.
- N’ajoutez pas le champ. Un message de protocole sans champ de mot de passe ne peut pas en laisser fuir un par un chemin de code que quelqu’un oublie de relire.
- Scellez là où les liens sont connus. Si le créateur d’une tâche ignore encore qui l’exécutera, le secret ne peut pas naître avec la tâche.
- Prouvez l’absence en cherchant, pas en affirmant. Plantez une valeur et traquez-la dans chaque sérialisation, base64 comprise.
- Préfixez la longueur de vos données authentifiées. Un séparateur fait produire les mêmes octets à deux couples différents.
- Choisissez la primitive que votre banc de tests sait exécuter. Une construction plus forte jamais exercée est en pratique plus faible qu’une bonne qui l’est.
- Une clé qui est une identité appartient à l’identité. La révocation la nettoie alors et une nouvelle inscription la remplace, sans règle que quelqu’un doive retenir.
Pour être clair sur ce dont il s’agit : la supervision se trouve derrière un interrupteur de déploiement éteint par défaut, rien de tout cela n’est donc achetable aujourd’hui. Deux choses ne sont pas démontrées. L’échange d’authentification par mot de passe n’a été mesuré que contre l’implémentation de référence de la bibliothèque du protocole et non contre un vrai serveur. Et le binaire de sonde écrit en Rust n’a pas été exercé sur le fil lors de cette ronde.
Issu du travail de plateforme de Provibr. Pourquoi une sonde traite tout message reçu comme non fiable est expliqué dans une sonde qu’on ne peut convaincre de rien, et pourquoi les identifiants des panneaux d’un client ne nous parviennent jamais est expliqué dans où doivent vivre les identifiants de panneau.