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-131 — Descellement additif couche-1-v3.4 : le worker post-auth qui sert vraiment (I/O du proxy confiné, cage SFTP utilisable, registre de descripteurs qui ne ferme plus le neuf)

Statut : Accepté (2026-07-29). RFC de structure (ADR-015). Applique le modèle de re-sceau du sceau couche 1 (ADR-062) sous la délégation additive du superviseur (ADR-065). Implémente les prérequis couche 1 d’ADR-129 §D1/D2/D5 (sous-incrément V2.5b), dans la continuité d’ADR-125, ADR-127 et ADR-130.

Catégorie : Descellement ADDITIF de la couche 1 scellée (couche-1-v3.3couche-1-v3.4). Aucun retrait, aucun changement de signature : toute surface antérieure reste valide telle quelle. Une correction de défaut au comportement d’air_handle::register y figure (§3) — elle ne change aucune signature, mais elle change ce que la primitive fait, et elle est donc remontée explicitement au superviseur.

Le tag n’est pas posé par cet ADR. Conformément au modèle de re-sceau, la pose du tag couche-1-v3.4 revient au superviseur, après revue.

Contexte

V2.5a avait monté la mécanique de la transition post-auth : le tuyau clair, le passage du descripteur par SCM_RIGHTS, le worker setuid. V2.5b lui demande de servir : le proxy relaie le transport chiffré, le worker parle ssh-connection en clair, applique les options, et pose la cage sftp-worker avant de servir du SFTP.

Trois manques sont apparus dès qu’on a essayé de faire passer une vraie session. Aucun n’était visible tant que rien ne servait : ce sont des manques d’appelant, pas de conception.

Décision

Descellement additif couche-1-v3.4, sur trois crates.

1. air-socket — le proxy lit et écrit le réseau par read/write

Ajouts à AirTcpSocket : read_bytes, write_bytes, from_owned_fd, into_owned_fd.

ADR-130 avait ajouté read_bytes/write_bytes à AirUnixStream pour le tuyau clair, avec le motif : un étage confiné n’a, pour ses I/O, que read(2)/write(2) — le profil pre-auth ne contient aucun syscall de la famille socket hormis recvmsg. Le même motif vaut, mot pour mot, pour le socket réseau que le proxy lit et écrit, lequel est un socket TCP : sans ces variantes, le proxy passerait par recv(2)/send(2) et mourrait par SIGSYS au premier octet.

from_owned_fd/into_owned_fd complètent le pont : le socket accepté par le listener traverse deux fork par héritage, et l’étage qui le reprend n’a qu’un numéro de descripteur. C’est le miroir exact de ce qu’AirUnixStream offre déjà.

2. air-sandbox — le profil sftp-worker était inutilisable

Quinze AirSyscall ajoutés (openat2, statx, pread64, pwrite64, ftruncate, mkdirat, unlinkat, renameat2, symlinkat, fchmod, fchmodat, fchownat, utimensat, faccessat, getcwd), et ajoutés au seul profil sftp-worker.

C’est une correction de défaut, découverte en écrivant V2.5b. Le profil avait été écrit en ADR-112 sans appelant : il nomme les syscalls « fichier » de la libc classique (openat, fstat) là où air-filesystem n’émet que les variantes modernes prescrites par ADR-021 (openat2, statx). L’écart n’était pas cosmétique — il rendait le profil inopérant : la première ouverture de fichier d’un worker SFTP le tuait par SIGSYS, et le premier stat aussi. Un profil peut être fail-closed et inutilisable ; seul un appelant tranche.

Ce que ce bloc n’ouvre pas, et qui reste la substance de la cage : aucune création de processus (clone/fork/execve), aucun io_uring_*, aucune famille socket au-delà du recvmsg hérité, aucun ptrace, mount, chroot, bpf, prctl, setuid/setgid. Le périmètre des chemins n’est pas l’affaire de seccomp : il est borné par Landlock (§D1 — le sous-arbre autorisé). Deux tests l’épinglent (sftp_worker_names_the_modern_file_syscalls_air_filesystem_really_calls, sftp_worker_still_forbids_the_escape_classes), et le test de conformité table_matches_kernel_headers revérifie les quinze numéros contre les en-têtes du noyau.

fchownat mérite sa justification propre : le noyau refuse déjà (EPERM) un changement de propriétaire demandé par un worker non-root. L’exclure ne retirerait aucun pouvoir, mais transformerait un put -p légitime en mort par SIGSYS.

Le profil pre-auth, lui, ne gagne rien : l’étage le plus exposé reste sans accès fichier (vérifié par test).

3. air-handle — le registre fermait le descripteur neuf

register oublie (au lieu de la fermer) une entrée déjà présente pour le même numéro.

