BlackMesa Labs

Votre document et sa signature ne voyagent pas ensemble

Un artefact signé publié sur le web, ce sont au moins deux ressources HTTP. Le vérificateur les télécharge séparément, et rien ne garantit qu'elles viennent de la même génération. Quand elles n'en viennent pas, la vérification échoue exactement comme devant un faux. Voilà le mécanisme, ce qu'il casse, et les trois façons de le fermer.

Vous publiez un document signé. Le lecteur lance la commande que vous avez imprimée, et elle répond :

message imprint mismatch
Verification: FAILED

Cette réponse a deux causes possibles. L’une est qu’on a modifié votre document. L’autre est qu’il a téléchargé votre document d’hier et votre jeton d’aujourd’hui.

Rien, dans le message, ne les distingue.

Deux fichiers, deux ressources, deux durées de vie

Un artefact signé sur le web n’est jamais un fichier. C’est au moins deux ressources HTTP indépendantes : le document, et la signature détachée à côté. Souvent trois, quand un jeton d’horodatage s’y ajoute. Dans notre cas, cinq : un rapport, sa signature, et trois attestations de date.

Le vérificateur les récupère par des requêtes séparées. Chacune traverse son propre cache navigateur, son propre proxy, son propre CDN, et chacune en ressort avec sa propre fraîcheur. Rien dans le protocole ne les traite comme un ensemble. Il n’existe pas de « récupère-moi ces cinq fichiers tels qu’ils étaient au même instant ».

C’est une évidence quand on l’énonce. Elle ne l’est pas du tout quand on publie.

Ce que fait HTTP quand on ne lui dit rien

Si vous ne mettez pas de Cache-Control, le cache ne renonce pas. Il devine. La RFC 9111, section 4.2.2, dit exactement ceci :

A cache MAY assign a heuristic expiration time when an explicit time is not specified, employing algorithms that use other field values (such as the Last-Modified time) to estimate a plausible expiration time.

Et plus loin :

If the response has a Last-Modified header field, caches are encouraged to use a heuristic expiration value that is no more than some fraction of the interval since that time. A typical setting of this fraction might be 10%.

Dix pour cent de l’âge du document. Un rapport publié il y a dix jours est donc considéré comme frais pendant une journée entière, sans que personne demande quoi que ce soit au serveur. Un rapport publié il y a trois mois, pendant plus d’une semaine.

Et ce n’est pas un réglage que vous avez choisi : c’est ce qui se passe en l’absence de réglage. Le silence n’est pas neutre.

Pourquoi cet échec-là est plus grave que les autres

Un article de blog servi depuis le cache, c’est un désagrément. Ici, trois choses se combinent.

Le document porte l’empreinte de son propre contenu. C’est le principe : le rapport imprime l’empreinte des constats, et c’est elle qu’on compare au jeton. Un document périmé porte une empreinte périmée.

Et le lecteur n’a pas les deux fichiers dans le même état. C’est ici qu’on se trompe facilement, et nous nous sommes trompés en écrivant ce paragraphe : on imagine que les ressources ont des durées de fraîcheur différentes. Sur notre site, elles ont rigoureusement la même — le déploiement leur donne le même Last-Modified, donc la même fenêtre heuristique, à la seconde près.

L’asymétrie est ailleurs, et elle est comportementale : le lecteur a déjà visité la page, donc elle est dans son cache. Il n’a jamais téléchargé le jeton, donc il ne l’a pas. La première requête part en cache, la seconde part au serveur. Deux générations, par le simple fait qu’on lit une page plusieurs fois et qu’on vérifie une signature une fois.

Et le message d’erreur est le même que pour une falsification. C’est le point. Dans l’article de la semaine dernière, nous expliquions pourquoi openssl ts -verify confond deux situations qui n’ont rien à voir — une chaîne de certificats qu’on ne sait pas construire, et un jeton qui atteste un autre document. En voici une troisième, qui se range dans le même seau : deux fichiers parfaitement authentiques, mais pas de la même génération.

Le lecteur voit FAILED. Il en conclut ce que n’importe qui en conclurait.

Mesuré chez nous

Nous avons trouvé ça sur notre propre site, et la façon dont c’est arrivé vaut d’être dite : un lecteur a signalé que l’inventaire publié affichait encore une version que nous avions remplacée. Le serveur, lui, renvoyait bien la nouvelle — curl le montrait en une ligne.

La tentation est de répondre « videz votre cache ». C’est vrai, et c’est une mauvaise réponse : la cause était chez nous. Nos artefacts signés ne portaient aucun Cache-Control, seulement un Last-Modified. L’heuristique faisait exactement ce que la RFC décrit.

Personne n’a été lésé — l’écart portait sur un numéro de version et pas sur l’empreinte, donc la vérification passait quand même. Mais c’était de la chance, pas de la conception : si les constats avaient bougé, un lecteur consciencieux aurait conclu que notre sceau était cassé.

Ce n’est pas un problème de blog

Le mécanisme ne tient à rien de spécifique à nous. Partout où un artefact signé est publié avec sa signature à côté, la même désynchronisation est possible :

  • un SBOM et sa signature, dans une release GitHub ou un registre d’artefacts ;
  • une attestation de provenance — SLSA, in-toto — servie à côté de l’image qu’elle décrit ;
  • un bundle sigstore, dont le .sigstore est une seconde requête ;
  • un manifeste de release et son SHA256SUMS.asc ;
  • n’importe quel jeton RFC 3161 déposé à côté du document qu’il date.

