Blog ·
Quand une mise à jour signée se refuse elle-même
Une somme de contrôle prouve qu’un téléchargement est intact, pas qu’il vient de vous. Ce que coûte vraiment la signature d’un agent qui se met à jour seul.
Par Provibr
Tout logiciel qui se met à jour lui-même est une exécution de code à distance que vous avez invitée. Ce n’est pas un argument contre les mises à jour automatiques, qui sont le seul moyen réaliste d’éviter qu’un parc d’agents installés sur le matériel des clients ne se dégrade. C’est un argument pour être précis sur ce que promet le mécanisme de mise à jour, car la plupart d’entre eux promettent moins que ce que leur documentation laisse entendre.
Nous faisons tourner un agent sur les serveurs d’autres personnes. Il parle à notre plateforme, il applique des commandes à leur hyperviseur, et il remplace son propre binaire quand nous publions une nouvelle version. Voici ce qu’il a fallu construire avant que nous acceptions de le laisser faire cela.
Une somme de contrôle répond à la mauvaise question
Presque tous les outils qui se mettent à jour seuls publient un SHA-256 à côté de leur téléchargement, et beaucoup s’arrêtent là. Une somme de contrôle est utile, et elle ne fait pas le travail que les gens croient.
| Contrôle | Ce qu’il prouve réellement | Ce qu’il ne peut pas voir |
|---|---|---|
| TLS sur le téléchargement | Les octets sont venus de cet hôte, par cette connexion | Rien sur qui les a produits |
| SHA-256 publié à côté du fichier | Le téléchargement n’est ni tronqué ni corrompu | Qui peut modifier le fichier peut modifier le nombre |
| Signature sur les octets | Ces octets ont été produits par le détenteur d’une clé privée | Si cette clé vous appartient encore |
La ligne du milieu est tout le problème. La somme de contrôle et l’artefact vivent en général au même endroit et sont en général modifiables par le même processus. Si un attaquant peut remplacer l’un, il peut remplacer l’autre, et la vérification passera avec enthousiasme.
La somme de contrôle est donc un test préalable bon marché, et explicitement pas la frontière de confiance. Elle attrape un téléchargement tronqué en un seul passage sur des octets que nous faisions défiler de toute façon. La signature, elle, est l’ancre.
Où doit vivre la clé publique
Voici la première décision qui détermine si tout cela est réel : la clé de vérification ne peut pas être un fichier livré à côté du binaire, car compromettre le chemin de livraison compromettrait alors la clé aussi.
Notre agent reçoit la clé publique Ed25519 de la plateforme compilée en son sein, depuis une variable d’environnement de compilation. Dans une version de publication, il n’existe aucune autre source. Dans une version de débogage, un développeur peut la faire pointer vers une clé locale au moyen d’une variable d’environnement, et ce chemin est entièrement retiré à la compilation par un attribut cfg, car une clé que l’on peut remplacer à l’exécution n’est pas une ancre mais une suggestion.
Deux détails autour de cela comptent plus qu’il n’y paraît.
Le script de compilation déclare qu’il doit être réexécuté quand cette variable d’environnement change. Sans cette ligne, une compilation de publication qui partage un cache de cible avec une compilation antérieure réutilise allègrement la clé de la fois précédente, et personne ne s’en aperçoit avant qu’elle n’arrive chez un client.
Et l’analyseur de clé est strict sur l’encodage plutôt que sur la longueur. Une clé publique Ed25519 sous forme SPKI est un préfixe fixe de 12 octets suivi de 32 octets ; une clé X25519 fait elle aussi 32 octets, avec un identifiant d’algorithme différent. Accepter n’importe quoi de 32 octets reviendrait à prendre une clé destinée à un autre algorithme et à échouer plus tard, à un endroit qui n’a aucun sens.
Refuser avant de télécharger
Quand la plateforme demande à l’agent de se mettre à jour, celui-ci parcourt toute la liste des préconditions avant d’aller chercher le moindre octet :
- Y a-t-il un SHA-256 exploitable dans la directive ?
- Y a-t-il une URL de téléchargement ?
- Y a-t-il une signature, et fait-elle exactement 64 octets ?
- Sommes-nous déjà en train d’exécuter cette version ?
- Le script d’application privilégié existe-t-il sur cet hôte ?
- Le fichier d’ancre de confiance local existe-t-il ?
- Ce binaire a-t-il seulement une clé de plateforme compilée en lui ?
La troisième étape mérite l’attention. Une plateforme plus ancienne qui ne remplit pas le champ de signature doit échouer ici, bruyamment, plutôt que de retomber en silence sur la vérification plus faible que nous faisions autrefois. Une vérification facultative n’est pas une vérification. Faire du champ manquant une erreur fatale, c’est la différence entre un mécanisme et une habitude.
L’ordre des trois dernières est lui aussi délibéré. Les messages du type « il manque l’étape privilégiée sur votre hôte, relancez l’installateur » sont plus exploitables que « ce binaire n’a aucune clé de plateforme », ils viennent donc en premier.
Signer les octets, pas leur description
La signature couvre l’artefact exactement tel qu’il atterrit sur le disque. Ed25519 pur, sans pré-hachage, sans enveloppe canonique, sans métadonnées incluses. La raison n’est pas la pureté. C’est que deux implémentations totalement différentes doivent aboutir au même verdict.
Côté agent, c’est ed25519-dalek avec verify_strict, qui refuse les signatures faisant intervenir des composantes de torsion. Avec une clé et un fichier, il ne devrait exister qu’une seule signature valide, et la variante non stricte ne le garantit pas.
Sur l’hôte, le script privilégié qui installe réellement la mise à jour vérifie les mêmes octets avec openssl pkeyutl -verify -pubin -rawin. Même clé, mêmes octets, même réponse. Si nous avions pré-haché, ou signé un manifeste au lieu de l’artefact, la vérification au niveau du shell devrait réimplémenter notre découpage, et les deux contrôles finiraient par diverger.
C’est aussi la vérification que nous lançons à la main sur le serveur après chaque publication, contre la clé publique de production, avant de croire qu’une publication a bien eu lieu.
Le manifeste placé à côté de l’artefact porte la version, l’identifiant de compilation, la somme de contrôle, la taille, l’horodatage de publication, le type d’artefact et la signature. Ce sont des métadonnées destinées au panneau, et rien n’y est digne de confiance : la signature qu’il contient porte sur le binaire, pas sur le manifeste.
L’agent ne peut pas installer sa propre mise à jour
L’agent tourne sous un utilisateur non privilégié. Il ne peut pas écrire dans le répertoire où vit son propre binaire, et il ne devrait pas le pouvoir.
Il prépare donc. Il écrit d’abord la signature et la somme de contrôle dans un répertoire de préparation, et ce n’est qu’ensuite qu’il renomme à leurs côtés le binaire vérifié, car le script privilégié utilise la présence de ce binaire comme signal de départ et les deux autres fichiers doivent déjà être là. Puis il s’arrête proprement, et le gestionnaire de services le redémarre, en exécutant d’abord une étape privilégiée de pré-démarrage.
Ce script mérite d’être décrit, car ses hypothèses sont l’inverse de ce que l’on devinerait.
Il se termine toujours par un code zéro. Une étape de pré-démarrage en échec empêcherait purement et simplement le service de démarrer, et une mise à jour cassée qui met un client hors ligne est bien pire qu’une mise à jour cassée qui, en silence, ne s’applique pas.
Le répertoire de préparation n’est pas digne de confiance. Tout ce qui s’y trouve a été écrit par l’utilisateur non privilégié de l’agent : le binaire, la somme de contrôle, la signature. Quiconque prend le contrôle de cet utilisateur peut remplacer les trois. C’est pourquoi la signature, vérifiée avec la clé du côté privilégié, est la seule chose qui décide.
Il copie avant de vérifier. Les octets préparés sont copiés exactement une fois dans un nouveau fichier temporaire appartenant à root, à côté de la destination, et chaque contrôle ensuite lit la copie. Le fichier vérifié est exactement l’inode qui est renommé à sa place : il n’existe donc aucune fenêtre pendant laquelle un autre fichier serait glissé entre la vérification et l’installation.
Le reste est délibérément ennuyeux : refuser les liens symboliques, exiger que la somme de contrôle fasse exactement 64 caractères hexadécimaux, exiger que le fichier commence par le nombre magique ELF, n’élargir les permissions à l’exécution qu’une fois la signature validée, le lancer une fois sous l’utilisateur du service plutôt que sous root, et seulement ensuite le renommer par-dessus le binaire en service. Une signature dit d’où viennent des octets. Elle ne dit pas qu’ils méritent root.
La version que nous avons signée avec la mauvaise clé
Le mécanisme ci-dessus fonctionne. Nous avons quand même publié une version cassée, et c’est la manière dont elle a échoué qui est instructive.
Notre script de compilation acceptait un drapeau signifiant « ceci est une publication de production ». À l’époque, ce drapeau ne gouvernait que la publication, pas la clé utilisée pour signer. Une version construite sans nommer en plus la clé de production sur le serveur était signée avec la clé de développement, portait la clé de licence de développement compilée en elle, puis était vérifiée localement contre cette même clé de développement. Le contrôle disait que l’artefact allait bien, parce qu’il était cohérent avec lui-même. Il n’était simplement pas le nôtre.
Chaque agent réel a refusé la mise à jour, à juste titre, car la signature ne correspondait pas à la clé compilée en lui. Le mécanisme a fait exactement ce pour quoi il avait été construit. L’erreur se trouvait en amont de lui.
Deux garde-fous en sont sortis, tous deux dans le code plutôt que dans un document. Compiler pour la production sans nommer la clé de production est désormais un échec fatal qui arrête le script. Et la clé de signature et la clé de vérification doivent se trouver sur la même machine : contrôler une signature avec une clé récupérée ailleurs ne prouve absolument rien.
La leçon générale mérite d’être dite simplement. Une étape de vérification à laquelle on remet les deux moitiés d’une paire assortie venant du mauvais environnement passera, et elle passera avec assurance. Les schémas de signature protègent contre les attaquants. Ils ne protègent pas contre vous.
Une licence n’est pas un binaire, et les deux sont signés
Nous signons deux choses différentes avec la même clé : les binaires de l’agent et les fichiers de licence. C’est un danger, car une signature portant sur l’un ne doit jamais être acceptée comme une signature portant sur l’autre.
La séparation est structurelle plutôt qu’une convention de nommage. Un fichier de licence signe une enveloppe textuelle construite à partir de l’identifiant de format, du sel, du nonce et du message chiffré, joints par des sauts de ligne. Un binaire signe des octets ELF bruts. Le script d’installation cherche le nombre magique ELF avant de vérifier quoi que ce soit : une signature de licence valide ne peut donc jamais être présentée comme un binaire valide.
Le flux de licence possède une autre propriété qui mérite d’être volée : il vérifie la signature avant de dériver la clé de déchiffrement. Argon2id avec un coût mémoire de 64 Mio est coûteux par conception, et faire ce travail pour un fichier qui ne fut jamais le nôtre revient à offrir un déni de service. Les contrôles d’authenticité bon marché se placent avant la cryptographie coûteuse.
Ce que cela n’achète pas
Signer lie un artefact à une clé. Cela ne dit rien sur le fait que cette clé vous appartienne encore, ni sur la qualité de la version que vous avez signée. Si notre clé de signature fuit, chaque agent accepte ce que l’attaquant construit, et la remplacer suppose de recompiler chaque binaire et de remplacer l’ancre de confiance sur chaque hôte, puisque la clé est compilée dedans. C’est le prix à payer pour que la clé soit une ancre plutôt qu’une suggestion, c’est le bon compromis, mais c’est un coût réel et il a sa place dans le plan plutôt que dans une note de bas de page.
C’est ainsi que fonctionne la mise à jour de l’agent chez Provibr, où l’agent tourne sur le matériel du client, ne se connecte que vers l’extérieur et garde les identifiants d’hyperviseur sur cet hôte.