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-122 — air-sshd : séparation de privilèges (privsep) — monitor par connexion, enfant pré-auth confiné, transition post-auth par proxy

Statut : Accepté (2026-07-28, décision BDFL). RFC de direction (ADR-015). S’appuie sur ADR-112 (cage seccomp/Landlock air-sandbox), ADR-093 / ADR-094 (transport / userauth), ADR-104 (worker forké sans exec, discipline io_uring+fork), ADR-091 (motif réseau sans-IO, async-signal-safety), ADR-077 (managers couche 1), ADR-108 (keystore — garde de la clé d’hôte), ADR-111 (air-log).

Contexte

L’ADR-112 a livré le mécanisme de cage (air-sandbox : AirSandbox::enter, fail-closed, fuzzé) mais ne l’a pas câblé dans air-sshd. Aujourd’hui le démon sert toutes les connexions in-process dans un unique réacteur io_uring mono-thread (serve_forever) : le code qui parse le réseau pré-auth (KEX, userauth) tourne en root, sans cage. Le seul fork existant est celui du shell/exec (AirCommandclone3execve), et le sous-système SFTP tourne dans le démon, en root.

C’est la posture qu’OpenSSH a rejetée dès 2002 avec la séparation de privilèges. Sa méthode a un bilan mesurable (deux décennies de bugs mémoire cantonnés à l’enfant non privilégié) mais reste critiquable — et la faille regreSSHion (CVE-2024-6387, 2024 : course dans un handler SIGALRM du login grace time appelant malloc/syslogRCE root en pré-auth, dans le processus privilégié) prouve que le côté privilégié demeure une grande surface d’attaque, et que C + signaux est un champ de mines.

Décision BDFL (2026-07-28) : air-sshd adopte une privsep complète — worker post-auth et pré-auth confinés — mais débarrassée de ce qui n’existait que pour survivre au C. En Rust safe, la privsep ne sert plus d’abord à contenir des overflows (la sûreté mémoire le fait) : elle devient de la défense en profondeur — contenir les bugs de logique, les panics, et un éventuel bug unsafe/dépendance — et un socle de moindre autorité.

Décisions

1. Topologie de processus — monitor par connexion

systemd
└─ air-sshd (LISTENER)              root, long-lived : bind, réacteur io_uring, accept,
   │                                throttle, DÉTIENT la clé d'hôte. Surface = accept+fork+reap.
   └─ monitor (par connexion)       privilégié borné (capabilities, §6), durée = 1 connexion :
      │                             répond SIGN/AUTHORIZE/SPAWN, reap ses enfants.
      ├─ enfant pré-auth            NON privilégié + cage `pre-auth` : parse le réseau (KEX/userauth).
      └─ worker post-auth           setuid <user> + cage `sftp-worker` (ou drop-seul pour shell).

Un monitor par connexion (et non « listener = monitor ») : la surface privilégiée qui répond au RPC est ainsi séparée par connexion et de durée bornée. Un bug (même de logique, en Rust) dans le traitement RPC d’une connexion ne touche ni le listener ni les voisines. Coût assumé : un second fork par connexion.

Le listener est le seul composant privilégié long-vivant ; il ne parse aucune donnée hostile (accept + fork + reap). C’est l’inverse exact de regreSSHion.

2. Clé d’hôte — le monitor signe (jamais dans l’enfant)

La clé privée d’hôte vit dans le listener (surface minimale) et chaque monitor l’hérite par copy-on-write. L’enfant pré-auth confiné ne l’a jamais : pour la signature du hash d’échange KEX, il émet SIGN(hash 32 o) au monitor et reçoit la signature (64 o). Rationale : une divulgation mémoire du parseur pré-auth (le processus le plus exposé) n’exfiltre pas la clé d’hôte — c’est le but même de la privsep. Coût : un aller-retour socketpair par KEX (établissement + re-KEX ~horaire), hors chemin chaud. L’alternative « clé héritée COW, signature in-process » (seccomp n’entrave pas la crypto mémoire) est plus simple mais garde le secret dans le processus exposé — rejetée comme cible ; conservée comme repli d’incrément explicitement journalisé.

Borne anti-oracle : le monitor signe exactement le hash d’échange (32 octets, format fixe), jamais des données arbitraires — ce n’est pas un oracle de signature générique.

3. Vérification userauth — maths dans l’enfant, décision au monitor

  • La vérification cryptographique de la signature client (verify_publickey_signature, cert::verify_certificate) est de la crypto mémoire purereste dans l’enfant confiné.
  • La décision d’autorisation (authorized_keys par-utilisateur, CA de confiance) lit des fichiers d’autrui → déléguée au monitor via AUTHORIZE(user, algo, blob, now) → bool. Deux raisons : le profil pre-auth n’a pas openat (et on ne l’y ajoute pas), et lire un authorized_keys par-utilisateur exige le bon privilège (§6), pas l’identité confinée. serve_userauth<A: AuthorizedKeys> accepte déjà un A : seul l’impl change (MonitorAuthorizer qui round-trip).

