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.3 → couche-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.4revient 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
EBADFfantôme du §3 — soit les trois étages du privsep, et tout futur service qui suivra le même motif. - La cage
sftp-workerdevient utilisable pour la première fois depuis ADR-112. air-sshdgagne une dépendance couche 1 :air-poll(multiplexageppolldes é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/fstatrestent danssftp-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 surair-async. - Aucune décision de politique sur le périmètre SFTP (l’équivalent du
ChrootDirectoryd’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 queread/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 profilsftp-workerpour 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 parclose_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
- 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. - É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. - 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.