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-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_processAirForkResult (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 observe EOF sur 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 le H correspondant (l’enfant a déjà fait les maths KEX ; pas de recompute intégral de H dans 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 :

  1. résout l’utilisateur via air-account (déjà couche 1) ;
  2. lit ~user/.ssh/authorized_keys en tant que l’utilisateur — le monitor détient CAP_SETUID mais pas CAP_DAC_READ_SEARCH (ADR-122) : il pose un seteuid temporaire vers l’uid cible pour la lecture, ouverture O_NOFOLLOW + vérification de propriété/permissions (anti-TOCTOU, anti-symlink), puis restaure l’euid ;
  3. 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’un ALLOW dé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_RIGHTS reste disponible mais non requis pour le socket réseau dans ce modèle.

D7 — Confinement effectif par étage

ÉtageIdentitéConfinement
listenerrootnon confiné (accept + détient la clé d’hôte). Un seul, longue vie.
monitorroot (fork)capability-based : capbset_drop de tout sauf {SETUID,SETGID,CHOWN,KILL}. Pas de seccomp/Landlock (doit seteuid/lire/fork/signer). PDEATHSIG.
enfant pré-authcompte 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-authcompte 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) : messages SIGN/AUTHORIZE
    • réponses ; encode/decode bornés, parse, don't validate ; fuzz target dédié. 100 % couverture. Aucun processus encore.
  • V2.2 — Squelette de processus : listener → monitor → enfant pré-auth, socketpair SEQPACKET, capbset_drop du 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 — SIGN branché : 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 — AUTHORIZE branché : l’enfant délègue la vérif authorized_keys (seteuid temporaire + O_NOFOLLOW) ; retrait de l’accès authorized_keys hors 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_keys accessibles 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_SEARCH sur 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) :

  1. Profondeur d’anti-oracle du SIGN (D4)validation structurelle (typage + bornes + cohérence des longueurs, sans recompute intégral de H dans le privilège).
  2. Options d’authorized_keys (D5)appliquées par le monitor ; l’enfant reçoit un ALLOW déjà contraint, jamais les options brutes.
  3. uid privsep dédié (D7) → compte système non-login _air-sshd provisionné (ADR-100/121), analogue au compte sshd privsep 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 ») :

  • fork sans exec copie 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 :

  1. Étendre le schéma SshdAuthorizedKeys (champ additif @3, discipline ADR-012 respectée) pour porter les options. ✅ FaitADR-128 §1–§3 : command= + les 5 restrictions nommées + restrict en champ explicite (avec ses ré-autorisations). L’enforcement reste scindé : le monitor décide, le worker post-auth applique (V2.5).
  2. from= exige l’adresse du pair, absente de AuthorizeRequest (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 pas from= 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 dans etat-avancement.md.
  • Étoile polaire : air-sshd = démonstration de la couche 2 (vision ADR-122/roadmap).