ADR-104 — air-sshd phase 4 : SFTP v3 (subsystem, cœur sans-IO, worker privsep) + administration des authorized_keys binaires
Statut : Accepté (2026-07-25, décision BDFL). RFC d’implémentation
(ADR-015).
S’appuie sur ADR-097 (phase 3
ssh-connection, qui différait explicitement sftp/subsystem),
ADR-091 (motif réseau sans-IO, normatif),
ADR-096 (magasin authorized_keys
binaire), ADR-073 (configuration binaire),
ADR-101 (gate mot de passe admin),
ADR-098 (concurrence structurée).
Catégorie : Extension fonctionnelle de la couche 2 + descellement additif de la
couche 1 — décidé le 2026-07-25 à l’ouverture de S.3, la primitive fork sans exec
n’existant pas (voir §4, amendement).
Contexte
air-sshd sert aujourd’hui shell, exec et le port-forwarding, et interopère avec
OpenSSH dans les deux sens (phases 1→3). Il refuse en revanche la requête
subsystem (connection.rs, « différés / non traités en C.3 ») : un sftp de stock
échoue donc face à air-sshd. Or le transfert de fichiers est le second usage
quotidien de SSH après le shell, et la fonction qui manque pour qu’air-sshd
remplace un sshd OpenSSH sur une machine Air.
Directive BDFL (2026-07-25) — deux volets, une seule campagne :
- SFTP servi in-process — pas d’exécutable
air-sftp-serverséparé à la OpenSSH ; version 3 stricte (le clientsftpd’OpenSSH, notamment depuis un Mac, est la cible d’interopérabilité). air-sshddoit se comporter comme un serveur OpenSSH sur la résolution des clés : à la connexion de l’utilisateurthierry, lire les clés autorisées et dans le répertoire de configuration du serveur, et dans le répertoire Air du home de l’utilisateur — jamais$HOME/.ssh/. Les clés sont importées dans l’artefact binaire de l’utilisateur par une commande dédiée ; on ne recopie pas des fichiers de clés en clair comme le fait OpenSSH.
État réel de l’existant (vérifié) — le volet 2 est déjà conçu et à moitié bâti :
[ADR-096] a figé la cascade (par-utilisateur <home>/.config/air/sshd/authorized_keys,
home résolu par air-account ; système <racine>/etc/air/sshd/authorized_keys) et
FileAuthorizedKeys::with_system_file existe et est testé. Deux trous subsistent :
- aucun écrivain — le magasin binaire ne se peuple que par un helper de test ; aucune commande utilisateur ne sait le créer, ce qui rend la fonctionnalité inutilisable en pratique ;
- le chemin du magasin système n’est pas dans le schéma
sshd-config.capnp(Authorizationn’expose quebackend : denyAll | perUserAuthorizedKeys) ⇒with_system_fileest inatteignable depuis la configuration.
Décision
1. Périmètre protocolaire — SFTP v3 strict, extensions différées
On implémente le serveur SFTP version 3
(draft-ietf-secsh-filexfer-02) — la version que parlent sftp/scp -O d’OpenSSH,
et la seule que le client négocie par défaut. Sont dans le périmètre les opérations
qui font un serveur utilisable : INIT/VERSION, OPEN/CLOSE/READ/WRITE,
OPENDIR/READDIR, LSTAT/STAT/FSTAT, SETSTAT/FSETSTAT, REMOVE, MKDIR,
RMDIR, RENAME, REALPATH, READLINK, SYMLINK.
Sont différés, explicitement : les extensions @openssh.com
(posix-rename, statvfs, hardlink, fsync, limits), les versions 4→6, et
EXTENDED. Une extension inconnue reçoit SSH_FX_OP_UNSUPPORTED — jamais un
silence ni une approximation. Le client OpenSSH sonde ces extensions et retombe
proprement sur le v3 de base : l’interopérabilité ne dépend d’aucune d’elles.
Conséquence de typage, non négociable. En v3, un nom de fichier est une chaîne
d’octets sans encodage garanti — c’est un défaut du protocole, et c’est précisément
ce qu’un Path/OsStr Unix modélise (Principe 3, « zéro présomption »). Les noms
transitent donc en &[u8]/AirPath de bout en bout ; aucune conversion UTF-8 sur
le chemin de données. Le champ longname de READDIR (verrue v3 : une ligne façon
ls -l que le client affiche tel quel) est formaté par nous, en octets, sans
présumer de la locale.
2. Topologie — un cœur sans-IO, conformément au motif normatif
[ADR-091] est normatif : toute implémentation réseau se conforme au motif sans-IO. SFTP ne fait pas exception, et s’y prête idéalement.
- Cœur pur — nouvelle crate
air-sftp-proto(couche 2) : découpage en paquets (préfixe de longueur), codec des 20 messages, machine d’état de session (INIT→VERSION→requêtes), table de handles opaques. Zéro I/O, zéro syscall, zéro allocation cachée,#![forbid(unsafe_code)], fuzzé (un parseur qui lit des octets hostiles avant toute authentification d’intention est exactement la cible d’ADR-091), couverture ~100 % (unit + property + model-based). - Pilote mince — dans
air-sshd: traduit les requêtes décodées en opérationsair-filesystem(couche 1), et pompe le canal SSH. Il ne parse rien.
Crate séparée plutôt que module d’air-ssh-proto : SFTP est un protocole distinct,
qui se trouve seulement transporté par SSH. Le séparer garde air-ssh-proto centré, et
rend le cœur SFTP fuzzable et testable sans monter une session SSH.
3. Le chemin subsystem
connection.rs cesse de refuser en bloc : SessionRequest::Subsystem { name } est
accepté si et seulement si name == b"sftp" (tout autre sous-système reste refusé —
liste blanche, jamais liste noire). Le codec SessionRequest::Subsystem existe déjà
dans air-ssh-proto : rien à ajouter côté transport.
4. Modèle de privilèges — le point dur, et le seul vrai arbitrage
C’est ici que « in-process » demande une précision, car une lecture naïve introduirait
une régression de sécurité : shell/exec s’exécutent aujourd’hui via
AirCommand::spawn avec drop_privileges_to — donc dans une image de programme
neuve, sous l’identité de l’utilisateur authentifié. Un serveur SFTP qui ferait ses
open/read/unlink dans le démon les ferait sous l’identité du démon
(root) — il faudrait alors réimplémenter à la main le contrôle d’accès Unix
(uid/gid/mode, groupes secondaires, sticky bits, liens symboliques traversant une
frontière). C’est une classe de vulnérabilité historique, et le contraire de la posture
d’Air. Écarté.
Décision : un worker forké, sans exec. À l’ouverture du sous-système, le démon
fork (couche 1), le fils largue ses privilèges vers l’utilisateur authentifié
(AirPrivilegeDrop::for_user, déjà utilisé par le chemin shell) et exécute la boucle
SFTP ; le père pompe le canal SSH ↔ socketpair, exactement comme le pump PTY
déjà écrit (E.3). Le noyau applique alors le contrôle d’accès Unix réel : nous
n’en réimplémentons aucune partie.
C’est bien « in-process » au sens de la directive — un seul binaire, un seul arbre de
code, aucun exécutable air-sftp-server à installer, versionner et durcir à part —
tout en gardant la séparation de privilèges.
Sûreté du fork sans exec — le point à vérifier, pas à supposer. Le danger
classique (l’enfant hérite d’un verrou d’allocateur détenu par un autre thread au
moment du fork, cf. le deadlock io_uring+fork déjà rencontré et corrigé, #128/#161)
ne s’applique pas ici : air-sshd est mono-réacteur, mono-thread (daemon.rs),
donc aucun autre thread ne peut détenir un verrou à l’instant du fork. Deux règles
n’en restent pas moins impératives dans le fils :
- il ne touche jamais l’anneau
io_uringhérité (il ferme sa copie et fait des appels bloquants de couche 1 — un worker par session, la latence est locale) ; - il ferme tous les descripteurs hérités hors
socketpair(le transport chiffré, les sockets d’écoute) — un worker non privilégié n’a rien à faire avec eux.
Amendement du 2026-07-25 — la primitive n’existait pas, décision BDFL de la créer
La rédaction ci-dessus supposait qu’un fork sans exec était disponible. Vérification
faite à l’ouverture de S.3 : il ne l’était pas. Le clone3 d’air-process est
enfermé dans AirCommand::spawn et spawn_process, dont l’enfant va toujours
jusqu’à execve (run_child : dup3/chdir/execve/exit_group). Aucune API de
couche 1 ne rendait la main dans l’enfant.
Deux voies s’ouvraient donc, et l’arbitrage a été porté au BDFL :
- Créer la primitive — un additif couche 1 (
air-process), donc un descellement additif decouche-1-v2.x. Coût assumé : on fait entrer dans une couche scellée une primitive dont la sûreté ne peut pas être vérifiée par le compilateur et repose sur la discipline de l’appelant. - Ré-exécuter le binaire (
/proc/self/exe --sftp-worker) — zéro surface nouvelle, réutilisation du cheminAirCommand::spawndéjà audité, et image mémoire neuve pour le worker (ni anneauio_uring, ni clés d’hôte, ni matériel de session des autres connexions hérités). Prix : unexecpar session SFTP.
Décision BDFL (2026-07-25) : créer la primitive. air_process::fork_process est
ajoutée en couche 1 — unsafe fn (le contrat « ce que l’enfant a le droit de faire » est
reporté sur l’appelant et doit être lu, précédent air_thread::create_thread et
air_memory::unmap), rendant un [AirForkResult] Parent(AirProcess) / Child. Le
parent obtient un pidfd, jamais un PID nu (doctrine du projet). Descellement
additif de la couche 1 en conséquence.
La ré-exécution reste le repli documenté si les invariants du fils se révélaient intenables : image neuve, toujours un seul binaire. On ne retombe pas sur un exécutable séparé.
Quand privilegeSeparation est désactivé en configuration, le worker n’est pas
forké et la boucle tourne dans le démon — symétrique du chemin shell existant, et
du même niveau de risque assumé.
5. authorized_keys — écrivain, CLI d’import, et cascade configurable
Le stockage reste binaire (ADR-073/040 : enveloppe + checksum FNV, corruption ⇒ erreur, jamais lecture silencieuse). On n’introduit pas de fichier de clés en clair : la politique d’autorisation d’Air n’est pas un fichier texte que tout processus peut éditer à moitié, et le parseur de texte OpenSSH sort du chemin d’authentification — il n’est exercé qu’une fois, hors ligne, par la commande d’import. Le démon, lui, ne lit jamais qu’un capnp validé.
Trois additifs :
-
(a) Écrivain —
build_authorized_keys(entrées → message capnp scellé, tri canonique par algorithme puis blob, ADR-025) + écriture par swap atomique (write_atomic_with_mode,0600par-utilisateur). Symétrique exact du lecteur d’ADR-096. -
(b) Parseur de ligne OpenSSH —
parse_openssh_authorized_key: cœur sans-IO, fuzzé, décodage base64 borné,ssh-ed25519seul (moderne-only, cohérent U.2/U.3), commentaire conservé pour la traçabilité, options refusées (from=,command=… restent différées par ADR-096 : les accepter silencieusement serait prétendre les appliquer). -
(c) CLI — verbes portés par
air-sshd, car l’artefact est de la politique serveur :air-sshd authorized-keys list [--user NAME | --system] air-sshd authorized-keys add <ligne OpenSSH | --file cle.pub> [--comment …] air-sshd authorized-keys remove <index | --comment …> air-sshd authorized-keys import <fichier authorized_keys OpenSSH>Gate (ADR-101) : agir sur son propre magasin est libre ; toucher au magasin système ou au home d’un autre utilisateur exige le mot de passe administrateur. Le mécanisme existe déjà (
air-account) — on le réutilise, on ne le réinvente pas.Côté client,
air-sshgarde le complémentaire :pubkey(existe) etkeygen(différé, ADR-095) produisent la clé qu’on importe ici. -
(d) Champ additif de schéma —
sshd-config.capnp,struct AuthorizationreçoitsystemAuthorizedKeysPath @1 :Text(id croissant, jamais renuméroté — discipline ADR-012). Il câbleFileAuthorizedKeys::with_system_file, aujourd’hui écrit, testé, et inatteignable. Vide ⇒ pas de magasin système (comportement actuel inchangé).
La cascade devient alors exactement ce que demande la directive : à la connexion de
thierry, le serveur consulte son magasin (<home>/.config/air/sshd/authorized_keys)
puis le magasin système (/etc/air/sshd/authorized_keys, clés d’administration).
Absence de home ou de fichier ⇒ false, jamais une erreur distinguable (anti-énumération,
U.4). Fail-closed sur artefact corrompu.
6. Incréments — chaque incrément = 1 PR verte, cœur ~100 % + fuzz, interop réelle
| Inc. | Contenu | Preuve |
|---|---|---|
| S.0 | Cet ADR (design + plan). | ratifié |
| K.1 | Écrivain authorized_keys + parseur de ligne OpenSSH (fuzzé). | round-trip texte → binaire → lecteur |
| K.2 | CLI air-sshd authorized-keys (list/add/remove/import) + gate admin (ADR-101). | clé du Mac importée → ssh -i réel autorisé |
| K.3 | Champ systemAuthorizedKeysPath + câblage de la cascade + CLI config. | clé système autorise un utilisateur sans magasin perso |
| S.1 | Cœur air-sftp-proto : framer + codec des 20 messages + machine d’état + handles. Fuzz du parseur. | property/model-based ; fuzz ≥ 1 M exec, 0 crash |
| S.2 | Pilote lecture seule : subsystem sftp accepté, REALPATH/STAT/OPENDIR/READDIR/OPEN+READ/CLOSE. | vrai sftp OpenSSH : ls, get d’un fichier |
| S.3 | Worker privsep (fork + drop_privileges_to + pump socketpair) + invariants du fils. | fichier de root refusé par le noyau, pas par nous |
| S.4 | Écriture : OPEN+WRITE, MKDIR/RMDIR/REMOVE/RENAME/SETSTAT/SYMLINK/READLINK. | put, mkdir, rm, rename réels |
| S.5 | Interop scellée en CI + gros transfert (fenêtres de canal, READ concurrents). | sftp depuis un Mac, aller-retour d’un fichier ≥ 100 Mio, checksum identique |
Chaque incrément : cœur ~100 % (unit + property + model-based + fuzz), pilote > 90 %,
barrière verte, commit signé GPG+DCO sans Co-Authored-By, suivi mis à jour.
7. Contraintes de livraison — rappel normatif du BDFL (ratification 2026-07-25)
Deux invariants, déjà mécanisés, s’appliquent à tout code produit par cette campagne. Ils sont rappelés ici pour qu’aucun incrément ne puisse les contourner « juste une fois ».
- Le code nouveau ne repose que sur la couche 1.
air-sshdet la futureair-sftp-protosont couche 2 : ils consomment les Managers de couche 1 (air-filesystem,air-process,air-account,air-crypto,air-socket,air-base-core…) et jamais la couche 0 en direct. Un manque en couche 1 se comble par un additif couche 1, jamais par un raccourci — la fautesys/timecorrigée en #417 est le précédent à ne pas reproduire. Mécanisé parcargo xtask check-layers(règles (b) montée / (c) saut), branché dans la barrière et en CI. Seule exception structurelle :air-sys-types, crate transverse de types (newtypes,Errno,AirPath…) sans aucun syscall, explicitement exemptée des règles (b)/(c) par ADR-052 §D6 et utilisée par tout l’arbre. - Les exécutables produits ne dépendent JAMAIS de la libc gnu. Livraison
*-unknown-linux-aironly (ADR-103), liée à notre std linux-air (PAL couche 1, sans libc C), static-pie. Mécanisé deux fois :cargo xtask check-target(marquagetarget = "linux-air"par crate + couverture de livraison) et le garde-foureadelf -ddecargo xtask deb, qui refuse tout livrable présentant ne serait-ce qu’unNEEDED. La std gnu reste un banc de test hôte (couverture, fuzz, itération rapide) — jamais un livrable.
Conséquences
air-sshddevient un remplaçant fonctionnel d’OpenSSHsshdpour le trio shell + forwarding + transfert de fichiers.- Les
authorized_keysdeviennent administrables — aujourd’hui la fonctionnalité d’ADR-096 est, faute d’écrivain, inutilisable hors tests. - Nouvelle crate couche 2
air-sftp-proto: à déclarer[package.metadata.air](layer = 2), sans exécutable — donc hors périmètre du gate de livraisoncheck-target(règles (e)/(f)). - Aucun descellement de couche 1 prévu —
air-filesystemetair-processexposent déjà ce dont le worker a besoin. À re-confirmer en S.3/S.4 : un manque se comblerait par un additif couche 1, jamais par un raccourci vers la couche 0 (règle dure de layering). - Le re-sceau
couche-1-v2.4reste à faire après la clôture d’air-sshd(décision BDFL) : cette campagne l’alimente, elle ne le déclenche pas.
Alternatives rejetées
- Exécutable
air-sftp-serverséparé (modèle OpenSSH). Écarté par le BDFL : deux binaires à versionner, packager et durcir, une frontièreexecà protocoler, pour un bénéfice de privsep que le worker forké obtient déjà. - SFTP exécuté dans le démon, avec contrôle d’accès émulé. Rejeté : réimplémenter le contrôle d’accès Unix en espace utilisateur est une classe de vulnérabilité connue ; le noyau le fait, correctement, gratuitement.
- Recopier les fichiers de clés en clair dans le répertoire Air (modèle OpenSSH). Rejeté par le BDFL et par ADR-073 : cela remettrait un parseur de texte sur le chemin d’authentification, à chaque connexion.
- SFTP v6 (ou v4/v5). Rejeté : le client OpenSSH parle v3 ; implémenter des versions que personne ne négocie, c’est de la surface d’attaque sans usage.
- Un module SFTP dans
air-ssh-proto. Rejeté : SFTP est un protocole distinct, seulement transporté par SSH ; le séparer le rend fuzzable sans session SSH. - Verbes
authorized-keyssurair-ssh(le client). Rejeté : l’artefact est de la politique serveur, et le magasin système exige de toute façon le gate admin.