4. Transition pré→post-auth — modèle proxy (pas de keystate)

Un enfant pré-auth (no_new_privs + cage) ne peut pas setuid. Sur shell / exec / subsystem sftp, l’enfant — qui garde le transport chiffré — envoie SPAWN{username, kind, args, pty} au monitor ; le monitor forke le worker post-auth (setuid + cage), lui monte le stdio/PTY et renvoie l’extrémité par SCM_RIGHTS ; l’enfant pompe clair↔transport (l’architecture socketpair-pump de run_program/run_interactive_shell existe déjà).

Ce qui transite : username + fd de worker. Aucune clé de session. Le transport ne quitte jamais l’enfant. On évite ainsi entièrement la sérialisation d’état crypto (mm_send/recv_keystate) — la partie la plus bug-prone du privsep OpenSSH. L’export de keystate (le post-auth devient l’utilisateur bout-en-bout) reste l’alternative « fidèle OpenSSH » documentée, mais son coût/risque n’est pas justifié.

5. Refus d’io_uring dans les profils confinés + backend bloquant

io_uring est un contournement seccomp architectural : les opérations passent par un ring en mémoire partagée, exécutées par le noyau sans syscall par opération (voire zéro syscall en SQPOLL) — seccomp, qui filtre au point d’entrée syscall, ne les voit pas. Réponse : les profils pre-auth/sftp-worker n’autorisent pas les syscalls io_uring_setup/io_uring_enter/io_uring_register (seccomp peut les filtrer, ce sont des syscalls), et l’enfant ferme le ring hérité (close_range) avant la cage. Conséquence directe : l’enfant confiné fait son I/O réseau en bloquant (read/write/ppoll couche 1, cf. ADR-104 §4), pas via le ring. Le réacteur io_uring reste au listener (privilégié, de confiance, performant). (Landlock, lui, applique au niveau LSM/VFS que les ops io_uring traversent : il n’est pas contourné de la même façon — mais on refuse io_uring de toute manière.)

6. Confinement du monitor par capabilities (moindre autorité)

Le monitor est root par héritage (fork du listener), jamais par bit setuid. Mais root plein est excessif : le monitor largue tout sauf le strict nécessaire, dès sa naissance, avant de traiter le moindre octet de l’enfant.

Analyse — ce que le monitor fait vraiment :

Action du monitorCapability requise
Signer le hash KEX avec la clé d’hôteaucune (crypto mémoire pure)
Répondre AUTHORIZE — lire authorized_keys par-utilisateurlecture en tant que l’utilisateur (via CAP_SETUID, DAC appliqué par le noyau) — pas de CAP_DAC_READ_SEARCH
SPAWNsetgroups/setgid/setuid du worker vers le compteCAP_SETUID + CAP_SETGID
Attribuer le PTY au compte (chown de l’esclave)CAP_CHOWN (évitable avec devpts newinstance)
Tuer/superviser les workers setuid (uid différent)CAP_KILL
fork/clone3, SCM_RIGHTS, PR_SET_PDEATHSIGaucune

Ce que le monitor N’a PAS besoin (largué) : CAP_NET_BIND_SERVICE (le listener bind une fois au démarrage, le monitor ne bind jamais), CAP_SYS_ADMIN, CAP_NET_ADMIN, CAP_SYS_PTRACE, CAP_DAC_OVERRIDE (la lecture seule suffit), CAP_FOWNER, CAP_SETPCAP (après le largage initial), etc.

Ensemble cible (décidé) : { CAP_SETUID, CAP_SETGID, CAP_CHOWN, CAP_KILL }CAP_DAC_READ_SEARCH est EXCLU. authorized_keys est lu en tant que l’utilisateur : un mini-helper forké qui setuide au compte cible (via le CAP_SETUID déjà détenu) et lit le fichier — le contrôle d’accès DAC est alors appliqué par le noyau (impossible de lire par mégarde un fichier de root), et la capability est supprimée. Coût assumé : un fork par AUTHORIZE (borné par MaxAuthTries). Ce choix vaut aussi durcissement anti-confused-deputy : le privilégié ne lit jamais un chemin arbitraire avec un pouvoir de bypass DAC.

Mécanisme (à la naissance du monitor, avant tout traitement) :

  1. capset : réduire permitted + effective à l’ensemble cible ; vider inheritable et ambient.
  2. Largage du bounding set (prctl(PR_CAPBSET_DROP)) pour tout ce qui n’est pas dans la cible → une capability larguée ne peut jamais être re-levée.
  3. no_new_privs sur le monitor aussi (ceinture + bretelles ; n’entrave ni le setuid-vers-le-bas ni l’usage des caps détenues).
  4. Le worker : au setuid vers le compte, toutes les caps chutent automatiquement (sans PR_SET_KEEPCAPS) → worker sans capability, qui pose ensuite sa cage. Double largage (caps puis cage).

