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-093 — Couche transport SSH-2 d’air-sshd : cœur sans-IO air-ssh-proto, topologie de crates v1/v2, jeu d’algorithmes moderne, wire-compat OpenSSH

Statut : Accepté (2026-07-13, décision BDFL). Met en œuvre la phase 1 d’ADR-074 (vision air-sshd) selon le motif obligatoire d’ADR-091 (réseau sans-IO). Intègre la directive BDFL du 2026-07-13 : air-sshd v1 lancé par systemd (bon citoyen, a minima), v2 reposant sur le framework réseau air-network (une stack « ssh ») et lancé par air-launchd ; air-sshd publie ses événements de session sur AirCom ; configuration binaire via air-config. S’appuie sur ADR-092 (air-async), ADR-034 (air-crypto), ADR-069 (air-socket/air-handle), ADR-001 (AirCom), ADR-005 (systemd), ADR-033/ADR-040/ADR-073 (configuration binaire).

Catégorie : Architecture (couche 2, premier service réseau). Toute évolution structurante passe par un RFC (ADR-015).

Contexte

ADR-074 découpe air-sshd en trois phases, chacune instruite par un sous-ADR : (1) couche transport SSH-2, (2) ssh-userauth, (3) ssh-connection (canaux/PTY/shell). Le présent ADR instruit la phase 1 — mais fige la topologie de crates de tout air-sshd, car la directive BDFL impose des contraintes qui traversent les phases et qui doivent être honorées dès la première ligne (le découpage en crates est le seul choix qu’on ne peut pas rattraper après coup).

Cinq faits cadrent la décision :

  1. SSH n’utilise pas TLS. Le transport SSH-2 (RFC 4253) a son propre échange de version, sa négociation (KEXINIT), son KEX et son protocole de paquets binaire chiffré. Le framework générique air-network/air-tls (HTTP/QUIC) est hors du chemin critique de la v1 — mais la v2 l’utilisera (cf. §1.4).
  2. Le socle est prêt. air-async, air-socket, air-crypto (profil SSH moderne réellement câblé), air-config (~16 k lignes, ADR-033/040/073) et AirCom (inc.1→7, events pub-sub §7 + call sur objet) existent, scellés/testés. air-sshd n’a besoin ni de la façade libc C ni de libcrypto C-ABI (ADR-074).
  3. air-sshd est une fonction de forçage. Il produit des facilités partagées que tout service Air réutilisera — notamment l’hôte de service (§1.3) et le motif de publication d’événements sur AirCom (§5), pas des briques jetables propres à ssh.
  4. Contrainte v1/v2 (directive BDFL). v1 : air-sshd est lancé et supervisé par systemd (socket-activation + sd_notify, intégration a minima). v2 : il repose sur air-network (une stack « ssh ») et est lancé par air-launchd (le superviseur natif d’Air, à venir). La conception des crates doit rendre ce passage v1→v2 propre — idéalement sans toucher au cœur protocolaire.
  5. Observabilité (directive BDFL). Un consommateur AirCom doit pouvoir suivre les sessions : demande de connexion depuis telle machine, négociation ok/ko, shell lancé pour l’utilisateur X, session fermée — et interroger qui est connecté et où.

Décision

1. Topologie de crates — un cœur invariant, une périphérie swappable

Le motif sans-IO (ADR-091) est précisément ce qui rend le passage v1→v2 propre. On sépare ce qui ne change jamais (le protocole) de ce qui change (l’I/O, l’hôte).

1.1 air-ssh-proto — cœur PUR sans-IO (invariant v1↔v2)

Couche 2, no_std + alloc, #![forbid(unsafe_code)], zéro fd, zéro socket, zéro horloge, zéro I/O. La machine octets entrants → messages typés + octets sortants du protocole SSH-2 : Framer (paquets, RFC 4253 §6), Codec (SSH_MSG_*), StateMachine (transport + rekey), Handshaker (version + KEXINIT + KEX + dérivation), Timer (fenêtre de rekey). Flow/Mux/Session réservés (phase 3, canaux). Il ne sait jamais d’où viennent les octetsréutilisé verbatim en v2, sans une ligne changée. C’est l’invariant central.

