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-153 — Le programme de session est octroyé, pas hérité de la base des comptes

Statut : Accepté (2026-08-11, BDFL). Prolonge ADR-150 — l’octroi décidait de tout sauf de quel programme une session lance — et sert l’invariant d’exécution d’ ADR-046 D6.

Catégorie : Sécurité. Touche air-sshd (couche 2) et le vocabulaire d’octroi d’ADR-010 ; aucun descellement de couche 1.

Contexte

À l’état actuel, quand une connexion SSH aboutit, air-sshd lance le shell du champ 7 de la base des comptes :

#![allow(unused)]
fn main() {
// crates/air-sshd/src/monitor/session_spawn.rs
let entry = air_account::lookup_user_by_name(AirOsStr::from_bytes(username))?…;
shell: entry.shell.as_bytes().to_vec(),
// …
let shell = AirPath::from_bytes(&self.account.shell);
let command = AirCommand::new(&shell).env_clear()…
}

Deux formes : <shell> nu pour une session interactive, <shell> -c "commande" pour un ssh hôte "…". Une ForceCommand d’octroi (ADR-118) remplace la commande — jamais le programme.

Trois faits en découlent, et aucun n’est confortable.

1. Le champ 7 est une décision d’exécution que rien n’arbitre. ADR-150 fait décider l’octroi de la signature, de l’enveloppe et des capacités, et pose le refus par défaut au lancement. Mais ce qui s’exécute au bout d’une authentification réussie vient d’un fichier que l’arbitrage ne regarde pas. Qui peut écrire la base des comptes choisit le programme que toute session lancera — c’est un second chemin au sens d’ADR-149 D10, non marqué parce qu’il n’avait jamais été reconnu comme tel.

2. C’est un binaire étranger. air-account importe /etc/passwd du système existant : le champ 7 y désigne /bin/bash ou /bin/sh de la distribution, donc de la glibc. Sous une cage native, ce programme est tué — c’est exactement ce que constate le test a_confined_child_never_runs_a_program_outside_its_floor. Ce qui le fait vivre est le profil TransitionalGlibc. Le seuil d’exécution d’ADR-046 D6 tient donc partout sauf sur ce chemin, et le contournement n’est écrit nulle part : il est hérité.

3. Le mécanisme le plus visible d’Air est le moins gouverné. Une session SSH est, en pratique, la première chose qu’un administrateur fait sur une machine Air. C’est le chemin d’exécution le plus emprunté, et le seul dont la cible échappe à l’octroi.

Décision

D1 — L’octroi porte une liste de programmes de session admis

Une cinquième famille d’entitlements rejoint les quatre d’ADR-010 (filesystem, network, aircom, devices) : session, dont la seule règle utile à ce stade est un programme admis, désigné par un chemin absolu et exact.

L’octroi qui gouverne cette liste est celui d’air-sshd : c’est le service qui lance, donc c’est son enveloppe qui doit borner ce qu’il peut lancer. Aucun artefact nouveau, aucun second magasin — la porte existe déjà.

