ADR-130 — Descellement additif couche-1-v3.3 : le socle de la transition post-auth (tuyau clair, SCM_RIGHTS sous cage, cascade de mort qui survit au largage)
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 (sous-incrément V2.5a), dans la continuité d’ADR-125 et d’ADR-127.
Catégorie : Descellement ADDITIF de la couche 1 scellée
(couche-1-v3.2 → couche-1-v3.3). Aucun retrait, aucun changement de
signature : toute surface antérieure reste valide telle quelle. Une correction de
défaut au comportement de drop_privileges 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.3revient au superviseur, après revue.
Contexte
ADR-129 §D2 fixe la mécanique de
la transition post-auth : à l’ALLOW, le monitor crée un socketpair(AF_UNIX, SOCK_STREAM) — le tuyau clair —, en donne un bout au worker setuid par
héritage, et envoie l’autre à l’enfant pré-auth devenu proxy par SCM_RIGHTS sur
le canal RPC déjà établi. air-sshd étant couche 2, il ne peut appeler la couche 0
(ADR-077) : il lui faut, en couche 1, de quoi créer
ce tuyau, le lire et l’écrire sous cage, et le recevoir sous cage.
Trois manques, dont le troisième n’avait pas été vu.
Décision
Descellement additif couche-1-v3.3, sur trois crates.
1. air-socket — le tuyau clair (AirUnixStream)
AirUnixStream::pair()—socketpair(AF_UNIX, SOCK_STREAM). Pendant flux duAirUnixSeqpacket::pairdéjà offert. Le tuyau clair porte un flux d’octets (le canal ssh-connection que le proxy recopie sans l’interpréter) : lui imposer des frontières de message lui prêterait une structure qu’il n’a pas.AirUnixStream::read_bytes/write_bytes— même sémantique queread/write, mais parread(2)/write(2)au lieu derecv(2)/send(2). Exactement le motif d’AirUnixSeqpacket::write_packet/read_packet(ADR-125) et pour la même raison : le proxy est confiné, et son profilpre-authn’ouvre pas la famille socket. Piège documenté :write(2)ne porte pasMSG_NOSIGNAL⇒SIGPIPE(fail-closed voulu pour un étage privsep ; tout autre appelant doit l’ignorer).
2. air-sandbox — recvmsg dans le profil pre-auth
AirSyscall::Recvmsg(x86_6447, aarch64212), ajouté aux profilspre-authetsftp-worker(dont le contrat est «pre-authplus les fichiers », vérifié par test).
C’est le seul syscall de la famille socket du profil le plus exposé du démon, et
il y entre parce qu’aucun autre mécanisme ne fait ce qu’il fait : un descripteur ne
se sérialise pas, il voyage en ancillaire SCM_RIGHTS, et l’ancillaire ne se lit
que par recvmsg(2) — read(2) lirait la charge utile et jetterait le
descripteur.
Ce que cela ouvre, mesuré : recvmsg reçoit sur un descripteur que le processus
détient déjà ; il n’en crée aucun. socket, socketpair, connect, bind,
sendmsg, sendto, recvfrom restent hors profil, et un test le garde
(pre_auth_opens_the_socket_family_only_for_receiving_a_descriptor). Le seul pair
capable d’envoyer un ancillaire à l’enfant est le monitor, à l’autre bout d’un
socketpair anonyme.
Alternative écartée : pré-créer le tuyau clair avant le fork de l’enfant pour
qu’il en hérite (zéro recvmsg). Elle coûterait une paire de descripteurs par
connexion — y compris à toutes celles qui n’obtiennent jamais d’ALLOW, c’est-à-dire
la totalité du trafic hostile — et donnerait au code le plus exposé du démon un tuyau
vers un worker qui n’existe pas. ADR-129 §D2 a tranché SCM_RIGHTS ; ceci en est la
conséquence mécanique.
3. air-process — la cascade de mort survit au largage de privilèges
C’est une correction de défaut, découverte en écrivant V2.5a.
PR_SET_PDEATHSIG ne survit pas à un changement d’identifiants : dès que
euid/egid/fsuid/fsgid changent, commit_creds() remet pdeath_signal à
zéro. Vérifié sur le noyau de la machine de test : prctl(PR_SET_PDEATHSIG, SIGKILL) puis setresgid(…) ⇒ PR_GET_PDEATHSIG rend 0.
Conséquence pour Air : la séquence naturelle — et celle qu’ADR-124 §D7 prescrit à chaque étage privsep — « armer la cascade, puis larguer » laissait l’étage confiné sans cascade. Rien n’échouait, rien ne se voyait : l’enfant pré-auth de V2.2 survivait simplement à la mort de son monitor. Fail-open, et le plus discret qui soit. Le test de cascade existant ne l’avait pas vu — il tuait le monitor avant que celui-ci n’ait forké son enfant, et observait la fermeture du tube témoin par le monitor lui-même (faux vert ; corrigé par un rendez-vous explicite, cf. Conséquences).
Décision : drop_privileges mémorise le signal armé avant sa séquence et le
rétablit entre le changement d’identifiants et la pose de la cage. Quatre raisons
d’y mettre la réparation plutôt que chez l’appelant :
- C’est cette primitive qui casse l’invariant. Elle doit le rendre intact.
- L’appelant ne peut pas. Après
drop_privileges, la cage est posée etprctln’est dans aucun profil de confinement : le ré-armement n’est possible que dans la fenêtre interne à la séquence. - Le piège vaut pour tout appelant, pas seulement
air-sshd. Le laisser ouvert, c’est le reproduire à chaque service. - Aucun effet sur qui n’arme rien. Si l’appelant n’avait pas de cascade, aucun
prctln’est émis et rien ne change — la correction est invisible pour toute la surface existante.
La course, refermée aussi. Entre la lecture initiale et le ré-armement, le parent
peut mourir : le signal n’arriverait jamais (il n’est pas rétroactif). On relit
donc getppid après le ré-armement ; s’il a changé, le processus est orphelin et
drop_privileges échoue — fail-closed, l’appelant sort. C’est la même logique
que la relecture du parent que les étages font déjà à l’armement.
Aucun symbole ajouté : restore_parent_death_signal est privée, la signature de
drop_privileges est inchangée. Ce qui change est le comportement — d’où la
remontée explicite au superviseur (§Points remontés).
Conséquences
- ADR-129 V2.5a devient exprimable sans que la couche 2 touche la couche 0 : le
monitor crée le tuyau clair, forke le worker, lui passe un bout et envoie l’autre au
proxy ; l’octet traverse en clair entre deux processus qui ne partagent ni
identité, ni privilège, ni clé (
air-sshd/tests/transition_root.rs). - La cascade de mort couvre enfin les trois étages. Preuves de non-régression :
- couche 1, directe —
air-process/tests/privilege_root.rs,the_parent_death_cascade_survives_the_privilege_drop: l’enfant arme, largue, relitPR_GET_PDEATHSIG. Sans le rétablissement, il rendCHILD_PDEATHSIG_LOST; - couche 2, de bout en bout —
air-sshd/tests/transition_root.rs,killing_the_monitor_kills_the_setuid_worker: le worker s’annonce après son largage (rendez-vous, faute de quoi le test se tuerait lui-même en course), puis on tue son monitor et le tube témoin doit se fermer en moins de 5 s. Sans le rétablissement : 30 s, échec.
- couche 1, directe —
- Couverture.
air-socketrelève du plancher agrégat (tests unitaires ajoutés) ;air-sandboxetair-processsont réconciliés ligne à ligne — les lignes ajoutées àdrop_privileges_with_buffertombent dans l’entrée CHILD-EXIT existante de cette fonction, etrestore_parent_death_signalest, elle, couverte en process (trois tests unitaires : rien à rétablir, parent disparu, parent inchangé). - Aucun retrait, aucune signature modifiée. La surface
couche-1-v3.2reste valide à l’identique.
Ce que cet ADR ne fait pas
- Aucun tag.
couche-1-v3.3est posé par le superviseur. - Aucune modification de la couche 0. Tout est bâti sur des wrappers déjà scellés
(
socketpair,read/write,sendmsg/recvmsg,prctl,getppid). - Aucune politique. La couche 1 ne sait rien de SSH : elle offre un tuyau, un syscall dans un profil, et une cascade qui tient. Le worker, le proxy et le moment de la transition vivent en couche 2.
Alternatives rejetées
- Ré-armer la cascade depuis
air-sshdaprèsdrop_privileges. Impossible :prctln’est dans aucun profil de confinement, et la cage est posée pardrop_privilegeslui-même. - Scinder
drop_privilegesen « identifiants » puis « cage » pour laisser l’appelant ré-armer entre les deux. Rejeté : cela défait la composition d’ADR-123 (la cage attachée au descripteur de largage, appliquée d’un seul geste irréversible) et rend l’ordre sûr réinventable — donc oubliable — par chaque appelant. - Documenter le piège au lieu de le corriger. Rejeté : une note de documentation ne referme pas un fail-open dans un processus confiné. Le sceau existe pour que ces séquences soient justes par construction.
SOCK_SEQPACKETpour le tuyau clair (au lieu deSOCK_STREAM). Rejeté par ADR-129 §D2 : ce qui circule est un flux d’octets.
Points remontés au BDFL
- Correction de comportement de
drop_privileges(§3). Additive au sens de la surface (aucun symbole, aucune signature), mais elle modifie ce que fait une primitive scellée et lui ajoute un mode d’échec (orphelin ⇒ erreur). C’est le seul choix sûr, et le défaut qu’elle corrige est actif aujourd’hui dans le privsep V2.2 livré — mais la décision de la ranger sous la délégation additive ou d’exiger un major dédié appartient au superviseur. recvmsgdans le profilpre-auth(§2). Élargit l’allow-list du processus le plus exposé du démon, ce que le profil s’interdisait jusqu’ici par principe. La décision d’ADR-129 §D2 (SCM_RIGHTS) l’implique mécaniquement ; le durcissement possible (filtrer l’argumentfdderecvmsg, ou empiler un second filtre sansrecvmsgune fois le descripteur reçu) est noté pour V2.6.