Prérequis couche 0 : wrappers close_range (§5) et PR_CAPBSET_DROPabsents aujourd’hui, à ajouter (additifs couche 0, incrément dédié). capset/capget/ cap_ambient_* existent déjà.

Durcissement futur (hors cet ADR) : scinder le monitor en fragments — un service de signature seul (zéro capability, ne tient que la clé) séparé d’un service de spawn (CAP_SETUID/SETGID) — pour que la clé et les caps vivent dans des processus distincts.

7. Fail-closed + cascade de mort bornée à la connexion

  • Cage impossible dans un enfant (enter(Enforce) rend Err) → il journalise puis sort ≠0 sans servir (ADR-111 : cage_refused, AIR_SANDBOX_STAGE) ; le monitor observe le CHILD-EXIT (pidfd/waitid) et logue. Jamais déconfiné. Seul mode non-cagé = Disabled{reason}, opt-out WARNING journalisé, explicite.
  • PR_SET_PDEATHSIG(SIGKILL) dans chaque enfant → si le monitor meurt (crash), le noyau tue immédiatement l’enfant pré-auth et les workers : jamais d’orphelin privilégié/session sans son monitor.
  • Confinement de la mort : mort d’un monitor ⇒ emporte son sous-arbre (enfant + workers), rien d’autre — listener et connexions voisines survivent. Seule la mort du listener (arrêt systemd) termine tout (drain SIGTERM du cgroup).

8. shell/exec = drop de privilèges seul ; SFTP = cage complète

Le shell/exec est du code utilisateur arbitraire : le seccomp-cager est vain (OpenSSH ne le fait pas). Donc shell/exec = largage de privilèges (+ éventuel Landlock), sans seccomp. Seul SFTP (notre code, syscalls bornés) reçoit la cage sftp-worker() complète + Landlock borné au home.

Conséquences

Positives.

  • Privsep en Rust safe : la classe regreSSHion/overflow/UAF est éliminée à la racine ; la privsep devient défense en profondeur (logique, panics, unsafe).
  • Monitor à surface minimale : 3 verbes typés (capnp) vs ~30 mm_answer_* d’OpenSSH ; moindre autorité par capabilities ; clé d’hôte hors du processus exposé.
  • Pas de keystate : la partie la plus fragile du privsep OpenSSH disparaît.
  • Cage fondationnelle (fail-closed, fuzzée) + io_uring refusé (bypass que sshd classique ne bloque pas) + async-signal-safety par construction (grace-time = deadline ppoll, pas SIGALRM).
  • Layering réparé : la couche 2 (air-sshd) ne manipule plus jamais un SockFprog.

Négatives / coûts assumés.

  • Deux forks par connexion + I/O bloquante dans l’enfant (pas io_uring) : coût CPU/mémoire par connexion, borné par le throttle en amont du fork.
  • Refonte du réacteur serve_forever (monitor/enfant) + backend transport bloquant : incrément d’envergure, risque de régression (interop OpenSSH doit rester verte).
  • Additifs couche 0 (close_range, PR_CAPBSET_DROP) + re-sceau air-process (§5, incrément dédié).
  • Discipline fork mono-thread (deadlock allocateur post-clone3 neutralisé par le mono-thread, ADR-104 §4) : à graver et tester ; s’évapore si air-sshd passe multi-cœur → repli ré-exec /proc/self/exe.

Alternatives rejetées

Aucune alternative n’a été consignée lors de l’instruction.

Mise en œuvre (référence, hors décision)

Incréments, un par PR :

  • Inc. 0 (cet ADR) — conception. Débloque le reste.
  • Inc. 1 — additifs couche 0 : close_range + PR_CAPBSET_DROP (wrappers + tests + couverture).
  • Inc. 2 — §5 refonte air-process : AirPrivilegeDrop prend (AirSandboxProfile, AirLandlockPolicy) au lieu de SockFprog/LandlockRuleset ; applique via air-sandbox::apply (ordre sûr : setuid → no_new_privs → Landlock → seccomp).
  • Inc. 3 — re-sceau air-process : incrément séparé (bump de version + mise à jour du registre de sceau) qui trace formellement la modification de surface publique couche 1 (§5).
  • Inc. 4 — backend transport bloquant (SshByteStream sur fd brut, ppoll grace-time) ; handshake complet sur socketpair bloquant, in-process.
  • Inc. 5 — SFTP worker privsep (tête de pont du fork+cage post-auth).
  • Inc. 6 — canal RPC monitor (SIGN/AUTHORIZE/SPAWN + capabilities du monitor), exercé in-process.
  • Inc. 7a — split monitor/enfant + cage pré-auth (arrêt à l’auth réussie).
  • Inc. 7b — transition post-auth (proxy) → session complète (interop bout-en-bout).
  • Inc. 8 — durcissement : compte de service dédié pour l’enfant, grace-time, drain SIGTERM, fuzz du RPC, envisager le split signature/spawn.

Non engagés par cet ADR au-delà de la topologie et des sept décisions ci-dessus.