ADR-124 — Conception du privsep pré-auth d’air-sshd : topologie monitor / enfant / worker, canal RPC, transition proxy
Statut : Accepté (conception, 2026-07-28 ; les 3 points sécurité nouveaux ont
été remontés au BDFL et tranchés le même jour, cf. §Points tranchés ; amendé le
2026-07-28 — §D4, ordre de chargement de la clé d’hôte, et §D5, nature du magasin lu,
cf. §Amendements) — raffine
et détaille le §5 d’ADR-122 (déjà ratifié).
RFC de structure (ADR-015). Companion : ADR-112
(air-sandbox), ADR-108 (keystore / signature oracle-safe), ADR-091 (motif réseau
sans-IO), ADR-104 (fork_process), ADR-069 (socket / SCM_RIGHTS), ADR-123
(with_sandbox).
Catégorie : Conception d’architecture couche 2 (air-sshd). Aucun code dans
cet ADR : il fixe la topologie, le protocole du canal monitor↔enfant et le découpage
en incréments avant toute implémentation (le §Points remontés liste les choix
sécurité nouveaux à confirmer par le BDFL).
Contexte
ADR-122 §5 a ratifié le quoi du privsep d’air-sshd : un monitor privilégié
par connexion (root par héritage de fork, jamais setuid), un enfant pré-auth
confiné (KEX + userauth), une transition post-auth par proxy (pas de transfert
d’état de chiffrement), un backend bloquant (io_uring refusé en pré-auth), un
monitor confiné par capabilities {SETUID, SETGID, CHOWN, KILL} sans
CAP_DAC_READ_SEARCH, la cascade de mort fail-closed + PR_SET_PDEATHSIG.
Le comment reste à concevoir. Aujourd’hui, KEX/userauth d’air-sshd tournent dans
le réacteur mono-process (transport.rs/userauth.rs/authorize.rs) — aucun
fork. Le volet 1 (refonte air-process, ADR-123) a donné à AirPrivilegeDrop la
capacité de poser une cage haut niveau ; il reste à découper le processus et
à médiatiser les opérations privilégiées (signature de la clé d’hôte, lecture
d’authorized_keys) par un canal RPC. Cet ADR fixe ces choix.
Socle déjà disponible (vérifié) : fork_process → AirForkResult (ADR-104) ;
socketpair(2) AF_UNIX/SOCK_SEQPACKET câblé couche 0 (net::socketpair) et
couche 1 (air-socket::unix), SocketType::SeqPacket typé ; sendmsg/SCM_RIGHTS
(ADR-069) ; AirPrivilegeDrop::with_sandbox + profils pre-auth/sftp-worker
(ADR-112/123) ; capbset_read/capbset_drop, close_range (ADR-122 Inc.1).
Transport<S> (transport.rs) détient l’état de chiffrement (SealingKey/
OpeningKey/SessionKeys) — c’est précisément l’état qui ne doit pas franchir la
frontière de privilège.
Décisions de conception
D1 — Topologie de processus (raffine ADR-122)
Quatre rôles, reliés par des forks successifs :
listener (root, 1 seul, détient la clé d'hôte en mémoire, accept())
│ fork_process (par connexion)
▼
monitor (root, hérite la clé d'hôte COW ; capbset réduit à {SETUID,SETGID,CHOWN,KILL} ;
PDEATHSIG=SIGKILL ; boucle de service RPC bloquante ; AUCUNE I/O réseau)
│ fork_process
▼
enfant pré-auth (drop vers uid privsep dédié ; cage pre-auth : no_new_privs +
Landlock VIDE + seccomp pre-auth ; PDEATHSIG=SIGKILL ; détient le SOCKET
RÉSEAU client ; fait KEX + userauth)
│ (à l'ALLOW final, c'est le MONITOR qui fork le worker)
▼
worker post-auth (setuid compte cible ; cage shell/exec=drop-only OU sftp-worker=full ;
PDEATHSIG=SIGKILL ; parle ssh-connection EN CLAIR au proxy ; AUCUNE clé de session)
- Le monitor n’a aucune I/O réseau : il ne parle qu’au socketpair RPC de son enfant. Surface d’attaque du code privilégié = minimale (le parsing du protocole SSH réseau vit hors du privilège, dans l’enfant confiné).
- Le socket réseau appartient à l’enfant pré-auth (comme OpenSSH). Il n’est jamais passé au monitor ni au worker.
- Cascade de mort : chaque enfant pose
PR_SET_PDEATHSIG(SIGKILL). Mort du listener ⇒ tout meurt ; mort du monitor ⇒ enfant + worker meurent ; mort de l’enfant (fin de connexion) ⇒ le monitor observeEOFsur le RPC et sort. Fail-closed.
D2 — Canal RPC monitor↔enfant : socketpair SEQPACKET, requête/réponse synchrone
socketpair(AF_UNIX, SOCK_SEQPACKET, 0) (déjà disponible couche 1). SEQPACKET est
retenu contre STREAM : il fournit des frontières de message natives — une requête =
un datagramme, aucun framing applicatif, aucun re-parsing d’un flux (pas de classe
de bugs « longueur préfixée » à fuzzer). Le monitor est une boucle mono-thread
bloquante : il lit une requête, la traite, répond ; l’enfant émet puis bloque sur
la réponse. Pas d’io_uring, pas d’async (cohérent ADR-122 : backend bloquant).
D3 — Schéma des messages : codec binaire bespoke sans-IO, fuzzé, PAS capnp
Le jeu de messages est fixe, minuscule et interne (SIGN, AUTHORIZE, + réponses).
On écrit un codec dédié sans-IO (motif ADR-091 : cœur pur encode/decode
fuzzable, zéro I/O), dans un module air-sshd::monitor::proto, plutôt que capnp.
Justification : capnp d’air-sshd-schema sert la configuration et les events
persistés/externes ; le canal monitor est éphémère, interne, sur un chemin chaud
de sécurité — un codec dédié minimal réduit la surface (pas de générateur, pas de
runtime capnp dans le processus privilégié) et se fuzz trivialement.
Posture de confiance (capitale). Le monitor traite l’enfant pré-auth comme
hostile : l’enfant peut être compromis par une faille dans le parsing SSH réseau.
Donc le monitor valide strictement (schema-first, bornes, parse, don't validate)
chaque requête entrante, et impose la politique — il n’est jamais un oracle
générique. Le fuzzing du décodeur côté monitor est obligatoire (données externes
= couche non fiable, doctrine couches 0/1 étendue ici au périmètre de sécurité).
D4 — SIGN : le monitor signe l’échange de clés, l’enfant ne détient jamais la clé d’hôte
L’enfant fait les maths KEX (ADR-122) et calcule le hash d’échange H. Pour la
signature de H par la clé d’hôte, l’enfant envoie une requête SIGN au monitor
avec les composants du transcript (identifiants V_C/V_S, payloads KEXINIT,
clés publiques échangées, K/H selon RFC 4253 §8 / RFC 8731). Le monitor détient
la clé d’hôte (héritée COW du listener), recompute/valide ce qu’il peut du
transcript, puis signe uniquement un H bien formé et renvoie la signature.
- Anti-oracle (leçon
ssh-keysign, ADR-108 §5 / ADR-120) : le monitor ne signe pas un blob arbitraire fourni par l’enfant. Tranché (BDFL 2026-07-28) : validation structurelle — le monitor valide typage + bornes + cohérence des longueurs du transcript reçu, puis signe leHcorrespondant (l’enfant a déjà fait les maths KEX ; pas de recompute intégral deHdans le privilège). La surface d’oracle est bornée par la validation stricte, pour un coût CPU minimal côté privilège. - Rejeté : clé d’hôte héritée en clair et signée dans l’enfant. Un enfant pré-auth compromis exfiltrerait la clé d’hôte de tout le service. La clé ne franchit jamais la frontière vers du code non fiable.
D5 — AUTHORIZE : le monitor décide, l’enfant calcule
Sur SSH_MSG_USERAUTH_REQUEST/publickey, l’enfant vérifie la signature du client
(pur calcul crypto — maths dans l’enfant, ADR-122), puis délègue la décision :
requête AUTHORIZE{username, algo, clé_publique}. Le monitor :
- résout l’utilisateur via
air-account(déjà couche 1) ; - lit
~user/.ssh/authorized_keysen tant que l’utilisateur — le monitor détientCAP_SETUIDmais pasCAP_DAC_READ_SEARCH(ADR-122) : il pose un seteuid temporaire vers l’uid cible pour la lecture, ouvertureO_NOFOLLOW+ vérification de propriété/permissions (anti-TOCTOU, anti-symlink), puis restaure l’euid ; - répond
ALLOW/DENY. Tranché (BDFL 2026-07-28) : les options de la ligne autorisée (restrict,command=,from=, …) sont appliquées par le monitor lui-même — il ne renvoie à l’enfant qu’unALLOWdéjà contraint (commande forcée, restrictions résolues). L’interprétation des options reste dans le processus de confiance ; l’enfant ne voit jamais les options brutes.
Le monitor ne divulgue jamais le contenu d’authorized_keys à l’enfant.
D6 — Transition post-auth par proxy chiffré (raffine ADR-122)
À l’ALLOW final, le monitor fork_process un worker post-auth : setuid
vers le compte cible + cage (shell/exec = drop-only ; SFTP = sftp-worker full). Le
worker parle le protocole ssh-connection en clair sur un pipe/socketpair le
reliant à l’enfant pré-auth. L’enfant pré-auth conserve Transport<S> et devient
un proxy : déchiffre le réseau → transmet en clair au worker, chiffre le clair du
worker → réseau.
- Aucun état de chiffrement ne franchit la frontière (ADR-122). Le worker n’a aucune clé de session ; la frontière ne voit que du plaintext applicatif.
- Avantage vs OpenSSH (qui re-exec avec transfert d’état monitor-médié) : Air évite de sérialiser l’état cipher à travers un privilège. Coût : un déchiffrement/copie de proxy supplémentaire — assumé (Principe 5 : sécurité d’abord, mesure ensuite ; l’enfant proxy reste confiné, zéro privilège).
- fd : le worker n’a pas le socket réseau (il ne l’a jamais eu) ; seulement le
pipe vers le proxy.
SCM_RIGHTSreste disponible mais non requis pour le socket réseau dans ce modèle.
D7 — Confinement effectif par étage
| Étage | Identité | Confinement |
|---|---|---|
| listener | root | non confiné (accept + détient la clé d’hôte). Un seul, longue vie. |
| monitor | root (fork) | capability-based : capbset_drop de tout sauf {SETUID,SETGID,CHOWN,KILL}. Pas de seccomp/Landlock (doit seteuid/lire/fork/signer). PDEATHSIG. |
| enfant pré-auth | compte système _air-sshd (tranché BDFL) | with_sandbox(pre_auth, ∅) : no_new_privs + Landlock vide (aucun fichier) + seccomp pre-auth. Zéro capability. Détient le socket réseau. PDEATHSIG. |
| worker post-auth | compte cible (setuid) | shell/exec = drop-only ; SFTP = sftp-worker full. PDEATHSIG. |
D8 — Backend d’I/O bloquant, fermeture des rings hérités
Pré-auth et proxy en I/O bloquante (read/write/ppoll). Le seccomp
pre-auth n’autorise pas io_uring_* (il contournerait seccomp) ; l’enfant
ferme tout ring hérité via close_range (additif ADR-122 Inc.1) juste après le
fork, avant de poser la cage. Le réacteur io_uring d’air-sshd reste possible sur les
chemins non pré-auth — son articulation avec ce modèle est hors périmètre ici.
Plan d’incréments (volet 2)
- V2.1 — Codec RPC monitor (
monitor::proto, sans-IO) : messagesSIGN/AUTHORIZE- réponses ; encode/decode bornés,
parse, don't validate; fuzz target dédié. 100 % couverture. Aucun processus encore.
- réponses ; encode/decode bornés,
- V2.2 — Squelette de processus :
listener → monitor → enfant pré-auth, socketpair SEQPACKET,capbset_dropdu monitor, cage pré-auth, cascade PDEATHSIG. Round-trip RPC ping prouvé (sans crypto). Tests privilégiés (root, enfant forké, exception CHILD-EXIT documentée). - V2.3 —
SIGNbranché : l’enfant délègue la signature KEX au monitor ; retrait de tout accès clé d’hôte hors du monitor. Anti-oracle. - V2.4 —
AUTHORIZEbranché : l’enfant délègue la vérifauthorized_keys(seteuid temporaire +O_NOFOLLOW) ; retrait de l’accèsauthorized_keyshors du monitor. - V2.5 — Transition proxy post-auth : worker
setuid+ cage ; l’enfant devient proxy chiffré ; le worker parle ssh-connection en clair. - V2.6 — Durcissement + campagne de tests de sécurité (flood/DoS/brute-force/timing, attaquant carbon vs speedy:2222 en linux-air — voir la campagne ADR-105).
Chaque incrément = branche + PR verte + re-vérification (barrière, couverture
exception-aware couvrable-vide, fuzz le cas échéant). La clôture du volet 2 = clôture
de la vague 1 ⇒ déclenche le cycle de livraison (bump air-sshd + cargo xtask deb +
déploiement speedy/carbon/raspi-srv-2, ADR-122/release).
Conséquences
La topologie monitor / enfant / worker impose un canal RPC explicite entre processus : ce qui était un appel de fonction devient un message, borné et fuzzable.
Le coût est réel — plus de processus, plus de sérialisation — et il est assumé : c’est le prix d’un enfant pré-auth confiné qui ne détient aucun secret.
Alternatives rejetées
- Canal RPC en capnp. Rejeté : surface (générateur + runtime) sur un chemin de sécurité chaud, pour un jeu de messages fixe minuscule. Codec bespoke sans-IO fuzzé.
- Framing longueur-préfixée sur SOCK_STREAM. Rejeté : réintroduit une classe de bugs de framing ; SEQPACKET donne les frontières gratuitement.
- Clé d’hôte /
authorized_keysaccessibles dans l’enfant (COW ou Landlock-autorisé). Rejeté (D4/D5) : un enfant pré-auth compromis exfiltrerait la clé d’hôte / lirait des fichiers utilisateur. Le monitor médiatise ; l’enfant ne détient rien de sensible. - Transfert d’état de chiffrement au worker (modèle re-exec OpenSSH). Rejeté (D6) : sérialiser l’état cipher à travers un privilège est complexe et fragile ; le proxy garde les clés dans l’enfant confiné, la frontière ne voit que du plaintext.
- io_uring en pré-auth. Rejeté (ADR-122/D8) : contourne seccomp (opérations via ring partagé, hors filtre par-syscall). Bloquant strict en pré-auth.
CAP_DAC_READ_SEARCHsur le monitor (lecture directe d’authorized_keys). Rejeté (ADR-122) : lire en tant que l’utilisateur (seteuid) évite qu’un monitor bogué contourne les permissions ; capability en moins.
Points tranchés par le BDFL (2026-07-28)
Ces sous-décisions dépassaient le quoi ratifié d’ADR-122 ; remontées au BDFL et tranchées le 2026-07-28 (les défauts recommandés ont été retenus) :
- Profondeur d’anti-oracle du
SIGN(D4) → validation structurelle (typage + bornes + cohérence des longueurs, sans recompute intégral deHdans le privilège). - Options d’
authorized_keys(D5) → appliquées par le monitor ; l’enfant reçoit unALLOWdéjà contraint, jamais les options brutes. - uid privsep dédié (D7) → compte système non-login
_air-sshdprovisionné (ADR-100/121), analogue au comptesshdprivsep d’OpenSSH.
Amendements
§D4 : ordre de chargement de la clé d’hôte (BDFL, 2026-07-28)
Le §D4 décrivait la clé d’hôte comme « héritée COW du listener ». La mise en œuvre V2.3 (PR #498) + sa revue adverse ont montré que cette formulation, prise à la lettre, défait la propriété-phare de §D4 (« la clé d’hôte ne franchit jamais la frontière vers l’enfant ») :
forksansexeccopie l’espace d’adressage. Une clé privée vivante dans le processus au moment où il fork l’enfant pré-auth est donc dupliquée dans le tas de l’enfant — une divulgation mémoire dans l’étage pré-auth (la menace même que le privsep contre) la lirait.
Correction ratifiée. La clé d’hôte privée est chargée dans le corps du
monitor, APRÈS le fork de l’enfant (magasin air-keystore, ADR-108) — jamais
dans le listener, ni capturée par une closure vivante au fork. Le listener ne
matérialise que la partie publique K_S (le seul élément dont l’enfant a besoin,
pour émettre son KEXINIT). Au niveau API, le seam HostKeySigner est neutre ;
c’est l’ordre de chargement qui décide de l’isolation mémoire.
Corollaire (à trancher en V2.5). Le proto RPC (V2.1) n’a pas de message
HOSTKEY : l’enfant doit obtenir K_S public autrement — soit le listener le lui
transmet (hérité avant confinement), soit on ajoute un message HOSTKEY au canal.
Décision reportée au câblage du démon (V2.5).
Note sur l’oracle de signature (consignée, pas un changement). Un enfant pré-auth
compromis, tant qu’il vit, peut faire signer par le monitor un H correspondant à
une session qu’il fabrique (l’anti-oracle borne à notre clé d’hôte via ForeignHostKey,
mais ne recompute pas H). C’est inhérent au modèle privsep-sign (OpenSSH compris)
et non un artefact du choix « validation structurelle » : un recompute ne le
fermerait pas (l’enfant présente un transcript cohérent). Le privsep protège la clé
(jamais exfiltrée, rien ne lui survit), pas la capacité de signer pendant une
compromission live ; les mitigations sont le confinement fort + le caractère
éphémère par connexion de l’enfant. Propriété assumée.
§D5 : le magasin lu est le binaire, et les options ne sont pas encore exprimables (2026-07-28)
Le §D5 écrit « lit ~user/.ssh/authorized_keys ». C’est une inexactitude de
rédaction : Air ne lit jamais un authorized_keys texte à l’exécution
(ADR-073 doctrine de configuration binaire, ADR-096 §3/§4). Ce que le monitor lit
est le magasin binaire par-utilisateur <home>/.config/air/sshd/authorized_keys
(enveloppe AIRCFGv1 + message Cap’n Proto SshdAuthorizedKeys, mode 0600),
éventuellement complété d’un magasin système paramétré (jamais /etc codé en dur).
Le parseur du format texte d’OpenSSH existe, mais sert uniquement au CLI
d’import — hors du chemin d’authentification. Tout le reste de §D5 (seteuid temporaire,
O_NOFOLLOW, vérification de propriété/permissions, décision rendue par le monitor)
s’applique tel quel.
Les options n’étaient pas encore exprimables — tranché depuis par ADR-128. Le
point tranché n° 2 (« les options sont appliquées par le monitor ») est mis en œuvre
au bon endroit — la décision et la traduction des options vivent dans
MonitorAuthorizer::decide, côté privilège, et le fil porte
AuthorizeAllow{forced_command, permissions}. Mais à la livraison de V2.4, le schéma
v1 du magasin ne portait aucune option : command=, from=, restrict y étaient
explicitement différées (ADR-096, clause « Différés » de ses Conséquences — et
non sa §6, qui traite de la production d’artefacts : citation rectifiée par
ADR-128 §7). Conséquence factuelle de V2.4 : un ALLOW était toujours
forced_command: None, non par raccourci d’implémentation mais parce qu’aucune
contrainte n’était représentable dans le format. Les deux décisions alors remontées au
superviseur plutôt que contournées ont été arbitrées le 2026-07-28 :
- Étendre le schéma
SshdAuthorizedKeys(champ additif@3, discipline ADR-012 respectée) pour porter les options. ✅ Fait — ADR-128 §1–§3 :command=+ les 5 restrictions nommées +restricten champ explicite (avec ses ré-autorisations). L’enforcement reste scindé : le monitor décide, le worker post-auth applique (V2.5). from=exige l’adresse du pair, absente deAuthorizeRequest(V2.1). L’y ajouter est un additif de proto (champ supplémentaire, codec + fuzz à étendre) ; ne pas l’ajouter, c’est renoncer àfrom=. Aucun contournement n’a été improvisé : V2.4 n’applique pasfrom=et ne prétend pas le faire. ✅ Tranché — ADR-128 §5 :from=n’est pas stocké aujourd’hui (l’afficher sans l’honorer serait une fausse promesse) ; il sera livré en V2.5, en un seul additif, avec l’adresse du pair renseignée par le monitor et son application.
Suite
- Les 3 points étant tranchés : démarrer V2.1 (codec RPC sans-IO + fuzz).
- Enregistrement :
SUMMARY.md,registre-adrs-fr.md, note dansetat-avancement.md. - Étoile polaire :
air-sshd= démonstration de la couche 2 (vision ADR-122/roadmap).