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-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.2couche-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.3 revient 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 du AirUnixSeqpacket::pair dé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 que read/write, mais par read(2)/write(2) au lieu de recv(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 profil pre-auth n’ouvre pas la famille socket. Piège documenté : write(2) ne porte pas MSG_NOSIGNALSIGPIPE (fail-closed voulu pour un étage privsep ; tout autre appelant doit l’ignorer).

2. air-sandboxrecvmsg dans le profil pre-auth

  • AirSyscall::Recvmsg (x86_64 47, aarch64 212), ajouté aux profils pre-auth et sftp-worker (dont le contrat est « pre-auth plus 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 :

  1. C’est cette primitive qui casse l’invariant. Elle doit le rendre intact.
  2. L’appelant ne peut pas. Après drop_privileges, la cage est posée et prctl n’est dans aucun profil de confinement : le ré-armement n’est possible que dans la fenêtre interne à la séquence.
  3. Le piège vaut pour tout appelant, pas seulement air-sshd. Le laisser ouvert, c’est le reproduire à chaque service.
  4. Aucun effet sur qui n’arme rien. Si l’appelant n’avait pas de cascade, aucun prctl n’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 échouefail-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, relit PR_GET_PDEATHSIG. Sans le rétablissement, il rend CHILD_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.
  • Couverture. air-socket relève du plancher agrégat (tests unitaires ajoutés) ; air-sandbox et air-process sont réconciliés ligne à ligne — les lignes ajoutées à drop_privileges_with_buffer tombent dans l’entrée CHILD-EXIT existante de cette fonction, et restore_parent_death_signal est, 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.2 reste valide à l’identique.

Ce que cet ADR ne fait pas

  • Aucun tag. couche-1-v3.3 est 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-sshd après drop_privileges. Impossible : prctl n’est dans aucun profil de confinement, et la cage est posée par drop_privileges lui-même.
  • Scinder drop_privileges en « 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_SEQPACKET pour le tuyau clair (au lieu de SOCK_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.
  • recvmsg dans le profil pre-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’argument fd de recvmsg, ou empiler un second filtre sans recvmsg une fois le descripteur reçu) est noté pour V2.6.