C’est une correction de défaut, et elle a mordu. Le registre supposait qu’« un fd frais possède un numéro inutilisé, donc aucune collision par construction ». La prémisse est vraie du noyau ; elle est fausse du registre, dès lors qu’un descripteur est fermé hors de lui — ce que fait, précisément, air_process::close_inherited_fds_except (la purge de tout étage privsep, ADR-125 §D8) qui ferme par close_range sans passer par le registre, parce qu’elle s’exécute après un fork où l’on n’alloue ni ne verrouille rien.

L’entrée survivante était alors Dropée à l’enregistrement suivant — et son Drop ferme le numéro, c’est-à-dire le descripteur neuf qu’on venait d’enregistrer. Symptôme : EBADF sur un descripteur valide, à la première lecture qui suit. C’est exactement ce qui a été observé en V2.5b — le worker post-auth ouvrait le socketpair de stdio de son shell, en héritait un numéro déjà purgé, et lisait EBADF sur une sortie de commande pourtant bien produite.

Décision : une collision prouve que l’ancienne entrée est périmée (le noyau ne réattribue pas un numéro vivant) ; on l’oublie donc sans la fermer. Et si, par impossible, elle était vivante, l’oublier ne ferait que fuir un descripteur, là où la fermer arracherait à un autre propriétaire un descripteur qu’il croit sien — entre les deux, la fuite est de loin la moins nocive.

Aucun symbole ajouté, aucune signature modifiée. Le test de régression registering_over_a_stale_number_does_not_close_the_fresh_fd échoue sans le correctif (vérifié) et passe avec.

Conséquences

  • air-sshd (couche 2) peut monter une session privsep complète : proxy de transport confiné, worker post-auth non-root, cage SFTP réelle. Prouvé par un vrai client OpenSSH (tests/privsep_session_root.rs).
  • Tout étage qui purge ses descripteurs puis en ouvre de nouveaux cesse d’être exposé au EBADF fantôme du §3 — soit les trois étages du privsep, et tout futur service qui suivra le même motif.
  • La cage sftp-worker devient utilisable pour la première fois depuis ADR-112.
  • air-sshd gagne une dépendance couche 1 : air-poll (multiplexage ppoll des étages sans réacteur). Ce n’est pas un descellement — la crate est déjà publique — et l’usage de son API est intégral (une fonction, poll), donc conforme à la règle des 80 % (Principe 6, ADR-024).

Ce que cet ADR ne fait pas

  • Aucun retrait. openat/fstat restent dans sftp-worker : les retirer n’apporterait rien (leurs équivalents modernes sont désormais autorisés) et risquerait de tuer un appelant inconnu.
  • Aucun élargissement du profil pre-auth.
  • Aucun io_uring_* nulle part. Un anneau soumet ses opérations par la mémoire partagée, hors du chemin syscall : seccomp ne les voit pas. Et un anneau créé avant une restriction Landlock n’y est pas soumis. L’autoriser dans la cage SFTP annulerait les deux moitiés du confinement. C’est la raison pour laquelle les étages privsep font leur I/O en bloquant (read/write + ppoll), et non sur air-async.
  • Aucune décision de politique sur le périmètre SFTP (l’équivalent du ChrootDirectory d’OpenSSH). V2.5b prend le répertoire personnel du compte comme sous-arbre Landlock ; en faire un réglage relèvera d’un ADR de politique.

Alternatives rejetées

  • Faire passer le socket TCP du proxy pour un AirUnixStream (les deux n’utilisant que read/write). Rejeté : un mensonge de type qui économise dix lignes et coûte la lisibilité de la frontière (Principe 7).
  • Ajouter io_uring_* au profil sftp-worker pour réutiliser les pumps asynchrones existants. Rejeté — cf. ci-dessus : c’est l’évasion elle-même.
  • Corriger le §3 dans air-process (faire purger le registre par close_inherited_fds_except). Rejeté : la purge s’exécute dans la fenêtre post-fork, où prendre un verrou et allouer est exactement ce qu’on s’interdit. Le bon endroit est celui qui sait ce qu’une collision signifie : le registre.
  • Paniquer sur collision (« invariant violé »). Rejeté : dans un processus privilégié ou confiné, une panique est un déni de service offert à l’appelant, là où le comportement correct est parfaitement défini.

Points remontés au BDFL

  1. Correction de comportement d’une primitive scellée (§3, air_handle::register). Aucune signature ne change, mais une fermeture qui avait lieu n’a plus lieu. Le défaut était latent depuis l’introduction de la purge (ADR-125) et n’avait aucun symptôme tant qu’aucun étage purgé n’ouvrait de nouveaux descripteurs — V2.5b est le premier.
  2. Élargissement du profil sftp-worker (§2). Quinze syscalls, tous du domaine « fichiers », dans le profil qui est précisément celui qui a le droit de toucher des fichiers, et dont les chemins restent bornés par Landlock. C’est néanmoins le plus gros élargissement d’un profil depuis ADR-112, et il mérite un regard.
  3. Périmètre Landlock du SFTP = le home du compte (choix de V2.5b, faute d’ADR de politique). C’est le seul périmètre qui restreigne réellement quelque chose sans configuration ; le rendre réglable est une décision de politique à prendre.