1.2 air-sshd — le démon, scindé en deux responsabilités

  • Pilote de transport d’octets (la seule pièce que la v2 remplace) — déplace les octets entre le socket et le cœur, exécute les primitives air-crypto, fournit l’aléa. Derrière un trait mince SshByteStream (lecture/écriture async d’octets ordonnés). v1 : implémenté sur air-async/air-socket (io_uring). v2 : implémenté par la stack « ssh » d’air-network (§1.4).
  • Couche service (invariante v1↔v2)SessionRegistry (source de vérité des sessions vivantes, §5), publication AirCom (§5), configuration (air-config, §7), hôte de service (§1.3), et — aux phases 2/3 — privsep (air-process), PTY (air-terminal), politique de login (air-account).

1.3 air-service — hôte de service réutilisable (facilité partagée)

Petite crate couche 2 exposant un trait ServiceHost qui abstrait d’où vient le socket d’écoute, comment notifier le readiness/état, et comment recevoir l’ordre d’arrêt. v1 = implémentation systemd (§6). v2 = implémentation air-launchd. air-sshd (comme tout futur démon Air) ne référence jamais systemd en dur — il dépend de ServiceHost. C’est le seam v1→v2 de la supervision, et une facilité que tout service Air réutilise (fonction de forçage, ADR-074).

1.4 v2 — la stack « ssh » d’air-network enveloppe le cœur

En v2, air-network gagne une option de protocole « ssh » : elle n’implémente pas SSH — elle enveloppe air-ssh-proto comme couche transport/sécurité d’une AirConnection, et fournit un SshByteStream au-dessus du substrat air-network. v1 et v2 pilotent le même cœur. Rien à jeter : la transition = remplacer le pilote d’octets (§1.2) par la stack air-network. (Instruit par un ADR compagnon quand air-network sera bâti.)

2. Jeu d’algorithmes — moderne uniquement, refus du legacy par construction

KEXINIT n’annonce que ces algorithmes (aucun legacy proposé → aucun downgrade) :

RôleAlgorithme(s)Référence
Échange de clés (KEX)curve25519-sha256 (+ alias curve25519-sha256@libssh.org)RFC 8731
Clé d’hôte / signaturessh-ed25519RFC 8709
Chiffrement (AEAD)chacha20-poly1305@openssh.com (primaire), aes256-gcm@openssh.com (secondaire)PROTOCOL.chacha20poly1305 / RFC 5647
MACimplicite (AEAD intégré) — aucun hmac-* négocié
Compressionnone

Refusés (jamais annoncés) : RSA/DSA (clé d’hôte), diffie-hellman-group*, chiffrements CBC/CTR non-AEAD, hmac-sha1/hmac-md5, compression zlib.

3. Format de paquet — deux régimes gérés par le Framer

  • Avant NEWKEYS : paquet binaire en clair RFC 4253 §6 (packet_length | padding_length | payload | random padding), sans MAC.
  • Après NEWKEYS : construction AEAD compatible OpenSSHchacha20-poly1305@openssh.com (longueur chiffrée par une clé séparée, corps par une autre, tag Poly1305 ; PROTOCOL.chacha20poly1305) ou aes256-gcm@openssh.com (longueur en clair = AAD, RFC 5647). Le Framer bascule sur SSH_MSG_NEWKEYS.

4. Échange de clés et dérivation

  • Bannière : SSH-2.0-Air_<version>\r\n (version injectée par le pilote).
  • KEXINIT (§2), puis KEX curve25519-sha256 : SSH_MSG_KEX_ECDH_INIT (Q_C) / SSH_MSG_KEX_ECDH_REPLY (K_S, Q_S, sig) — RFC 5656/RFC 8731.
  • Hash d’échange H = SHA-256(V_C || V_S || I_C || I_S || K_S || Q_C || Q_S || K) ; le serveur signe H avec sa clé d’hôte Ed25519 ; session_id = H (premier KEX).
  • Dérivation RFC 4253 §7.2 (HASH = SHA-256, extension itérative). Rekey via le Timer du cœur (RFC 4253 §9), clés renouvelées sans interruption.

