← Récit
Chapitre 6Août 2026

Ferry — construire pour un monde sans serveur dedans

En août 2026, j'avais un document que je trimballais de projet en projet — mon propre plan d'architecture. Dix décisions dans l'ordre de leur importance, une carte d'architecture, vingt invariants numérotés, des patterns pour la couche API, la base de données, le front-end, la sécurité et les tests, une liste de ce qui ne doit jamais arriver, et une liste de simplifications délibérées. Il est sorti d'EduQate, où il a survécu à une vraie charge et à de vraies échéances.

Ferry, c'est là où j'ai apporté ce plan à un projet auquel il ne convenait surtout pas, et où j'ai appris quelles parties étaient réellement porteuses.

Ce qu'est Ferry

Un outil pair-à-pair open source pour les équipes de développeurs. Fichiers, secrets et messages courts déplacés directement entre machines sur un LAN ou un partage de connexion — pas de serveur, pas de relais, aucun composant hébergé nulle part, jamais. Chaque machine fait tourner le même démon, qui est à la fois client et serveur. Trois surfaces — une CLI, une extension VS Code, une application de bureau — parlent toutes au même démon local.

Huit crates Rust dans un seul workspace. Environ 16 700 lignes de Rust, 350+ tests, et la direction des dépendances entre les crates est imposée par le compilateur — une violation est une erreur de build, pas une remarque de revue de code qu'on pourrait manquer.

Le plan supposait un monde qui n'est pas celui-ci

Mon plan suppose : un serveur. Une base de données qui est le produit. Des requêtes HTTP. Des rôles humains. Et une autorité qui fait respecter une règle une fois, au nom de tous.

Ferry n'a rien de tout ça. Il n'y a pas de serveur pour contenir la base de données. Il n'y a pas de HTTP. Il n'y a pas de rôles. Et surtout, il n'y a pas de tiers de confiance — chaque règle doit être imposée indépendamment, par la machine qui s'en soucie, contre une preuve cryptographique plutôt que la parole de quelqu'un.

Alors j'ai fait la chose qui fait que la réutilisation fonctionne au lieu de virer au culte du cargo : j'ai écrit, à chaque point de divergence, ce qui se reportait tel quel, ce qui se reportait modifié, ce que j'ai abandonné entièrement, et ce que j'ai dû ajouter parce que le plan n'avait jamais affronté ce problème.

Ce qui s'est reporté, tel quel, s'est révélé être ce qui n'a jamais vraiment été une affaire de web : un seul endroit où chaque règle s'applique. De vraies frontières de couches. Découpler une décision de son annonce. Et ne rien appeler fait tant qu'on ne l'a pas vu fonctionner.

Ce qui s'est inversé : « la base de données est l'actif » est devenu « la base de données n'est pas l'actif ». Le SQLite de Ferry contient l'état de la file et l'historique — tout recréable. La chose qui vraiment ne peut pas être perdue, c'est la clé d'identité et le roster. Cette seule inversion a supprimé tout un job CI (le contrôle de dérive de schéma) et tout un rituel de sauvegarde que j'aurais sinon réutilisé par réflexe, et a redirigé tout ce soin vers le trousseau de clés de l'OS.

Ce qui s'est déplacé : le refus par défaut. Dans le plan, il vit à un wrapper de route. Dans Ferry, il est descendu jusqu'au handshake cryptographique — un pair non-rosterisé échoue pendant le handshake Noise XK, avant qu'un seul octet ne soit parsé comme une commande. Il n'y a aucun chemin de code où le message d'un pair non autorisé est lu puis rejeté, parce que la connexion ne devient jamais un canal de commande du tout. Faire respecter ça dans l'interface serait du théâtre.

Ce qui s'est délibérément violé : « rien ne supprime définitivement ». Pour les secrets, brûler-après-lecture doit vraiment détruire. Donc la règle est devenue : le journal d'audit enregistre *qu'*un événement a eu lieu — acteur, type, id, heure, résultat — et jamais ce qu'il contenait.

Et « fait » est devenu plus strict. Dans le plan, fait signifie « parcouru de bout en bout en tant que vrai utilisateur ». Pour Ferry, ça signifie « parcouru de bout en bout sur deux vraies machines physiques sur un vrai réseau, y compris au moins une déconnexion et une reconnexion ». Un harnais de test à deux nœuds en mémoire qui passe te dit beaucoup sur la machine à états et presque rien sur un partage de connexion avec un portable en veille dessus.

Les problèmes sortis du build

bincode ne peut pas désérialiser un enum étiqueté. Pas du tout. L'enveloppe de fil utilisait une représentation d'étiquette auto-descriptive et l'encodait avec un format binaire qui structurellement ne peut pas regarder en avant une étiquette de chaîne. Pas un bug dans l'une ou l'autre bibliothèque — une inadéquation entre deux choix individuellement raisonnables. Attrapé par les propres tests unitaires du moteur de transfert plutôt que laissé latent, ce qui est tout l'argument pour rendre le moteur de transfert testable sans socket. Et en le corrigeant j'ai réalisé que ces types de fil sont le protocole pair-à-pair et que l'interface n'y touche jamais — elle ne parle qu'au démon local — donc leurs bindings TypeScript auto-générés étaient inutiles depuis le début. Huit fichiers de bindings périmés en sont tombés.

