Jusqu'à la v1.7.0, une seule clé chiffrait tous vos messages avec un contact donné, à vie. Téléphone saisi ou phrase de récupération fuitée : tout l'historique devenait déchiffrable. La v1.7.0 remplace ça par un Double Ratchet : une clé par message, détruite après usage.
Cet article explique le défaut sans le maquiller, le correctif, et surtout ce qu'on ne prétend toujours pas.
Le défaut
L'ancienne implémentation faisait un unique échange Diffie-Hellman entre deux clés X25519 statiques — les identités permanentes des deux personnes — passait le résultat dans HKDF, et mettait la clé AES obtenue en cache pour toujours :
// AVANT — e2e_crypto.dart
final shared = ECDH(maCléPrivéeStatique, saCléPubliqueStatique);
final clé = HKDF(shared);
_cache[lui] = clé; // ← la même clé. pour toujours.
Chaque message échangé avec quelqu'un, du premier au dernier, était chiffré sous cette même clé. Elle ne changeait jamais. Elle ne tournait jamais. Elle dépendait uniquement de deux secrets qui, eux, ne changent jamais non plus.
La conséquence, en clair : quiconque obtenait votre clé privée — téléphone saisi, malware, sauvegarde qui fuit — pouvait déchiffrer l'intégralité de vos conversations passées. Et comme la phrase de récupération BIP39 est aussi votre identifiant de connexion, « on m'a pris ma seed » voulait dire « on a trois ans de mes messages ».
C'est ce qu'on appelle l'absence de forward secrecy. Et c'est d'autant plus gênant que tout notre discours porte sur le fait qu'on ne peut pas être contraints de scanner vos messages. Sauf que le vrai mode d'échec n'a jamais été le scan côté serveur. C'est le téléphone qu'on vous confisque.
On aurait pu corriger ça en silence. On préfère l'écrire : si vous avez utilisé Nøbody avant la v1.7.0, les messages envoyés à cette époque n'ont pas de forward secrecy. C'est un fait qui vous concerne, vous méritez de le savoir.
Le correctif : un Double Ratchet
Le Double Ratchet est le protocole popularisé par Signal. L'idée tient en deux mouvements :
- Un cliquet symétrique. Chaque message dérive sa clé d'une chaîne KDF, puis la clé est détruite. On ne peut pas remonter la chaîne : connaître la clé d'aujourd'hui ne donne rien sur celle d'hier.
- Un cliquet Diffie-Hellman. À chaque réponse, chacun génère une nouvelle paire de clés éphémère et la mélange au secret racine. C'est ce qui guérit la session : un attaquant qui a volé l'état est rejeté dehors dès l'aller-retour suivant.
Avant
1 clé × toute la vie de la conversation. Téléphone saisi = tout l'historique lisible.
Après
1 clé par message, détruite après usage. Téléphone saisi = le passé reste illisible.
Le piège qu'on a dû désamorcer
Signal amorce son ratchet avec X3DH, qui exige des « prekeys » stockées sur le serveur. On ne voulait pas toucher au serveur — alors on amorce depuis l'ECDH statique-statique que les deux côtés savent déjà calculer, et l'en-tête du ratchet voyage à l'intérieur du champ chiffré, que le serveur traite déjà comme une chaîne opaque. Zéro changement côté serveur.
Sauf qu'un amorçage naïf a un défaut vicieux : si les deux personnes écrivent en premier, en même temps, chacune démarre sa propre session, les racines divergent, et la conversation est brisée définitivement. C'est le genre de bug qu'on ne voit pas en développement et qui détruit des messages en production.
La parade : les rôles sont attribués de manière déterministe en comparant les deux clés publiques (les deux appareils tombent d'accord sans se parler), et le « répondeur » reçoit une chaîne d'amorçage dérivée du secret racine — que l'initiateur peut déjà calculer. Résultat : n'importe qui peut écrire en premier, même simultanément, et les deux déchiffrent.
On ne teste pas des aller-retours. On teste les propriétés.
« Le message part et revient » ne prouve rien sur la sécurité. Les 15 tests vérifient les propriétés réelles — notamment celle-ci, qui est le cœur du sujet :
// L'attaquant saisit le téléphone de Bob et obtient TOUT :
// sa clé d'identité ET l'état courant de son ratchet.
final attaquant = DoubleRatchet(state: étatVoléDeBob, key: cléDeBob);
// Il essaie de déchiffrer un vieux message.
expect(await attaquant.decrypt(vieuxMessage), isNull);
// ↑
// la clé de ce message a été dérivée... puis détruite.
Les autres tests couvrent la guérison post-compromission, la race du premier message, la livraison dans le désordre, le rejet des en-têtes falsifiés (l'en-tête est authentifié en AEAD : un serveur hostile ne peut pas le réécrire), l'absence de réutilisation de clé, et la persistance après redémarrage.
Anecdote honnête : au premier passage, le test de guérison post-compromission a échoué. Le réflexe facile aurait été d'assouplir le test. En fait c'est le test qui avait tort : la guérison n'est pas instantanée, il faut un cycle complet pour que la partie compromise réintroduise de l'aléa neuf. On a corrigé le test pour modéliser la vraie propriété, plutôt que de l'édulcorer.
Ce qu'on ne prétend PAS
C'est la section la plus importante de cet article. Toute app qui vous vend un chiffrement « parfait » vous ment. Voici nos limites, écrites noir sur blanc — elles sont aussi dans le SECURITY.md :
- Les tout premiers messages ne sont pas encore protégés. Comme on amorce depuis l'ECDH statique, les quelques messages envoyés avant le premier aller-retour restent vulnérables à la compromission d'une clé d'identité à long terme. Tout ce qui suit le premier cliquet DH est protégé. Fermer ce dernier trou demande des prekeys côté serveur (X3DH) — c'est la prochaine étape.
- Les groupes ne sont pas ratchetés. Ils utilisent toujours une clé de groupe partagée. La forward secrecy en groupe demande un schéma à « sender keys » qu'on n'a pas encore fait. Ne supposez pas qu'elle existe.
- Rien de tout ça ne vous sauve si on vous force à déverrouiller votre téléphone. La cryptographie protège les données, pas les gens sous contrainte.
Vérifiez-le vous-même
Ne nous croyez pas sur parole — c'est tout l'intérêt du logiciel libre. Le ratchet tient dans un seul fichier lisible, lib/services/double_ratchet.dart, et les tests sont dans test/double_ratchet_test.dart. Le code est sur Codeberg, sous AGPL-3.0.
Si vous trouvez une faille dans ce raisonnement, écrivez-nous. Un rapport qui nous humilie vaut mieux qu'une faille qui dort.