5. Publication AirCom des événements de session (directive BDFL)

air-sshd est le premier vrai citoyen AirCom : il publie le cycle de vie des sessions et l’expose interrogeable. Deux facettes, sur un SessionRegistry unique (source de vérité des sessions vivantes) :

  1. Flux d’événements (fire-and-forget, pub-sub AirCom §7) : ConnectionRequested{peer}, KexOk/KexFailed, AuthOk/AuthFailed{user, method, peer}, SessionOpened{session_id, user, pty}, ShellStarted{user}, SessionClosed{session_id}
  2. État interrogeable (« qui est connecté, où ») : air-sshd expose un objet-service AirCom (list_sessions() / session_info(id) via call sur AirObject, inc.7).

Le schéma d’événements/sessions (capnp) est figé dès la phase 1 ; les événements se remplissent au fil des phases (P1 : ConnectionRequested, KexOk/KexFailed ; P2 : Auth* ; P3 : SessionOpened/ShellStarted/SessionClosed).

Conséquence actée : air-com devient une dépendance de la v1 d’air-sshd (ADR-074 §4.3 disait « IPC pas requis pour un premier prototype » ; la directive BDFL prime). Coût nul : AirCom est livré (inc.1→7).

6. Hôte de service v1 = systemd (a minima), v2 = air-launchd

Implémentation systemd du trait ServiceHost (§1.3), minimale mais bon citoyen :

  • Socket-activation : sd_listen_fds (lire LISTEN_PID/LISTEN_FDS, fds à partir de 3) → le socket d’écoute vient de systemd (pas de bind en propre si activé).
  • sd_notify : datagramme AF_UNIX vers $NOTIFY_SOCKET (READY=1 après binding des clés d’hôte, STOPPING=1, STATUS=…).
  • Arrêt propre : SIGTERM via signalfd (déjà en couche 0) → drain des sessions.
  • Unité systemd (.socket + .service) fournie ; effort d’intégration au strict nécessaire pour être un bon citoyen (Type=notify, socket activation).

Ces deux protocoles (sd_listen_fds, sd_notify) sont triviaux en Rust safe (~35 l. au total sur air-socket/air-env) — aucun binding libsystemd C. La v2 fournira une implémentation air-launchd du même trait ServiceHost.

7. Configuration binaire (air-config, ADR-073) — existe déjà

air-config (capnp) est mature — on ne le bâtit pas, on définit un schéma « sshd » par-dessus. Phase 1 : clés d’hôte (Ed25519), jeu d’algorithmes autorisé (sous-ensemble du §2), paramètres d’écoute, bannière. Phase 2 : fichier de conf binaire par-utilisateur dans le HomeDirectory (options user + clés autorisées ≈ authorized_keys), lu via air-config. Jamais de sshd_config texte (ADR-073) ; import/export possibles.

8. Cible de compatibilité on-the-wire

Un client OpenSSH ssh de stock complète le handshake transport (atteint SSH_MSG_NEWKEYS puis débute ssh-userauth) contre air-sshd. Critère d’acceptation de la phase 1, prouvé par un test d’intégration exécutant un vrai client ssh. Le rôle client d’air-sshd est hors phase 1.

9. Neutralité sync/async (ADR-038/092)

Pilote async 1ʳᵉ classe sur air-async ; façade bloquante de confort (harnais de test) sur air-socket bloquant, pilotée par le même cœur (test croisé sync↔async, ADR-091).

10. Ordre des incréments (phase 1)