Pas de glob, pas de préfixe, pas de répertoire. /usr/bin/* admettrait demain ce qu’un paquet y déposera ce soir. La règle est une égalité d’octets sur un chemin absolu, comme l’égalité stricte demande↔octroi d’ADR-150 D3 §5.1.

D2 — La base des comptes exprime une préférence, l’octroi une permission

Le champ 7 n’est pas supprimé. Il est rétrogradé : il dit ce que l’utilisateur souhaite, l’octroi dit ce qui est permis. C’est exactement la relation manifeste↔octroi d’ADR-150 D1 — déclarer n’est pas obtenir —, appliquée un cran plus bas.

Trois conséquences, dans cet ordre :

  1. Le shell demandé par le compte est retenu s’il figure dans la liste admise ;
  2. sinon, la session est refusée ;
  3. et le refus est muet vers le client, tracé au journal (ADR-150 D4) — un message distinguant « shell non admis » de « authentification refusée » dirait à un attaquant qu’il a franchi la première porte.

Pourquoi refuser plutôt que replier sur un défaut. Un repli silencieux donnerait à l’utilisateur un shell qu’il n’a pas demandé, sur une machine qu’il croit connaître — et surtout, il ferait fonctionner une configuration fausse, donc la rendrait durable. Air refuse déjà de la même façon un octroi qui invoque une famille sans applicateur.

D3 — Le point de décision est le monitor, pas le worker

La résolution du programme a lieu là où la contrainte est déjà détenue : dans le monitor, au même endroit que la substitution de ForceCommand (session_spawn.rs::resolved_command). Le worker confiné ne voit jamais la liste admise — il reçoit un programme déjà arbitré, ou rien.

C’est la même raison qu’ADR-147 : ce qui décide ne doit pas vivre du côté qu’on suppose compromis.

D4 — Ordre d’arbitrage : le programme est résolu avant la commande

ForceCommand remplace ce que le client demande d’exécuter ; D1 borne avec quoi. Le second est en amont : une commande forcée s’exécute toujours par un programme admis, jamais l’inverse. Un octroi qui forcerait une commande sans admettre de programme ne lance rien.

D5 — La phase transitoire est déclarée, donc datée

Tant que les shells ne sont pas portés sur la libc d’Air, la liste admise contiendra des binaires glibc. C’est assumé — et c’est précisément le gain : ce qui était hérité d’un fichier système devient écrit dans un octroi signé, donc inventoriable (grep sur les octrois déployés), révocable par réinstallation (ADR-150 D1), et supprimable quand le portage sera fait.

Une machine dont l’octroi n’admet aucun programme de session n’ouvre pas de session : c’est l’état d’un système entièrement porté qui n’aurait pas encore déclaré son shell natif, et il vaut mieux qu’il se voie.

Conséquences

ForceCommand gagne un sens plus étroit et plus juste : elle contraint la commande à l’intérieur d’un programme admis, au lieu d’être la seule borne sur ce qu’une session exécute.

Le second chemin d’ADR-149 D10 disparaît au lieu d’être marqué. La base des comptes cesse d’être une porte d’exécution ; elle redevient ce qu’elle doit être — un annuaire d’identités.

Le refus muet a un coût d’exploitation, et il faut le dire : un administrateur dont la liste est mal écrite verra ses sessions refusées sans que le client l’explique. Le journal doit donc nommer le programme refusé, le compte, et la liste admise — c’est le pendant obligé de la mutité (ADR-150 D4), pas une option.

air-sshd devient consommateur d’octroi, ce qu’il n’était pas. Il faut donc que son octroi soit lu au démarrage et qu’un octroi absent empêche le démarrage — servir sans liste admise reviendrait à admettre tout.

Alternatives rejetées

  • Laisser le champ 7 décider, et se reposer sur la cage. Rejeté : la cage transitoire autorise justement les binaires glibc. Elle borne ce qu’un programme fait, pas lequel démarre — deux questions distinctes, et seule la seconde est ici.
  • Une liste dans la configuration d’air-sshd plutôt que dans son octroi. Rejeté : ADR-046 D6 et ADR-150 refusent qu’une décision de privilège soit portée par un fichier de configuration. Un réglage se change ; un octroi se signe.
  • Un octroi par compte (chaque utilisateur reçoit son programme). Rejeté à ce stade — cela demande un artefact par compte, donc un cycle de vie que le provisionnement (ADR-121) ne sait pas encore tenir. La liste au niveau du service couvre le besoin réel : borner ce qui peut s’exécuter, pas personnaliser par utilisateur. À rouvrir si le besoin se manifeste.
  • Un repli sur un shell natif quand le champ 7 n’est pas admis. Rejeté : donner un shell qu’on n’a pas demandé est une surprise, et faire fonctionner une configuration fausse la rend permanente.
  • Autoriser un préfixe de répertoire (/usr/bin/) pour la commodité. Rejeté : la tolérance est la faille (Principe 11), et un répertoire admis est un répertoire dont le contenu futur est admis d’avance.

Questions ouvertes

  1. Que fait un .airservice dont l’octroi n’admet aucun programme de session, mais qui n’ouvre pas de sessions ? TRANCHÉE le 2026-08-12, à l’implémentation : la famille est vide par défaut, conformément à l’option B d’ADR-150.

    Vide plutôt qu’absente, parce que les deux ne coûtent pas la même chose à qui lit. Une famille absente oblige chaque lecteur à décider ce que son absence veut dire — « rien n’est admis » ou « la question ne se pose pas » — et deux lecteurs finiront par choisir différemment. Vide ne se lit que d’une façon : aucun programme n’est admis, ce qui est exactement ce qu’on veut dire d’un service qui n’ouvre pas de session, et ce qui ne lui coûte rien puisqu’il ne demande rien.

    Le plafond, lui, refuse tout octroi non vide de cette famille tant qu’air-sshd ne la consulte pas : une permission que rien n’applique serait une promesse écrite dans un fichier. Ce mur est daté et tombera avec l’applicateur (incrément 2).

  2. Le programme de session d’un compte système (nologin, false) : admis explicitement, ou traité comme « pas de session » en amont de l’arbitrage ? TRANCHÉE le 2026-08-12, à l’implémentation : aucun cas particulier.

    nologin et false sont des programmes comme les autres. Non admis, ils font refuser la session — ce qui est exactement le résultat observable de leur exécution, obtenu un cran plus tôt et sans lancer quoi que ce soit. Les admettre explicitement reste possible pour qui veut leur message d’accueil, et c’est alors un choix écrit dans un octroi signé plutôt qu’une convention tacite du code.

    Un cas particulier aurait coûté ce que coûtent tous les cas particuliers : une seconde règle à se rappeler, et un endroit où la liste admise ne dit plus toute la vérité.

  3. La liste admise doit-elle borner aussi Subsystem (SFTP) ? TRANCHÉE le 2026-08-13 : non — il n’y a rien à borner.

    La raison est structurelle, pas pragmatique. Un subsystem sftp ne lance aucun programme : il est servi en processus dans le worker, cage déjà posée, et le code le dit sans ambiguïté — « il ne se lance pas, il se sert » (monitor/proto.rs). Il n’a même pas de variante dans le protocole de médiation, « et aucun mot pour le faire » : à la différence de shell et d’exec, aucun SpawnSession ne le concerne. Aucun chemin de sous-système n’est par ailleurs configurable — le seul subsystem du schéma de configuration est un compteur de canaux (MaxSessions).

    La famille session borne quel programme une session lance. SFTP n’en lance aucun ; l’y faire figurer créerait une règle sans référent — un droit dont le point d’application n’existe pas. C’est exactement ce que le plafond refuse ailleurs (option B d’ADR-150), et pour la même raison : une permission que rien n’applique est une promesse écrite dans un fichier.

    Cela ne veut pas dire que SFTP échappe à la gouvernance, et il faut le dire pour ne pas laisser croire à un trou : ce qu’il peut faire est borné par la cage posée avant la boucle, par MaxSessions, et par le fait qu’il ne détient aucun descripteur — « un canal de plus, qui ne vit que des données du client ». C’est la distinction que cet ADR pose lui-même dans ses alternatives rejetées : la cage borne ce qu’un programme fait, pas lequel démarre. Deux questions distinctes ; seule la seconde relève d’ici.

    Ce qui rouvrirait la question : le jour où un Subsystem désignerait un binaire externe — par configuration, ou parce que le serveur SFTP quitterait le processus. Il redeviendrait alors une décision d’exécution, et devrait rejoindre la famille. Tant que serve_sftp_request sert en processus, il n’y a pas de sujet.

  4. Un octroi absent au démarrage doit-il empêcher le service de démarrer, ou de servir ? TRANCHÉE le 2026-08-11 (BDFL) : pas d’octroi, pas de service. Le service ne démarre pas.

    La question portait un réflexe d’exploitant — garder une porte pour l’administrateur distant qui vient réparer. Le BDFL l’a écartée dans les termes qui conviennent : ce besoin est réel et il faudra le traiter, mais il ne doit pas faire baisser le niveau de sécurité de la machine. Une porte de secours dans le service est une porte tout court.

    L’accès de réparation se traitera ailleurs et autrement — console physique ou série, image de secours, chemin d’administration hors bande — et fait l’objet d’une instruction séparée. Le noter ici évite qu’il revienne un jour sous la forme d’une exception dans ce mécanisme-ci.


Licence du document : MPL 2.0