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
(AirCommand→clone3→execve), 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/syslog → RCE 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 pure → reste dans l’enfant confiné. - La décision d’autorisation (
authorized_keyspar-utilisateur, CA de confiance) lit des fichiers d’autrui → déléguée au monitor viaAUTHORIZE(user, algo, blob, now) → bool. Deux raisons : le profilpre-authn’a pasopenat(et on ne l’y ajoute pas), et lire unauthorized_keyspar-utilisateur exige le bon privilège (§6), pas l’identité confinée.serve_userauth<A: AuthorizedKeys>accepte déjà unA: seul l’impl change (MonitorAuthorizerqui 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 monitor | Capability requise |
|---|---|
| Signer le hash KEX avec la clé d’hôte | aucune (crypto mémoire pure) |
Répondre AUTHORIZE — lire authorized_keys par-utilisateur | lecture en tant que l’utilisateur (via CAP_SETUID, DAC appliqué par le noyau) — pas de CAP_DAC_READ_SEARCH |
SPAWN — setgroups/setgid/setuid du worker vers le compte | CAP_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_PDEATHSIG | aucune |
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) :
capset: réduire permitted + effective à l’ensemble cible ; vider inheritable et ambient.- 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. no_new_privssur le monitor aussi (ceinture + bretelles ; n’entrave ni lesetuid-vers-le-bas ni l’usage des caps détenues).- Le worker : au
setuidvers le compte, toutes les caps chutent automatiquement (sansPR_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_DROP — absents
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)rendErr) → il journalise puis sort ≠0 sans servir (ADR-111 :cage_refused,AIR_SANDBOX_STAGE) ; le monitor observe leCHILD-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
SIGTERMdu 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, pasSIGALRM). - 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-sceauair-process(§5, incrément dédié). - Discipline fork mono-thread (deadlock allocateur post-
clone3neutralisé 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:AirPrivilegeDropprend(AirSandboxProfile, AirLandlockPolicy)au lieu deSockFprog/LandlockRuleset; applique viaair-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 (
SshByteStreamsur fd brut,ppollgrace-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.