Une constante de taille de chunk qui n'aurait jamais pu marcher. Une crate définissait le chunk de transfert par défaut à 64 Kio. Un vrai chunk encodé à cette taille — plus la surcharge de l'enveloppe, plus le tag de chiffrement — dépasse la taille maximale de trame que la couche de framing autorise. Chaque gros transfert sur une vraie connexion aurait échoué. Ça n'avait jamais été attrapé parce qu'une seconde crate définissait sa propre constante de chunk, plus petite, et cette plus petite était la seule que quoi que ce soit exerçait réellement. Donc il y avait deux constantes pour un concept, et la porteuse était correcte tandis que le défaut exporté était faux et non testé en même temps. Le harnais à deux nœuds — la première chose à jamais pousser des octets de taille réelle à travers la vraie couche de framing — est ce qui l'a fait remonter. Une constante dupliquée est un endroit où une copie est porteuse et l'autre est de la décoration, et la décorative est exactement là où un bug se cache indéfiniment.

Le temps, quand on ne peut pas faire confiance à une horloge. Un secret de dix minutes est envoyé à un portable en veille. Il arrive vingt minutes plus tard. Quand expire-t-il ? Utiliser l'horodatage de l'expéditeur est faux deux fois — deux machines sur un partage de connexion sont vraiment en désaccord sur l'heure, et l'horloge de l'expéditeur est contrôlée par l'attaquant, donc un attaquant qui peut régler une horloge peut faire vivre un secret pour toujours. La règle est que l'expiration tourne sur l'horloge monotone du receveur, à partir de la livraison. Ça tue une classe de bugs et une classe d'attaques en une phrase. Le difficile était de survivre à un redémarrage de processus, parce qu'une lecture d'horloge monotone n'a pas de sens hors du processus qui l'a prise et n'a aucune forme persistable. Le correctif : chaque processus reçoit un identifiant de session aléatoire stocké à côté de la lecture. Ne fais confiance à la valeur monotone que dans la session qui l'a produite ; sinon retombe sur une estimation conservatrice à l'horloge murale ; quand les deux sont en désaccord, expire — la direction sûre.

Le pairing ne peut pas vivre dans le démon. L'échange de pairing tourne dans la CLI, sur un socket brut, pas dans le démon. Parce que le pairing exige qu'un humain lise une phrase de vérification à l'écran et confirme à voix haute qu'elle correspond à celle de l'autre personne, et le démon est sans tête sans terminal garanti. Évident une fois qu'on le dit, facile à concevoir de travers quand on pense à quel composant « possède » le protocole au lieu de quel composant peut atteindre une personne. Une étape qui exige structurellement un humain ne peut pas vivre dans un processus qui structurellement ne peut pas en atteindre un.

La crypto s'assemble, ne se conçoit jamais. age pour le scellement, Noise XK pour le transport, SPAKE2 pour le pairing, ed25519 pour les signatures. Aucune construction nouvelle, aucune dérivation de clé faite main. Quand j'ai eu besoin que l'identité contienne à la fois une paire de clés de signature et une paire de clés de scellement, j'ai généré deux clés indépendantes plutôt que d'en dériver une de l'autre — parce que la dérivation implique une conversion par application birationnelle qui se lit exactement comme la dérivation de clé faite main que les règles interdisent. Deux clés coûtent légèrement plus d'espace dans le trousseau. C'est tout l'inconvénient, et ça en vaut la peine.

Le standard auquel je l'ai construit

La chose dont je suis le plus fier sur Ferry n'est pas une fonctionnalité, c'est la barre. Chaque flux visible par l'utilisateur a une définition de « fait » délibérément dure : parcouru de bout en bout sur deux vraies machines physiques, sur un vrai réseau, y compris au moins une déconnexion et une reconnexion quand c'est pertinent. Compiler ne compte pas. Des tests unitaires qui passent ne comptent pas. Même le harnais à deux nœuds en mémoire qui passe ne compte pas — ça vaut au flux une note différente, plus basse, parce qu'un harnais te renseigne sur la machine à états et un partage de connexion avec un portable en veille te renseigne sur la réalité.

Je garde cette barre écrite, par flux, comme source unique de vérité de ce qui marche — pas la base de code, pas la sortie des tests, pas ce qui « devrait » marcher parce que le code existe. C'est la même discipline qui a attrapé onze bugs en une passe sur EduQate, portée sur un projet où le réseau lui-même est la partie difficile.

C'est un thème récurrent chez moi, si tu as lu jusqu'ici. Je préfère tenir la barre haute et te dire exactement où est la ligne, plutôt que de déplacer la ligne pour qu'une phrase sonne mieux.