Et ça empire avec les couches qu’on empile. Un CDN garde sa propre copie, avec sa propre durée, et il ne purge pas forcément les cinq fichiers au même instant. Un miroir de paquets se resynchronise par lots. Plus il y a d’intermédiaires, plus la fenêtre pendant laquelle vos ressources sont de générations différentes s’allonge.

Trois façons de fermer ça, et ce qu’elles coûtent

1. Interdire d’utiliser sans demander. Cache-Control: no-cache sur la famille d’URL qui porte les artefacts signés. Le nom trompe : ça n’interdit pas de stocker, seulement de servir sans revalider. Le coût réel est un 304 Not Modified — quelques centaines d’octets — et le bénéfice est qu’un lecteur ne peut plus comparer deux générations.

# ce que le serveur dit maintenant, sur /audit/*
cache-control: no-cache

# et ce que coûte la revalidation
curl -s -o /dev/null -w '%{http_code}\n' -H "If-None-Match: $ETAG" …
304

C’est le remède le moins cher et le plus immédiat. C’est celui que nous avons appliqué.

2. Faire porter l’empreinte par l’URL. Un fichier nommé report-8ecb829c.html ne peut pas être périmé : si le contenu change, le nom change, donc l’adresse change. Un cache qui vous sert l’ancienne URL vous sert l’ancien document — ce qui est correct, et vérifiable, parce que son empreinte est dans son nom.

C’est plus solide que le point 1, et plus cher : les liens bougent à chaque publication, et il faut garder les anciennes versions accessibles ou accepter des liens morts. À réserver aux artefacts qu’on archive plutôt qu’à ceux qu’on republie.

3. Imprimer l’empreinte dans le document. Celui-ci ne remplace pas les deux autres, il les rend diagnosticables. Si le rapport affiche l’empreinte sur laquelle il porte, le vérificateur peut comparer trois valeurs au lieu de deux : celle que le document annonce, celle que le jeton atteste, et celle qu’on recalcule.

Et la combinaison distingue enfin les cas :

ce qu’on observe ce que c’est
le document annonce A, le jeton atteste A, le recalcul donne A tout va bien
le document annonce A, le jeton atteste B les deux fichiers ne sont pas d’accord. Presque toujours une copie périmée — rechargez tout, sans cache, avant d’envisager autre chose
le document annonce A, le jeton atteste A, le recalcul donne B là, quelqu’un a modifié le document

Trois lignes qui transforment un FAILED muet en trois diagnostics séparés.

Ce que ça dit d’un outil de vérification

C’est la même leçon que pour les quatre états d’un horodatage, et elle se généralise : un outil qui vérifie doit distinguer les raisons d’échouer, pas seulement constater l’échec.

« Ces deux fichiers ne sont pas de la même génération » et « ce document a été modifié » demandent des gestes opposés. Le premier se règle en rechargeant. Le second est un incident. Les confondre, c’est soit déclencher une alerte pour un problème de cache, soit — bien pire — classer une vraie falsification au rayon des bizarreries réseau et passer à autre chose.

C’est aussi pour ça qu’il faut tester la commande de vérification depuis une machine qui n’est pas la vôtre, et de préférence depuis un conteneur vide. Le poste qui a produit l’artefact a toutes les bonnes versions en local ; c’est précisément le seul endroit où le bug est invisible.

Bonnes pratiques

  • Traitez le document, sa signature et son jeton comme un ensemble, alors que HTTP ne le fera jamais pour vous. Si vous n’avez qu’une action à mener, c’est celle-là.
  • Mettez un Cache-Control explicite sur les artefacts signés. Ne rien mettre n’est pas neutre : la RFC autorise le cache à deviner, et il devine.
  • Imprimez l’empreinte dans le document lui-même. Elle coûte une ligne et permet de distinguer une copie périmée d’une falsification.
  • Vérifiez depuis ailleurs. Un conteneur vide, une autre machine, un autre réseau. L’origine ne reproduit pas le bug qu’elle cause.
  • Si vous archivez plutôt que vous republiez, mettez l’empreinte dans le nom du fichier. Une URL adressée par son contenu ne peut pas servir une version périmée.

Points de vigilance

  • no-cache et no-store ne disent pas la même chose. Le premier autorise à garder et impose de revalider ; le second interdit de garder. Pour un artefact public, le premier suffit, et le second gaspille de la bande passante sans rien apporter.
  • Votre CDN a sa propre politique, qui peut survivre à celle de l’origine. Vérifiez sur le fil, depuis l’extérieur, et pas dans votre fichier de configuration.
  • Purger un cache fichier par fichier rouvre la fenêtre : pendant la purge, les ressources sont de générations différentes. Purgez la famille d’un coup, ou laissez la revalidation faire le travail.
  • Un vérificateur qui télécharge ses fichiers à quelques minutes d’intervalle peut tomber en plein déploiement. C’est rare, c’est réel, et c’est encore un échec qui ressemble à une falsification.

← Tous les articles