Inc.ContenuPreuve
T.1Crates air-ssh-proto/air-sshd/air-service + traits de seam (SshByteStream, ServiceHost) + schéma capnp événements/sessions (figé) + échange d’identification + Framer paquet clair (RFC 4253 §6) + squelette Codec/StateMachine. Fuzz du parseur de paquets. Impl systemd minimale de ServiceHost.banner échangé ; paquet clair round-trip ; ServiceHost systemd notifie READY
T.2KEXINIT + négociation (moderne only, first-match) + StateMachine transport (transitions illégales rejetées). Model-based tests. Événement AirCom ConnectionRequested publié.négociation déterministe ; event visible d’un abonné AirCom
T.3KEX curve25519-sha256 (X25519, aléa injecté) + hash H + signature Ed25519 de la clé d’hôte + dérivation (RFC 4253 §7.2). Vecteurs de test. KexOk/KexFailed publiés.H/clés conformes aux vecteurs
T.4NEWKEYS + régime chiffré chacha20-poly1305@openssh.com (+ aes256-gcm) + rekey. Wire-compat : un vrai client ssh atteint ssh-userauth.ssh -v traverse le transport

Chaque incrément : cœur ~100 % (unit + property + fuzz + model-based), pilote > 90 %, barrière verte, délégué à carbon puis re-vérifié et mergé (comme AirCom).

Conséquences

  • Transition v1→v2 garantie par construction : le cœur air-ssh-proto est réutilisé verbatim ; seuls le pilote d’octets (→ stack air-network) et l’impl ServiceHost (→ air-launchd) changent. Aucune réécriture protocolaire.
  • air-service = facilité partagée : tout démon Air (v1 systemd / v2 air-launchd) la réutilise ; c’est le modèle de service (prérequis §4.2 de la note d’archi) livré a minima.
  • air-sshd = premier citoyen AirCom observable : events + objet-service interrogeable ; air-com dépendance v1 (assumée). Exerce concrètement AirCom (pub-sub §7 + call inc.7).
  • Zéro C, zéro unsafe dans le cœur ; chemin 100 % Rust safe (ni libc C ni libcrypto).
  • Arêtes de couche : air-ssh-protoair-crypto (2→1) ; air-sshdair-async/air-socket/air-config/air-com/air-service/air-ssh-proto (2→2 et 2→1) ; air-serviceair-socket/air-env/air-signal (2→1). Jamais 2→0 (check-layers).
  • Deux ADR compagnons à venir (v2) : (a) modèle de service / air-launchd ; (b) stack « ssh » d’air-network enveloppant air-ssh-proto.
  • Sécurité : moderne only (aucun downgrade), AEAD, constant-time (air-crypto), clé d’hôte Ed25519, aléa injecté et audité.

Alternatives rejetées

  • Porter OpenSSH C / se lier à libcrypto. Tranché par ADR-074.
  • Supporter le legacy (RSA/DSA, dh-group14, hmac-sha1, CBC, zlib). Refusé (surface d’attaque, profil moderne ADR-074).
  • Attendre / passer par air-network/air-tls en v1. SSH n’est pas du TLS ; le framework générique est différé — mais la v2 l’utilisera via une stack « ssh » enveloppant le même cœur (§1.4), d’où la topologie choisie.
  • Câbler systemd en dur dans air-sshd. Refusé : le trait ServiceHost (air-service) est le seam v1(systemd)→v2(air-launchd) et une facilité partagée.
  • Réimplémenter SSH dans air-network en v2. Refusé : la stack « ssh » enveloppe air-ssh-proto, elle ne le duplique pas.
  • Configuration texte façon sshd_config / serde. Refusé : air-config capnp (ADR-073) existe ; serde le dupliquerait (règle des 80 %, ADR-024).
  • Cœur appelant le socket / tirant son aléa en interne. Violerait le motif sans-IO (ADR-091) et interdirait le fuzzing déterministe.

Références : RFC 4251 (architecture), RFC 4253 (transport SSH-2), RFC 5656 (ECC), RFC 8731 (curve25519-sha256), RFC 8709 (ssh-ed25519), RFC 5647 (AES-GCM), OpenSSH PROTOCOL.chacha20poly1305.