Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 :

  1. SFTP servi in-process — pas d’exécutable air-sftp-server séparé à la OpenSSH ; version 3 stricte (le client sftp d’OpenSSH, notamment depuis un Mac, est la cible d’interopérabilité).
  2. air-sshd doit se comporter comme un serveur OpenSSH sur la résolution des clés : à la connexion de l’utilisateur thierry, 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 (Authorization n’expose que backend : denyAll | perUserAuthorizedKeys) ⇒ with_system_file est 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_UNSUPPORTEDjamais 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 (INITVERSION→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érations air-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 :

  1. il ne touche jamais l’anneau io_uring hérité (il ferme sa copie et fait des appels bloquants de couche 1 — un worker par session, la latence est locale) ;
  2. 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 de couche-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 chemin AirCommand::spawn déjà audité, et image mémoire neuve pour le worker (ni anneau io_uring, ni clés d’hôte, ni matériel de session des autres connexions hérités). Prix : un exec par 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) Écrivainbuild_authorized_keys (entrées → message capnp scellé, tri canonique par algorithme puis blob, ADR-025) + écriture par swap atomique (write_atomic_with_mode, 0600 par-utilisateur). Symétrique exact du lecteur d’ADR-096.

  • (b) Parseur de ligne OpenSSHparse_openssh_authorized_key : cœur sans-IO, fuzzé, décodage base64 borné, ssh-ed25519 seul (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-ssh garde le complémentaire : pubkey (existe) et keygen (différé, ADR-095) produisent la clé qu’on importe ici.

  • (d) Champ additif de schémasshd-config.capnp, struct Authorization reçoit systemAuthorizedKeysPath @1 :Text (id croissant, jamais renuméroté — discipline ADR-012). Il câble FileAuthorizedKeys::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.ContenuPreuve
S.0Cet ADR (design + plan).ratifié
K.1Écrivain authorized_keys + parseur de ligne OpenSSH (fuzzé).round-trip texte → binaire → lecteur
K.2CLI air-sshd authorized-keys (list/add/remove/import) + gate admin (ADR-101).clé du Mac importée → ssh -i réel autorisé
K.3Champ systemAuthorizedKeysPath + câblage de la cascade + CLI config.clé système autorise un utilisateur sans magasin perso
S.1Cœ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.2Pilote lecture seule : subsystem sftp accepté, REALPATH/STAT/OPENDIR/READDIR/OPEN+READ/CLOSE.vrai sftp OpenSSH : ls, get d’un fichier
S.3Worker 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.5Interop 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 ».

  1. Le code nouveau ne repose que sur la couche 1. air-sshd et la future air-sftp-proto sont 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 faute sys/time corrigée en #417 est le précédent à ne pas reproduire. Mécanisé par cargo 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.
  2. Les exécutables produits ne dépendent JAMAIS de la libc gnu. Livraison *-unknown-linux-air only (ADR-103), liée à notre std linux-air (PAL couche 1, sans libc C), static-pie. Mécanisé deux fois : cargo xtask check-target (marquage target = "linux-air" par crate + couverture de livraison) et le garde-fou readelf -d de cargo xtask deb, qui refuse tout livrable présentant ne serait-ce qu’un NEEDED. La std gnu reste un banc de test hôte (couverture, fuzz, itération rapide) — jamais un livrable.

Conséquences

  • air-sshd devient un remplaçant fonctionnel d’OpenSSH sshd pour le trio shell + forwarding + transfert de fichiers.
  • Les authorized_keys deviennent 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 livraison check-target (règles (e)/(f)).
  • Aucun descellement de couche 1 prévuair-filesystem et air-process exposent 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.4 reste à 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-server séparé (modèle OpenSSH). Écarté par le BDFL : deux binaires à versionner, packager et durcir, une frontière exec à 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-keys sur air-ssh (le client). Rejeté : l’artefact est de la politique serveur, et le magasin système exige de toute façon le gate admin.

Références

  • ADR-097 — phase 3 (différait sftp).
  • ADR-096 — magasin binaire, cascade.
  • ADR-091 — motif sans-IO (normatif).
  • ADR-101 — gate mot de passe admin.
  • draft-ietf-secsh-filexfer-02 — SSH File Transfer Protocol, version 3.